seisin 0.1.1 → 0.3.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/src/keys.js ADDED
@@ -0,0 +1,585 @@
1
+ /**
2
+ * Keys that are references rather than files.
3
+ *
4
+ * Until this existed a key was a path: `keys = ["netlify-token.txt"]` named a
5
+ * file inside a declared `[keys] dir`, and the whole mechanism was the
6
+ * filesystem's — resolve it, check it lands inside the directory, add it to
7
+ * `allowRead`. That works and it stays. What it cannot do is reach a secret
8
+ * that has no path, which is where most secrets that are looked after at all
9
+ * actually live: a keychain, 1Password, Bitwarden, sops, Vault.
10
+ *
11
+ * So a key may also be a **reference with a scheme**, resolved by a provider
12
+ * declared in the same file:
13
+ *
14
+ * [keys.providers.keychain]
15
+ * command = ["security", "find-generic-password", "-w", "-s", "{ref}"]
16
+ *
17
+ * [roles.frontend]
18
+ * keys = ["keychain://netlify-token", "netlify-token.txt"]
19
+ *
20
+ * A provider is a command with a placeholder. Adding Bitwarden or
21
+ * `sops` is then three lines of TOML and no code — which is what makes this one
22
+ * integration instead of one per vault, and what keeps seisin from aging with
23
+ * somebody else's CLI.
24
+ *
25
+ * ## What this does not do
26
+ *
27
+ * It resolves the secret **at rest**, not in the agent's context. The value
28
+ * still reaches the process and the agent can still read it. That is a strict
29
+ * improvement over a plaintext file — the secret stops living on disk, the
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.
35
+ *
36
+ * ## Why the value is never written down
37
+ *
38
+ * The reference is what gets committed, diffed and approved; the value is what
39
+ * gets handed to one process and forgotten. A log that carries the secret is a
40
+ * second plaintext copy with worse access control than the first.
41
+ */
42
+ import { spawnSync } from "node:child_process";
43
+ import { readFileSync } from "node:fs";
44
+ import { resolve, isAbsolute, delimiter } from "node:path";
45
+ import { BASE, DEFAULTS } from "./env.js";
46
+ import { SOCK_ENV } from "./spool.js";
47
+ import { resolveExecutable, trustedPath } from "./surface.js";
48
+
49
+ /**
50
+ * How long a provider may take. A minute, because the slow case is a person:
51
+ * `op` and the keychain can put a prompt in front of someone who is not
52
+ * looking at the screen yet. Before this there was no limit, and a provider
53
+ * stuck on a prompt nobody could see held the run with no message at all.
54
+ */
55
+ export const PROVIDER_TIMEOUT_MS = 60_000;
56
+
57
+ /** A credential is bytes, not megabytes. Past this it is not a key. */
58
+ const PROVIDER_MAX_BYTES = 1024 * 1024;
59
+
60
+ /**
61
+ * `scheme://rest`, and nothing cleverer.
62
+ *
63
+ * The scheme is matched the way a URL scheme is, so a Windows path (`C:\…`) and
64
+ * a relative path with a colon in it do not read as references. `rest` is the
65
+ * whole remainder including any slashes, because `op://vault/item/field` is one
66
+ * reference and not three.
67
+ */
68
+ const RE_REF = /^([a-z][a-z0-9+.\-]*):\/\/(.+)$/;
69
+
70
+ /** `NAME=` in front of a reference: the environment variable to deliver it as. */
71
+ const RE_NAMED = /^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/;
72
+
73
+ /**
74
+ * The name a reference arrives under when nobody said.
75
+ *
76
+ * Derived rather than required, because `keys = ["keychain://netlify-token"]`
77
+ * is the spelling in front of everyone and making it illegal buys nothing. The
78
+ * derivation is dull — last segment, uppercased, anything that is
79
+ * not a letter or a digit becomes `_` — and `seisin check` prints the result,
80
+ * so a name that surprises somebody is visible before a run rather than after.
81
+ */
82
+ export function defaultName(ref) {
83
+ const last = ref.split("/").filter(Boolean).pop() ?? ref;
84
+ const name = last.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase();
85
+ // A reference of punctuation only would otherwise derive the empty string and
86
+ // produce an unsettable variable. It is not reachable from a sane vault name;
87
+ // it is here because an unnamed failure at spawn time is worse than a refusal.
88
+ return /^[A-Za-z_]/.test(name) ? name : `KEY_${name}`;
89
+ }
90
+
91
+ /**
92
+ * One entry of `keys = [...]`, read.
93
+ *
94
+ * Returns `{ kind: "file", raw }` for a path — which is everything that was
95
+ * valid before this module existed, unchanged — or
96
+ * `{ kind: "ref", scheme, ref, name, raw }` for a reference.
97
+ *
98
+ * It does not decide whether the scheme is *known*: that needs the config, and
99
+ * separating the two is what lets `check` report every unknown scheme in a file
100
+ * at once instead of dying on the first.
101
+ */
102
+ export function parseKey(entry) {
103
+ if (typeof entry !== "string") throw new Error(`keys: expected a string, got ${typeof entry}`);
104
+ const named = RE_NAMED.exec(entry);
105
+ const body = named ? named[2] : entry;
106
+ const m = RE_REF.exec(body);
107
+ if (!m) {
108
+ // `NAME=path` is refused rather than read as a file called `NAME=path`.
109
+ // A name in front of a path means the writer expected delivery by
110
+ // environment, and a path key is delivered by being readable — so the
111
+ // spelling asks for something that will not happen, silently.
112
+ if (named)
113
+ throw new Error(
114
+ `keys = "${entry}": "${named[1]}=" names an environment variable, but ` +
115
+ `"${body}" is a path, not a reference.\n` +
116
+ ` A path key is delivered by being readable, so there is no variable to name. ` +
117
+ `Drop the prefix, or point it at a provider: "${named[1]}=keychain://${named[2]}".`);
118
+ return { kind: "file", raw: entry };
119
+ }
120
+ const [, scheme, ref] = m;
121
+ return { kind: "ref", scheme, ref, name: named ? named[1] : defaultName(ref), raw: entry };
122
+ }
123
+
124
+ /**
125
+ * `file://` — the one provider seisin ships, because it is the common case.
126
+ *
127
+ * Everything else stays out: a provider is a command,
128
+ * and wiring 1Password or Vault into the package would be choosing for you and
129
+ * ageing with somebody else's CLI. This one is different, and the difference is
130
+ * not convenience.
131
+ *
132
+ * The secret in a plain file is **the case the tool exists for**. Measured on
133
+ * one real deployment: 128 credential files in the clear, 79 of them
134
+ * world-readable, and not one role using the key mechanism at all. Telling
135
+ * that person "declare a provider that runs `cat`" puts a papercut on the only
136
+ * path most people will ever take, and `cat` is worse than it looks — it
137
+ * resolves relative to whatever directory the parent happened to be in, and it
138
+ * hands back the whole file when the file holds twelve variables.
139
+ *
140
+ * So `file://` is native, resolves against the policy's own directory, and
141
+ * takes a fragment:
142
+ *
143
+ * keys = ["TOKEN=file://.secrets/netlify.txt"] the whole file
144
+ * keys = ["RESEND_KEY=file://.secrets/all.env#RESEND_KEY"] one key of many
145
+ *
146
+ * It grants **no read**. That is the entire point: the role gets the value and
147
+ * cannot open the file, which is the thing `keys = ["all.env"]` cannot do
148
+ * because a file grant is a file grant.
149
+ *
150
+ * It does not require the file to live under `[keys] dir`. The path form does,
151
+ * because it opens a read inside a denied directory and has to be bounded. This
152
+ * opens nothing — it hands over a string — so the same rule would be a
153
+ * restriction copied for looking like the other one.
154
+ */
155
+ export const BUILTIN = new Set(["file"]);
156
+
157
+ /** The file as a JSON object, or null if it is not one. */
158
+ function parseJsonObject(text) {
159
+ const t = text.trim();
160
+ if (!t.startsWith("{")) return null;
161
+ try {
162
+ const v = JSON.parse(t);
163
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ /** One trailing newline is the editor's, not the secret's. */
170
+ const unterminate = (v) => v.replace(/\r?\n$/, "");
171
+
172
+ /**
173
+ * `KEY=value` out of a file that holds several.
174
+ *
175
+ * Deliberately small: `export` is tolerated because half the world's `.env`
176
+ * files have it, surrounding quotes come off, and everything else is left
177
+ * alone. This is not a shell parser and should never become one — a config
178
+ * language that grows an interpreter is how a permission tool gets a CVE.
179
+ */
180
+ /**
181
+ * One value out of a JSON object.
182
+ *
183
+ * JSON is here and Markdown is not, and the line between them is not effort:
184
+ * **JSON is a format, a runbook is a document.** A service-account key, a
185
+ * `credentials.json`, anything a cloud CLI writes — those have one shape, so
186
+ * reading them is a rule. A table of passwords inside a page of prose has no
187
+ * shape; extracting from it would be guessing, and a credential tool that
188
+ * guesses hands over the wrong secret rather than failing.
189
+ *
190
+ * The literal key first, then a dotted path, because a key that contains a dot
191
+ * is real and should win over a reading of it as a path.
192
+ *
193
+ * Only scalars. An object or an array is not a credential, and returning one
194
+ * stringified would put `[object Object]` in an environment variable and call
195
+ * it a token.
196
+ */
197
+ function fromJson(obj, key, ref) {
198
+ let v = Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
199
+ if (v === undefined && key.includes(".")) {
200
+ v = key.split(".").reduce((o, k) => (o && typeof o === "object" ? o[k] : undefined), obj);
201
+ }
202
+ if (v === undefined) {
203
+ const top = Object.keys(obj).slice(0, 8).join(", ");
204
+ throw new Error(
205
+ `key "${ref}": that JSON has no ${key}.\n` +
206
+ ` Top-level names: ${top}${Object.keys(obj).length > 8 ? ", …" : ""}.\n` +
207
+ ` A nested one is reached with dots: "#credentials.token".`);
208
+ }
209
+ if (v === null || typeof v === "object")
210
+ throw new Error(
211
+ `key "${ref}": ${key} is ${v === null ? "null" : Array.isArray(v) ? "an array" : "an object"}, ` +
212
+ `not a value.\n A credential is a string or a number. Point the fragment at one, or drop ` +
213
+ `the fragment to hand over the whole file.`);
214
+ return String(v);
215
+ }
216
+
217
+ /**
218
+ * One `NAME=value` line's value, the way dotenv reads it — or a refusal.
219
+ *
220
+ * Compared against dotenv 16 on the same file (2026-09-23), three lines came
221
+ * back different, and a different value is a wrong credential handed over
222
+ * without a word:
223
+ *
224
+ * - `A=abc # comment` — dotenv drops the comment; this kept it.
225
+ * - a name defined twice — dotenv takes the last; this took the first.
226
+ * - a quoted value spanning lines — dotenv reads it whole; this returned
227
+ * the first line with its quote.
228
+ *
229
+ * The comment is read like dotenv, because there is one reading of it. The
230
+ * other two are refused, because loaders disagree about them and picking one
231
+ * is guessing which credential somebody meant.
232
+ */
233
+ function dotEnvValue(raw, ref, key) {
234
+ const v = raw.trim();
235
+ const q = v[0];
236
+ if (q === '"' || q === "'" || q === "`") {
237
+ // The closing quote is the first one not escaped with a backslash, as in
238
+ // dotenv's own pattern: `"a\"b"` is one value, `a\"b`, not `a\`.
239
+ let end = -1;
240
+ for (let i = 1; i < v.length; i++) {
241
+ if (v[i] === "\\") { i++; continue; }
242
+ if (v[i] === q) { end = i; break; }
243
+ }
244
+ if (end === -1)
245
+ throw new Error(
246
+ `key "${ref}": ${key}= opens a ${q} quote that does not close on its line.\n` +
247
+ ` A value across several lines is read differently by different loaders, so seisin ` +
248
+ `does not read it. Put the credential on one line, or in a file of its own.`);
249
+ // Only a comment may follow the closing quote. `A="a" b` is read by dotenv
250
+ // as the whole line, quotes included, and by others as `a`.
251
+ const rest = v.slice(end + 1).trim();
252
+ if (rest && !rest.startsWith("#"))
253
+ throw new Error(
254
+ `key "${ref}": ${key}= has text after its closing quote (${rest.slice(0, 20)}…).\n` +
255
+ ` Loaders disagree on what that value is. Quote the whole value.`);
256
+ const inner = v.slice(1, end);
257
+ // Inside double quotes dotenv turns \n and \r into the characters — the
258
+ // shape a PEM key takes in a .env. Handed over literally, the key has a
259
+ // backslash and an n where its line breaks go, and it does not parse.
260
+ return q === '"' ? inner.replace(/\\n/g, "\n").replace(/\\r/g, "\r") : inner;
261
+ }
262
+ const bare = v.replace(/\s+#.*$/, "");
263
+ // A `#` inside an unquoted value: dotenv 16 cuts the value there
264
+ // (`tok_#_123` → `tok_`), python-dotenv and docker compose keep it.
265
+ if (bare.includes("#"))
266
+ throw new Error(
267
+ `key "${ref}": the value of ${key}= contains a # without quotes.\n` +
268
+ ` Some loaders read everything after it as a comment and some do not, so the ` +
269
+ `credential depends on who reads the file. Quote it: ${key}="…".`);
270
+ return bare;
271
+ }
272
+
273
+ function fromDotEnv(text, key, ref) {
274
+ let sawAny = false;
275
+ let found = null;
276
+ for (const line of text.split(/\r?\n/)) {
277
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
278
+ if (!m) continue;
279
+ sawAny = true;
280
+ if (m[1] !== key) continue;
281
+ if (found !== null)
282
+ throw new Error(
283
+ `key "${ref}": ${key}= is defined more than once in that file.\n` +
284
+ ` dotenv takes the last one and other loaders the first, so which credential is meant ` +
285
+ `is not written down. Keep one.`);
286
+ found = dotEnvValue(m[2], ref, key);
287
+ }
288
+ if (found !== null) return found;
289
+ /**
290
+ * Two different failures, and telling them apart is the whole message.
291
+ *
292
+ * "this file has no `TOKEN=`" is a typo in the reference. "this file has no
293
+ * `NAME=` lines at all" is a person pointing a fragment at something that is
294
+ * not an env file — and in the field that was a 146-line Markdown runbook
295
+ * with the credentials in a table, which no extractor is going to read.
296
+ * Saying "has no TOKEN=" about that sends somebody hunting for a typo in a
297
+ * file where the answer is that the secrets are in prose.
298
+ */
299
+ if (!sawAny)
300
+ throw new Error(
301
+ `key "${ref}": nothing in that file looks like NAME=value, so there is no ${key} to take.\n` +
302
+ ` A fragment reads one line out of an env file. If the secret lives inside a document ` +
303
+ `— a runbook, a table, a note — seisin cannot reach it, and the fix is to move the ` +
304
+ `secret out of the document rather than to teach a parser about it.\n` +
305
+ ` Without the "#${key}", file:// hands over the whole file as the value.`);
306
+ throw new Error(
307
+ `key "${ref}": the file has NAME=value lines, but no ${key}=.\n` +
308
+ ` A name that is not there is not an empty value — nothing is substituted for a key ` +
309
+ `that did not resolve.`);
310
+ }
311
+
312
+ /**
313
+ * Read `file://<path>` or `file://<path>#<KEY>`, relative to the policy.
314
+ *
315
+ * Relative to the policy and not to the process: a key that resolves
316
+ * differently depending on where you were standing when you typed the command
317
+ * is a key that works in your shell and fails in the agent's.
318
+ */
319
+ export function readFileRef(entry, root = ".") {
320
+ const hash = entry.ref.lastIndexOf("#");
321
+ const path = hash === -1 ? entry.ref : entry.ref.slice(0, hash);
322
+ const key = hash === -1 ? null : entry.ref.slice(hash + 1);
323
+ const full = isAbsolute(path) ? path : resolve(root, path);
324
+ let text;
325
+ try {
326
+ text = readFileSync(full, "utf8");
327
+ } catch (e) {
328
+ throw new Error(
329
+ `key "${entry.raw}": cannot read ${full} (${e.code ?? e.message}).\n` +
330
+ ` The path is resolved against the policy file's directory, not the current one.`);
331
+ }
332
+ let value;
333
+ if (key === null) {
334
+ value = unterminate(text);
335
+ } else {
336
+ // Sniffed, not taken from the extension. The file that motivated this was
337
+ // named `.txt` and the one before it `.env`; the name is a hint and the
338
+ // content is the fact.
339
+ const asJson = parseJsonObject(text);
340
+ value = asJson ? fromJson(asJson, key, entry.raw) : fromDotEnv(text, key, entry.raw);
341
+ }
342
+ if (value === "")
343
+ throw new Error(`key "${entry.raw}": ${full} is empty. An empty credential is not a credential.`);
344
+ return value;
345
+ }
346
+
347
+ /**
348
+ * Variable names a key may not take, and why each group is here.
349
+ *
350
+ * A key delivered by `env` is written into the child's environment after it is
351
+ * built, so whatever it is called, it wins. That is fine until the name is one
352
+ * of these:
353
+ *
354
+ * - **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
356
+ * 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.
359
+ * - **What the child needs to be a child.** `keys = ["keychain://path"]`
360
+ * derives `PATH`, and the measured result is the sandbox failing with
361
+ * `env: node: No such file or directory` — a config mistake that reads
362
+ * exactly like a broken installation. `HOME`, `TMPDIR` and the TLS
363
+ * variables are the same shape, and `NODE_EXTRA_CA_CERTS` is worse than
364
+ * cosmetic.
365
+ *
366
+ * Refused when the policy loads rather than when it runs, so `seisin check`
367
+ * catches it and nobody debugs it from inside a sandbox.
368
+ */
369
+ export const RESERVED_ENV = new Set([
370
+ "SEISIN_ROLE", "SEISIN_CONFIG", SOCK_ENV,
371
+ ...BASE, ...Object.keys(DEFAULTS),
372
+ ]);
373
+
374
+ /**
375
+ * Every delivered name a role's keys would claim — the variable, and for
376
+ * `scratch` the `_FILE` that carries the path.
377
+ *
378
+ * Checked as a set because two keys resolving to one name is the other half of
379
+ * the same failure: `keys = ["T=a://x", "T=b://y"]` used to deliver the second
380
+ * and drop the first, in silence, in a credential list. Measured before this
381
+ * existed; the run printed `T=b` and said nothing about `a://x`.
382
+ */
383
+ export function checkNames(role) {
384
+ const taken = new Map();
385
+ for (const e of role.keyEntries ?? []) {
386
+ if (e.kind !== "ref") continue;
387
+ for (const name of [e.name, `${e.name}_FILE`]) {
388
+ if (RESERVED_ENV.has(name))
389
+ throw new Error(
390
+ `roles.${role.name}: key "${e.raw}" would arrive as ${name}, which seisin or the ` +
391
+ `child already needs.\n` +
392
+ ` Give it a name of its own: keys = ["MY_${name}=${e.scheme}://${e.ref}"].`);
393
+ const first = taken.get(name);
394
+ if (first !== undefined)
395
+ throw new Error(
396
+ `roles.${role.name}: "${first}" and "${e.raw}" both arrive as ${name}. ` +
397
+ `One would silently replace the other.\n` +
398
+ ` Name at least one of them: keys = ["SOMETHING_ELSE=${e.scheme}://${e.ref}"].`);
399
+ taken.set(name, e.raw);
400
+ }
401
+ }
402
+ }
403
+
404
+ /**
405
+ * A role's key entries, however the role object was built.
406
+ *
407
+ * `loadConfig` fills `keyEntries`; a role assembled by hand — a test, or an
408
+ * embedder holding seisin as a library — has only `keys`, and that shape
409
+ * shipped, so it keeps working. One helper rather than the same fallback
410
+ * written in three files, because the day they disagree is the day a key is
411
+ * scoped in one place and not in another.
412
+ */
413
+ export function entriesOf(role) {
414
+ return role.keyEntries ?? (role.keys ?? []).map(parseKey);
415
+ }
416
+
417
+ /**
418
+ * The three ways a resolved value can reach a role.
419
+ *
420
+ * The middle one is called `scratch` and not `file`, which looks like a
421
+ * cosmetic choice and is not. The runtime has a field named
422
+ * `credentials.files`, it is the one that looks like seisin's `keys` (both take
423
+ * paths), and on macOS it does **not** mask — it makes the file unreadable, so
424
+ * a `cat` returns `Operation not permitted` and the reader concludes the
425
+ * feature is broken rather than misused. That mistake has already been made
426
+ * once here and cost a measurement that got written up as a finding about the
427
+ * runtime. A mode called `file` sitting ten centimetres from a runtime field
428
+ * called `files` that fails that way is a trap with a date on it.
429
+ *
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.
432
+ */
433
+ export const MODES = ["env", "scratch", "inject"];
434
+
435
+ /**
436
+ * How a role receives its reference keys, and where that is written.
437
+ *
438
+ * Per role (`key_mode`) or per provider (`mode`), role first. It is not a
439
+ * per-key setting, and that is a limit of the config language rather than a
440
+ * judgement: the parser covers `[table]` headers and `key = value` pairs, so a
441
+ * mode attached to one entry of an array has nowhere to live without inline
442
+ * tables — and growing the parser to hold one field is the trade this repo has
443
+ * already decided against out loud.
444
+ *
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
447
+ * "this role sees the value" without opening any code, and a per-role setting
448
+ * answers exactly that question.
449
+ *
450
+ * There is no silent default. A reference with no mode anywhere is an error,
451
+ * because the two available answers differ in what the agent can walk away
452
+ * with, and picking one for somebody is picking how much a leak costs them.
453
+ */
454
+ export function modeOf(config, role, entry) {
455
+ const provider = config.keyProviders?.[entry.scheme];
456
+ const mode = role.keyMode ?? provider?.mode ?? null;
457
+ if (mode === null) {
458
+ // A built-in scheme has no `[keys.providers.…]` table to put a default in —
459
+ // declaring one is refused, because a scheme cannot mean two things. So
460
+ // offering it here sent the first person who tried the feature from this
461
+ // error straight into another one. Found in the field, on the first wall.
462
+ const second = BUILTIN.has(entry.scheme)
463
+ ? ""
464
+ : ` Or set one for every key of that provider: ` +
465
+ `[keys.providers.${entry.scheme}] mode = "env".`;
466
+ throw new Error(
467
+ `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.`);
471
+ }
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
+ : ""));
491
+ return mode;
492
+ }
493
+
494
+ /**
495
+ * Run a provider and hand back what it printed.
496
+ *
497
+ * Three properties, each of which was a requirement before it was code:
498
+ *
499
+ * - **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.
502
+ * A provider command spawned from within the sandbox would need the vault's
503
+ * own credential in there with it, which is the problem this feature exists
504
+ * to remove.
505
+ * - **A failure is a failure.** Not empty, not the file of the same name, not
506
+ * a skipped key. The house rule is already written twice: a check that
507
+ * cannot measure and says ok lies more than one that says no. A role that
508
+ * starts without the credential it declared fails later, further away, and
509
+ * 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.
514
+ */
515
+ export function resolveRef(entry, provider, { run = spawnSync, root = ".", config = null, env = process.env } = {}) {
516
+ if (provider?.builtin === "file" || (provider === undefined && entry.scheme === "file"))
517
+ return readFileRef(entry, root);
518
+ const argv = provider.command.map((part) => part.replaceAll("{ref}", entry.ref));
519
+ /**
520
+ * Found where no role can write, and handed the same PATH.
521
+ *
522
+ * `spawnSync("fakeprov")` searched the parent's PATH, and a PATH directory
523
+ * 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.
525
+ * The provider's own PATH is narrowed the same way, because a provider that
526
+ * is a script starts with `#!/usr/bin/env bash` and looks its interpreter up
527
+ * again. Without a config (a caller embedding seisin), or with a `run` of the
528
+ * caller's own, the argv is handed over as given: finding the program is part
529
+ * of spawning it, and whoever replaced the spawn owns that too.
530
+ */
531
+ const opts = { encoding: "utf8", timeout: PROVIDER_TIMEOUT_MS, maxBuffer: PROVIDER_MAX_BYTES };
532
+ if (config && run === spawnSync) {
533
+ argv[0] = resolveExecutable(argv[0], config, env);
534
+ opts.env = { ...env, PATH: trustedPath(config, env).join(delimiter) };
535
+ }
536
+ const r = run(argv[0], argv.slice(1), opts);
537
+ if (r.error?.code === "ETIMEDOUT")
538
+ throw new Error(
539
+ `key "${entry.raw}": the ${entry.scheme} provider (${argv[0]}) did not answer in ` +
540
+ `${PROVIDER_TIMEOUT_MS / 1000} s and was stopped.\n` +
541
+ ` A provider that waits on a prompt nobody sees would otherwise hold the run forever.`);
542
+ if (r.error)
543
+ throw new Error(
544
+ `key "${entry.raw}": could not run the ${entry.scheme} provider (${argv[0]}): ${r.error.message}`);
545
+ 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
+ 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 ` +
556
+ `than starting a role without a credential it declared.`);
557
+ }
558
+ // One trailing newline is the shell's, not the secret's: `security -w` and
559
+ // `op read` both add one, and a token with a newline welded to it fails
560
+ // authentication in a way that looks like a wrong token.
561
+ const value = (r.stdout ?? "").replace(/\r?\n$/, "");
562
+ if (value === "")
563
+ throw new Error(
564
+ `key "${entry.raw}": the ${entry.scheme} provider succeeded and printed nothing.\n` +
565
+ ` An empty credential is not a credential. Check the reference — most providers exit 0 ` +
566
+ `for a name they do not have.`);
567
+ return value;
568
+ }
569
+
570
+ /**
571
+ * Every reference a role declares, resolved, as `{ entry, mode, value }`.
572
+ *
573
+ * Validation of schemes and modes happens over the whole list before the first
574
+ * provider runs. A config with three bad references should say so once, and it
575
+ * should say so without asking anyone's keychain for a password first.
576
+ */
577
+ export function resolveKeys(config, role, opts = {}) {
578
+ opts = { root: config.root ?? ".", config, ...opts };
579
+ const refs = role.keyEntries.filter((e) => e.kind === "ref");
580
+ 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
+ }));
585
+ }