@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 +1 -1
- package/src/agent-defs.ts +9 -0
- package/src/auth.ts +182 -14
- package/src/daemon.ts +235 -10
- package/src/step-up.ts +316 -0
- package/src/terminal-ui.ts +73 -0
- package/src/transports/http-ui.ts +10 -8
- package/src/ui-kit.ts +6 -3
- package/src/ui-ticket.ts +121 -0
- package/web/ui/dist/assets/index-CAQMmePW.js +60 -0
- package/web/ui/dist/assets/{index-tvKbxee4.css → index-Dhr5Kl_d.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-0e7eQymr.js +0 -60
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/agent",
|
|
3
|
-
"version": "0.2.3-rc.
|
|
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
|
|
11
|
-
* `send` POST
|
|
12
|
-
*
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
90
|
-
// Bearer header, so they
|
|
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
|
|
103
|
-
* headers).
|
|
104
|
-
* confined to
|
|
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
|
|
1128
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
2762
|
-
//
|
|
2763
|
-
//
|
|
2764
|
-
// stdio-bridge /events SSE uses a
|
|
2765
|
-
|
|
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();
|