seisin 0.5.0 → 0.6.0

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.
Files changed (76) hide show
  1. package/README.md +385 -129
  2. package/package.json +13 -5
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +47 -23
  6. package/src/commands/check.js +154 -3
  7. package/src/commands/explain.js +2 -5
  8. package/src/commands/hook.js +7 -3
  9. package/src/commands/init.js +57 -50
  10. package/src/commands/requests.js +31 -56
  11. package/src/commands/review.js +33 -6
  12. package/src/commands/run.js +332 -110
  13. package/src/commands/walls.js +39 -7
  14. package/src/commands/whose.js +2 -1
  15. package/src/config.js +321 -15
  16. package/src/controls.js +5 -5
  17. package/src/diagnose.js +67 -16
  18. package/src/env.js +3 -0
  19. package/src/grants.js +107 -2
  20. package/src/handoff.js +285 -10
  21. package/src/help.js +39 -19
  22. package/src/hook.js +59 -30
  23. package/src/index.d.ts +217 -0
  24. package/src/index.js +20 -12
  25. package/src/inspect/codex.js +64 -0
  26. package/src/inspect/credentials.js +48 -0
  27. package/src/inspect/enforce.js +24 -0
  28. package/src/inspect/files.js +71 -0
  29. package/src/inspect/home.js +43 -0
  30. package/src/inspect/inject.js +68 -0
  31. package/src/inspect/keys.js +74 -0
  32. package/src/inspect/layout.js +258 -0
  33. package/src/inspect/limits.js +98 -0
  34. package/src/inspect/machine.js +107 -0
  35. package/src/inspect/network.js +37 -0
  36. package/src/inspect/providers.js +71 -0
  37. package/src/inspect/read-territory.js +105 -0
  38. package/src/inspect/role-keys.js +136 -0
  39. package/src/inspect/shared.js +37 -0
  40. package/src/inspect/warnings.js +58 -0
  41. package/src/inspect.js +13 -815
  42. package/src/intake.js +131 -21
  43. package/src/keys.js +223 -67
  44. package/src/kinds.js +42 -0
  45. package/src/lines.js +26 -0
  46. package/src/log.js +7 -2
  47. package/src/mcp.js +28 -43
  48. package/src/nested.js +12 -3
  49. package/src/notify.js +53 -12
  50. package/src/owners.js +165 -3
  51. package/src/paths.js +17 -1
  52. package/src/provider-run.js +81 -0
  53. package/src/redact.js +34 -15
  54. package/src/render.js +35 -9
  55. package/src/requests.js +363 -45
  56. package/src/review.js +21 -9
  57. package/src/rundir.js +29 -8
  58. package/src/sandboxes/command.js +93 -0
  59. package/src/sandboxes/index.js +113 -0
  60. package/src/sandboxes/mach-profile.js +31 -0
  61. package/src/sandboxes/srt-mach.mjs +43 -0
  62. package/src/sandboxes/srt.js +163 -0
  63. package/src/sandboxes/tartaros.js +80 -0
  64. package/src/scan.js +7 -3
  65. package/src/serve.js +35 -17
  66. package/src/spool.js +8 -17
  67. package/src/srt.js +263 -33
  68. package/src/surface.js +129 -16
  69. package/src/territory.js +261 -0
  70. package/src/trace.js +33 -0
  71. package/src/uilink.js +15 -6
  72. package/src/views.js +40 -14
  73. package/src/violations.js +143 -7
  74. package/src/walls.js +62 -9
  75. package/ui/index.html +85 -13
  76. package/ui/favicon.svg +0 -1
package/src/keys.js CHANGED
@@ -28,10 +28,10 @@
28
28
  * still reaches the process and the agent can still read it. That is a strict
29
29
  * improvement over a plaintext file — the secret stops living on disk, the
30
30
  * policy holds a reviewable reference instead of a path, and every resolution
31
- * is logged without its value — but it is not "the agent never sees it". That
32
- * is `inject`, it costs terminating the role's TLS with a CA of your own, and
33
- * it is not built. `modeOf` refuses it by name rather than letting it read as
34
- * available.
31
+ * is logged without its value (`event: "key"`, see commands/run.js) — but it is not "the agent never sees it". That
32
+ * is `inject`, which costs terminating the role's TLS with a CA of your own and
33
+ * is Linux-only for now (see `injectGate` and `injectKeysOf` below); off Linux
34
+ * `modeOf` holds it, with the reason in the message.
35
35
  *
