seisin 0.1.1 → 0.2.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/inspect.js CHANGED
@@ -8,9 +8,11 @@
8
8
  * does — each one is a way the policy silently does not hold — so they belong
9
9
  * where they can be exercised directly.
10
10
  */
11
- import { resolve, dirname, basename, relative } from "node:path";
12
- import { realpathSync, lstatSync, readdirSync } from "node:fs";
11
+ import { resolve, dirname, basename, relative, join, delimiter } from "node:path";
12
+ import { realpathSync, lstatSync, readdirSync, existsSync, readFileSync, statSync } from "node:fs";
13
13
  import { ownersOf } from "./owners.js";
14
+ import { entriesOf } from "./keys.js";
15
+ import { SHAPES } from "./scan.js";
14
16
  import { wired } from "./commands/wire.js";
15
17
  import { RUNTIME_WRITES, CREDENTIAL_HOMES, expand, settingsFor } from "./srt.js";
16
18
 
@@ -28,6 +30,17 @@ export function inspect(config, only = null, where = config.path) {
28
30
  return {
29
31
  where,
30
32
  roles: roles.map((r) => ({ name: r.name, writes: r.writes, keys: r.keys })),
33
+ /**
34
+ * The provider commands this config would run, listed because they are the
35
+ * one thing in a `seisin.toml` that **executes**, and it executes in the
36
+ * parent, unsandboxed, as you. Everything else in the file only describes a
37
+ * boundary. `check` runs nothing, so this is where you see them first — and
38
+ * seeing them first is the whole point when the file came with a repo you
39
+ * cloned rather than one you wrote.
40
+ */
41
+ providers: Object.values(config.keyProviders ?? {}).map((p) => ({
42
+ name: p.name, command: p.command, mode: p.mode,
43
+ })),
31
44
  shared: sharedPaths(config, roles),
32
45
  warnings: warningsFor(config, roles),
33
46
  limits: LIMITS,
@@ -102,7 +115,21 @@ export function sharedPaths(config, roles = Object.values(config.roles)) {
102
115
  for (const r of roles)
103
116
  for (const glob of r.writes)
104
117
  if (ownersOf(config, glob.replace(/\/\*\*$/, "")).length > 1) seen.add(glob);
105
- return [...seen];
118
+ /**
119
+ * A shared database counts once, not four times.
120
+ *
121
+ * `writes` carries the sidecar expansion so that the grant and the sentence
122
+ * agree (see config.js), and this list read straight off it turned nine
123
+ * shared things into thirty-three — the same name with three suffixes,
124
+ * announced as if it were three more decisions to make. The expansion exists
125
+ * to stop a policy saying one thing four times; a warning that undoes that
126
+ * is the feature leaking into the place it was meant to clean up.
127
+ *
128
+ * A sidecar shared WITHOUT its database still counts on its own, because
129
+ * that is a real and odd thing to have.
130
+ */
131
+ const todas = new Set(seen);
132
+ return [...seen].filter((p) => !(/-(wal|shm|journal)$/.test(p) && todas.has(p.replace(/-(wal|shm|journal)$/, ""))));
106
133
  }
107
134
 
108
135
  /**
@@ -147,6 +174,58 @@ function homeReachWarning(config) {
147
174
 
148
175
 
149
176
  /** Every way this policy does not hold, each with what to do about it. */
177
+ /**
178
+ * Credential-shaped names at the top of the repository, and nothing deeper.
179
+ *
180
+ * One `readdir`, no recursion, conventional names only. It exists to answer
181
+ * "does this repo plausibly have secrets" fast enough to run on every `check`,
182
+ * not to find them — `seisin scan` is the one that looks properly.
183
+ */
184
+ const CREDENTIAL_NAMES = /^(\.env(\..+)?|\.secrets|secrets|\.netrc|.*\.(pem|key|p12|pfx))$/i;
185
+
186
+ function credentialish(root) {
187
+ let entries;
188
+ try {
189
+ entries = readdirSync(root, { withFileTypes: true });
190
+ } catch {
191
+ return [];
192
+ }
193
+ const found = new Set(entries.filter((e) => CREDENTIAL_NAMES.test(e.name)).map((e) => e.name));
194
+
195
+ /**
196
+ * And the ones whose name gives nothing away.
197
+ *
198
+ * The name test misses `credentials.txt`, `tokens.conf`, `config.local` —
199
+ * which a field review found by writing a test file this did not catch, and
200
+ * then blaming the test. The test was fine; the detector was name-only.
201
+ *
202
+ * So the root's small text files are also read, with the same shapes
203
+ * `seisin scan` uses. Root only, small only, and it stops at the first hit
204
+ * per file: this runs on every `check` and the full walk is `scan`'s job.
205
+ */
206
+ const CERTAIN = SHAPES.filter(([c]) => c === "certain").map(([, , re]) => re);
207
+ for (const e of entries) {
208
+ if (found.size >= 4) break;
209
+ if (!e.isFile() || found.has(e.name)) continue;
210
+ try {
211
+ const p = join(root, e.name);
212
+ if (statSync(p).size > 64 * 1024) continue;
213
+ const text = readFileSync(p, "utf8");
214
+ if (CERTAIN.some((re) => re.test(text))) found.add(e.name);
215
+ } catch {
216
+ /* unreadable, or not text: not our business here */
217
+ }
218
+ }
219
+ return [...found].sort().slice(0, 4);
220
+ }
221
+
222
+ /** Is this a command the shell would find? Walks PATH; asks no shell. */
223
+ function onPath(cmd) {
224
+ if (cmd.includes("/")) return existsSync(cmd);
225
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
226
+ return dirs.some((d) => existsSync(join(d, cmd)));
227
+ }
228
+
150
229
  function warningsFor(config, roles) {
151
230
  const warnings = [];
152
231
 
@@ -400,13 +479,84 @@ function warningsFor(config, roles) {
400
479
  });
401
480
  }
402
481
 
403
- if (config.keyDirs.length === 0 && Object.values(config.roles).some((r) => r.keys.length))
482
+ /**
483
+ * A policy with no credential floor at all, in a repo that has credentials.
484
+ *
485
+ * `[keys] dir` is what produces `denyRead`. Without it the emitted settings
486
+ * carry an empty deny list, so every role reads every secret in the
487
+ * repository — and until this warning, nothing said so. The existing
488
+ * `keys-unscoped` only fires when a role *lists* keys, which is the case
489
+ * where somebody already thought about it.
490
+ *
491
+ * The dangerous shape is the other one, and it is not hypothetical: a policy
492
+ * generated by a script. The deployment this was built against generates its
493
+ * `seisin.toml` from a Python program, and declares `[keys] dir` mostly so
494
+ * `scan` skips those directories. A generator that dropped that one line
495
+ * would hand every role the credential tree and produce a clean `check`.
496
+ *
497
+ * Credit where it is due: this is `L-01` of a code review of another
498
+ * project's contract type, which makes its credential floor impossible to
499
+ * construct empty. That is the right shape and it is a bigger change than a
500
+ * warning. This is the cheap half — and the review's claim that the file
501
+ * path was safe turned out to be generous: it was not.
502
+ *
503
+ * Deliberately NOT a full `seisin scan`: that walks the tree and `check` has
504
+ * to stay instant. A shallow look at the repository root catches the
505
+ * conventional places and costs one `readdir`.
506
+ */
507
+ if ((config.keyDirs ?? []).length === 0) {
508
+ const found = credentialish(config.root);
509
+ if (found.length)
510
+ warnings.push({
511
+ kind: "no-key-floor",
512
+ headline: `no [keys] dir is declared, and this repo has ${found.join(", ")} — every role can read them`,
513
+ detail:
514
+ "Without a key directory the emitted policy denies no reads at all. Declare the " +
515
+ "directory your secrets live in, and the roles that may read each one.",
516
+ });
517
+ }
518
+
519
+ // Only a key that is a PATH needs a key directory. A config whose secrets all
520
+ // live in a vault has no `[keys] dir` and nothing wrong with it, and warning
521
+ // there would be the tool insisting on the arrangement it was built for.
522
+ if (config.keyDirs.length === 0 &&
523
+ Object.values(config.roles).some((r) => entriesOf(r).some((k) => k.kind === "file")))
404
524
  warnings.push({
405
525
  kind: "keys-unscoped",
406
526
  headline: "keys are listed but [keys] dir is unset — nothing will be scoped",
407
527
  detail: null,
408
528
  });
409
529
 
530
+ /**
531
+ * A provider whose command is not on PATH.
532
+ *
533
+ * This is the half of validating references that can be done without
534
+ * resolving one — which is the whole requirement: a broken policy has to be
535
+ * visible in `check`, not on the first run, and certainly not by asking
536
+ * somebody's keychain for a password to find out. The scheme and the
537
+ * provider are checked when the config loads, so by the time we are here
538
+ * they exist; what is left is whether the command does.
539
+ *
540
+ * A warning and not an error: the binary can legitimately be missing on the
541
+ * machine reading the policy and present on the one that runs it, which is
542
+ * every CI checkout.
543
+ */
544
+ const used = new Set();
545
+ for (const r of Object.values(config.roles))
546
+ for (const k of entriesOf(r)) if (k.kind === "ref") used.add(k.scheme);
547
+ for (const scheme of [...used].sort()) {
548
+ const provider = config.keyProviders?.[scheme];
549
+ if (!provider || onPath(provider.command[0])) continue;
550
+ warnings.push({
551
+ kind: "provider-missing",
552
+ headline: `the ${scheme} provider runs "${provider.command[0]}", which is not on PATH`,
553
+ detail:
554
+ "every key of that scheme will stop the run rather than resolve. Nothing is " +
555
+ "substituted for a key that did not resolve, so this fails closed — but it fails " +
556
+ "at the start of a turn, not here.",
557
+ });
558
+ }
559
+
410
560
  return warnings;
411
561
  }
