Read-only demoLive view of gallirohik/research-canvas at 1ebb712, scanned 20 September 2026 — nothing here can be edited.
Join the waitlistlgcDeploymentUrl originates in the browser's own query string
(model-selector-provider.tsx:40 → page.tsx:24), so it is fully
caller-controlled. The route reads it (route.ts:92) and runs
isSafeDeploymentUrl, whose terminal test is addresses.every(addr => !isPrivate…) (route.ts:81) — a deny-private check, not an allow-known one.
The value that passes becomes deploymentUrl on LangGraphAgent alongside
langsmithApiKey (route.ts:122-123), so the LangSmith key rides the
outbound request to whatever public host was named. The only thing in front
of this is isAuthorized, documented in-repo as not access control
(route.ts:22-23).
P1 not P0: LANGSMITH_API_KEY is unset today and there is no deployment
surface in this repo, so the finding is latent. It becomes P0 the moment the
key is set in any network-reachable environment.
The server already knows its legitimate target — LGC_DEPLOYMENT_URL
(route.ts:102) is the fallback used when no ?lgcDeploymentUrl= param is
supplied. Comparing the parsed hostname to that one closes the gap; deny-list
hardening (DNS resolution, private-IP checks) stays as defence-in-depth.
Bundled in the same sitting: unencoded-lgc-deployment-url (open) —
page.tsx:24 interpolates lgcDeploymentUrl into the query string without
encodeURIComponent, so a &/# in the value injects or truncates query
parameters. Same file, same path, named in the P1 row's own fix text.
Ledger: lgc-deployment-url-key-exfiltration (P1, security).
The child task's Done-check passes: the allowlist guard is added alongside the existing checks (nothing removed/weakened), the client-side value is encoded, and typecheck is clean.
Allowlist LGC_DEPLOYMENT_URL's host in isSafeDeploymentUrl; encode lgcDeploymentUrl in page.tsx
lgc-allowlist-fixstatus: donerafa run plan
ladder's audit step hard-blocked on 28 pre-existing, out-of-scope dependency CVEs
(rafa audit exits 1 on any finding by design — unrelated to this fix; already
tracked as next-dependency-cves / next-transitive-postcss-sharp-cves /
copilotkit-runtime-transitive-cves). Recall, bloom's blast-radius pull, and the
security-audit transparency pass were completed via the ladder before it was
abandoned; this plan carries that grounding forward.isSafeDeploymentUrl in src/app/api/copilotkit/route.ts rejects any
lgcDeploymentUrl whose parsed hostname does not case-insensitively equal
LGC_DEPLOYMENT_URL's parsed hostname, in addition to (not replacing) the
existing protocol / localhost/.local / DNS-resolution / private-IP
checks.LGC_DEPLOYMENT_URL is unset or fails to parse, any caller-supplied
lgcDeploymentUrl is rejected with 400 (no known host to allow). Requests
that supply no lgcDeploymentUrl param are unaffected by this change:
deploymentUrl still falls back to LGC_DEPLOYMENT_URL (route.ts:102) and
the runtime selection at route.ts:118 is untouched — when neither is set,
the route continues to serve LangGraphHttpAgent against
REMOTE_ACTION_URL/localhost:8000 (route.ts:93-118). The 400 applies only
to caller-supplied values; rejection and fallback are mutually exclusive,
never both claimed for the same request.src/app/page.tsx's runtimeUrl template wraps lgcDeploymentUrl in
encodeURIComponent(...) before interpolating it into the query string.pnpm exec tsc --noEmit exits 0 with no new errors (baseline is already
clean at HEAD).lgcDeploymentUrl
hostname equals the configured LGC_DEPLOYMENT_URL host still passes
(existing behavior preserved); a request naming a different public https
host that resolves to a public, non-private address — https://example.com
(verified: resolves to public IPv4/IPv6 addresses, clears the protocol /
.local / DNS / private-IP checks, and is therefore accepted by the
pre-fix guard) — is rejected with 400 post-fix solely because its
hostname ≠ LGC_DEPLOYMENT_URL's hostname. (https://attacker.example is
NOT a valid exemplar: .example is RFC 2606 reserved and does not
resolve, so it is already rejected by the existing DNS-resolution catch
regardless of the allowlist.)route.ts: added module-scope allowedDeploymentHost
derived from LGC_DEPLOYMENT_URL (null if unset/unparseable), and
isSafeDeploymentUrl now rejects any hostname that doesn't match it — added
alongside the existing protocol/localhost/DNS/private-IP checks, all untouched.
page.tsx: runtimeUrl wraps lgcDeploymentUrl in encodeURIComponent.
tsc --noEmit exits 0.&/# payloads. Residual (non-blocking, matches Done-check's literal
"hostname" wording, flagged as a follow-up not this task): the allowlist
compares hostname only, not port — a different service on another port of the
same allowed host would still pass. Status → done.why: Deny-listing can never close this class of bug (the set of non-legitimate hosts is the whole internet); the server already knows its one legitimate target via LGC_DEPLOYMENT_URL, so comparing against it is both correct and requires no new configuration surface. A separate allowlist env var was considered but rejected as unnecessary indirection when the existing var already names the truth. Bundling the encoding fix was validated by prism as appropriate scope (same file/line, same request path, named in the P1 ledger row's own fix text) rather than YAGNI creep.
conductor · lgc-allowlist-fix · 8/1/2026, 7:33:43 AM
The agent's DeleteResources tool routes to an interrupt, and the frontend renders the confirm dialog via useCopilotAction under the SAME string name — a mismatch means the delete never prompts and silently stalls
Authoritative trace (tight, cite-proven) of a CopilotChat turn through the /api/copilotkit runtime into the LangGraph agent graph and how streamed state signals render the canvas (download → chat → search/delete/fact-check → frontend render).
Both chat nodes re-serialise all downloaded resources into the system prompt each turn, so one long article can exceed an entire per-minute token budget in a single request; the TS agent caps this (8k/resource, 24k total) as of 2026-07-28, the Python agent does NOT. Rule: any new node that injects resource text into a prompt MUST budget it (per-resource and aggregate caps, like MAX_TOTAL_RESOURCE_CHARS in the TypeScript agent) — the missing Python cap is an open P1 improvement, not permission to skip the cap
The ModelSelector writes ?coAgentsModel to the URL; that string becomes state.model and drives get_model on the backend — the agent NAME only branches google_genai vs not, and both names resolve to the same graph. The wiring is correct for all four options, but the anthropic MODEL ID is dead (claude-3-5-sonnet-20240620, retired 2025-10-28): check the id before the key
Add the no-op @tool schema, bind it in the chat node, route on its name, and — if it drives UI or streams a state field — mirror the string on the frontend (useCopilotAction) or in an emit_intermediate_state mapping
One Next.js route handler proxies the browser to the LangGraph agents; it uses EmptyAdapter (agents own the LLM), gates on a public "api key", toggles HTTP vs LangGraph-Cloud mode by URL param, and DNS-resolves the lgcDeploymentUrl before trusting it
"research_agent" / "research_agent_google_genai" must match across the frontend agent prop, the runtime registry, and each agent's langgraph.json + endpoint path, or the coagent silently never connects
NEXT_PUBLIC_COPILOTKIT_API_KEY is REQUIRED for the app to function — unset means every POST /api/copilotkit 401s silently; beyond that the runtime route reads LangSmith/deployment keys, each agent reads MODEL + provider keys + TAVILY_API_KEY; OPENAI_API_KEY appears only commented-out in the runtime route while provider-specific keys (Anthropic/xAI/etc.) are read implicitly by the LangChain constructors in this repo's agent code. ANTHROPIC_API_KEY is not present in the cited files; a repo-wide grep is required to prove global absence.
Two servers, one thin proxy and one wide-open agent: the Next.js route has a cosmetic x-api-key (the key ships in client JS) and the Python FastAPI agent has NO auth at all, so port 8000 is the real exposure; there is no user identity anywhere; the two SSRF guards on user-supplied URLs are the only guarded edges and both are DENY-PRIVATE, not allow-known — so the ?lgcDeploymentUrl one still forwards LANGSMITH_API_KEY to any public host a caller names (open P1 lgc-deployment-url-key-exfiltration)