36
36
  * ## Why the value is never written down
37
37
  *
@@ -41,6 +41,7 @@
41
41
  */
42
42
  import { spawnSync } from "node:child_process";
43
43
  import { readFileSync } from "node:fs";
44
+ import { fileURLToPath } from "node:url";
44
45
  import { resolve, isAbsolute, delimiter } from "node:path";
45
46
  import { BASE, DEFAULTS } from "./env.js";
46
47
  import { SOCK_ENV } from "./spool.js";
@@ -197,7 +198,8 @@ const unterminate = (v) => v.replace(/\r?\n$/, "");
197
198
  function fromJson(obj, key, ref) {
198
199
  let v = Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
199
200
  if (v === undefined && key.includes(".")) {
200
- v = key.split(".").reduce((o, k) => (o && typeof o === "object" ? o[k] : undefined), obj);
201
+ v = key.split(".").reduce((o, k) =>
202
+ (o && typeof o === "object" && Object.hasOwn(o, k) ? o[k] : undefined), obj);
201
203
  }
202
204
  if (v === undefined) {
203
205
  const top = Object.keys(obj).slice(0, 8).join(", ");
@@ -251,7 +253,7 @@ function dotEnvValue(raw, ref, key) {
251
253
  const rest = v.slice(end + 1).trim();
252
254
  if (rest && !rest.startsWith("#"))
253
255
  throw new Error(
254
- `key "${ref}": ${key}= has text after its closing quote (${rest.slice(0, 20)}…).\n` +
256
+ `key "${ref}": ${key}= has text after its closing quote.\n` +
255
257
  ` Loaders disagree on what that value is. Quote the whole value.`);
256
258
  const inner = v.slice(1, end);
257
259
  // Inside double quotes dotenv turns \n and \r into the characters — the
@@ -316,7 +318,7 @@ function fromDotEnv(text, key, ref) {
316
318
  * differently depending on where you were standing when you typed the command
317
319
  * is a key that works in your shell and fails in the agent's.
318
320
  */
319
- export function readFileRef(entry, root = ".") {
321
+ export function readFileRef(entry, root = ".", { exact = false } = {}) {
320
322
  const hash = entry.ref.lastIndexOf("#");
321
323
  const path = hash === -1 ? entry.ref : entry.ref.slice(0, hash);
322
324
  const key = hash === -1 ? null : entry.ref.slice(hash + 1);
@@ -331,7 +333,11 @@ export function readFileRef(entry, root = ".") {
331
333
  }
332
334
  let value;
333
335
  if (key === null) {
334
- value = unterminate(text);
336
+ // `exact` (run-file) hands the file over byte for byte: the file is the
337
+ // value, and an ssh key without its last newline is an invalid ssh key.
338
+ if (unterminate(text) === "")
339
+ throw new Error(`key "${entry.raw}": ${full} is empty. An empty credential is not a credential.`);
340
+ value = exact ? text : unterminate(text);
335
341
  } else {
336
342
  // Sniffed, not taken from the extension. The file that motivated this was
337
343
  // named `.txt` and the one before it `.env`; the name is a hint and the
@@ -354,8 +360,8 @@ export function readFileRef(entry, root = ".") {
354
360
  * - **What seisin itself injects.** `SEISIN_ROLE` is how the hook inside the
355
361
  * sandbox learns which role it is. A key called that would let a policy tell
356
362
  * the hook it is somebody else — privilege confusion written in the one
357
- * file that is supposed to prevent it. Same for the config path and the
358
- * audit socket.
363
+ * file that is supposed to prevent it. Same for the config path, the
364
+ * audit socket and the handoff chain.
359
365
  * - **What the child needs to be a child.** `keys = ["keychain://path"]`
360
366
  * derives `PATH`, and the measured result is the sandbox failing with
361
367
  * `env: node: No such file or directory` — a config mistake that reads
@@ -367,13 +373,15 @@ export function readFileRef(entry, root = ".") {
367
373
  * catches it and nobody debugs it from inside a sandbox.
368
374
  */
369
375
  export const RESERVED_ENV = new Set([
370
- "SEISIN_ROLE", "SEISIN_CONFIG", SOCK_ENV,
376
+ // The last is handoff.js's CHAIN_ENV, spelled out: handoff.js reaches this
377
+ // module through owners.js, and importing it back would be a cycle.
378
+ "SEISIN_ROLE", "SEISIN_CONFIG", SOCK_ENV, "SEISIN_HANDOFF_CHAIN",
371
379
  ...BASE, ...Object.keys(DEFAULTS),
372
380
  ]);
373
381
 
374
382
  /**
375
383
  * Every delivered name a role's keys would claim — the variable, and for
376
- * `scratch` the `_FILE` that carries the path.
384
+ * `run-file` the `_FILE` that carries the path.
377
385
  *
378
386
  * Checked as a set because two keys resolving to one name is the other half of
379
387
  * the same failure: `keys = ["T=a://x", "T=b://y"]` used to deliver the second
@@ -417,7 +425,7 @@ export function entriesOf(role) {
417
425
  /**
418
426
  * The three ways a resolved value can reach a role.
419
427
  *
420
- * The middle one is called `scratch` and not `file`, which looks like a
428
+ * The middle one is called `run-file` and not `file`, which looks like a
421
429
  * cosmetic choice and is not. The runtime has a field named
422
430
  * `credentials.files`, it is the one that looks like seisin's `keys` (both take
423
431
  * paths), and on macOS it does **not** mask — it makes the file unreadable, so
@@ -427,12 +435,40 @@ export function entriesOf(role) {
427
435
  * runtime. A mode called `file` sitting ten centimetres from a runtime field
428
436
  * called `files` that fails that way is a trap with a date on it.
429
437
  *
430
- * `scratch` said what happened when it was named: the value landed in scratch
431
- * space and was removed after the run, which `file` does not say at all. Since
432
- * the 2026-09-23 fix it lands in the run's private directory instead (rundir.js),
433
- * and the name is a breaking rename listed in docs/glossary.md, not made here.
438
+ * `run-file` says what happens: the value lands in a file in the run directory
439
+ * (rundir.js) and goes when the run does, which `file` does not say at all. It
440
+ * was `scratch` until 0.6.0, from when the file sat in shared scratch space;
441
+ * since the 2026-09-23 fix it has not, and a name that says the place it left
442
+ * is the leak that fix closed, said again. RETIRED_MODES refuses the old name.
434
443
  */
435
- export const MODES = ["env", "scratch", "inject"];
444
+ export const MODES = ["env", "run-file", "inject"];
445
+
446
+ /**
447
+ * Mode names that were renamed, and the name now. Refused with that sentence
448
+ * wherever a mode is read (config.js for the policy, modeOf for a role built by
449
+ * hand), never read as the new one: a policy says one thing in one word.
450
+ */
451
+ export const RETIRED_MODES = { scratch: "run-file" };
452
+
453
+ /** The error for a mode that is not one, with the rename when there was one. */
454
+ export function unknownMode(where, setting, mode) {
455
+ const now = Object.hasOwn(RETIRED_MODES, mode) ? RETIRED_MODES[mode] : null;
456
+ if (now)
457
+ return new Error(
458
+ `${where} ${setting} = "${mode}" was renamed "${now}" in seisin 0.6.0 — the file is ` +
459
+ `written to the run directory, not to scratch.\n` +
460
+ ` Change it to ${setting} = "${now}". Nothing else changes: same file, same _FILE variable.`);
461
+ return new Error(
462
+ `${where} ${setting} = "${mode}" is not a delivery mode. Known: ${MODES.join(", ")}.` +
463
+ // The one wrong answer named, because it is the word everybody
464
+ // reaches for and it is one letter from a runtime field that fails
465
+ // silently. Guessing costs a debugging session; saying so costs a line.
466
+ (mode === "file"
467
+ ? `\n "file" is not one of them on purpose — the runtime has a "credentials.files" that ` +
468
+ `takes paths and, on macOS, makes them unreadable instead of masking. The mode that ` +
469
+ `hands a role a file is "run-file".`
470
+ : ""));
471
+ }
436
472
 
437
473
  /**
438
474
  * How a role receives its reference keys, and where that is written.
@@ -453,9 +489,18 @@ export const MODES = ["env", "scratch", "inject"];
453
489
  * because the two available answers differ in what the agent can walk away
454
490
  * with, and picking one for somebody is picking how much a leak costs them.
455
491
  */
492
+ /**
493
+ * The delivery mode a role declares for an entry — the role's `key_mode` first,
494
+ * else the provider's `mode`, else null. The precedence rule (role over
495
+ * provider) lives here so {@link modeOf} and {@link injectKeysOf} cannot drift.
496
+ */
497
+ function declaredMode(config, role, entry) {
498
+ return role.keyMode ?? config.keyProviders?.[entry.scheme]?.mode ?? null;
499
+ }
500
+
456
501
  export function modeOf(config, role, entry) {
457
502
  const provider = config.keyProviders?.[entry.scheme];
458
- const mode = role.keyMode ?? provider?.mode ?? null;
503
+ const mode = declaredMode(config, role, entry);
459
504
  if (mode === null) {
460
505
  // A built-in scheme has no `[keys.providers.…]` table to put a default in —
461
506
  // declaring one is refused, because a scheme cannot mean two things. So
@@ -468,31 +513,125 @@ export function modeOf(config, role, entry) {
468
513
  throw new Error(
469
514
  `roles.${role.name}: key "${entry.raw}" has no delivery mode.\n` +
470
515
  ` Declare one beside the key: [roles.${role.name}] key_mode = "env".${second}\n` +
471
- ` "env" passes the value as ${entry.name}; "scratch" writes it to a file in the ` +
516
+ ` "env" passes the value as ${entry.name}; "run-file" writes it to a file in the ` +
472
517
  `run's private directory, grants that one path, and removes it when the run ends.`);
473
518
  }
474
- if (mode === "inject")
475
- throw new Error(
476
- `roles.${role.name}: key_mode = "inject" is not implemented.\n` +
477
- ` It is the one mode where the agent never sees the value, and it is not free: the ` +
478
- `runtime only masks a credential when the role's TLS is terminated with a CA of ` +
479
- `seisin's own — MITM over all of that role's traffic, not just the host holding the ` +
480
- `secret. It is refused rather than half-available.\n` +
481
- ` Use "env" or "file" today. See docs/decisions.md.`);
482
- if (!MODES.includes(mode))
483
- throw new Error(
484
- `roles.${role.name}: key_mode = "${mode}" is not a delivery mode. Known: ${MODES.join(", ")}.` +
485
- // The one wrong answer named, because it is the word everybody
486
- // reaches for and it is one letter from a runtime field that fails
487
- // silently. Guessing costs a debugging session; saying so costs a line.
488
- (mode === "file"
489
- ? `\n "file" is not one of them on purpose — the runtime has a "credentials.files" that ` +
490
- `takes paths and, on macOS, makes them unreadable instead of masking. The mode that ` +
491
- `hands a role a file is "scratch".`
492
- : ""));
519
+ if (mode === "inject") injectGate(role);
520
+ if (!MODES.includes(mode)) throw unknownMode(`roles.${role.name}:`, "key_mode", mode);
493
521
  return mode;
494
522
  }
495
523
 
524
+ /**
525
+ * `inject` is the mode where the agent never sees the value: the runtime hands
526
+ * the sandbox a sentinel and substitutes the real value on egress to the
527
+ * credential's declared hosts. It is Linux-only in this version — masked
528
+ * delivery is validated on the Linux backend; the macOS path is not enabled
529
+ * yet and is tracked upstream, where the fit for it is a credential computed
530
+ * per run rather than read from the environment
531
+ * (anthropics/sandbox-runtime#593). Until that lands, macOS `inject` is held
532
+ * rather than offered, with the alternatives in the message.
533
+ */
534
+ function injectGate(role) {
535
+ if (process.platform === "linux") return;
536
+ throw new Error(
537
+ `roles.${role.name}: key_mode = "inject" is not available on ${process.platform} yet.\n` +
538
+ ` It is Linux-only for now; masked injection on macOS is tracked upstream ` +
539
+ `(anthropics/sandbox-runtime#593) and enables once that lands.\n` +
540
+ ` Use "env" or "run-file" here, or run this role on Linux. ` +
541
+ `See https://github.com/carlostapiacl/seisin/blob/main/docs/decisions.md.`);
542
+ }
543
+
544
+ /**
545
+ * The role's `inject` keys as `{ entry, name, hosts }`, the single source both
546
+ * the settings (srt.js) and `check` read. Throws when the policy is internally
547
+ * inconsistent — an inject key with no `inject_hosts` entry, or an
548
+ * `inject_hosts` entry naming a key that is not injected — because either one
549
+ * is a masked credential that silently injects nowhere, or a host list that
550
+ * guards nothing. Platform is NOT checked here: this answers "what does the
551
+ * policy say", and {@link injectGate} answers "can we run it here".
552
+ */
553
+ export function injectKeysOf(config, role) {
554
+ const refs = entriesOf(role).filter((e) => e.kind === "ref");
555
+ const inject = refs.filter((e) => declaredMode(config, role, e) === "inject");
556
+ const hosts = role.injectHosts ?? {};
557
+ const out = inject.map((entry) => {
558
+ const h = hosts[entry.name];
559
+ if (!h)
560
+ throw new Error(
561
+ `roles.${role.name}: key "${entry.raw}" is injected but has no hosts.\n` +
562
+ ` A masked credential needs the host its token authenticates to:\n` +
563
+ ` [roles.${role.name}.inject_hosts]\n ${entry.name} = ["api.example.com"]`);
564
+ return { entry, name: entry.name, hosts: h };
565
+ });
566
+ const named = new Set(out.map((c) => c.name));
567
+ for (const name of Object.keys(hosts))
568
+ if (!named.has(name))
569
+ throw new Error(
570
+ `roles.${role.name}.inject_hosts.${name}: no injected key delivers ${name}.\n` +
571
+ ` inject_hosts names the hosts a masked credential may reach; ${name} is not one. ` +
572
+ `Add a key that delivers it with key_mode = "inject", or remove the entry.`);
573
+ return out;
574
+ }
575
+
576
+ /**
577
+ * How long a provider's stdout is still read after it exits.
578
+ *
579
+ * Bytes written just before exit may still be in the pipe. A descendant that
580
+ * kept the pipe open is not the provider, and its silence is not an answer
581
+ * worth waiting a minute for.
582
+ */
583
+ export const PROVIDER_DRAIN_MS = 250;
584
+
585
+ const HELPER = fileURLToPath(new URL("./provider-run.js", import.meta.url));
586
+
587
+ /**
588
+ * Run the provider through provider-run.js, which returns when the provider
589
+ * exits rather than when every holder of its stdout lets go, and stops it with
590
+ * SIGKILL at the deadline. The outer call has a deadline of its own, a little
591
+ * later, so a helper that never answers cannot hold the run either.
592
+ */
593
+ function spawnProvider(argv, { env, timeoutMs }) {
594
+ const r = spawnSync(process.execPath,
595
+ [HELPER, String(timeoutMs), String(PROVIDER_MAX_BYTES), String(PROVIDER_DRAIN_MS), ...argv], {
596
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env,
597
+ timeout: timeoutMs + 5_000, killSignal: "SIGKILL", maxBuffer: PROVIDER_MAX_BYTES * 2 + 4096,
598
+ });
599
+ let got = null;
600
+ try { got = JSON.parse(r.stdout ?? ""); } catch {}
601
+ if (!got) return { status: null, stdout: "", errBytes: 0, timedOut: true };
602
+ if (got.error) return { error: new Error(`spawn ${got.error}`) };
603
+ return {
604
+ status: got.status, stdout: Buffer.from(got.out, "base64").toString("utf8"),
605
+ errBytes: got.errBytes, timedOut: got.timedOut, tooBig: got.tooBig,
606
+ };
607
+ }
608
+
609
+ /** What a runner of the caller's own returned, in the shape spawnProvider returns. */
610
+ function normalise(r) {
611
+ return {
612
+ ...r,
613
+ timedOut: r.error?.code === "ETIMEDOUT",
614
+ tooBig: r.error?.code === "ENOBUFS",
615
+ errBytes: Buffer.byteLength(r.stderr ?? ""),
616
+ };
617
+ }
618
+
619
+ /**
620
+ * The value as a provider or a file gave it, refused if it cannot be delivered.
621
+ *
622
+ * A NUL byte cannot be in an environment variable, and Node's refusal to set
623
+ * one quotes the whole string — the value, in the terminal. Checked here, by a
624
+ * message that does not carry it.
625
+ */
626
+ function deliverable(entry, value) {
627
+ if (value.includes("\0"))
628
+ throw new Error(
629
+ `key "${entry.raw}": the value contains a NUL byte, which cannot be delivered.\n` +
630
+ ` The value is not shown. A credential with a NUL in it is binary; store it encoded ` +
631
+ `(base64) and decode it where it is used.`);
632
+ return value;
633
+ }
634
+
496
635
  /**
497
636
  * Run a provider and hand back what it printed.
498
637
  *
@@ -509,14 +648,19 @@ export function modeOf(config, role, entry) {
509
648
  * cannot measure and says ok lies more than one that says no. A role that
510
649
  * starts without the credential it declared fails later, further away, and
511
650
  * looking like something else.
512
- * - **The value is not in the error.** `stderr` from the provider is passed
513
- * through because it is how `op` says "not signed in", but `stdout` never
514
- * is — a provider that prints the secret and then exits non-zero would
515
- * otherwise put it in the terminal and the log.
651
+ * - **The value is not in the error.** Neither stream of a failed provider is
652
+ * shown. Taking stdout out of stderr was not enough: a provider that printed
653
+ * the value only on stderr, or printed it beside other text on stdout, got
654
+ * it into the terminal through the error meant to explain it. The error
655
+ * says the exit status, how much was written to stderr, and the command to
656
+ * run by hand to read it.
657
+ *
658
+ * `exact` hands the value back as printed. Without it exactly one trailing
659
+ * newline comes off, which is right for a variable and wrong for a file.
516
660
  */
517
- export function resolveRef(entry, provider, { run = spawnSync, root = ".", config = null, env = process.env } = {}) {
661
+ export function resolveRef(entry, provider, { run = spawnSync, root = ".", config = null, env = process.env, exact = false, timeoutMs = PROVIDER_TIMEOUT_MS } = {}) {
518
662
  if (provider?.builtin === "file" || (provider === undefined && entry.scheme === "file"))
519
- return readFileRef(entry, root);
663
+ return deliverable(entry, readFileRef(entry, root, { exact }));
520
664
  const argv = provider.command.map((part) => part.replaceAll("{ref}", entry.ref));
521
665
  /**
522
666
  * Found where no role can write, and handed the same PATH.
@@ -530,43 +674,46 @@ export function resolveRef(entry, provider, { run = spawnSync, root = ".", confi
530
674
  * caller's own, the argv is handed over as given: finding the program is part
531
675
  * of spawning it, and whoever replaced the spawn owns that too.
532
676
  */
533
- const opts = { encoding: "utf8", timeout: PROVIDER_TIMEOUT_MS, maxBuffer: PROVIDER_MAX_BYTES };
677
+ const opts = { encoding: "utf8", timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: PROVIDER_MAX_BYTES };
534
678
  if (config && run === spawnSync) {
535
679
  argv[0] = resolveExecutable(argv[0], config, env);
536
680
  opts.env = { ...env, PATH: trustedPath(config, env).join(delimiter) };
537
681
  }
538
- const r = run(argv[0], argv.slice(1), opts);
539
- if (r.error?.code === "ETIMEDOUT")
682
+ const r = run === spawnSync
683
+ ? spawnProvider(argv, { env: opts.env, timeoutMs })
684
+ : normalise(run(argv[0], argv.slice(1), opts));
685
+ if (r.timedOut)
540
686
  throw new Error(
541
687
  `key "${entry.raw}": the ${entry.scheme} provider (${argv[0]}) did not answer in ` +
542
- `${PROVIDER_TIMEOUT_MS / 1000} s and was stopped.\n` +
688
+ `${timeoutMs / 1000} s and was killed (SIGKILL).\n` +
543
689
  ` A provider that waits on a prompt nobody sees would otherwise hold the run forever.`);
690
+ if (r.tooBig)
691
+ throw new Error(
692
+ `key "${entry.raw}": the ${entry.scheme} provider (${argv[0]}) printed more than ` +
693
+ `${PROVIDER_MAX_BYTES / 1024 / 1024} MiB on stdout and was stopped.\n` +
694
+ ` A credential is bytes, not megabytes: check that the command prints one value.`);
544
695
  if (r.error)
545
696
  throw new Error(
546
697
  `key "${entry.raw}": could not run the ${entry.scheme} provider (${argv[0]}): ${r.error.message}`);
547
698
  if (r.status !== 0) {
548
- // Whatever it printed on stdout is taken out of stderr before stderr is
549
- // shown: a provider that echoes the secret to both and then fails would
550
- // otherwise put it in the terminal through the error meant to explain it.
551
- const leaked = (r.stdout ?? "").trim();
552
- const clean = leaked.length >= 4 ? (r.stderr ?? "").split(leaked).join("‹redacted›") : (r.stderr ?? "");
553
- const said = clean.trim().split("\n").slice(0, 3).map((l) => l.slice(0, 200)).join("\n ");
554
699
  throw new Error(
555
- `key "${entry.raw}": the ${entry.scheme} provider exited ${r.status ?? "on a signal"}.` +
556
- (said ? `\n ${said}` : "") +
557
- `\n Nothing is substituted for a key that did not resolve — the run stops here rather ` +
700
+ `key "${entry.raw}": the ${entry.scheme} provider exited ${r.status ?? "on a signal"}` +
701
+ (r.errBytes ? ` and wrote ${r.errBytes} bytes to stderr` : "") + `.\n` +
702
+ ` What it printed is not shown, because a provider can print the value. To read it, ` +
703
+ `run it yourself: ${argv.join(" ")}\n` +
704
+ ` Nothing is substituted for a key that did not resolve — the run stops here rather ` +
558
705
  `than starting a role without a credential it declared.`);
559
706
  }
707
+ const printed = r.stdout ?? "";
560
708
  // One trailing newline is the shell's, not the secret's: `security -w` and
561
709
  // `op read` both add one, and a token with a newline welded to it fails
562
710
  // authentication in a way that looks like a wrong token.
563
- const value = (r.stdout ?? "").replace(/\r?\n$/, "");
564
- if (value === "")
711
+ if (unterminate(printed) === "")
565
712
  throw new Error(
566
713
  `key "${entry.raw}": the ${entry.scheme} provider succeeded and printed nothing.\n` +
567
714
  ` An empty credential is not a credential. Check the reference — most providers exit 0 ` +
568
715
  `for a name they do not have.`);
569
- return value;
716
+ return deliverable(entry, exact ? printed : unterminate(printed));
570
717
  }
571
718
 
572
719
  /**
@@ -576,12 +723,21 @@ export function resolveRef(entry, provider, { run = spawnSync, root = ".", confi
576
723
  * provider runs. A config with three bad references should say so once, and it
577
724
  * should say so without asking anyone's keychain for a password first.
578
725
  */
579
- export function resolveKeys(config, role, opts = {}) {
726
+ export function resolveKeys(config, role, { record = () => {}, ...opts } = {}) {
580
727
  opts = { root: config.root ?? ".", config, ...opts };
581
- const refs = role.keyEntries.filter((e) => e.kind === "ref");
728
+ const refs = entriesOf(role).filter((e) => e.kind === "ref");
582
729
  const plan = refs.map((entry) => ({ entry, mode: modeOf(config, role, entry) }));
583
- return plan.map((p) => ({
584
- ...p,
585
- value: resolveRef(p.entry, config.keyProviders[p.entry.scheme], opts),
586
- }));
730
+ return plan.map((p) => {
731
+ // `run-file` writes a file, and a file is handed over as the provider gave
732
+ // it; `env` drops the one trailing newline a shell adds. See resolveRef.
733
+ let value;
734
+ try {
735
+ value = resolveRef(p.entry, config.keyProviders[p.entry.scheme], { ...opts, exact: p.mode === "run-file" });
736
+ } catch (e) {
737
+ record({ entry: p.entry, mode: p.mode, ok: false });
738
+ throw e;
739
+ }
740
+ record({ entry: p.entry, mode: p.mode, ok: true });
741
+ return { ...p, value };
742
+ });
587
743
  }
package/src/kinds.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * clock, so it is the same answer on every screen that asks. It never changes
20
20
  * what is allowed; it changes what is said about a refusal.
21
21
  */
22
+ import { homedir } from "node:os";
22
23
  import { CREDENTIAL_HOMES } from "./grants.js";
23
24
 
24
25
  /** Every kind, in the order a reader should deal with them, and the one line each says. */
@@ -205,3 +206,44 @@ export function kindsOf(targets, opts = {}) {
205
206
  }
206
207
  return out;
207
208
  }
209
+
210
+ /**
211
+ * A refusal that is expected, and why — apart from the kinds above.
212
+ *
213
+ * The kinds name a path no role owns and the move that fits it. This is the
214
+ * other case: a path the agent program itself reaches for from inside the
215
+ * sandbox, that the boundary is right to refuse, and that no move fixes because
216
+ * nothing is wrong. Claude Code checks for plugin updates on start and writes
217
+ * the new version into `~/.claude/plugins/cache`; under seisin that write comes
218
+ * from inside the run and the kernel says no. Plugins run outside the sandbox,
219
+ * and they update there, from the Claude Code you start without seisin.
220
+ *
221
+ * Shown as a wall or as "denied repeatedly", it read as a role stuck on
222
+ * something to grant — and the only grant that would quiet it is a write into
223
+ * what Claude Code runs outside the sandbox. So every screen says it apart, as
224
+ * expected, and none counts it as friction. It changes no verdict.
225
+ *
226
+ * Only the cache: the rest of `~/.claude/plugins` (which plugins are installed,
227
+ * which marketplaces are trusted) is not housekeeping, and a write there stays
228
+ * an ordinary protected refusal, said in full.
229
+ */
230
+ export const EXPECTED = {
231
+ plugins:
232
+ "Claude Code updating its plugins from inside the sandbox: plugins run outside it and update there, so the denial is expected — nothing to grant",
233
+ };
234
+
235
+ /**
236
+ * `{ kind, hint }` when the refusal of `target` is expected, else null.
237
+ *
238
+ * `target` as the log keeps it: absolute outside the repository. The home and
239
+ * `CLAUDE_CONFIG_DIR` are parameters so a test does not depend on the machine.
240
+ */
241
+ export function expectedOf(target, { home = homedir(), env = process.env } = {}) {
242
+ const t = String(target ?? "").replace(/\\/g, "/");
243
+ const roots = [
244
+ env.CLAUDE_CONFIG_DIR,
245
+ home && `${home.replace(/\/+$/, "")}/.claude`,
246
+ "~/.claude",
247
+ ].filter(Boolean).map((r) => `${String(r).replace(/\/+$/, "")}/plugins/cache`);
248
+ return roots.some((r) => t === r || t.startsWith(r + "/")) ? { kind: "plugins", hint: EXPECTED.plugins } : null;
249
+ }
package/src/lines.js ADDED
@@ -0,0 +1,26 @@
1
+ /** Bounded newline framing for the audit channel and the stdio MCP server. */
2
+ export function lineReader(consume, { maxBytes = 1_000_000, onTooLong } = {}) {
3
+ let buffer = "", bytes = 0, discarding = false;
4
+ return (chunk) => {
5
+ let from = 0;
6
+ for (;;) {
7
+ const nl = chunk.indexOf("\n", from);
8
+ const part = nl === -1 ? chunk.slice(from) : chunk.slice(from, nl);
9
+ if (!discarding) {
10
+ bytes += Buffer.byteLength(part);
11
+ if (bytes > maxBytes) {
12
+ buffer = ""; bytes = 0; discarding = true;
13
+ onTooLong?.();
14
+ } else buffer += part;
15
+ }
16
+ if (nl === -1) return;
17
+ // Discard the entire oversized frame, including any later chunks, up
18
+ // to its newline. Its suffix is never a fresh request. A burst of many
19
+ // valid frames, however, is not one oversized frame.
20
+ if (!discarding && buffer.trim()) consume(buffer);
21
+ buffer = ""; bytes = 0; discarding = false;
22
+ from = nl + 1;
23
+ if (from === chunk.length) return;
24
+ }
25
+ };
26
+ }
package/src/log.js CHANGED
@@ -306,7 +306,12 @@ export function alive(pid) {
306
306
  */
307
307
  function reapable(lock, staleMs) {
308
308
  let seen, mtime;
309
- try { seen = readFileSync(lock, "utf8"); mtime = statSync(lock).mtimeMs; } catch { return true; }
309
+ try { seen = readFileSync(lock, "utf8"); mtime = statSync(lock).mtimeMs; }
310
+ catch (e) {
311
+ // Only a disappeared lock is an immediate retry. An unreadable lock or
312
+ // a directory at this name otherwise spins forever, bypassing deadline.
313
+ return e.code === "ENOENT";
314
+ }
310
315
  const pid = Number(seen.split(" ")[0]);
311
316
  const named = Number.isInteger(pid) && pid > 0;
312
317
  // A named holder that is alive is waited on, however long it takes: its age
@@ -499,7 +504,7 @@ export function read(file, { role, verdict, since, limit = 0 } = {}) {
499
504
  }
500
505
  const got = [];
501
506
  for (const e of readEntries(seg)) {
502
- if (e.event === "rotated") continue; // a structural marker, not a decision
507
+ if (e.event === "rotated") continue; // an internal marker, not activity
503
508
  if (role && e.role !== role) continue;
504
509
  if (verdict && e.verdict !== verdict) continue;
505
510
  if (since && e.at < since) continue;