seisin 0.4.2 → 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 (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. 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
@@ -352,10 +358,10 @@ export function readFileRef(entry, root = ".") {
352
358
  * of these:
353
359
  *
354
360
  * - **What seisin itself injects.** `SEISIN_ROLE` is how the hook inside the
355
- * box learns which role it is. A key called that would let a policy tell
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,10 +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` also says what actually happens: the value lands in scratch space
431
- * for the turn and is removed after it, which `file` does not say at all.
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.
432
443
  */
433
- 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
+ }
434
472
 
435
473
  /**
436
474
  * How a role receives its reference keys, and where that is written.
@@ -442,8 +480,8 @@ export const MODES = ["env", "scratch", "inject"];
442
480
  * tables — and growing the parser to hold one field is the trade this repo has
443
481
  * already decided against out loud.
444
482
  *
445
- * The important half survives that limit. The encargo's argument for declaring
446
- * the mode beside the permission is that a reader of `seisin.toml` can say
483
+ * The important half survives that limit. The argument for declaring
484
+ * the mode beside the key is that a reader of `seisin.toml` can say
447
485
  * "this role sees the value" without opening any code, and a per-role setting
448
486
  * answers exactly that question.
449
487
  *
@@ -451,9 +489,18 @@ export const MODES = ["env", "scratch", "inject"];
451
489
  * because the two available answers differ in what the agent can walk away
452
490
  * with, and picking one for somebody is picking how much a leak costs them.
453
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
+
454
501
  export function modeOf(config, role, entry) {
455
502
  const provider = config.keyProviders?.[entry.scheme];
456
- const mode = role.keyMode ?? provider?.mode ?? null;
503
+ const mode = declaredMode(config, role, entry);
457
504
  if (mode === null) {
458
505
  // A built-in scheme has no `[keys.providers.…]` table to put a default in —
459
506
  // declaring one is refused, because a scheme cannot mean two things. So
@@ -465,40 +512,134 @@ export function modeOf(config, role, entry) {
465
512
  `[keys.providers.${entry.scheme}] mode = "env".`;
466
513
  throw new Error(
467
514
  `roles.${role.name}: key "${entry.raw}" has no delivery mode.\n` +
468
- ` Declare one beside the permission: [roles.${role.name}] key_mode = "env".${second}\n` +
469
- ` "env" passes the value as ${entry.name}; "scratch" writes it to a file inside the ` +
470
- `role's scratch space, grants that one path, and removes it when the turn ends.`);
515
+ ` Declare one beside the key: [roles.${role.name}] key_mode = "env".${second}\n` +
516
+ ` "env" passes the value as ${entry.name}; "run-file" writes it to a file in the ` +
517
+ `run's private directory, grants that one path, and removes it when the run ends.`);
471
518
  }
472
- if (mode === "inject")
473
- throw new Error(
474
- `roles.${role.name}: key_mode = "inject" is not implemented.\n` +
475
- ` It is the one mode where the agent never sees the value, and it is not free: the ` +
476
- `runtime only masks a credential when the role's TLS is terminated with a CA of ` +
477
- `seisin's own — MITM over all of that role's traffic, not just the host holding the ` +
478
- `secret. It is refused rather than half-available.\n` +
479
- ` Use "env" or "file" today. See docs/decisions.md.`);
480
- if (!MODES.includes(mode))
481
- throw new Error(
482
- `roles.${role.name}: key_mode = "${mode}" is not a delivery mode. Known: ${MODES.join(", ")}.` +
483
- // The one wrong answer named, because it is the word everybody
484
- // reaches for and it is one letter from a runtime field that fails
485
- // silently. Guessing costs a debugging session; saying so costs a line.
486
- (mode === "file"
487
- ? `\n "file" is not one of them on purpose — the runtime has a "credentials.files" that ` +
488
- `takes paths and, on macOS, makes them unreadable instead of masking. The mode that ` +
489
- `hands a role a file is "scratch".`
490
- : ""));
519
+ if (mode === "inject") injectGate(role);
520
+ if (!MODES.includes(mode)) throw unknownMode(`roles.${role.name}:`, "key_mode", mode);
491
521
  return mode;
492
522
  }
493
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
+
494
635
  /**
495
636
  * Run a provider and hand back what it printed.
496
637
  *
497
638
  * Three properties, each of which was a requirement before it was code:
498
639
  *
499
640
  * - **The parent runs it, never the confined process.** Same reason `owners`
500
- * recomputes instead of believing what it is told: the inside of the box
501
- * cannot be allowed to reach the thing that decides what the box contains.
641
+ * recomputes instead of believing what it is told: the inside of the sandbox
642
+ * cannot be allowed to reach the thing that decides what the sandbox contains.
502
643
  * A provider command spawned from within the sandbox would need the vault's
503
644
  * own credential in there with it, which is the problem this feature exists
504
645
  * to remove.
@@ -507,64 +648,72 @@ export function modeOf(config, role, entry) {
507
648
  * cannot measure and says ok lies more than one that says no. A role that
508
649
  * starts without the credential it declared fails later, further away, and
509
650
  * looking like something else.
510
- * - **The value is not in the error.** `stderr` from the provider is passed
511
- * through because it is how `op` says "not signed in", but `stdout` never
512
- * is — a provider that prints the secret and then exits non-zero would
513
- * 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.
514
660
  */
