Skip to content

Commit ef851ae

Browse files
committed
fix(consent,relay-eager): require a dashboard session, and inspect before abort
Two findings from the second audit round. The star mutation required a GUI session only when isAgentDriven() was true, and that function reads the SERVER's environment rather than the caller's. A proxy running as a service — no agent markers, the normal remote setup — therefore accepted a raw-admin-token star from anyone who could read the token, which is every agent on the machine. Caller provenance is not knowable at this endpoint; the credential is. The dashboard session is now required unconditionally, and the refusal names the agent markers only when there are any. The former "hand-typed run stars over HTTP" test encoded exactly the hole, so it is replaced by its inverse plus a dashboard-click case on the same clean environment. The eager producer honored the abort signal before examining a settled read. A read can settle with a real chunk in the same tick the signal fires — the post-cancel drain does exactly this: the terminal frame arrives, then the drain deadline aborts upstream — so the terminal was discarded and the turn was accounted as a cancel. The chunk is inspected first now, and abort is honored immediately after. Driven red by restoring the old order.
1 parent f2eb047 commit ef851ae

5 files changed

Lines changed: 82 additions & 31 deletions

File tree

src/server/management/sidebar-routes.ts

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,22 @@
99
* this surface only learns the yes/no answer. `gh` writes the authenticated account
1010
* name to stderr, so that output is discarded at the source rather than forwarded.
1111
*
12-
* The star POST additionally refuses agent-driven programmatic callers. Management
13-
* auth proves the caller reached the admin token, not that a person chose to star:
14-
* a coding agent runs on the user's machine and can read that token from disk, so
15-
* the CLI's "ask the user" deferral would be bypassable with one `curl` here.
12+
* The star POST additionally requires a dashboard session. Management auth proves
13+
* the caller reached the admin token, not that a person chose to star: a coding
14+
* agent runs on the user's machine and can read that token from disk, so the CLI's
15+
* "ask the user" deferral would be bypassable with one `curl` here.
1616
*
17-
* The dashboard button must keep working even when the proxy itself was started by
18-
* an agent, which is the common case — the person is at the browser, not at the
19-
* spawning shell. A GUI click is therefore distinguished by the CREDENTIAL that
20-
* authorized the request — a GUI session this process minted for a browser, which
21-
* the auth gate only accepts after matching origin and the per-session CSRF token —
22-
* rather than by the proxy's own env or by request headers.
17+
* The requirement is unconditional, and that is the point. It used to apply only
18+
* when `isAgentDriven()` was true — but that function reads the SERVER's
19+
* environment, not the caller's, so a proxy already running as a service (no agent
20+
* markers, the normal remote setup) accepted a raw-token star from anyone who could
21+
* read the token, which includes every agent on the machine. The provenance of the
22+
* HTTP caller is not knowable from the server's env; only the credential is. So the
23+
* mutation asks for a GUI session this process minted for a browser, which the auth
24+
* gate accepts only after matching origin and the per-session CSRF token.
2325
*/
2426
import { jsonResponse } from "../auth-cors";
25-
import { agentDrivenMarkers, isAgentDriven } from "../../cli/agent-driven";
27+
import { agentDrivenMarkers } from "../../cli/agent-driven";
2628
import type { ManagementContext } from "./context";
2729

