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/README.md +287 -339
- package/package.json +7 -6
- package/src/cli.js +17 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/init.js +1 -1
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +8 -3
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +180 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +30 -0
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +310 -5
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +45 -5
- package/src/index.js +12 -0
- package/src/inspect.js +332 -9
- package/src/intake.js +215 -0
- package/src/keys.js +585 -0
- package/src/log.js +96 -2
- package/src/mcp.js +87 -11
- package/src/nested.js +86 -0
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +63 -1
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +263 -19
- package/src/spool.js +98 -20
- package/src/srt.js +148 -203
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +184 -0
- package/ui/favicon.svg +1 -1
- package/ui/index.html +585 -56
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
|
+
}
|