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.
- package/README.md +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- 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`,
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
* `
|
|
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 `
|
|
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
|
-
* `
|
|
431
|
-
*
|
|
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", "
|
|
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
|
|
446
|
-
* the mode beside the
|
|
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
|
|
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
|
|
469
|
-
` "env" passes the value as ${entry.name}; "
|
|
470
|
-
`
|
|
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
|
-
|
|
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
|
|
501
|
-
* cannot be allowed to reach the thing that decides what the
|
|
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.**
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
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
|
|
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:
|
|
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
|
|
537
|
-
|
|
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
|
-
`${
|
|
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
|
-
(
|
|
555
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
583
|
-
|
|
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
|
}
|