515
- 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 } = {}) {
516
662
  if (provider?.builtin === "file" || (provider === undefined && entry.scheme === "file"))
517
- return readFileRef(entry, root);
663
+ return deliverable(entry, readFileRef(entry, root, { exact }));
518
664
  const argv = provider.command.map((part) => part.replaceAll("{ref}", entry.ref));
519
665
  /**
520
666
  * Found where no role can write, and handed the same PATH.
521
667
  *
522
668
  * `spawnSync("fakeprov")` searched the parent's PATH, and a PATH directory
523
669
  * inside a territory made the next run execute whatever that role had put
524
- * there — outside the box, as you. Measured on 2026-09-23 with a marker file.
670
+ * there — outside the sandbox, as you. Measured on 2026-09-23 with a marker file.
525
671
  * The provider's own PATH is narrowed the same way, because a provider that
526
672
  * is a script starts with `#!/usr/bin/env bash` and looks its interpreter up
527
673
  * again. Without a config (a caller embedding seisin), or with a `run` of the
528
674
  * caller's own, the argv is handed over as given: finding the program is part
529
675
  * of spawning it, and whoever replaced the spawn owns that too.
530
676
  */
531
- 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 };
532
678
  if (config && run === spawnSync) {
533
679
  argv[0] = resolveExecutable(argv[0], config, env);
534
680
  opts.env = { ...env, PATH: trustedPath(config, env).join(delimiter) };
535
681
  }
536
- const r = run(argv[0], argv.slice(1), opts);
537
- 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)
538
686
  throw new Error(
539
687
  `key "${entry.raw}": the ${entry.scheme} provider (${argv[0]}) did not answer in ` +
540
- `${PROVIDER_TIMEOUT_MS / 1000} s and was stopped.\n` +
688
+ `${timeoutMs / 1000} s and was killed (SIGKILL).\n` +
541
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.`);
542
695
  if (r.error)
543
696
  throw new Error(
544
697
  `key "${entry.raw}": could not run the ${entry.scheme} provider (${argv[0]}): ${r.error.message}`);
545
698
  if (r.status !== 0) {
546
- // Whatever it printed on stdout is taken out of stderr before stderr is
547
- // shown: a provider that echoes the secret to both and then fails would
548
- // otherwise put it in the terminal through the error meant to explain it.
549
- const leaked = (r.stdout ?? "").trim();
550
- const clean = leaked.length >= 4 ? (r.stderr ?? "").split(leaked).join("‹redacted›") : (r.stderr ?? "");
551
- const said = clean.trim().split("\n").slice(0, 3).map((l) => l.slice(0, 200)).join("\n ");
552
699
  throw new Error(
553
- `key "${entry.raw}": the ${entry.scheme} provider exited ${r.status ?? "on a signal"}.` +
554
- (said ? `\n ${said}` : "") +
555
- `\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 ` +
556
705
  `than starting a role without a credential it declared.`);
557
706
  }
707
+ const printed = r.stdout ?? "";
558
708
  // One trailing newline is the shell's, not the secret's: `security -w` and
559
709
  // `op read` both add one, and a token with a newline welded to it fails
560
710
  // authentication in a way that looks like a wrong token.
561
- const value = (r.stdout ?? "").replace(/\r?\n$/, "");
562
- if (value === "")
711
+ if (unterminate(printed) === "")
563
712
  throw new Error(
564
713
  `key "${entry.raw}": the ${entry.scheme} provider succeeded and printed nothing.\n` +
565
714
  ` An empty credential is not a credential. Check the reference — most providers exit 0 ` +
566
715
  `for a name they do not have.`);
567
- return value;
716
+ return deliverable(entry, exact ? printed : unterminate(printed));
568
717
  }
569
718
 
570
719
  /**
@@ -574,12 +723,21 @@ export function resolveRef(entry, provider, { run = spawnSync, root = ".", confi
574
723
  * provider runs. A config with three bad references should say so once, and it
575
724
  * should say so without asking anyone's keychain for a password first.
576
725
  */
577
- export function resolveKeys(config, role, opts = {}) {
726
+ export function resolveKeys(config, role, { record = () => {}, ...opts } = {}) {
578
727
  opts = { root: config.root ?? ".", config, ...opts };
579
- const refs = role.keyEntries.filter((e) => e.kind === "ref");
728
+ const refs = entriesOf(role).filter((e) => e.kind === "ref");
580
729
  const plan = refs.map((entry) => ({ entry, mode: modeOf(config, role, entry) }));
581
- return plan.map((p) => ({
582
- ...p,
583
- value: resolveRef(p.entry, config.keyProviders[p.entry.scheme], opts),
584
- }));
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
+ });
585
743
  }