seisin 0.1.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.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `seisin ui` — the console, reading the real config and the real log.
3
+ *
4
+ * The only long-lived process in the tool, and scoped hard on purpose: a
5
+ * foreground command you start and stop, serving one page and one JSON
6
+ * document, on loopback. Nothing in the enforcement path touches it. If it is
7
+ * not running you lose a window, not a boundary.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { relative } from "node:path";
11
+ import { serve } from "../serve.js";
12
+ import { C, out } from "../render.js";
13
+
14
+ export async function ui(config, argv = []) {
15
+ const i = argv.indexOf("--port");
16
+ const port = i === -1 ? 4178 : Number(argv[i + 1]);
17
+
18
+ const server = await serve(config.path, port);
19
+ const url = `http://127.0.0.1:${port}`;
20
+
21
+ out(
22
+ `\n ${C.b}${url}${C.off}\n` +
23
+ ` ${C.dim}reading ${relative(process.cwd(), config.path)} and the log, live. ctrl-c to stop.${C.off}\n\n`
24
+ );
25
+
26
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
27
+ spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
28
+
29
+ process.on("SIGINT", () => { server.close(); process.exit(0); });
30
+ return server;
31
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `seisin whose <path>` — the lookup a confined process is allowed to run.
3
+ *
4
+ * The README's opening claim is that a refusal names an owner. That is true
5
+ * through the hook, and false for anyone who only wraps a process: the kernel
6
+ * carries no reason, so the child sees `Operation not permitted` and guesses.
7
+ * Measured in the field twice — an agent diagnosed a territory denial as
8
+ * "possibly sandbox restrictions on macOS", which is a plausible sentence and
9
+ * the wrong one.
10
+ *
11
+ * This closes it without any integration at all. The instruction becomes "on
12
+ * EPERM, ask whose it is", which any agent can follow, and the answer comes
13
+ * from the same config the boundary was built from.
14
+ *
15
+ * It reads and prints. There is deliberately nothing here that could make it
16
+ * worth attacking, because unlike everything else in this tool it is meant to
17
+ * be called *from inside* the box.
18
+ */
19
+ import { ownersOf, keyHolders } from "../owners.js";
20
+ import { twinsOf, whereIs } from "../worktree.js";
21
+ import { C, out } from "../render.js";
22
+
23
+ export function whose(config, argv = []) {
24
+ const target = argv[0];
25
+ if (!target) throw new Error("usage: seisin whose <path>");
26
+
27
+ const rel = target.startsWith(config.root + "/") ? target.slice(config.root.length + 1) : target;
28
+ const isKey = (config.keyDirs ?? []).some((d) => rel === d || rel.startsWith(d + "/"));
29
+ const owners = isKey ? keyHolders(config, rel) : ownersOf(config, rel);
30
+
31
+ // Who is asking, when there is one. A confined child has it in its
32
+ // environment; a person at a prompt does not, and neither case is an error.
33
+ const asking = process.env.SEISIN_ROLE;
34
+ const mine = asking && owners.includes(asking);
35
+
36
+ // A key is a file in a declared directory, not a place in a checkout, so a
37
+ // worktree has nothing to say about one.
38
+ const worktree = isKey ? [] : elsewhere(config, rel, owners);
39
+
40
+ if (owners.length === 0) {
41
+ out(
42
+ `\n ${C.yellow}nobody${C.off} owns ${C.b}${rel}${C.off}\n` +
43
+ ` ${C.dim}No role can ${isKey ? "read" : "write"} it until one claims it in ${config.path}.${C.off}\n\n`
44
+ );
45
+ for (const w of worktree) out(renderElsewhere(w, asking));
46
+ return { target: rel, owners: [], mine: false, worktree };
47
+ }
48
+
49
+ const verb = isKey ? "is declared for" : "belongs to";
50
+ out(
51
+ `\n ${C.b}${rel}${C.off} ${verb} ${C.b}${owners.join(", ")}${C.off}\n` +
52
+ (mine
53
+ ? ` ${C.green}that is you${C.off}${C.dim} — if it was refused, the reason is not ownership.${C.off}\n\n`
54
+ : asking
55
+ ? ` ${C.dim}you are ${asking}. Hand it over rather than working around it.${C.off}\n\n`
56
+ : "\n")
57
+ );
58
+ for (const w of worktree) out(renderElsewhere(w, asking));
59
+ return { target: rel, owners, mine, worktree };
60
+ }
61
+
62
+ /**
63
+ * The same file in the other checkouts of this repo, when it is owned
64
+ * differently there.
65
+ *
66
+ * This is the line the EPERM was missing. An agent refused inside a worktree
67
+ * asks whose the file is, hears "nobody", and concludes the map has a hole —
68
+ * when the map is fine and names the canonical checkout. A twin owned exactly
69
+ * the same way says nothing new and gets no line.
70
+ */
71
+ function elsewhere(config, rel, owners) {
72
+ const { here, twins } = twinsOf(config, rel);
73
+ const sameOwners = (a, b) => a.length === b.length && a.every((o) => b.includes(o));
74
+ return twins
75
+ .map((t) => ({ ...t, owners: ownersOf(config, t.rel) }))
76
+ .filter((t) => !sameOwners(t.owners, owners))
77
+ .map((t) => ({ ...t, here, where: whereIs(config, here, t), askedOwners: owners }));
78
+ }
79
+
80
+ function renderElsewhere(t, asking) {
81
+ const kind = (c) => (c.worktree ? "the worktree" : "the canonical checkout");
82
+ const there = t.owners.length
83
+ ? `belongs to ${t.owners.join(", ")}` + (asking && t.owners.includes(asking) ? ` — that is you` : "")
84
+ : "has no owner";
85
+ const named =
86
+ t.owners.length && t.askedOwners.length
87
+ ? "The two checkouts are not owned by the same role."
88
+ : `The policy names ${kind(t.owners.length ? t : t.here)}, not ${kind(t.owners.length ? t.here : t)}.`;
89
+ return (
90
+ ` ${C.yellow}worktree${C.off} ${t.where}\n` +
91
+ ` ${C.dim} the same file there is ${C.off}${C.b}${t.rel}${C.off}${C.dim}, and it ${there}.\n` +
92
+ ` ${named}${C.off}\n\n`
93
+ );
94
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `seisin wire` — put the hook where the agent will find it.
3
+ *
4
+ * Nothing wrote the log before this existed. The hook is what records a
5
+ * decision, and the hook only runs if the agent has been told to run it — which
6
+ * the README described as a thing that happens and never once said to set up.
7
+ * So `seisin log` came back empty after a dozen real runs, and with it went
8
+ * `watch`, `requests`, `grant`, `review` and the denial-becomes-a-handoff story
9
+ * the README opens with. Enforcement was solid; everything that remembers was
10
+ * unwired.
11
+ *
12
+ * It writes the project's own `.claude/settings.json`, not the user's global
13
+ * config: a permission tool that edits your machine's settings to install
14
+ * itself has misread the room.
15
+ */
16
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { C, out } from "../render.js";
19
+
20
+ const SETTINGS = join(".claude", "settings.json");
21
+
22
+ /** Where the PreToolUse entry lives, and what it has to say. */
23
+ export function hookEntry() {
24
+ return {
25
+ matcher: "*",
26
+ hooks: [{ type: "command", command: "seisin hook" }],
27
+ };
28
+ }
29
+
30
+ /** Is the hook already wired in this repo? */
31
+ export function wired(root) {
32
+ const file = join(root, SETTINGS);
33
+ if (!existsSync(file)) return false;
34
+ try {
35
+ return JSON.stringify(JSON.parse(readFileSync(file, "utf8"))).includes("seisin hook");
36
+ } catch {
37
+ return false; // unreadable settings is not "wired"
38
+ }
39
+ }
40
+
41
+ export function wire(config) {
42
+ const file = join(config.root, SETTINGS);
43
+
44
+ // Merged, never replaced. Someone's hooks are their own and a tool that
45
+ // overwrites them to add itself does not get a second chance.
46
+ let settings = {};
47
+ if (existsSync(file)) {
48
+ try {
49
+ settings = JSON.parse(readFileSync(file, "utf8"));
50
+ } catch (e) {
51
+ throw new Error(`${SETTINGS} is not valid JSON (${e.message}). Fix it, or move it aside.`);
52
+ }
53
+ }
54
+
55
+ if (wired(config.root)) {
56
+ out(`\n ${C.dim}already wired — ${SETTINGS} runs "seisin hook"${C.off}\n\n`);
57
+ return { changed: false, file };
58
+ }
59
+
60
+ settings.hooks ??= {};
61
+ settings.hooks.PreToolUse ??= [];
62
+ settings.hooks.PreToolUse.push(hookEntry());
63
+
64
+ mkdirSync(join(config.root, ".claude"), { recursive: true });
65
+ writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
66
+
67
+ out(
68
+ `\n ${C.green}wired${C.off} ${SETTINGS}\n` +
69
+ ` ${C.dim}the agent now runs "seisin hook" before each tool call, which is what fills${C.off}\n` +
70
+ ` ${C.dim}the log. Without it the boundary still holds — you just cannot see it work.${C.off}\n\n` +
71
+ ` ${C.dim}Commit this file if the rest of your team should get it too.${C.off}\n\n`
72
+ );
73
+ return { changed: true, file };
74
+ }
package/src/config.js ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Reads and validates seisin.toml.
3
+ *
4
+ * The parser covers a deliberate subset of TOML: `[table.name]` headers, and
5
+ * `key = ["a", "b"]` / `key = "a"` values. That is the whole config language,
6
+ * and keeping it in-house is why installing seisin pulls in exactly one
7
+ * dependency (the sandbox runtime) instead of a parser too.
8
+ *
9
+ * If your config outgrows this subset, the config is doing too much.
10
+ */
11
+ import { readFileSync, existsSync } from "node:fs";
12
+ import { resolve, dirname, join } from "node:path";
13
+ import { CONFIG_NAME } from "./layout.js";
14
+
15
+ export { CONFIG_NAME } from "./layout.js";
16
+
17
+ /** Walk up from `from` until a seisin.toml shows up. Returns its path or null. */
18
+ export function findConfig(from = process.cwd()) {
19
+ let dir = resolve(from);
20
+ for (;;) {
21
+ const candidate = join(dir, CONFIG_NAME);
22
+ if (existsSync(candidate)) return candidate;
23
+ const parent = dirname(dir);
24
+ if (parent === dir) return null;
25
+ dir = parent;
26
+ }
27
+ }
28
+
29
+ const RE_TABLE = /^\[([A-Za-z0-9_.\-]+)\]$/;
30
+ const RE_PAIR = /^([A-Za-z0-9_\-]+)\s*=\s*(.+)$/;
31
+
32
+ /** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
33
+ /**
34
+ * Names a table or key may not have.
35
+ *
36
+ * `[roles.__proto__]` does not show up in `Object.keys(roles)`, so `check`
37
+ * prints the roles that exist and says nothing — while every other role
38
+ * inherits whatever it declared. A config reading `[roles.frontend]` with no
39
+ * `writes` came out of the parser owning the entire repo and holding
40
+ * GITHUB_TOKEN, and no amount of reading the file would show it.
41
+ *
42
+ * The tables below are made with a null prototype, which stops the inheritance
43
+ * on its own. The names are refused as well, because a config containing them
44
+ * is not a config with a mistake in it.
45
+ */
46
+ const FORBIDDEN = new Set(["__proto__", "prototype", "constructor"]);
47
+
48
+ /** A table with no prototype, so nothing is inherited into it. */
49
+ function dict() {
50
+ return Object.create(null);
51
+ }
52
+
53
+ /** Read a property only if the object actually has it. */
54
+ export function own(obj, key) {
55
+ return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
56
+ }
57
+
58
+ export function parseToml(text) {
59
+ const out = dict();
60
+ let table = out;
61
+ let tableName = "";
62
+ // "Last one wins" is TOML-ish and wrong for a permission file. A policy can
63
+ // look restrictive at the top and be cancelled forty lines down, and the
64
+ // person reviewing it reads the first block. Both shapes are refused.
65
+ const seenTables = new Set();
66
+ const seenKeys = new Set();
67
+ const lines = text.split(/\r?\n/);
68
+
69
+ for (let i = 0; i < lines.length; i++) {
70
+ const raw = lines[i];
71
+ const line = stripComment(raw).trim();
72
+ if (!line) continue;
73
+
74
+ const header = RE_TABLE.exec(line);
75
+ if (header) {
76
+ tableName = header[1];
77
+ for (const part of tableName.split("."))
78
+ if (FORBIDDEN.has(part))
79
+ throw new Error(
80
+ `${CONFIG_NAME}:${i + 1}: "[${tableName}]" uses the reserved name "${part}". ` +
81
+ `A table named that would not appear as a role and would hand its settings to ` +
82
+ `every other one.`);
83
+ if (seenTables.has(tableName))
84
+ throw new Error(
85
+ `${CONFIG_NAME}:${i + 1}: [${tableName}] appears twice. ` +
86
+ `A later block would silently override the earlier one — put every setting for ` +
87
+ `${tableName} in one place.`);
88
+ seenTables.add(tableName);
89
+ table = tableName.split(".").reduce((node, key) => {
90
+ if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
91
+ return node[key];
92
+ }, out);
93
+ continue;
94
+ }
95
+
96
+ const pair = RE_PAIR.exec(line);
97
+ if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
98
+
99
+ let [, key, value] = pair;
100
+ // An array may span lines; keep pulling until the brackets balance.
101
+ if (value.startsWith("[") && !value.includes("]")) {
102
+ while (!value.includes("]") && i + 1 < lines.length) value += " " + stripComment(lines[++i]).trim();
103
+ }
104
+ if (FORBIDDEN.has(key))
105
+ throw new Error(`${CONFIG_NAME}:${i + 1}: "${key}" is a reserved name and cannot be a setting.`);
106
+ const seen = `${tableName}.${key}`;
107
+ if (seenKeys.has(seen))
108
+ throw new Error(
109
+ `${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
110
+ `The second would win, which is not a thing a permission file should do quietly.`);
111
+ seenKeys.add(seen);
112
+ table[key] = readValue(value, i + 1);
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** Strips a trailing `#` comment, but not one inside a quoted string. */
118
+ function stripComment(line) {
119
+ let quoted = false;
120
+ for (let i = 0; i < line.length; i++) {
121
+ const c = line[i];
122
+ if (c === '"') quoted = !quoted;
123
+ else if (c === "#" && !quoted) return line.slice(0, i);
124
+ }
125
+ return line;
126
+ }
127
+
128
+ /**
129
+ * One value, read strictly.
130
+ *
131
+ * Strict is the whole point, and it was not before. The array reader used to
132
+ * pick quoted runs out of the line with a regex and ignore everything between
133
+ * them, so `["a" GARBAGE "b"]` parsed as `["a","b"]` and an unterminated string
134
+ * parsed as `""`. A permission file that is half-understood is worse than one
135
+ * that is rejected: the half that was dropped is the half you meant.
136
+ *
137
+ * So this scans rather than matches, and anything it does not recognise is an
138
+ * error with a line number. There are no escapes — a `"` ends the string. If a
139
+ * path of yours needs a quote in it, that is outside this subset and the
140
+ * message says so instead of guessing.
141
+ */
142
+ function readValue(value, lineNo) {
143
+ const v = value.trim();
144
+ const bad = (msg) => { throw new Error(`${CONFIG_NAME}:${lineNo}: ${msg}`); };
145
+
146
+ if (v === "true") return true;
147
+ if (v === "false") return false;
148
+
149
+ if (v.startsWith('"')) {
150
+ const end = v.indexOf('"', 1);
151
+ if (end === -1) bad(`unterminated string — no closing quote in ${v}`);
152
+ const rest = v.slice(end + 1).trim();
153
+ if (rest) bad(`unexpected ${JSON.stringify(rest)} after the string`);
154
+ return v.slice(1, end);
155
+ }
156
+
157
+ if (v.startsWith("[")) {
158
+ if (!v.endsWith("]")) bad("unterminated array — no closing bracket");
159
+ const items = [];
160
+ let i = 1;
161
+ const body = v.slice(0, -1); // everything up to the closing bracket
162
+ let expectItem = true; // a list alternates item, comma, item…
163
+
164
+ while (i < body.length) {
165
+ const c = body[i];
166
+ if (c === " " || c === "\t") { i++; continue; }
167
+
168
+ if (c === ",") {
169
+ if (expectItem) bad("empty array slot — two commas in a row, or a leading comma");
170
+ expectItem = true; i++; continue;
171
+ }
172
+ if (!expectItem) bad(`missing comma before ${JSON.stringify(body.slice(i, i + 12))}`);
173
+ if (c !== '"') bad(`array items must be double-quoted, found ${JSON.stringify(body.slice(i, i + 12))}`);
174
+
175
+ const end = body.indexOf('"', i + 1);
176
+ if (end === -1) bad("unterminated string inside the array");
177
+ items.push(body.slice(i + 1, end));
178
+ i = end + 1;
179
+ expectItem = false; // a trailing comma is fine; a trailing item is not
180
+ }
181
+ return items;
182
+ }
183
+
184
+ bad("value must be a string, an array of strings, or a boolean");
185
+ }
186
+
187
+ /**
188
+ * Turns the parsed file into the shape the rest of the program uses, and
189
+ * rejects anything ambiguous.
190
+ *
191
+ * Rejecting is the point. The sandbox runtime refuses to start on an invalid
192
+ * settings file rather than falling back to a permissive default, and seisin
193
+ * matches that: a permission tool that guesses is worse than no permission tool.
194
+ */
195
+ /**
196
+ * `[runtime] isolate` — one setting, two levels, because they were one and
197
+ * should not have been.
198
+ *
199
+ * false (default) the role reads your home like any process you run
200
+ * "credentials" the places credentials live go dark. HOME is untouched
201
+ * "home" / true the above, plus a HOME, TMPDIR and XDG set of the role's own
202
+ *
203
+ * **Why the middle one exists.** These were a single switch, and the half that
204
+ * is worth having was welded to the half that breaks things. Measured on macOS:
205
+ * with a home of its own, `claude -p` answers `Not logged in - please run
206
+ * /login`, and the cause is not this sandbox at all. Claude Code keeps its
207
+ * credential in the macOS Keychain, the login keychain lives at
208
+ * `$HOME/Library/Keychains`, and moving HOME points that at a keychain which
209
+ * does not exist. `HOME=/empty claude -p` reproduces it with no sandbox in
210
+ * sight, so the same would happen to any tool that relocates a home.
211
+ *
212
+ * Meanwhile the part people actually want - `~/.ssh`, `~/.aws`, `~/.npmrc` and
213
+ * `~/.config/gh/hosts.yml` closed to every role - never needed a new HOME at
214
+ * all. Measured, with HOME left alone: all four refused, and the agent starts.
215
+ *
216
+ * So the levels split along what each one costs. `"credentials"` is adoptable
217
+ * on a machine you are already working on; `"home"` is the stronger claim and
218
+ * keeps its honest price. `true` still means what it meant, so a config written
219
+ * before this reads the same.
220
+ *
221
+ * Rejected: linking the real keychain into the isolated home to get the best of
222
+ * both. Measured against a throwaway keychain - a confined role reads **every**
223
+ * item in it, including one added without `-A`, because a macOS ACL is per
224
+ * application and `security` is the application. That trades one hole for a
225
+ * larger one: the login keychain is every password you have.
226
+ */
227
+ export function readIsolate(v) {
228
+ if (v === true || v === "home") return "home";
229
+ if (v === "credentials") return "credentials";
230
+ // Anything else is off, including a misspelling. A permission tool must not
231
+ // read a word it does not know as a stronger setting than the reader meant.
232
+ return false;
233
+ }
234
+
235
+ export function loadConfig(path) {
236
+ const parsed = parseToml(readFileSync(path, "utf8"));
237
+ // Read only what the file actually declared. The parser refuses the names
238
+ // that make inheritance possible, and this is the second half of the same
239
+ // rule: if one of them ever gets through, a role still cannot pick up a
240
+ // setting it never wrote down. Two independent stops, because the failure
241
+ // they prevent is invisible in review — the config reads one way and the
242
+ // policy is another.
243
+ const roles = own(parsed, "roles") ?? {};
244
+
245
+ /**
246
+ * A role is a leaf. `[roles.a.b]` is a typo, with certainty.
247
+ *
248
+ * In TOML the dot separates keys, so `[roles.mimo-v2.5-free-1]` declares a
249
+ * role called `mimo-v2` with a nested table inside it. `check` printed a list
250
+ * of plausible-looking names and said nothing; the failure arrived later as
251
+ * `unknown role "mimo-v2.5-free-1"`. Fifteen of eighteen runs on somebody's
252
+ * bench died on it, and the surviving three were the model names with no dots
253
+ * in them.
254
+ *
255
+ * There is no ambiguity to preserve here — a nested table under [roles] has
256
+ * no meaning in this tool — so it is an error, and it names both what was
257
+ * written and what it became.
258
+ */
259
+ for (const name of Object.keys(roles)) {
260
+ const r = own(roles, name);
261
+ const nested = Object.keys(r ?? {}).filter((k) => r[k] && typeof r[k] === "object" && !Array.isArray(r[k]));
262
+ if (nested.length)
263
+ throw new Error(
264
+ `${path}: [roles.${name}.${nested[0]}] — a dot separates keys in TOML, so this ` +
265
+ `declares a role called "${name}", not "${name}.${nested[0]}".\n` +
266
+ ` Role names cannot contain dots. Use "${name}-${nested[0]}".`);
267
+ }
268
+ const names = Object.keys(roles);
269
+ if (names.length === 0) throw new Error(`${path}: no [roles.<name>] sections found`);
270
+
271
+ // One directory or several. Several is the common case once a repo has more
272
+ // than one kind of secret, and making people flatten them to satisfy the tool
273
+ // is how a tool gets kept out.
274
+ const keyDir = own(own(parsed, "keys"), "dir");
275
+ const keyDirs = keyDir === undefined ? [] : asArray(keyDir, "keys.dir");
276
+ // `undefined` means "use the defaults"; an explicit empty array means "none".
277
+ // The difference matters: one is a user who has not thought about it, the
278
+ // other is a user who has.
279
+ const runtime = own(parsed, "runtime");
280
+ const runtimeWrites = own(runtime, "writes");
281
+
282
+ const out = {
283
+ root: dirname(path), path, keyDirs,
284
+ allowedDomains: own(own(parsed, "network"), "allow") ?? [],
285
+ runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
286
+ // `[runtime] isolate` — how much of your home a role stops reaching. See
287
+ // srt.js for what each level emits, and readIsolate() below for why there
288
+ // are two of them.
289
+ isolate: readIsolate(own(runtime, "isolate")),
290
+ redact: own(runtime, "redact"),
291
+ scanIgnore: asArray(own(own(parsed, "scan"), "ignore"), "scan.ignore"),
292
+ roles: {},
293
+ };
294
+
295
+ for (const name of names) {
296
+ const r = own(roles, name);
297
+ const writes = asArray(own(r, "writes"), `roles.${name}.writes`);
298
+ const keys = asArray(own(r, "keys"), `roles.${name}.keys`);
299
+ if (keys.length && keyDirs.length === 0)
300
+ throw new Error(`${path}: roles.${name} lists keys, but [keys] dir is not set`);
301
+ out.roles[name] = {
302
+ name,
303
+ writes,
304
+ keys,
305
+ env: asArray(own(r, "env"), `roles.${name}.env`),
306
+ network: own(r, "network") === undefined ? null : asArray(own(r, "network"), `roles.${name}.network`),
307
+ };
308
+ }
309
+ return out;
310
+ }
311
+
312
+ function asArray(value, where) {
313
+ if (value === undefined) return [];
314
+ if (typeof value === "string") return [value];
315
+ if (Array.isArray(value)) return value;
316
+ throw new Error(`${where}: expected a string or an array of strings`);
317
+ }
package/src/env.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The child's environment, built rather than inherited.
3
+ *
4
+ * Measured before this existed: 93 variables crossed into every sandboxed turn,
5
+ * a planted secret among them. `denyRead` protects files, and an environment
6
+ * variable is not a file — so a policy that scoped keys perfectly still handed
7
+ * over every token the parent shell happened to be carrying.
8
+ *
9
+ * So the rule is the same as everywhere else in seisin: nothing is inherited
10
+ * unless it is named. The base list below is what a command needs to be a
11
+ * command at all, and it deliberately contains no credential-shaped names.
12
+ */
13
+
14
+ /**
15
+ * What survives with no policy at all.
16
+ *
17
+ * Each of these is here because removing it breaks something ordinary, not
18
+ * because it looked harmless: PATH finds the binary, HOME finds the toolchain's
19
+ * config, TMPDIR pairs with the scratch grant, TERM and the locale keep output
20
+ * readable, and the CI/tty pair keeps tools from guessing wrong about the shell.
21
+ */
22
+ export const BASE = [
23
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "PWD", "TMPDIR",
24
+ // Set explicitly by `run` in isolated mode, and carried here so a tool that
25
+ // reads them directly lands in the role's own home rather than the real one.
26
+ "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME",
27
+ "TERM", "TERM_PROGRAM", "COLORTERM", "LANG", "LC_ALL", "LC_CTYPE", "TZ",
28
+ "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
29
+ ];
30
+
31
+ /**
32
+ * Names that must never ride along, even if a role asks for them by pattern.
33
+ *
34
+ * A role can name a variable explicitly and get it — that is the point of the
35
+ * `env` list. What this blocks is a wildcard quietly sweeping up a credential,
36
+ * which is how "pass through what the build needs" becomes "pass through
37
+ * everything" three commits later.
38
+ */
39
+ const NEVER = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|SESSION|COOKIE|PRIVATE)(_|$)/i;
40
+
41
+ /**
42
+ * (env, dropped) — the child's environment, and the names left behind.
43
+ *
44
+ * The second half is not bookkeeping. A tool that silently drops the one
45
+ * variable your build needed is worse to debug than one that never touched the
46
+ * environment, so `seisin run` prints the count and `--debug-env` prints the names.
47
+ */
48
+ export function buildEnv(parent, role, extra = []) {
49
+ const wanted = new Set([...BASE, ...extra, ...(role.env ?? [])]);
50
+ const env = {};
51
+ const dropped = [];
52
+
53
+ for (const [name, value] of Object.entries(parent)) {
54
+ if (!wanted.has(name)) { dropped.push(name); continue; }
55
+ if (NEVER.test(name) && !(role.env ?? []).includes(name)) { dropped.push(name); continue; }
56
+ env[name] = value;
57
+ }
58
+ // A role may name a variable the parent does not have. That is not an error —
59
+ // it is a policy written ahead of the environment, which is the right order.
60
+ return { env, dropped: dropped.sort() };
61
+ }