@openparachute/agent 0.2.3-rc.5 → 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.5",
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
@@ -40,6 +40,7 @@
40
40
  import { validateHubJwt, HubJwtError } from "./hub-jwt.ts";
41
41
  import { extractBearer } from "@openparachute/scope-guard";
42
42
  import { consumeTicket } from "./ui-ticket.ts";
43
+ import { isStepUpTokenValid, isStepUpConfigured } from "./step-up.ts";
43
44
 
44
45
  /** Agent scopes, declared here so callers share one spelling. */
45
46
  export const SCOPE_READ = "agent:read" as const;
@@ -227,3 +228,81 @@ export function requireSseTicket(url: URL, scope: string): Response | null {
227
228
  }
228
229
  return null;
229
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
@@ -87,6 +87,8 @@ import {
87
87
  requireScope,
88
88
  mintSseTicket,
89
89
  requireSseTicket,
90
+ requireStepUp,
91
+ grantsScope,
90
92
  extractToken,
91
93
  json as authJson,
92
94
  SCOPE_READ,
@@ -95,6 +97,15 @@ import {
95
97
  SCOPE_ADMIN,
96
98
  SCOPE_TERMINAL,
97
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";
98
109
  import { mintTicket } from "./ui-ticket.ts";
99
110
  import {
100
111
  createTerminalWsHandlers,
@@ -1190,6 +1201,12 @@ export async function authorizeTerminalUpgrade(
1190
1201
  const denied = await requireScope(req, url, SCOPE_TERMINAL, true);
1191
1202
  if (denied) return { ok: false, response: denied };
1192
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
+
1193
1210
  // tmux session name convention: `<name>-agent`. Attach a viewer pty to THIS
1194
1211
  // session; the session itself is created by the spawn path.
1195
1212
  const session = `${agentName}-agent`;
@@ -1791,6 +1808,169 @@ export function createFetchHandler(
1791
1808
  }
1792
1809
  }
1793
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
+
1794
1974
  // ---------------------------------------------------------------------
1795
1975
  // Claude OAuth credential store (design §6) — the per-channel secret a
1796
1976
  // launched agent session runs on (`CLAUDE_CODE_OAUTH_TOKEN`). Same
@@ -1810,11 +1990,15 @@ export function createFetchHandler(
1810
1990
 
1811
1991
  if (req.method === "GET") {
1812
1992
  // Inspect WITHOUT leaking the secret: whether a default is set + which
1813
- // channels carry an override (names only).
1993
+ // channels carry an override (names only). A status read — no step-up.
1814
1994
  return json(describeClaudeCredentials(defaultStateDir()));
1815
1995
  }
1816
1996
 
1817
- // 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
+
1818
2002
  let credBody: { token?: unknown };
1819
2003
  try {
1820
2004
  credBody = (await req.json()) as typeof credBody;
@@ -1837,6 +2021,10 @@ export function createFetchHandler(
1837
2021
  if (credMatch && (req.method === "POST" || req.method === "DELETE")) {
1838
2022
  const denied = await requireScope(req, url, SCOPE_ADMIN);
1839
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;
1840
2028
  const channel = decodeURIComponent(credMatch[1]!);
1841
2029
 
1842
2030
  if (req.method === "DELETE") {
@@ -1891,9 +2079,15 @@ export function createFetchHandler(
1891
2079
 
1892
2080
  if (req.method === "GET") {
1893
2081
  // Inspect WITHOUT leaking values: names per channel + the default layer.
2082
+ // A status read — no step-up.
1894
2083
  return json(describeChannelEnv(defaultStateDir()));
1895
2084
  }
1896
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
+
1897
2091
  let envBody: { channel?: unknown; name?: unknown; value?: unknown };
1898
2092
  try {
1899
2093
  envBody = (await req.json()) as typeof envBody;
@@ -1987,6 +2181,15 @@ export function createFetchHandler(
1987
2181
  throw err;
1988
2182
  }
1989
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
+
1990
2193
  // CHANNEL EXCLUSION: a channel routes inbound to at most one agent. Refuse a
1991
2194
  // spawn for a DIFFERENT programmatic agent onto an already-occupied wake channel
1992
2195
  // (re-spawning the SAME name onto its OWN channel is the idempotent-replace path).