@openparachute/agent 0.2.3-rc.4 → 0.2.3-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/agent",
3
- "version": "0.2.3-rc.4",
3
+ "version": "0.2.3-rc.6",
4
4
  "description": "Vault-native agents for Claude Code — a #agent/definition note + an inbound message becomes a sandboxed claude turn; the reply is written back as a note. Messaging gateway on :1941.",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
package/src/agent-defs.ts CHANGED
@@ -389,6 +389,15 @@ export function parseAgentDef(note: {
389
389
  }
390
390
 
391
391
  // Filesystem read scope.
392
+ //
393
+ // NOTE (step-up, agent#80): `filesystem: "full"` is the dangerous, full-disk
394
+ // case. The step-up PIN gate is enforced on the HTTP spawn path only
395
+ // (`POST /api/agents` in daemon.ts). This VAULT-NATIVE path (a #agent/definition
396
+ // note with `filesystem: full`) is NOT step-up-gated — registering it requires
397
+ // `vault:write` to author the note, which is itself separately scope-gated, so a
398
+ // step-up challenge here would gate a capability the caller already had to hold a
399
+ // write credential to reach. If the threat model is ever revisited (e.g. less-
400
+ // trusted note authors), this is the gap to close.
392
401
  const filesystem = metaStr(meta.filesystem);
393
402
  if (filesystem !== undefined) {
394
403
  if (filesystem !== "workspace" && filesystem !== "full") {
package/src/auth.ts CHANGED
@@ -7,13 +7,26 @@
7
7
  * - Layer 1 (bridge / session↔channel): the bridge presents the token as an
8
8
  * `Authorization: Bearer` header on `/events` + `/api/*`.
9
9
  * - Layer 2 (human / chat UI): the page fetches a short-lived token from the
10
- * hub (`/admin/agent-token`) and attaches it as a Bearer header on the
11
- * `send` POST, and as a `?token=` query param on the `/ui/events` SSE
12
- * (EventSource can't set headers).
10
+ * hub (`/admin/agent-token`) and attaches it as a Bearer header on the
11
+ * `send` POST. For the browser SSE streams (`/ui/events`,
12
+ * `/api/channels/<ch>/turn-events`) — which an `EventSource` can't set a
13
+ * header on — the page does NOT put the JWT in the URL. Instead it mints a
14
+ * one-time SSE TICKET (`POST /api/ui/sse-ticket`, Bearer-authenticated) and
15
+ * opens `…?ticket=<nonce>`. See `requireSseTicket` below + `ui-ticket.ts`.
13
16
  *
14
- * `requireScope` accepts the token from EITHER source so one helper guards both
15
- * layers. The no-token path short-circuits before any JWKS fetch, keeping it
16
- * unit-testable without a live hub (same approach Layer 1 used).
17
+ * `requireScope` accepts the token from a Bearer header (and, for the
18
+ * agent:admin terminal WebSocket only, a `?token=` query param). The no-token
19
+ * path short-circuits before any JWKS fetch, keeping it unit-testable without a
20
+ * live hub (same approach Layer 1 used).
21
+ *
22
+ * WHY THE TICKET (agent#25). A full hub JWT in a `?token=` URL lands in any
23
+ * access/proxy log, browser history, or network trace — a credential leak
24
+ * mitigated before only by the token's short TTL. The browser SSE endpoints now
25
+ * trade the JWT for an opaque, single-use, ≤60s ticket (`requireSseTicket`); the
26
+ * JWT only ever travels in a `fetch` Bearer header. The legacy `?token=` SSE
27
+ * path was REMOVED (pre-1.0, no deprecation window). The terminal WebSocket
28
+ * (`agent:admin`) still uses `?token=` — a separate, operator-gated mechanism
29
+ * out of this change's scope.
17
30
  *
18
31
  * DUAL-ACCEPT (channel→agent rename transition,
19
32
  * `parachute-patterns/migrations/2026-06-17-channel-to-agent.md` rule 1). New
@@ -26,6 +39,8 @@
26
39
 
27
40
  import { validateHubJwt, HubJwtError } from "./hub-jwt.ts";
28
41
  import { extractBearer } from "@openparachute/scope-guard";
42
+ import { consumeTicket } from "./ui-ticket.ts";
43
+ import { isStepUpTokenValid, isStepUpConfigured } from "./step-up.ts";
29
44
 
30
45
  /** Agent scopes, declared here so callers share one spelling. */
31
46
  export const SCOPE_READ = "agent:read" as const;
@@ -79,15 +94,18 @@ export function json(data: unknown, status = 200): Response {
79
94
 
80
95
  /**
81
96
  * Extract a presented token from a request: the `Authorization: Bearer` header
82
- * first (the bridge + the UI's POST), falling back to a `?token=` query param
83
- * (the SSE case `EventSource` can't set headers). Returns null if neither is
84
- * present.
97
+ * first (the bridge, the UI's POST, the SSE-ticket mint), falling back to a
98
+ * `?token=` query param only when `allowQueryParam` is set. The ONLY caller that
99
+ * opts into the query param is the agent:admin terminal WebSocket
100
+ * (`new WebSocket()` can't set headers); the browser SSE streams moved to the
101
+ * one-time-ticket path (`requireSseTicket`) so a JWT never rides in a URL. Returns
102
+ * null if neither source is present.
85
103
  */
86
104
  export function extractToken(req: Request, url: URL, allowQueryParam = false): string | null {
87
105
  const bearer = extractBearer(req.headers.get("authorization"));
88
106
  if (bearer) return bearer;
89
- // `?token=` is opt-in (the SSE case only). The bridge + the UI POST present a
90
- // Bearer header, so they never enable it — keeps query-param tokens off every
107
+ // `?token=` is opt-in (the terminal WebSocket only). Every other caller presents
108
+ // a Bearer header, so they leave it false keeping query-param JWTs off every
91
109
  // endpoint that doesn't strictly need them (and out of those access logs).
92
110
  if (allowQueryParam) {
93
111
  const q = url.searchParams.get("token");
@@ -99,9 +117,10 @@ export function extractToken(req: Request, url: URL, allowQueryParam = false): s
99
117
  /**
100
118
  * Guard an HTTP endpoint on a hub-issued JWT carrying `scope`. The token arrives
101
119
  * as an `Authorization: Bearer` header; pass `allowQueryParam: true` to also
102
- * accept a `?token=` query param (the SSE case only — `EventSource` can't set
103
- * headers). Bridge + UI-POST callers leave it false, so query-param tokens are
104
- * confined to the one endpoint that needs them.
120
+ * accept a `?token=` query param (the agent:admin terminal WebSocket only —
121
+ * `new WebSocket()` can't set headers). All other callers leave it false, so
122
+ * query-param JWTs are confined to that one endpoint. Browser SSE streams use
123
+ * `requireSseTicket` (the one-time ticket), not this.
105
124
  *
106
125
  * Returns `null` when the request is authorized (caller proceeds), or a
107
126
  * `Response` (401/403) the caller must return as-is.
@@ -138,3 +157,152 @@ export async function requireScope(
138
157
  );
139
158
  }
140
159
  }
160
+
161
+ /**
162
+ * Mint endpoint for a one-time SSE ticket (agent#25). Authenticate the presented
163
+ * Bearer JWT for `scope` (the SAME validation `requireScope` runs — no-token →
164
+ * 401 pre-JWKS, bad/insufficient → 401/403), then issue a single-use, ≤60s
165
+ * opaque ticket carrying ONLY the token's validated scopes. The ticket — never
166
+ * the JWT — goes in the SSE URL. Returns the mint `Response` (200 `{ ticket,
167
+ * expires_at }`, or the gate's 401/403) for the caller to return as-is.
168
+ *
169
+ * `mintTicket` is injected (defaults to the real `ui-ticket.ts` store) so unit
170
+ * tests can assert what scopes get carried without reaching into the singleton.
171
+ * Critically, an UNAUTHENTICATED mint is impossible: the scope gate runs first
172
+ * and short-circuits before any ticket is created — minting without a valid
173
+ * bearer would be an auth bypass.
174
+ */
175
+ export async function mintSseTicket(
176
+ req: Request,
177
+ url: URL,
178
+ scope: string,
179
+ mint: (scopes: readonly string[]) => { ticket: string; expiresAt: number },
180
+ ): Promise<Response> {
181
+ const token = extractToken(req, url); // Bearer header ONLY — never a query param.
182
+ if (!token) {
183
+ return json({ error: "unauthorized", message: "Bearer token required" }, 401);
184
+ }
185
+ let scopes: string[];
186
+ try {
187
+ const claims = await validateHubJwt(token);
188
+ if (!grantsScope(claims.scopes, scope)) {
189
+ return json(
190
+ { error: "insufficient_scope", message: `requires ${scope}`, granted: claims.scopes },
191
+ 403,
192
+ );
193
+ }
194
+ // Carry the token's OWN validated scopes — never widen beyond what it holds.
195
+ scopes = claims.scopes;
196
+ } catch (err) {
197
+ return json(
198
+ { error: "unauthorized", message: err instanceof HubJwtError ? err.message : "invalid token" },
199
+ 401,
200
+ );
201
+ }
202
+ const { ticket, expiresAt } = mint(scopes);
203
+ return json({ ticket, expires_at: new Date(expiresAt).toISOString() });
204
+ }
205
+
206
+ /**
207
+ * Guard a browser SSE endpoint on a one-time `?ticket=<nonce>` (agent#25 — the
208
+ * EventSource auth path that replaced the leaky `?token=<JWT>`). Look up + CONSUME
209
+ * the ticket (single-use: a second connect 401s), then assert the ticket's carried
210
+ * scopes include `scope` (the ticket can never authorize more than the JWT that
211
+ * minted it — `mintSseTicket` stored exactly that JWT's scopes). Returns `null`
212
+ * when authorized (caller opens the stream) or a 401 `Response` to return as-is.
213
+ *
214
+ * No JWKS fetch on this path — the JWT was validated at MINT time and its scopes
215
+ * captured in the ticket; consume is a pure in-memory lookup. So an absent /
216
+ * expired / already-used / under-scoped ticket all map to 401 with no network I/O.
217
+ */
218
+ export function requireSseTicket(url: URL, scope: string): Response | null {
219
+ const consumed = consumeTicket(url.searchParams.get("ticket"));
220
+ if (!consumed) {
221
+ return json({ error: "unauthorized", message: "valid one-time SSE ticket required" }, 401);
222
+ }
223
+ if (!grantsScope(consumed.scopes, scope)) {
224
+ return json(
225
+ { error: "insufficient_scope", message: `ticket lacks ${scope}`, granted: consumed.scopes },
226
+ 403,
227
+ );
228
+ }
229
+ return null;
230
+ }
231
+
232
+ /**
233
+ * The header a request carries the step-up token on (agent#80). The terminal
234
+ * WebSocket — which `new WebSocket()` can't set a header on — uses the
235
+ * `?step_up=` query param instead (mirroring the `?token=` exception).
236
+ */
237
+ export const STEP_UP_TOKEN_HEADER = "x-step-up-token";
238
+
239
+ /** Extract a presented step-up token: the header first, then `?step_up=` when allowed. */
240
+ export function extractStepUpToken(req: Request, url: URL, allowQueryParam = false): string | null {
241
+ const header = req.headers.get(STEP_UP_TOKEN_HEADER);
242
+ if (header && header.length > 0) return header;
243
+ if (allowQueryParam) {
244
+ const q = url.searchParams.get("step_up");
245
+ if (q && q.length > 0) return q;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * SECOND-FACTOR gate (agent#80) for the genuinely dangerous `agent:admin` actions:
252
+ * set/rotate credentials, open a terminal, spawn a `filesystem: full` agent. The
253
+ * caller runs {@link requireScope}(`agent:admin`) FIRST; this asserts — IN ADDITION —
254
+ * a valid step-up token (the operator entered their PIN recently).
255
+ *
256
+ * - Step-up NOT configured (no PIN set) → returns `{ ok: false, reason: "setup" }`.
257
+ * The caller maps it to `403 { error: "step_up_required", reason: "setup" }` so
258
+ * the UI runs its FIRST-TIME PIN-setup flow before the action.
259
+ * - PIN configured + valid token → `{ ok: true }` (the action proceeds).
260
+ * - PIN configured + missing/expired token → `{ ok: false, reason: "token" }` →
261
+ * `403 { error: "step_up_required" }` so the UI PROMPTS for the PIN.
262
+ *
263
+ * The 403 is deliberately DISTINCT from `requireScope`'s 401 (no/invalid Bearer):
264
+ * a 401 means "re-authenticate", a 403 `step_up_required` means "enter your PIN".
265
+ * The step-up token NEVER widens scope — the request already passed `agent:admin`;
266
+ * this is purely a recency re-confirm on top.
267
+ *
268
+ * `allowQueryParam: true` accepts `?step_up=` for the terminal WebSocket only.
269
+ * Pure in-memory token check — no I/O on the gated request path, no secret logged.
270
+ */
271
+ export function requireStepUp(
272
+ req: Request,
273
+ url: URL,
274
+ allowQueryParam = false,
275
+ opts?: { configured?: () => boolean; valid?: (token: string | null) => boolean },
276
+ ): { ok: true } | { ok: false; response: Response } {
277
+ const isConfigured = opts?.configured ?? (() => isStepUpConfigured());
278
+ const isValid = opts?.valid ?? ((t: string | null) => isStepUpTokenValid(t));
279
+ if (!isConfigured()) {
280
+ // No PIN yet — the UI must set one before this action can proceed.
281
+ return {
282
+ ok: false,
283
+ response: json(
284
+ {
285
+ error: "step_up_required",
286
+ reason: "setup",
287
+ message: "set a step-up PIN before performing this action",
288
+ },
289
+ 403,
290
+ ),
291
+ };
292
+ }
293
+ const token = extractStepUpToken(req, url, allowQueryParam);
294
+ if (!isValid(token)) {
295
+ return {
296
+ ok: false,
297
+ response: json(
298
+ {
299
+ error: "step_up_required",
300
+ reason: "token",
301
+ message: "enter your step-up PIN to confirm this action",
302
+ },
303
+ 403,
304
+ ),
305
+ };
306
+ }
307
+ return { ok: true };
308
+ }
package/src/daemon.ts CHANGED
@@ -85,6 +85,10 @@ import { ClientRegistry, sseFrame } from "./routing.ts";
85
85
  import { DeliveryState } from "./delivery-state.ts";
86
86
  import {
87
87
  requireScope,
88
+ mintSseTicket,
89
+ requireSseTicket,
90
+ requireStepUp,
91
+ grantsScope,
88
92
  extractToken,
89
93
  json as authJson,
90
94
  SCOPE_READ,
@@ -93,6 +97,16 @@ import {
93
97
  SCOPE_ADMIN,
94
98
  SCOPE_TERMINAL,
95
99
  } from "./auth.ts";
100
+ import {
101
+ isStepUpConfigured,
102
+ isValidPinFormat,
103
+ setStepUpPin,
104
+ verifyStepUpPin,
105
+ mintStepUpToken,
106
+ stepUpLimiter,
107
+ StepUpPinFormatError,
108
+ } from "./step-up.ts";
109
+ import { mintTicket } from "./ui-ticket.ts";
96
110
  import {
97
111
  createTerminalWsHandlers,
98
112
  type TerminalWsData,
@@ -1124,8 +1138,13 @@ function redirect(location: string): Response {
1124
1138
  // is `agent:write`.
1125
1139
  //
1126
1140
  // Layer 2 — human / chat UI — gates the http-ui transport's `send` (POST,
1127
- // `agent:send`) + `/ui/events` SSE (`?token=` query, `agent:read`) inside
1128
- // `http-ui.ts`'s ingestHttp using the same `requireScope`.
1141
+ // `agent:send`, Bearer) with `requireScope`. The browser SSE streams
1142
+ // (`/ui/events`, `/api/channels/<ch>/turn-events`, `agent:read`) gate on a
1143
+ // ONE-TIME ticket (`requireSseTicket`) instead of a `?token=<JWT>` query —
1144
+ // `EventSource` can't set a header, and a JWT in a URL leaks into access logs
1145
+ // (agent#25). The page mints the ticket at `POST /api/ui/sse-ticket` (Bearer,
1146
+ // agent:read) and opens `…?ticket=<nonce>`; the ticket is single-use + ≤60s and
1147
+ // carries only the minting token's scopes.
1129
1148
  //
1130
1149
  // Discovery + the page itself (/health, /.parachute/config[/schema], /ui) stay
1131
1150
  // OPEN — non-sensitive, and /ui must load to bootstrap its token fetch.
@@ -1182,6 +1201,12 @@ export async function authorizeTerminalUpgrade(
1182
1201
  const denied = await requireScope(req, url, SCOPE_TERMINAL, true);
1183
1202
  if (denied) return { ok: false, response: denied };
1184
1203
 
1204
+ // STEP-UP required (agent#80): a terminal is a raw host shell — the single most
1205
+ // dangerous capability. allowQueryParam: true so the WS presents the step-up
1206
+ // token as `?step_up=` (it can't set the `X-Step-Up-Token` header).
1207
+ const step = requireStepUp(req, url, true);
1208
+ if (!step.ok) return { ok: false, response: step.response };
1209
+
1185
1210
  // tmux session name convention: `<name>-agent`. Attach a viewer pty to THIS
1186
1211
  // session; the session itself is created by the spawn path.
1187
1212
  const session = `${agentName}-agent`;
@@ -1783,6 +1808,169 @@ export function createFetchHandler(
1783
1808
  }
1784
1809
  }
1785
1810
 
1811
+ // ---------------------------------------------------------------------
1812
+ // STEP-UP AUTH (PIN) — second factor for high-privilege actions (agent#80).
1813
+ //
1814
+ // The dangerous `agent:admin` actions (set credentials, open a terminal,
1815
+ // spawn a `filesystem: full` agent) require a step-up token IN ADDITION to
1816
+ // the `agent:admin` Bearer. This block is the PIN setup + exchange surface;
1817
+ // the gating lives at each dangerous endpoint (via `requireStepUp`).
1818
+ //
1819
+ // GET /api/step-up → { configured } — is a PIN set? (UI: setup vs prompt)
1820
+ // POST /api/step-up { pin } → validate PIN (rate-limited) → { stepUpToken, expires_at }
1821
+ // POST /api/step-up/pin { newPin, currentPin? } → set/rotate the PIN
1822
+ //
1823
+ // All `agent:admin`-gated (the operator's cookie-minted Bearer). The PIN is
1824
+ // hashed+salted server-side (step-up.ts); it is NEVER returned or logged.
1825
+ // Externally hub strips `/agent`, so these are `<hub>/agent/api/step-up`.
1826
+ // ---------------------------------------------------------------------
1827
+ if (url.pathname === "/api/step-up" && req.method === "GET") {
1828
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1829
+ if (denied) return denied;
1830
+ // Whether a PIN is configured — the UI branches setup-flow vs PIN-prompt.
1831
+ return json({ configured: isStepUpConfigured() });
1832
+ }
1833
+
1834
+ if (url.pathname === "/api/step-up" && req.method === "POST") {
1835
+ // Exchange: validate the PIN, then mint a short-lived step-up token. The
1836
+ // session must already hold `agent:admin` (this is a SECOND factor on top,
1837
+ // never a substitute — the token carries no scope of its own).
1838
+ let claims;
1839
+ try {
1840
+ const token = extractToken(req, url);
1841
+ if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
1842
+ claims = await validateHubJwt(token);
1843
+ } catch (err) {
1844
+ return json(
1845
+ { error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
1846
+ 401,
1847
+ );
1848
+ }
1849
+ if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
1850
+ return json(
1851
+ { error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
1852
+ 403,
1853
+ );
1854
+ }
1855
+ // No PIN configured yet — there's nothing to exchange. Tell the UI to run
1856
+ // its first-time setup (distinct from a wrong-PIN 401).
1857
+ if (!isStepUpConfigured()) {
1858
+ return json(
1859
+ { error: "step_up_not_configured", message: "set a step-up PIN first (POST /api/step-up/pin)" },
1860
+ 409,
1861
+ );
1862
+ }
1863
+ let body: { pin?: unknown };
1864
+ try {
1865
+ body = (await req.json()) as typeof body;
1866
+ } catch {
1867
+ return json({ error: "invalid JSON body" }, 400);
1868
+ }
1869
+ if (typeof body.pin !== "string" || body.pin.length === 0) {
1870
+ return json({ error: "body.pin (non-empty string) is required" }, 400);
1871
+ }
1872
+ // Rate-limit BEFORE the (expensive, brute-forceable) argon2 verify, keyed by
1873
+ // the operator subject — a stolen-cookie attacker can't grind the PIN. A
1874
+ // DENIED attempt returns 429 (the limiter does not count it again).
1875
+ const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
1876
+ if (!limited.allowed) {
1877
+ return new Response(
1878
+ JSON.stringify({
1879
+ error: "rate_limited",
1880
+ message: "too many PIN attempts — wait before retrying",
1881
+ retry_after_seconds: limited.retryAfterSeconds,
1882
+ }),
1883
+ {
1884
+ status: 429,
1885
+ headers: {
1886
+ "content-type": "application/json",
1887
+ "retry-after": String(limited.retryAfterSeconds ?? 60),
1888
+ },
1889
+ },
1890
+ );
1891
+ }
1892
+ const ok = await verifyStepUpPin(body.pin);
1893
+ if (!ok) {
1894
+ // Wrong PIN — 401. The attempt already counted toward the lockout above.
1895
+ // Never echo the PIN back.
1896
+ return json({ error: "invalid_pin", message: "incorrect PIN" }, 401);
1897
+ }
1898
+ // Correct PIN — clear the attempt bucket (a fresh window for the next time)
1899
+ // and mint a reusable, short-TTL step-up token.
1900
+ stepUpLimiter.clear(`step-up:${claims.sub}`);
1901
+ const { token: stepUpToken, expiresAt } = mintStepUpToken();
1902
+ return json({ stepUpToken, expires_at: new Date(expiresAt).toISOString() });
1903
+ }
1904
+
1905
+ if (url.pathname === "/api/step-up/pin" && req.method === "POST") {
1906
+ // Set (first time) or rotate the step-up PIN. agent:admin-gated; if a PIN
1907
+ // already exists, the CURRENT PIN must be supplied + verified (rotation
1908
+ // needs the old PIN, so a hijacked session can't silently replace it).
1909
+ let claims;
1910
+ try {
1911
+ const token = extractToken(req, url);
1912
+ if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
1913
+ claims = await validateHubJwt(token);
1914
+ } catch (err) {
1915
+ return json(
1916
+ { error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
1917
+ 401,
1918
+ );
1919
+ }
1920
+ if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
1921
+ return json(
1922
+ { error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
1923
+ 403,
1924
+ );
1925
+ }
1926
+ let body: { newPin?: unknown; currentPin?: unknown };
1927
+ try {
1928
+ body = (await req.json()) as typeof body;
1929
+ } catch {
1930
+ return json({ error: "invalid JSON body" }, 400);
1931
+ }
1932
+ if (!isValidPinFormat(body.newPin)) {
1933
+ return json({ error: "body.newPin must be 4–12 digits" }, 400);
1934
+ }
1935
+ // Rotation: a PIN already exists → require + verify the current one (rate-limited).
1936
+ // SHARES the exchange bucket (same `step-up:<sub>` key) on purpose: both verify
1937
+ // the PIN, so an attacker can't get a fresh grind window by alternating endpoints.
1938
+ if (isStepUpConfigured()) {
1939
+ const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
1940
+ if (!limited.allowed) {
1941
+ return new Response(
1942
+ JSON.stringify({
1943
+ error: "rate_limited",
1944
+ message: "too many PIN attempts — wait before retrying",
1945
+ retry_after_seconds: limited.retryAfterSeconds,
1946
+ }),
1947
+ {
1948
+ status: 429,
1949
+ headers: {
1950
+ "content-type": "application/json",
1951
+ "retry-after": String(limited.retryAfterSeconds ?? 60),
1952
+ },
1953
+ },
1954
+ );
1955
+ }
1956
+ if (typeof body.currentPin !== "string" || !(await verifyStepUpPin(body.currentPin))) {
1957
+ return json(
1958
+ { error: "invalid_pin", message: "the current PIN is required to change it" },
1959
+ 401,
1960
+ );
1961
+ }
1962
+ stepUpLimiter.clear(`step-up:${claims.sub}`);
1963
+ }
1964
+ try {
1965
+ await setStepUpPin(body.newPin);
1966
+ } catch (err) {
1967
+ if (err instanceof StepUpPinFormatError) return json({ error: err.message }, 400);
1968
+ return json({ error: `failed to set PIN: ${(err as Error).message}` }, 500);
1969
+ }
1970
+ // Echo back only the fact of the write — never the PIN.
1971
+ return json({ ok: true, configured: true });
1972
+ }
1973
+
1786
1974
  // ---------------------------------------------------------------------
1787
1975
  // Claude OAuth credential store (design §6) — the per-channel secret a
1788
1976
  // launched agent session runs on (`CLAUDE_CODE_OAUTH_TOKEN`). Same
@@ -1802,11 +1990,15 @@ export function createFetchHandler(
1802
1990
 
1803
1991
  if (req.method === "GET") {
1804
1992
  // Inspect WITHOUT leaking the secret: whether a default is set + which
1805
- // channels carry an override (names only).
1993
+ // channels carry an override (names only). A status read — no step-up.
1806
1994
  return json(describeClaudeCredentials(defaultStateDir()));
1807
1995
  }
1808
1996
 
1809
- // POST — set the default / operator-level token.
1997
+ // POST — set the default / operator-level token. STEP-UP required (agent#80):
1998
+ // setting a credential can exfiltrate the operator's Claude token.
1999
+ const step = requireStepUp(req, url);
2000
+ if (!step.ok) return step.response;
2001
+
1810
2002
  let credBody: { token?: unknown };
1811
2003
  try {
1812
2004
  credBody = (await req.json()) as typeof credBody;
@@ -1829,6 +2021,10 @@ export function createFetchHandler(
1829
2021
  if (credMatch && (req.method === "POST" || req.method === "DELETE")) {
1830
2022
  const denied = await requireScope(req, url, SCOPE_ADMIN);
1831
2023
  if (denied) return denied;
2024
+ // STEP-UP required (agent#80): both set + remove of a per-channel Claude
2025
+ // credential are high-privilege credential-store mutations.
2026
+ const step = requireStepUp(req, url);
2027
+ if (!step.ok) return step.response;
1832
2028
  const channel = decodeURIComponent(credMatch[1]!);
1833
2029
 
1834
2030
  if (req.method === "DELETE") {
@@ -1883,9 +2079,15 @@ export function createFetchHandler(
1883
2079
 
1884
2080
  if (req.method === "GET") {
1885
2081
  // Inspect WITHOUT leaking values: names per channel + the default layer.
2082
+ // A status read — no step-up.
1886
2083
  return json(describeChannelEnv(defaultStateDir()));
1887
2084
  }
1888
2085
 
2086
+ // STEP-UP required (agent#80): set/remove of an env secret (GH_TOKEN,
2087
+ // CLOUDFLARE_API_TOKEN, …) is a credential-store mutation.
2088
+ const step = requireStepUp(req, url);
2089
+ if (!step.ok) return step.response;
2090
+
1889
2091
  let envBody: { channel?: unknown; name?: unknown; value?: unknown };
1890
2092
  try {
1891
2093
  envBody = (await req.json()) as typeof envBody;
@@ -1979,6 +2181,15 @@ export function createFetchHandler(
1979
2181
  throw err;
1980
2182
  }
1981
2183
 
2184
+ // STEP-UP required (agent#80) ONLY for the dangerous filesystem case: a
2185
+ // `filesystem: "full"` agent runs UNSANDBOXED with read access to the whole
2186
+ // disk. Ordinary sandboxed (workspace-confined) spawns stay frictionless —
2187
+ // gate just the high-blast-radius case.
2188
+ if (spec.filesystem === "full") {
2189
+ const step = requireStepUp(req, url);
2190
+ if (!step.ok) return step.response;
2191
+ }
2192
+
1982
2193
  // CHANNEL EXCLUSION: a channel routes inbound to at most one agent. Refuse a
1983
2194
  // spawn for a DIFFERENT programmatic agent onto an already-occupied wake channel
1984
2195
  // (re-spawning the SAME name onto its OWN channel is the idempotent-replace path).
@@ -2745,8 +2956,21 @@ export function createFetchHandler(
2745
2956
  return json({ ok: true, reloaded: result });
2746
2957
  }
2747
2958
 
2959
+ // One-time SSE ticket mint — POST /api/ui/sse-ticket (agent#25). The chat
2960
+ // page can't put its hub JWT in an EventSource URL without leaking it into
2961
+ // access logs, so it trades the JWT (presented HERE as a Bearer header — no
2962
+ // leak) for a single-use, ≤60s opaque ticket it puts in the SSE URL instead.
2963
+ // Bearer-gated on `agent:read` (the scope both browser SSE streams require);
2964
+ // the minted ticket carries ONLY the token's own validated scopes, so it can
2965
+ // never authorize more than the JWT did. An unauthenticated mint is impossible
2966
+ // — `mintSseTicket` runs the scope gate before issuing anything. Returns
2967
+ // `{ ticket, expires_at }`. Externally `<hub>/agent/api/ui/sse-ticket`.
2968
+ if (req.method === "POST" && url.pathname === "/api/ui/sse-ticket") {
2969
+ return mintSseTicket(req, url, SCOPE_READ, mintTicket);
2970
+ }
2971
+
2748
2972
  // Turn-event SSE — GET /api/channels/<ch>/turn-events (chat-facing; gated on
2749
- // `agent:read`, same scope as the transcript poll + /ui/events). The streaming
2973
+ // a one-time SSE ticket carrying `agent:read`). The streaming
2750
2974
  // view (design 2026-06-16 build item #1): the chat subscribes here to watch a
2751
2975
  // PROGRAMMATIC turn work in real time — interim assistant text + tool_use, then a
2752
2976
  // done/error lifecycle event. EPHEMERAL by design: no backlog/replay (the durable
@@ -2758,11 +2982,12 @@ export function createFetchHandler(
2758
2982
  {
2759
2983
  const turnMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/turn-events$/);
2760
2984
  if (req.method === "GET" && turnMatch) {
2761
- // allowQueryParam=true: this SSE is consumed by a browser EventSource, which
2762
- // cannot set an Authorization headerit authenticates via ?token=. Without
2763
- // this the live-streaming view 401s in the browser and never connects. (The
2764
- // stdio-bridge /events SSE uses a Bearer header, so it doesn't need this.)
2765
- const denied = await requireScope(req, url, SCOPE_READ, true);
2985
+ // Browser EventSource can't set an Authorization header, so this SSE
2986
+ // authenticates via a one-time `?ticket=<nonce>` (agent#25) minted by
2987
+ // POST /api/ui/sse-ticket (Bearer-gated) and consumed single-use here. The
2988
+ // hub JWT never rides in this URL. (The stdio-bridge /events SSE uses a
2989
+ // Bearer header, so it never needed a query credential at all.)
2990
+ const denied = requireSseTicket(url, SCOPE_READ);
2766
2991
  if (denied) return denied;
2767
2992
  const channelName = decodeURIComponent(turnMatch[1]!);
2768
2993
  const clientId = crypto.randomUUID();