2830
/**
@@ -40,12 +42,11 @@ function hasBrowserSessionEvidence(ctx: ManagementContext): boolean {
4042
return ctx.principal === "gui-session";
4143
}
4244

43-
// Known edge, deliberately fail-closed: a non-loopback operator dashboard signs in
44-
// with the raw admin token instead of a minted GUI session, so its clicks carry no
45-
// CSRF header. If that proxy was *also* started from an agent shell, the button is
46-
// refused and the response names the one-line `gh` command to run by hand. A
47-
// service-run proxy (`OCX_SERVICE`, the usual remote setup) is not agent-driven and
48-
// is unaffected.
45+
// Known edge, deliberately fail-closed: a non-loopback operator dashboard that signs
46+
// in with the raw admin token instead of a minted GUI session gets its click refused,
47+
// and the response names the one-line `gh` command to run by hand. That is the
48+
// correct trade — an endpoint reachable with a readable token cannot establish that
49+
// a human chose to spend their own GitHub identity.
4950

5051
export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Response | null> {
5152
const { req, url } = ctx;
@@ -58,19 +59,21 @@ export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Respo
5859
if (url.pathname === "/api/github/star" && req.method === "POST") {
5960
const { STAR_REPO, STAR_REPO_URL, starRepository } = await import("../../github/star-state");
6061
// Starring uses the user's GitHub identity, so consent must come from the
61-
// account owner. An agent-driven caller without browser-session evidence
62-
// cannot have obtained it, and must relay the question instead of answering
63-
// it with an HTTP call.
64-
if (isAgentDriven() && !hasBrowserSessionEvidence(ctx)) {
62+
// account owner. Only a minted dashboard session evidences that; a caller
63+
// holding the admin token must relay the question instead of answering it
64+
// with an HTTP call.
65+
if (!hasBrowserSessionEvidence(ctx)) {
66+
const markers = agentDrivenMarkers().slice(0, 3);
6567
return jsonResponse({
6668
ok: false,
6769
state: "not-starred",
6870
repo: STAR_REPO,
6971
url: STAR_REPO_URL,
7072
code: "agent_consent_required",
7173
message:
72-
`Refused: agent session detected (${agentDrivenMarkers().slice(0, 3).join(", ")}) and this request `
73-
+ `carries no dashboard session. Starring writes to the user's own GitHub account, so ask the user `
74+
`Refused: this request carries no dashboard session`
75+
+ `${markers.length ? ` (agent session detected: ${markers.join(", ")})` : ""}. `
76+
+ `Starring writes to the user's own GitHub account, so ask the user `
7477
+ `directly as a required Yes/No choice — not a soft aside, and no answer is not a No, so re-ask `
7578
+ `it unchanged while it is open — and only if they say yes run: `
7679
+ `gh api -X PUT /user/starred/${STAR_REPO}`,

src/server/relay-eager.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,14 @@ export function relaySseEagerBounded(
206206
try {
207207
for (;;) {
208208
const result = await reader.read();
209-
if (upstream.signal.aborted) break;
210209
const { done: upstreamDone, value } = result;
210+
// A chunk that already settled is INSPECTED before abort is honored. A read
211+
// can settle with a real chunk in the same tick the signal fires (post-cancel
212+
// drain: the terminal frame arrives, then the drain timer aborts upstream).
213+
// Checking the signal first discarded that frame, so the terminal was never
214+
// recorded and the turn was accounted as a plain cancel.
215+
if (!upstreamDone && value !== undefined) hooks.inspectChunk(value);
216+
if (upstream.signal.aborted) break;
211217
if (upstreamDone) {
212218
hooks.finishInspection();
213219
if (rewrite) {
@@ -222,7 +228,6 @@ export function relaySseEagerBounded(
222228
}
223229
break;
224230
}
225-
hooks.inspectChunk(value);
226231
if (cancelled) {
227232
// Discard-drain: inspection only, nothing queued. Stop at terminal
228233
// or when the bounded window expires.

tests/relay-eager.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,28 @@ async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
111111
}
112112

113113
describe("relaySseEagerBounded — inline payload rewrite (#864)", () => {
114+
test("a terminal frame settling in the same tick as abort is still recorded", async () => {
115+
// Post-cancel drain: the terminal arrives, and the drain deadline aborts
116+
// upstream in the same tick. Honoring the signal before examining the settled
117+
// read discarded that frame, so the turn was accounted as a cancel instead of
118+
// the completion it actually reached.
119+
const up = controlledUpstream();
120+
const { hooks, rec } = makeHooks();
121+
const upstream = new AbortController();
122+
const relayed = relaySseEagerBounded(up.stream, upstream, hooks);
123+
const reading = readAll(relayed);
124+
125+
up.push(sse(DELTA));
126+
await settle();
127+
// Enqueue the terminal and abort without yielding in between.
128+
up.push(enc.encode(`event: response.completed\ndata: ${COMPLETED}\n\n`));
129+
upstream.abort(new Error("drain window expired"));
130+
up.close();
131+
await reading;
132+
133+
expect(rec.terminals.map(t => t.status)).toContain("completed");
134+
});
135+
114136
test("rewrites complete blocks across fragmented chunks and flushes the tail at EOF", async () => {
115137
const up = controlledUpstream();
116138
const { hooks } = makeHooks();

tests/sidebar-routes.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,14 +240,34 @@ describe("route surface", () => {
240240
expect(calls).toEqual([]);
241241
});
242242

243-
test("a hand-typed run is not blocked by the agent guard", async () => {
243+
test("a clean-environment server still refuses a raw-token star", async () => {
244+
// The guard used to fire only when isAgentDriven() was true — but that reads
245+
// the SERVER's environment, not the caller's. A proxy running as a service has
246+
// no agent markers, so this exact request (raw admin token, no dashboard
247+
// session) starred the repository for anyone who could read the token, which
248+
// is every agent on the machine. Caller provenance is not knowable here; the
249+
// credential is, so the dashboard session is required unconditionally.
244250
const calls: string[][] = [];
245251
await withEnv(NO_AGENT_ENV, () => withStarDeps({
246252
nowMs: () => 0,
247253
async runGh(args) { calls.push(args); return { status: 0 }; },
248254
}, async () => {
249255
invalidateStarStatusCache();
250-
const { status } = await call("POST", "/api/github/star");
256+
const { status, body } = await call("POST", "/api/github/star", {}, "admin-token");
257+
expect(status).toBe(403);
258+
expect((body as Record<string, unknown>).code).toBe("agent_consent_required");
259+
}));
260+
expect(calls).toEqual([]);
261+
});
262+
263+
test("a dashboard click on a clean-environment server still stars", async () => {
264+
const calls: string[][] = [];
265+
await withEnv(NO_AGENT_ENV, () => withStarDeps({
266+
nowMs: () => 0,
267+
async runGh(args) { calls.push(args); return { status: 0 }; },
268+
}, async () => {
269+
invalidateStarStatusCache();
270+
const { status } = await call("POST", "/api/github/star", {}, "gui-session");
251271
expect(status).toBe(200);
252272
}));
253273
expect(calls.some(args => args.includes("PUT"))).toBe(true);

tests/startup-prompt.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,13 @@ describe("startup star prompt", () => {
115115

116116
// The CLI deferral is worthless if an agent can reach the same write over
117117
// HTTP: it runs on the user's machine and can read the admin token from disk.
118-
expect(routes).toContain("isAgentDriven()");
119118
expect(routes).toContain("agent_consent_required");
120-
// A dashboard click must still work when an agent started the proxy, so the
121-
// refusal is conditioned on the absence of browser-session evidence.
122119
expect(routes).toContain("hasBrowserSessionEvidence");
123-
expect(routes).toMatch(/isAgentDriven\(\)\s*&&\s*!hasBrowserSessionEvidence\(ctx\)/);
120+
// The requirement is UNCONDITIONAL. Gating it on isAgentDriven() reads the
121+
// server's environment, not the caller's, so a service-run proxy (no agent
122+
// markers) accepted a raw-token star from any agent on the machine.
123+
expect(routes).toMatch(/if\s*\(!hasBrowserSessionEvidence\(ctx\)\)/);
124+
expect(routes).not.toMatch(/isAgentDriven\(\)\s*&&/);
124125
// And that evidence must be the authenticating CREDENTIAL, never a request
125126
// header: the admin token is readable by anything running as the user, so a
126127
// header-shaped check is forgeable by the exact caller this guard refuses.

0 commit comments

Comments
 (0)