412
562
 
package/src/keys.js ADDED
@@ -0,0 +1,483 @@
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 } from "node:path";
45
+ import { BASE, DEFAULTS } from "./env.js";
46
+ import { SOCK_ENV } from "./spool.js";
47
+
48
+ /**
49
+ * `scheme://rest`, and nothing cleverer.
50
+ *
51
+ * The scheme is matched the way a URL scheme is, so a Windows path (`C:\…`) and
52
+ * a relative path with a colon in it do not read as references. `rest` is the
53
+ * whole remainder including any slashes, because `op://vault/item/field` is one
54
+ * reference and not three.
55
+ */
56
+ const RE_REF = /^([a-z][a-z0-9+.\-]*):\/\/(.+)$/;
57
+
58
+ /** `NAME=` in front of a reference: the environment variable to deliver it as. */
59
+ const RE_NAMED = /^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/;
60
+
61
+ /**
62
+ * The name a reference arrives under when nobody said.
63
+ *
64
+ * Derived rather than required, because `keys = ["keychain://netlify-token"]`
65
+ * is the spelling in front of everyone and making it illegal buys nothing. The
66
+ * derivation is dull — last segment, uppercased, anything that is
67
+ * not a letter or a digit becomes `_` — and `seisin check` prints the result,
68
+ * so a name that surprises somebody is visible before a run rather than after.
69
+ */
70
+ export function defaultName(ref) {
71
+ const last = ref.split("/").filter(Boolean).pop() ?? ref;
72
+ const name = last.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase();
73
+ // A reference of punctuation only would otherwise derive the empty string and
74
+ // produce an unsettable variable. It is not reachable from a sane vault name;
75
+ // it is here because an unnamed failure at spawn time is worse than a refusal.
76
+ return /^[A-Za-z_]/.test(name) ? name : `KEY_${name}`;
77
+ }
78
+
79
+ /**
80
+ * One entry of `keys = [...]`, read.
81
+ *
82
+ * Returns `{ kind: "file", raw }` for a path — which is everything that was
83
+ * valid before this module existed, unchanged — or
84
+ * `{ kind: "ref", scheme, ref, name, raw }` for a reference.
85
+ *
86
+ * It does not decide whether the scheme is *known*: that needs the config, and
87
+ * separating the two is what lets `check` report every unknown scheme in a file
88
+ * at once instead of dying on the first.
89
+ */
90
+ export function parseKey(entry) {
91
+ if (typeof entry !== "string") throw new Error(`keys: expected a string, got ${typeof entry}`);
92
+ const named = RE_NAMED.exec(entry);
93
+ const body = named ? named[2] : entry;
94
+ const m = RE_REF.exec(body);
95
+ if (!m) {
96
+ // `NAME=path` is refused rather than read as a file called `NAME=path`.
97
+ // A name in front of a path means the writer expected delivery by
98
+ // environment, and a path key is delivered by being readable — so the
99
+ // spelling asks for something that will not happen, silently.
100
+ if (named)
101
+ throw new Error(
102
+ `keys = "${entry}": "${named[1]}=" names an environment variable, but ` +
103
+ `"${body}" is a path, not a reference.\n` +
104
+ ` A path key is delivered by being readable, so there is no variable to name. ` +
105
+ `Drop the prefix, or point it at a provider: "${named[1]}=keychain://${named[2]}".`);
106
+ return { kind: "file", raw: entry };
107
+ }
108
+ const [, scheme, ref] = m;
109
+ return { kind: "ref", scheme, ref, name: named ? named[1] : defaultName(ref), raw: entry };
110
+ }
111
+
112
+ /**
113
+ * `file://` — the one provider seisin ships, because it is the common case.
114
+ *
115
+ * Everything else stays out: a provider is a command,
116
+ * and wiring 1Password or Vault into the package would be choosing for you and
117
+ * ageing with somebody else's CLI. This one is different, and the difference is
118
+ * not convenience.
119
+ *
120
+ * The secret in a plain file is **the case the tool exists for**. Measured on
121
+ * one real deployment: 128 credential files in the clear, 79 of them
122
+ * world-readable, and not one role using the key mechanism at all. Telling
123
+ * that person "declare a provider that runs `cat`" puts a papercut on the only
124
+ * path most people will ever take, and `cat` is worse than it looks — it
125
+ * resolves relative to whatever directory the parent happened to be in, and it
126
+ * hands back the whole file when the file holds twelve variables.
127
+ *
128
+ * So `file://` is native, resolves against the policy's own directory, and
129
+ * takes a fragment:
130
+ *
131
+ * keys = ["TOKEN=file://.secrets/netlify.txt"] the whole file
132
+ * keys = ["RESEND_KEY=file://.secrets/all.env#RESEND_KEY"] one key of many
133
+ *
134
+ * It grants **no read**. That is the entire point: the role gets the value and
135
+ * cannot open the file, which is the thing `keys = ["all.env"]` cannot do
136
+ * because a file grant is a file grant.
137
+ *
138
+ * It does not require the file to live under `[keys] dir`. The path form does,
139
+ * because it opens a read inside a denied directory and has to be bounded. This
140
+ * opens nothing — it hands over a string — so the same rule would be a
141
+ * restriction copied for looking like the other one.
142
+ */
143
+ export const BUILTIN = new Set(["file"]);
144
+
145
+ /** The file as a JSON object, or null if it is not one. */
146
+ function parseJsonObject(text) {
147
+ const t = text.trim();
148
+ if (!t.startsWith("{")) return null;
149
+ try {
150
+ const v = JSON.parse(t);
151
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ /** One trailing newline is the editor's, not the secret's. */
158
+ const unterminate = (v) => v.replace(/\r?\n$/, "");
159
+
160
+ /**
161
+ * `KEY=value` out of a file that holds several.
162
+ *
163
+ * Deliberately small: `export` is tolerated because half the world's `.env`
164
+ * files have it, surrounding quotes come off, and everything else is left
165
+ * alone. This is not a shell parser and should never become one — a config
166
+ * language that grows an interpreter is how a permission tool gets a CVE.
167
+ */
168
+ /**
169
+ * One value out of a JSON object.
170
+ *
171
+ * JSON is here and Markdown is not, and the line between them is not effort:
172
+ * **JSON is a format, a runbook is a document.** A service-account key, a
173
+ * `credentials.json`, anything a cloud CLI writes — those have one shape, so
174
+ * reading them is a rule. A table of passwords inside a page of prose has no
175
+ * shape; extracting from it would be guessing, and a credential tool that
176
+ * guesses hands over the wrong secret rather than failing.
177
+ *
178
+ * The literal key first, then a dotted path, because a key that contains a dot
179
+ * is real and should win over a reading of it as a path.
180
+ *
181
+ * Only scalars. An object or an array is not a credential, and returning one
182
+ * stringified would put `[object Object]` in an environment variable and call
183
+ * it a token.
184
+ */
185
+ function fromJson(obj, key, ref) {
186
+ let v = Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
187
+ if (v === undefined && key.includes(".")) {
188
+ v = key.split(".").reduce((o, k) => (o && typeof o === "object" ? o[k] : undefined), obj);
189
+ }
190
+ if (v === undefined) {
191
+ const top = Object.keys(obj).slice(0, 8).join(", ");
192
+ throw new Error(
193
+ `key "${ref}": that JSON has no ${key}.\n` +
194
+ ` Top-level names: ${top}${Object.keys(obj).length > 8 ? ", …" : ""}.\n` +
195
+ ` A nested one is reached with dots: "#credentials.token".`);
196
+ }
197
+ if (v === null || typeof v === "object")
198
+ throw new Error(
199
+ `key "${ref}": ${key} is ${v === null ? "null" : Array.isArray(v) ? "an array" : "an object"}, ` +
200
+ `not a value.\n A credential is a string or a number. Point the fragment at one, or drop ` +
201
+ `the fragment to hand over the whole file.`);
202
+ return String(v);
203
+ }
204
+
205
+ function fromDotEnv(text, key, ref) {
206
+ let sawAny = false;
207
+ for (const line of text.split(/\r?\n/)) {
208
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
209
+ if (!m) continue;
210
+ sawAny = true;
211
+ if (m[1] !== key) continue;
212
+ return m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
213
+ }
214
+ /**
215
+ * Two different failures, and telling them apart is the whole message.
216
+ *
217
+ * "this file has no `TOKEN=`" is a typo in the reference. "this file has no
218
+ * `NAME=` lines at all" is a person pointing a fragment at something that is
219
+ * not an env file — and in the field that was a 146-line Markdown runbook
220
+ * with the credentials in a table, which no extractor is going to read.
221
+ * Saying "has no TOKEN=" about that sends somebody hunting for a typo in a
222
+ * file where the answer is that the secrets are in prose.
223
+ */
224
+ if (!sawAny)
225
+ throw new Error(
226
+ `key "${ref}": nothing in that file looks like NAME=value, so there is no ${key} to take.\n` +
227
+ ` A fragment reads one line out of an env file. If the secret lives inside a document ` +
228
+ `— a runbook, a table, a note — seisin cannot reach it, and the fix is to move the ` +
229
+ `secret out of the document rather than to teach a parser about it.\n` +
230
+ ` Without the "#${key}", file:// hands over the whole file as the value.`);
231
+ throw new Error(
232
+ `key "${ref}": the file has NAME=value lines, but no ${key}=.\n` +
233
+ ` A name that is not there is not an empty value — nothing is substituted for a key ` +
234
+ `that did not resolve.`);
235
+ }
236
+
237
+ /**
238
+ * Read `file://<path>` or `file://<path>#<KEY>`, relative to the policy.
239
+ *
240
+ * Relative to the policy and not to the process: a key that resolves
241
+ * differently depending on where you were standing when you typed the command
242
+ * is a key that works in your shell and fails in the agent's.
243
+ */
244
+ export function readFileRef(entry, root = ".") {
245
+ const hash = entry.ref.lastIndexOf("#");
246
+ const path = hash === -1 ? entry.ref : entry.ref.slice(0, hash);
247
+ const key = hash === -1 ? null : entry.ref.slice(hash + 1);
248
+ const full = isAbsolute(path) ? path : resolve(root, path);
249
+ let text;
250
+ try {
251
+ text = readFileSync(full, "utf8");
252
+ } catch (e) {
253
+ throw new Error(
254
+ `key "${entry.raw}": cannot read ${full} (${e.code ?? e.message}).\n` +
255
+ ` The path is resolved against the policy file's directory, not the current one.`);
256
+ }
257
+ let value;
258
+ if (key === null) {
259
+ value = unterminate(text);
260
+ } else {
261
+ // Sniffed, not taken from the extension. The file that motivated this was
262
+ // named `.txt` and the one before it `.env`; the name is a hint and the
263
+ // content is the fact.
264
+ const asJson = parseJsonObject(text);
265
+ value = asJson ? fromJson(asJson, key, entry.raw) : fromDotEnv(text, key, entry.raw);
266
+ }
267
+ if (value === "")
268
+ throw new Error(`key "${entry.raw}": ${full} is empty. An empty credential is not a credential.`);
269
+ return value;
270
+ }
271
+
272
+ /**
273
+ * Variable names a key may not take, and why each group is here.
274
+ *
275
+ * A key delivered by `env` is written into the child's environment after it is
276
+ * built, so whatever it is called, it wins. That is fine until the name is one
277
+ * of these:
278
+ *
279
+ * - **What seisin itself injects.** `SEISIN_ROLE` is how the hook inside the
280
+ * box learns which role it is. A key called that would let a policy tell
281
+ * the hook it is somebody else — privilege confusion written in the one
282
+ * file that is supposed to prevent it. Same for the config path and the
283
+ * audit socket.
284
+ * - **What the child needs to be a child.** `keys = ["keychain://path"]`
285
+ * derives `PATH`, and the measured result is the sandbox failing with
286
+ * `env: node: No such file or directory` — a config mistake that reads
287
+ * exactly like a broken installation. `HOME`, `TMPDIR` and the TLS
288
+ * variables are the same shape, and `NODE_EXTRA_CA_CERTS` is worse than
289
+ * cosmetic.
290
+ *
291
+ * Refused when the policy loads rather than when it runs, so `seisin check`
292
+ * catches it and nobody debugs it from inside a sandbox.
293
+ */
294
+ export const RESERVED_ENV = new Set([
295
+ "SEISIN_ROLE", "SEISIN_CONFIG", SOCK_ENV,
296
+ ...BASE, ...Object.keys(DEFAULTS),
297
+ ]);
298
+
299
+ /**
300
+ * Every delivered name a role's keys would claim — the variable, and for
301
+ * `scratch` the `_FILE` that carries the path.
302
+ *
303
+ * Checked as a set because two keys resolving to one name is the other half of
304
+ * the same failure: `keys = ["T=a://x", "T=b://y"]` used to deliver the second
305
+ * and drop the first, in silence, in a credential list. Measured before this
306
+ * existed; the run printed `T=b` and said nothing about `a://x`.
307
+ */
308
+ export function checkNames(role) {
309
+ const taken = new Map();
310
+ for (const e of role.keyEntries ?? []) {
311
+ if (e.kind !== "ref") continue;
312
+ for (const name of [e.name, `${e.name}_FILE`]) {
313
+ if (RESERVED_ENV.has(name))
314
+ throw new Error(
315
+ `roles.${role.name}: key "${e.raw}" would arrive as ${name}, which seisin or the ` +
316
+ `child already needs.\n` +
317
+ ` Give it a name of its own: keys = ["MY_${name}=${e.scheme}://${e.ref}"].`);
318
+ const first = taken.get(name);
319
+ if (first !== undefined)
320
+ throw new Error(
321
+ `roles.${role.name}: "${first}" and "${e.raw}" both arrive as ${name}. ` +
322
+ `One would silently replace the other.\n` +
323
+ ` Name at least one of them: keys = ["SOMETHING_ELSE=${e.scheme}://${e.ref}"].`);
324
+ taken.set(name, e.raw);
325
+ }
326
+ }
327
+ }
328
+
329
+ /**
330
+ * A role's key entries, however the role object was built.
331
+ *
332
+ * `loadConfig` fills `keyEntries`; a role assembled by hand — a test, or an
333
+ * embedder holding seisin as a library — has only `keys`, and that shape
334
+ * shipped, so it keeps working. One helper rather than the same fallback
335
+ * written in three files, because the day they disagree is the day a key is
336
+ * scoped in one place and not in another.
337
+ */
338
+ export function entriesOf(role) {
339
+ return role.keyEntries ?? (role.keys ?? []).map(parseKey);
340
+ }
341
+
342
+ /**
343
+ * The three ways a resolved value can reach a role.
344
+ *
345
+ * The middle one is called `scratch` and not `file`, which looks like a
346
+ * cosmetic choice and is not. The runtime has a field named
347
+ * `credentials.files`, it is the one that looks like seisin's `keys` (both take
348
+ * paths), and on macOS it does **not** mask — it makes the file unreadable, so
349
+ * a `cat` returns `Operation not permitted` and the reader concludes the
350
+ * feature is broken rather than misused. That mistake has already been made
351
+ * once here and cost a measurement that got written up as a finding about the
352
+ * runtime. A mode called `file` sitting ten centimetres from a runtime field
353
+ * called `files` that fails that way is a trap with a date on it.
354
+ *
355
+ * `scratch` also says what actually happens: the value lands in scratch space
356
+ * for the turn and is removed after it, which `file` does not say at all.
357
+ */
358
+ export const MODES = ["env", "scratch", "inject"];
359
+
360
+ /**
361
+ * How a role receives its reference keys, and where that is written.
362
+ *
363
+ * Per role (`key_mode`) or per provider (`mode`), role first. It is not a
364
+ * per-key setting, and that is a limit of the config language rather than a
365
+ * judgement: the parser covers `[table]` headers and `key = value` pairs, so a
366
+ * mode attached to one entry of an array has nowhere to live without inline
367
+ * tables — and growing the parser to hold one field is the trade this repo has
368
+ * already decided against out loud.
369
+ *
370
+ * The important half survives that limit. The encargo's argument for declaring
371
+ * the mode beside the permission is that a reader of `seisin.toml` can say
372
+ * "this role sees the value" without opening any code, and a per-role setting
373
+ * answers exactly that question.
374
+ *
375
+ * There is no silent default. A reference with no mode anywhere is an error,
376
+ * because the two available answers differ in what the agent can walk away
377
+ * with, and picking one for somebody is picking how much a leak costs them.
378
+ */
379
+ export function modeOf(config, role, entry) {
380
+ const provider = config.keyProviders?.[entry.scheme];
381
+ const mode = role.keyMode ?? provider?.mode ?? null;
382
+ if (mode === null) {
383
+ // A built-in scheme has no `[keys.providers.…]` table to put a default in —
384
+ // declaring one is refused, because a scheme cannot mean two things. So
385
+ // offering it here sent the first person who tried the feature from this
386
+ // error straight into another one. Found in the field, on the first wall.
387
+ const second = BUILTIN.has(entry.scheme)
388
+ ? ""
389
+ : ` Or set one for every key of that provider: ` +
390
+ `[keys.providers.${entry.scheme}] mode = "env".`;
391
+ throw new Error(
392
+ `roles.${role.name}: key "${entry.raw}" has no delivery mode.\n` +
393
+ ` Declare one beside the permission: [roles.${role.name}] key_mode = "env".${second}\n` +
394
+ ` "env" passes the value as ${entry.name}; "scratch" writes it to a file inside the ` +
395
+ `role's scratch space, grants that one path, and removes it when the turn ends.`);
396
+ }
397
+ if (mode === "inject")
398
+ throw new Error(
399
+ `roles.${role.name}: key_mode = "inject" is not implemented.\n` +
400
+ ` It is the one mode where the agent never sees the value, and it is not free: the ` +
401
+ `runtime only masks a credential when the role's TLS is terminated with a CA of ` +
402
+ `seisin's own — MITM over all of that role's traffic, not just the host holding the ` +
403
+ `secret. It is refused rather than half-available.\n` +
404
+ ` Use "env" or "file" today. See docs/decisions.md.`);
405
+ if (!MODES.includes(mode))
406
+ throw new Error(
407
+ `roles.${role.name}: key_mode = "${mode}" is not a delivery mode. Known: ${MODES.join(", ")}.` +
408
+ // The one wrong answer named, because it is the word everybody
409
+ // reaches for and it is one letter from a runtime field that fails
410
+ // silently. Guessing costs a debugging session; saying so costs a line.
411
+ (mode === "file"
412
+ ? `\n "file" is not one of them on purpose — the runtime has a "credentials.files" that ` +
413
+ `takes paths and, on macOS, makes them unreadable instead of masking. The mode that ` +
414
+ `hands a role a file is "scratch".`
415
+ : ""));
416
+ return mode;
417
+ }
418
+
419
+ /**
420
+ * Run a provider and hand back what it printed.
421
+ *
422
+ * Three properties, each of which was a requirement before it was code:
423
+ *
424
+ * - **The parent runs it, never the confined process.** Same reason `owners`
425
+ * recomputes instead of believing what it is told: the inside of the box
426
+ * cannot be allowed to reach the thing that decides what the box contains.
427
+ * A provider command spawned from within the sandbox would need the vault's
428
+ * own credential in there with it, which is the problem this feature exists
429
+ * to remove.
430
+ * - **A failure is a failure.** Not empty, not the file of the same name, not
431
+ * a skipped key. The house rule is already written twice: a check that
432
+ * cannot measure and says ok lies more than one that says no. A role that
433
+ * starts without the credential it declared fails later, further away, and
434
+ * looking like something else.
435
+ * - **The value is not in the error.** `stderr` from the provider is passed
436
+ * through because it is how `op` says "not signed in", but `stdout` never
437
+ * is — a provider that prints the secret and then exits non-zero would
438
+ * otherwise put it in the terminal and the log.
439
+ */
440
+ export function resolveRef(entry, provider, { run = spawnSync, root = "." } = {}) {
441
+ if (provider?.builtin === "file" || (provider === undefined && entry.scheme === "file"))
442
+ return readFileRef(entry, root);
443
+ const argv = provider.command.map((part) => part.replaceAll("{ref}", entry.ref));
444
+ const r = run(argv[0], argv.slice(1), { encoding: "utf8" });
445
+ if (r.error)
446
+ throw new Error(
447
+ `key "${entry.raw}": could not run the ${entry.scheme} provider (${argv[0]}): ${r.error.message}`);
448
+ if (r.status !== 0) {
449
+ const said = (r.stderr ?? "").trim().split("\n").slice(0, 3).join("\n ");
450
+ throw new Error(
451
+ `key "${entry.raw}": the ${entry.scheme} provider exited ${r.status ?? "on a signal"}.` +
452
+ (said ? `\n ${said}` : "") +
453
+ `\n Nothing is substituted for a key that did not resolve — the run stops here rather ` +
454
+ `than starting a role without a credential it declared.`);
455
+ }
456
+ // One trailing newline is the shell's, not the secret's: `security -w` and
457
+ // `op read` both add one, and a token with a newline welded to it fails
458
+ // authentication in a way that looks like a wrong token.
459
+ const value = (r.stdout ?? "").replace(/\r?\n$/, "");
460
+ if (value === "")
461
+ throw new Error(
462
+ `key "${entry.raw}": the ${entry.scheme} provider succeeded and printed nothing.\n` +
463
+ ` An empty credential is not a credential. Check the reference — most providers exit 0 ` +
464
+ `for a name they do not have.`);
465
+ return value;
466
+ }
467
+
468
+ /**
469
+ * Every reference a role declares, resolved, as `{ entry, mode, value }`.
470
+ *
471
+ * Validation of schemes and modes happens over the whole list before the first
472
+ * provider runs. A config with three bad references should say so once, and it
473
+ * should say so without asking anyone's keychain for a password first.
474
+ */
475
+ export function resolveKeys(config, role, opts = {}) {
476
+ opts = { root: config.root ?? ".", ...opts };
477
+ const refs = role.keyEntries.filter((e) => e.kind === "ref");
478
+ const plan = refs.map((entry) => ({ entry, mode: modeOf(config, role, entry) }));
479
+ return plan.map((p) => ({
480
+ ...p,
481
+ value: resolveRef(p.entry, config.keyProviders[p.entry.scheme], opts),
482
+ }));
483
+ }