seisin 0.6.3 → 0.6.5

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.
Files changed (50) hide show
  1. package/README.md +143 -686
  2. package/package.json +2 -2
  3. package/src/canary.js +1 -1
  4. package/src/cli.js +47 -3
  5. package/src/commands/check.js +68 -12
  6. package/src/commands/hook.js +1 -1
  7. package/src/commands/log.js +21 -14
  8. package/src/commands/requests.js +10 -15
  9. package/src/commands/review.js +2 -1
  10. package/src/commands/run.js +331 -245
  11. package/src/commands/walls.js +12 -5
  12. package/src/config-fields.js +305 -0
  13. package/src/config.js +114 -534
  14. package/src/controls.js +1 -1
  15. package/src/decide-request.js +37 -0
  16. package/src/denial-attribution.js +338 -0
  17. package/src/grants.js +1 -1
  18. package/src/help.js +6 -4
  19. package/src/index.js +1 -1
  20. package/src/inspect/enforce.js +1 -1
  21. package/src/inspect/files.js +1 -1
  22. package/src/inspect/home.js +1 -1
  23. package/src/inspect/inject.js +1 -1
  24. package/src/intake.js +3 -2
  25. package/src/keys.js +2 -2
  26. package/src/kinds.js +19 -0
  27. package/src/layout.js +1 -1
  28. package/src/log.js +3 -2
  29. package/src/mcp.js +1 -1
  30. package/src/notify-config.js +58 -0
  31. package/src/notify.js +4 -52
  32. package/src/owners.js +2 -2
  33. package/src/requests.js +9 -4
  34. package/src/rundir.js +2 -2
  35. package/src/runs.js +29 -10
  36. package/src/sandboxes/command.js +0 -2
  37. package/src/sandboxes/index.js +15 -2
  38. package/src/sandboxes/srt.js +1 -1
  39. package/src/sandboxes/tartaros.js +34 -6
  40. package/src/serve.js +11 -22
  41. package/src/spool.js +1 -1
  42. package/src/{srt.js → srt-settings.js} +5 -2
  43. package/src/state.js +25 -0
  44. package/src/suggest.js +26 -1
  45. package/src/surface.js +3 -2
  46. package/src/territory.js +20 -1
  47. package/src/toml.js +250 -0
  48. package/src/violations.js +16 -315
  49. package/src/walls.js +4 -2
  50. package/ui/index.html +35 -9
package/src/controls.js CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { createHash } from "node:crypto";
21
21
  import { loadConfig, CONTROL_FAMILIES, stripComment, closesArray } from "./config.js";
22
- import { settingsFor } from "./srt.js";
22
+ import { settingsFor } from "./srt-settings.js";
23
23
  import { denyFor } from "./surface.js";
24
24
  import { cleanReason } from "./requests.js";
25
25
 
@@ -0,0 +1,37 @@
1
+ /** A person's decision, shared by the CLI and the console, never the MCP. */
2
+ import { loadConfig } from "./config.js";
3
+ import { editPolicy, requestsPath, asFileGrant, refuseIfBarred, applyGrant, settle } from "./requests.js";
4
+
5
+ /**
6
+ * Looked up, applied and settled under one lock — the policy's, the one
7
+ * every grant takes (editPolicy). The lookup used to happen before it and
8
+ * the settle after it, so the console and `seisin grant` could both find the
9
+ * same request pending and both act on it. A decline takes the lock as well,
10
+ * though it writes no policy, for the same reason.
11
+ *
12
+ * The config is edited as text, so comments and order survive, and it is
13
+ * written before the queue is settled: if the write throws, the request is
14
+ * still open rather than marked done against a file that never changed.
15
+ *
16
+ * Selection stays with the interface: the CLI accepts positions and decorates
17
+ * the queue; the console accepts only a stable id and has its own errors.
18
+ * `reload: false` preserves CLI decline's use of its already loaded config;
19
+ * the console reloads even for a decline, so malformed policy still refuses it.
20
+ * `includeOnly: false` keeps CLI declines free of file-grant metadata, even
21
+ * when a sibling request reopened after an earlier one-file grant.
22
+ */
23
+ export function decideRequest(config, { decision, reason, only = false, select, reload = true, includeOnly = true }) {
24
+ let request;
25
+ const result = editPolicy(config, (before) => {
26
+ const current = reload ? loadConfig(config.path, before) : config;
27
+ request = select(current);
28
+ if (decision !== "granted") return { toml: before, changed: false };
29
+ if (only) request = asFileGrant(request);
30
+ refuseIfBarred(current, request);
31
+ return applyGrant(before, request, reason);
32
+ }, {
33
+ // Keep settlement after the policy write, still under its lock.
34
+ after: () => settle(requestsPath(config.root), request.key, decision, reason, includeOnly ? { only: request.only } : undefined),
35
+ });
36
+ return { request, ...result };
37
+ }
@@ -0,0 +1,338 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ /**
4
+ * Split a POSIX shell word list, the way a shell would.
5
+ *
6
+ * This reads the runtime's attribution tag, which carries the wrapped command
7
+ * re-quoted for `sh -c` and truncated to 100 characters. Parsing it beats
8
+ * re-generating it: any correct quoting of the same argument list parses back to
9
+ * that list, so this keeps matching if the runtime changes how it quotes, which
10
+ * re-implementing its `quote()` would not.
11
+ *
12
+ * Truncation means the last word is usually a fragment and the closing quote is
13
+ * usually missing. That is expected, not an error — an unterminated word is
14
+ * returned as far as it got, and {@link isOurs} treats the final word as a
15
+ * prefix.
16
+ */
17
+ export function shellSplit(line) {
18
+ const words = [];
19
+ let word = null;
20
+ let quote = null;
21
+ for (let i = 0; i < line.length; i++) {
22
+ const c = line[i];
23
+ if (quote) {
24
+ if (c === quote) quote = null;
25
+ else word += c;
26
+ continue;
27
+ }
28
+ if (c === "'" || c === '"') { quote = c; word ??= ""; continue; }
29
+ if (c === "\\" && i + 1 < line.length) { word = (word ?? "") + line[++i]; continue; }
30
+ if (/\s/.test(c)) { if (word !== null) { words.push(word); word = null; } continue; }
31
+ word = (word ?? "") + c;
32
+ }
33
+ if (word !== null) words.push(word);
34
+ return words;
35
+ }
36
+
37
+ /**
38
+ * Was this tagged command the one this run launched?
39
+ *
40
+ * The second of two anchors, and it exists because the first one is racy: a
41
+ * refused `sh -c` can be dead before `ps` runs, and then a process tree proves
42
+ * nothing. This one does not depend on the process still existing.
43
+ *
44
+ * Compared as a *prefix*, because the tag holds only the first 100 characters:
45
+ * every complete word must match exactly, and the last word — the one the
46
+ * truncation cut — only has to start the same way. A command that agrees with
47
+ * ours for 100 characters and then differs would be credited to us, which is a
48
+ * wrong attribution we accept: it requires another sandbox on the same machine
49
+ * launching a command that matches ours to the byte for 100 bytes.
50
+ */
51
+ export function isOurs(tagged, argv) {
52
+ if (!tagged) return false;
53
+ const words = shellSplit(tagged);
54
+ if (words.length === 0 || words.length > argv.length) return false;
55
+ for (let i = 0; i < words.length - 1; i++) if (words[i] !== argv[i]) return false;
56
+ const last = words[words.length - 1];
57
+ return argv[words.length - 1]?.startsWith(last) ?? false;
58
+ }
59
+
60
+ /**
61
+ * Is `pid` a descendant of `root`?
62
+ *
63
+ * This is the anchor for attribution, and it is the reason a denial from
64
+ * somebody else's sandbox never lands in this repo's log. The alternative was
65
+ * matching the decoded command against the one we launched, which would mean
66
+ * reimplementing the runtime's shell quoting and inheriting its bugs; a process
67
+ * tree is a fact the OS already holds.
68
+ *
69
+ * At most one `ps` per TREE_MS, and only until the session suffix is learned —
70
+ * after that the suffix alone is exact, because it is generated once per `srt`
71
+ * process. In practice that is one `ps` per run.
72
+ */
73
+ export function descends(pid, root, snapshot) {
74
+ const parent = new Map(snapshot);
75
+ const seen = new Set();
76
+ let at = pid;
77
+ while (at && at !== 1 && !seen.has(at)) {
78
+ if (at === root) return true;
79
+ seen.add(at);
80
+ at = parent.get(at);
81
+ }
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * pid → ppid for every process this user can see, and in `tree.roots` the
87
+ * pids that are a sandbox run's root: an `srt`, or a `seisin run`.
88
+ *
89
+ * The roots are what makes "not ours" provable. A process outside our tree is
90
+ * someone else's only if its ancestry reaches another run; one whose ancestry
91
+ * ends at init was orphaned — `cmd &`, a build daemon, git's fsmonitor — and
92
+ * may well be ours, reparented when the shell that started it exited.
93
+ */
94
+ export function processTree(run = execFileSync) {
95
+ const tree = new Map();
96
+ tree.roots = new Set();
97
+ try {
98
+ const out = run("/bin/ps", ["-Ao", "pid=,ppid=,args="], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
99
+ for (const line of out.split("\n")) {
100
+ const m = line.trim().match(/^(\d+)\s+(\d+)(?:\s+(.*))?$/);
101
+ if (!m) continue;
102
+ tree.set(Number(m[1]), Number(m[2]));
103
+ if (m[3] && RUN_ROOT.test(m[3])) tree.roots.add(Number(m[1]));
104
+ }
105
+ } catch {
106
+ // No tree means nothing can be attributed, which the caller handles by
107
+ // recording nothing. A permission tool does not invent an owner.
108
+ }
109
+ return tree;
110
+ }
111
+
112
+ /** The command line of an `srt` (however it was installed) or of `seisin run`. */
113
+ const RUN_ROOT = /(?:^|[\s/])srt(?:\s|$)|sandbox-runtime\/dist\/cli\.js(?:\s|$)|(?:^|[\s/])(?:seisin|cli\.js)\s+run\s/;
114
+
115
+ /**
116
+ * Does `pid` descend from a run root other than `root`?
117
+ *
118
+ * A table without command lines (`roots` absent) cannot tell an orphan from a
119
+ * stranger, and answers as before: alive and not ours is someone else's.
120
+ */
121
+ export function underAnotherRun(pid, root, snapshot) {
122
+ if (!snapshot.roots) return true;
123
+ const seen = new Set();
124
+ for (let at = pid; at && at !== 1 && !seen.has(at); at = snapshot.get(at)) {
125
+ if (at !== root && snapshot.roots.has(at)) return true;
126
+ seen.add(at);
127
+ }
128
+ return false;
129
+ }
130
+
131
+ /**
132
+ * Attribution state for one run, independent of the log reader.
133
+ *
134
+ * The transport owns scheduling and shutdown. A refused snapshot requests a
135
+ * later look; revisiting held events must bypass arrival deduplication.
136
+ */
137
+ export function createDenialAttribution(onDeny, {
138
+ pid = null, argv = null, treeFn = processTree, now = () => performance.now(),
139
+ stats = { attributed: 0, foreign: 0, unattributed: 0, dropped: 0 },
140
+ requestRecheck = () => {},
141
+ } = {}) {
142
+ let suffix = null; // learned once, exact from then on
143
+ let tree = null; // refreshed only while still learning
144
+ let treeAt = -Infinity; // when `tree` was taken
145
+ let root = pid; // the srt pid; not known until after spawn
146
+ const mine = new Set(); // pids already proven to be ours
147
+ const held = []; // denials seen before they could be attributed
148
+ const foreignSuffixes = new Set(); // other sandboxes, proven while they were alive
149
+
150
+ /**
151
+ * One `ps` per TREE_MS at most, and a later look instead of a skipped one.
152
+ *
153
+ * `ps` is synchronous and it was taken for every denial from another sandbox
154
+ * while this run had not learned its own suffix — which, for a run that is
155
+ * never refused anything, is the whole run. Several cells at once is exactly
156
+ * that: 83 ms of blocked event loop per foreign line, measured. A line whose
157
+ * process may be younger than the last snapshot is not decided on it; it is
158
+ * held, and looked at again once the window has passed.
159
+ */
160
+ const snapshot = () => { tree = treeFn(); treeAt = now(); };
161
+
162
+ /**
163
+ * Two anchors, and either one is enough.
164
+ *
165
+ * Neither works alone. The process tree is exact while the process exists and
166
+ * proves nothing once it has exited — which, for the short commands that get
167
+ * refused most, is by the time the line arrives. The command tag survives the
168
+ * process but depends on the runtime's quoting staying recognisable.
169
+ *
170
+ * Whichever answers first hands over the session suffix, and from that point
171
+ * attribution is one string comparison: the suffix is generated once per `srt`
172
+ * process, so it separates this run from every other sandbox on the machine
173
+ * exactly. In practice the tag settles it on the first denial and the tree is
174
+ * never consulted.
175
+ */
176
+ const attribute = (d) => {
177
+ if (suffix) return d.suffix === suffix;
178
+ if (argv && isOurs(d.command, argv)) {
179
+ if (d.suffix) suffix = d.suffix;
180
+ return true;
181
+ }
182
+ if (root === null) return false; // nothing to descend from yet
183
+ if (mine.has(d.pid)) return true;
184
+ // A suffix already proven foreign needs no `ps`: the suffix is per `srt`.
185
+ if (d.suffix && foreignSuffixes.has(d.suffix)) return false;
186
+ if (tree === null) snapshot();
187
+ if (!descends(d.pid, root, tree)) {
188
+ // A pid the snapshot does not have may simply be younger than it.
189
+ // Refresh once, then believe the answer — unless the last refresh was
190
+ // moments ago, and then look again later rather than refresh now. A pid
191
+ // it does have is answered: its parents do not change into ours.
192
+ if (treeAt < d.seen && !tree.has(d.pid)) {
193
+ if (now() - treeAt < TREE_MS) { requestRecheck(); return LATER; }
194
+ snapshot();
195
+ }
196
+ if (!descends(d.pid, root, tree)) {
197
+ // Alive, not ours by either anchor, and under another run — the tag
198
+ // did not match above. Every later line from that `srt` is someone
199
+ // else's without asking. Alive but under no run at all is an orphan
200
+ // (`cmd &`, a daemon): possibly ours, so its suffix stays undecided
201
+ // rather than turning this run's own later lines into strangers.
202
+ if (d.suffix && tree.has(d.pid) && underAnotherRun(d.pid, root, tree)) foreignSuffixes.add(d.suffix);
203
+ return false;
204
+ }
205
+ }
206
+ mine.add(d.pid);
207
+ // The first proven denial hands us the suffix for every later one.
208
+ if (d.suffix) suffix = d.suffix;
209
+ return true;
210
+ };
211
+
212
+ /**
213
+ * The same refusal, reported twice.
214
+ *
215
+ * macOS emits a violation from `kernel` and, for some of them, a second
216
+ * extended report from `sandboxd` carrying the identical `Sandbox:` line.
217
+ * They are one event and the log has to say so — `review` counts lines, and a
218
+ * denial that shows as two is a policy that looks twice as wrong as it is.
219
+ * Measured on the first real run: 31 doubled events out of 730.
220
+ *
221
+ * Keyed on what the kernel said, not on when we read it: two deliveries of one
222
+ * event agree on pid, operation and path and disagree on arrival time.
223
+ */
224
+ const recent = new Map();
225
+ const isRepeat = (d) => {
226
+ const key = `${d.pid}|${d.operation}|${d.path}`;
227
+ const now = Date.now();
228
+ for (const [k, t] of recent) if (now - t > REPEAT_MS) recent.delete(k); else break;
229
+ if (recent.has(key)) return true;
230
+ recent.set(key, now);
231
+ return false;
232
+ };
233
+
234
+ const consider = (d) => {
235
+ const verdict = attribute(d);
236
+ if (verdict === true) {
237
+ stats.attributed++;
238
+ // Report the deciding denial first: callbacks observe this replay order.
239
+ onDeny(d);
240
+ // Learning the suffix makes every held line decidable. Without this,
241
+ // denials that arrived while the first process was being identified —
242
+ // which on a fast command is all of them — are lost.
243
+ if (suffix && held.length) revisit();
244
+ return;
245
+ }
246
+ // Not ours *yet*. Two reasons, both temporary: the run has not spawned the
247
+ // child whose pid anchors attribution, or the process died before `ps`
248
+ // could see it. Either way a foreign sandbox and one of ours look the same
249
+ // from here, so hold the line rather than credit or discard it.
250
+ // A line put off to the next snapshot is held with or without a suffix:
251
+ // the look it is waiting for is the only thing that can decide it.
252
+ if (verdict === LATER || (!suffix && d.suffix && !foreignSuffixes.has(d.suffix))) {
253
+ stats.unattributed++;
254
+ held.push(d);
255
+ // Bounded, because the common case on a busy machine is a run that is
256
+ // never refused anything: it never learns its own suffix, so every
257
+ // denial from every other sandbox lands here and nothing ever drains it.
258
+ // Several cells running at once for hours is exactly that shape, and an
259
+ // unbounded array is how an instrument becomes the leak.
260
+ //
261
+ // Dropping the oldest is the right end to drop. What is still worth
262
+ // replaying is whatever arrived near the run's own first refusal; a
263
+ // denial from an hour ago that is still unattributed is not ours.
264
+ while (held.length > HELD_MAX) { held.shift(); stats.unattributed--; stats.foreign++; }
265
+ return;
266
+ }
267
+ stats.foreign++;
268
+ };
269
+
270
+ /** Re-examine what was held, now that something new is known. */
271
+ const revisit = () => {
272
+ for (const h of held.splice(0)) { stats.unattributed--; consider(h); }
273
+ };
274
+
275
+ return {
276
+ stats,
277
+
278
+ accept(d) {
279
+ d.seen = now();
280
+ // Deduplicated here and not in consider(), because a held denial goes
281
+ // through consider() a second time when it is finally attributed. Doing
282
+ // this there would drop every line that arrived before the child had a
283
+ // pid — which, on a short command, is all of them.
284
+ if (!isRepeat(d)) consider(d);
285
+ },
286
+
287
+ /**
288
+ * Name the process whose descendants count as ours.
289
+ *
290
+ * Separate from the constructor because the stream has to be listening
291
+ * *before* the child starts. A short command — the kind an agent runs
292
+ * hundreds of times — is refused and gone in single-digit milliseconds, so
293
+ * a monitor started after the spawn misses precisely the denials it exists
294
+ * to catch. Measured: with the stream started afterwards, a one-line `sh -c`
295
+ * refusal recorded nothing at all.
296
+ */
297
+ attributeTo(childPid) {
298
+ root = childPid;
299
+ if (held.length) revisit();
300
+ },
301
+
302
+ revisitPending() {
303
+ if (!suffix && held.length) revisit();
304
+ },
305
+ };
306
+ }
307
+
308
+ /**
309
+ * The shortest time between two `ps` snapshots while attribution is learning.
310
+ *
311
+ * Long enough that a burst of foreign denials costs one snapshot, not one
312
+ * each; short enough that a line held for a younger process is decided before
313
+ * anyone reads the log.
314
+ */
315
+ export const TREE_MS = 250;
316
+
317
+ /** What `attribute` answers for a line it has put off to the next snapshot. */
318
+ const LATER = "later";
319
+
320
+ /**
321
+ * How many undecided denials to keep while waiting to learn our own suffix.
322
+ *
323
+ * Generous enough to cover the startup window — where every denial arrives
324
+ * before there is anything to attribute it to — and small enough that a run
325
+ * which is never refused anything cannot accumulate another sandbox's traffic
326
+ * for the length of an agent session.
327
+ */
328
+ const HELD_MAX = 500;
329
+
330
+ /**
331
+ * How long two reports of one refusal can be apart and still be one event.
332
+ *
333
+ * The second copy — `sandboxd`'s extended report — follows the kernel's within
334
+ * milliseconds. Kept short so that a role genuinely retrying the same path a
335
+ * second later is still recorded as a second attempt, which is what makes
336
+ * `asked 3×` mean anything.
337
+ */
338
+ const REPEAT_MS = 1000;
package/src/grants.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * What a line of policy grants on disk, as paths.
3
3
  *
4
- * Pulled out of srt.js, which used to hold both halves: translating a policy
4
+ * Pulled out of srt-settings.js, which used to hold both halves: translating a policy
5
5
  * into paths, and assembling the runtime's settings object out of them. The
6
6
  * first half has a second reader now — surface.js asks "what can ANY role
7
7
  * write?" to decide what the parent must not trust — and a module that needs
package/src/help.js CHANGED
@@ -12,14 +12,15 @@ import { LIMITS } from "./inspect/limits.js";
12
12
 
13
13
  export const COMMAND_HELP = {
14
14
  run: {
15
- usage: ["seisin run <role> [--observe] [--debug-env] [--agent <name>] [--handoff <id>] [--expect-denied <path>]... -- <command...>"],
16
- values: ["--agent", "--handoff", "--expect-denied"],
15
+ usage: ["seisin run <role> [--observe] [--debug-env] [--agent <name>] [--handoff <id>] [--expect-denied <path>]... [--run-id <id>] -- <command...>"],
16
+ values: ["--agent", "--handoff", "--expect-denied", "--run-id"],
17
17
  flags: [
18
18
  ["--observe", "open the repo for this run and record what the agent writes; keys and network stay shut"],
19
19
  ["--debug-env", "print the environment variables the role does not get"],
20
20
  ["--agent <name>", "claude, codex or none: which agent the command runs, when seisin cannot tell from it (a wrapper script). Its home is the run's scratch; the other agents' sign-in is closed"],
21
21
  ["--handoff <id>", "this run takes a pending request handed to this role, and starts only if the policy routes that request here. Its own requests join the handoff chain"],
22
22
  ["--expect-denied <path>", "a path the caller will probe and expects to be denied (its own canary). That exact path is logged as expected and files no request; repeatable. Any other path is asked as usual"],
23
+ ["--run-id <id>", "the launcher's own id for this run, used in the log instead of a random one, so an orchestrator's records and seisin's join on it"],
23
24
  ],
24
25
  notes: ["Everything after -- is the command's own, flags included: `-- claude --settings x.json` goes to claude."],
25
26
  examples: ['seisin run frontend -- claude', 'seisin run backend --observe -- claude -p "…"'],
@@ -28,11 +29,12 @@ export const COMMAND_HELP = {
28
29
  ["128+n", "killed by signal n (130 for ctrl-c)"], ["2", "seisin could not start it (usage, policy, runtime)"]],
29
30
  },
30
31
  check: {
31
- usage: ["seisin check [role] [--verbose] [--verify] [--parallel] [--system-log]"],
32
+ usage: ["seisin check [role] [--verbose] [--verify] [--parallel] [--system-log] [--json]"],
32
33
  flags: [["--verbose", "also print the protected paths and each warning's explanation (the limits of every repo: seisin help limits)"],
33
34
  ["--verify", "run each role's verify command inside its sandbox (the one thing check executes)"],
34
35
  ["--parallel", "experimental: with --verify, run role checks concurrently; shared resources may race"],
35
- ["--system-log", "macOS: measure for 3 s whether the system log is dropping messages, and who writes the most; exits 1 when it is"]],
36
+ ["--system-log", "macOS: measure for 3 s whether the system log is dropping messages, and who writes the most; exits 1 when it is"],
37
+ ["--json", "the same answer for a program: seisin's version and capabilities, the sandbox each role gets (path and version), the warnings"]],
36
38
  examples: ["seisin check", "seisin check frontend --verbose", "seisin check backend --verify", "seisin check --verify --parallel"],
37
39
  exits: [["0", "the policy can be enforced as written (warnings included)"], ["1", "a role cannot be enforced as written, or its verify failed"], ["2", "usage or policy error"]],
38
40
  },
package/src/index.js CHANGED
@@ -22,7 +22,7 @@ export { CONFIG_NAME, STATE_DIR, LOG_NAME } from "./layout.js";
22
22
  export { covers, ownersOf, keyHolders, explain, explainFileRead, readTarget } from "./owners.js";
23
23
 
24
24
  /** A policy, resolved into sandbox settings. */
25
- export { settingsFor } from "./srt.js";
25
+ export { settingsFor } from "./srt-settings.js";
26
26
 
27
27
  /**
28
28
  * Keys that are references, and how a resolved value reaches a role.
@@ -1,4 +1,4 @@
1
- import { settingsFor } from "../srt.js";
1
+ import { settingsFor } from "../srt-settings.js";
2
2
 
3
3
  export function enforceWarnings(config, roles) {
4
4
  const warnings = [];
@@ -4,7 +4,7 @@ import { lstatSync, readdirSync } from "node:fs";
4
4
  /**
5
5
  * A role's territory, sorted by what the kernel will do with each line.
6
6
  *
7
- * Three shapes get through `settingsFor` (see EXPRESSIBLE in srt.js): the whole
7
+ * Three shapes get through `settingsFor` (see EXPRESSIBLE in srt-settings.js): the whole
8
8
  * repo, a subtree `x/**`, and a literal path. The literal path is the one the
9
9
  * text cannot settle on its own — `data/base.sqlite` and `data` are both
10
10
  * literal, and to the kernel the second is a subtree because it is a directory.
@@ -1,4 +1,4 @@
1
- import { CREDENTIAL_HOMES } from "../srt.js";
1
+ import { CREDENTIAL_HOMES } from "../srt-settings.js";
2
2
 
3
3
  /**
4
4
  * What a role can still read in YOUR home, which is the limit people are most
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { injectKeysOf } from "../keys.js";
14
14
  import { sandboxesOf } from "../sandboxes/index.js";
15
- import { coversHost as covers } from "../srt.js";
15
+ import { coversHost as covers } from "../srt-settings.js";
16
16
 
17
17
  export function injectWarnings(config, roles) {
18
18
  const out = [];
package/src/intake.js CHANGED
@@ -47,10 +47,11 @@ export { policyId };
47
47
  * (`seisin run --expect-denied`): taken from the parent's own argv, never from
48
48
  * anything the sandbox can reach. See expectedByCaller below.
49
49
  */
50
- export function intake({ config, role, runId, observe = false, settings, notify, chain = null, expectDenied = [], trace = {} }) {
50
+ export function intake({ config, role, runId, label = null, observe = false, settings, notify, chain = null, expectDenied = [], trace = {} }) {
51
51
  // `trace` is the launcher's (trace.js), read by the parent: in the stamp, so a
52
52
  // line the sandbox sends cannot claim another run's trace.
53
- const stamp = { run: runId.slice(0, 8), policy: policyId(config), ...trace };
53
+ // The name the rest of the run's lines carry (the launcher's --run-id when it gave one).
54
+ const stamp = { run: label ?? runId.slice(0, 8), policy: policyId(config), ...trace };
54
55
  // No `at` from here or from the sandbox: append() stamps the parent's clock and
55
56
  // ignores any other. The confined process chose the time of its own lines —
56
57
  // it could date them into or out of every `since` window, or make its
package/src/keys.js CHANGED
@@ -375,7 +375,7 @@ export function readFileRef(entry, root = ".", { exact = false } = {}) {
375
375
  export const RESERVED_ENV = new Set([
376
376
  // The last is handoff.js's CHAIN_ENV, spelled out: handoff.js reaches this
377
377
  // module through owners.js, and importing it back would be a cycle.
378
- "SEISIN_ROLE", "SEISIN_CONFIG", SOCK_ENV, "SEISIN_HANDOFF_CHAIN",
378
+ "SEISIN_ROLE", "SEISIN_CONFIG", "SEISIN_ROOT", "SEISIN_STATE_DIR", SOCK_ENV, "SEISIN_HANDOFF_CHAIN",
379
379
  ...BASE, ...Object.keys(DEFAULTS),
380
380
  ]);
381
381
 
@@ -542,7 +542,7 @@ function injectGate(role) {
542
542
 
543
543
  /**
544
544
  * The role's `inject` keys as `{ entry, name, hosts }`, the single source both
545
- * the settings (srt.js) and `check` read. Throws when the policy is internally
545
+ * the settings (srt-settings.js) and `check` read. Throws when the policy is internally
546
546
  * inconsistent — an inject key with no `inject_hosts` entry, or an
547
547
  * `inject_hosts` entry naming a key that is not injected — because either one
548
548
  * is a masked credential that silently injects nowhere, or a host list that
package/src/kinds.js CHANGED
@@ -85,6 +85,10 @@ const SCRATCH_FILE = [
85
85
  /^\.!\d+!/, // `.!36165!file` — a copy in flight
86
86
  /^\.DS_Store$/,
87
87
  /^pytest-cache-files-/,
88
+ // Vite and Vitest bundle a TS config into `vite.config.ts.timestamp-<ms>-<hash>.mjs`
89
+ // beside it, load it and delete it: a name that never repeats, found asked for
90
+ // 28 times in one orchestrator's queue, owned by a phase that takes no work.
91
+ /\.timestamp-\d+(-[0-9a-f]+)?\.[cm]?js$/,
88
92
  /(^|[-_])logs?$/i, // `logs`, `latest_logs`: what a run leaves
89
93
  ];
90
94
  /**
@@ -230,8 +234,22 @@ export function kindsOf(targets, opts = {}) {
230
234
  export const EXPECTED = {
231
235
  plugins:
232
236
  "Claude Code updating its plugins from inside the sandbox: plugins run outside it and update there, so the denial is expected — nothing to grant",
237
+ "os-background":
238
+ "macOS frameworks reaching for shared state every program touches at start (preferences, notifications, a compatibility sysctl): denied, and nothing the agent does depends on it — nothing to grant",
233
239
  };
234
240
 
241
+ /**
242
+ * The system denials macOS frameworks cause in any sandboxed program, whatever
243
+ * it is doing: counted in one orchestrator's runs, 280 lines of them against the
244
+ * handful that were the task's. Exact names, so a service the agent itself asks
245
+ * for still reads as a wall.
246
+ */
247
+ const OS_BACKGROUND = new Set([
248
+ "apple.shm.notification_center", "apple.cfprefs.daemonv1", "com.apple.AppleDatabaseChanged",
249
+ "kcfpreferencesanyapplication", "kern.tcsm_enable", "kern.system_version_compat",
250
+ ]);
251
+ const OS_BACKGROUND_RE = /^apple\.cfprefs\.\d+v1$/; // the per-user cfprefs segment
252
+
235
253
  /**
236
254
  * `{ kind, hint }` when the refusal of `target` is expected, else null.
237
255
  *
@@ -240,6 +258,7 @@ export const EXPECTED = {
240
258
  */
241
259
  export function expectedOf(target, { home = homedir(), env = process.env } = {}) {
242
260
  const t = String(target ?? "").replace(/\\/g, "/");
261
+ if (OS_BACKGROUND.has(t) || OS_BACKGROUND_RE.test(t)) return { kind: "os-background", hint: EXPECTED["os-background"] };
243
262
  const roots = [
244
263
  env.CLAUDE_CONFIG_DIR,
245
264
  home && `${home.replace(/\/+$/, "")}/.claude`,
package/src/layout.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Where seisin's own files live. Names only — no logic, no imports.
3
3
  *
4
4
  * These were spread across the modules that happened to create each file, and
5
- * that produced one dependency that points the wrong way: `srt.js`, whose job
5
+ * that produced one dependency that points the wrong way: `srt-settings.js`, whose job
6
6
  * is translating a policy into a sandbox, imported `log.js` purely to learn the
7
7
  * name of the state directory it has to keep writable. A module that decides
8
8
  * what the kernel may allow should not know that logging exists.
package/src/log.js CHANGED
@@ -16,13 +16,14 @@
16
16
  import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
17
17
  import { createHash } from "node:crypto";
18
18
  import { basename, dirname, join } from "node:path";
19
- import { STATE_DIR, LOG_NAME } from "./layout.js";
19
+ import { LOG_NAME } from "./layout.js";
20
+ import { stateDir } from "./state.js";
20
21
  import { send } from "./spool.js";
21
22
 
22
23
  export { STATE_DIR, LOG_NAME } from "./layout.js";
23
24
 
24
25
  export function logPath(root) {
25
- return join(root, STATE_DIR, LOG_NAME);
26
+ return join(stateDir(root), LOG_NAME);
26
27
  }
27
28
 
28
29
  /**
package/src/mcp.js CHANGED
@@ -30,7 +30,7 @@ import { toRepoRelative } from "./paths.js";
30
30
  import { loadConfig, findConfig } from "./config.js";
31
31
  import { inspect } from "./inspect.js";
32
32
  import { explain, ownersOf } from "./owners.js";
33
- import { settingsFor } from "./srt.js";
33
+ import { settingsFor } from "./srt-settings.js";
34
34
  import { queueOf, queueData, refuseIfBarred, shellId } from "./requests.js";
35
35
  import { read, logPath } from "./log.js";
36
36
  import { causesOf, parseSince, queue as requestQueue, verdicts, wallsByRole } from "./views.js";
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `[notify]` as the config loader reads it: validation only, so loading a
3
+ * policy does not pull in the request queue the sender needs (notify.js).
4
+ * Why each rule exists is in notify.js.
5
+ */
6
+ import { join, resolve, sep } from "node:path";
7
+ import { realAncestor } from "./paths.js";
8
+
9
+ export const FORMATS = ["text", "json", "slack"];
10
+ export const ENV_URL = "SEISIN_NOTIFY_URL";
11
+
12
+ /**
13
+ * `[notify]` from seisin.toml, refused rather than guessed when it would leak.
14
+ * Called by the config loader.
15
+ */
16
+ export function readNotify(table, { path, root, keyDirs, roles, own }) {
17
+ if (table === undefined) return null;
18
+ if (own(table, "url") !== undefined)
19
+ throw new Error(
20
+ `${path}: [notify] url is readable by every role — seisin.toml is not a secret. ` +
21
+ `Put the URL in a file inside a key directory no role declares and name it with url_file, ` +
22
+ `or set ${ENV_URL} in the environment that starts seisin.`);
23
+ const urlFile = own(table, "url_file");
24
+ const format = own(table, "format") ?? "text";
25
+ if (!FORMATS.includes(format))
26
+ throw new Error(`${path}: [notify] format = "${format}" is not one of ${FORMATS.join(", ")}`);
27
+ if (urlFile === undefined) return { urlFile: null, format };
28
+ if (typeof urlFile !== "string" || !urlFile.trim() || urlFile.startsWith("/") || urlFile.split("/").includes(".."))
29
+ throw new Error(`${path}: [notify] url_file must be a path inside the repo`);
30
+ const canonical = (p) => realAncestor(resolve(root, p));
31
+ const file = canonical(urlFile);
32
+ const inKeyDir = keyDirs.some((d) => file.startsWith(canonical(d) + sep));
33
+ if (!inKeyDir)
34
+ throw new Error(
35
+ `${path}: [notify] url_file "${urlFile}" is not inside a key directory, so every role can read ` +
36
+ `it. Move it under ${keyDirs[0] ?? "a [keys] dir"}.`);
37
+ // A file key is written bare (`netlify.txt`, meaning the first key directory)
38
+ // or with its directory (`shared/api.txt`) — the same resolution settingsFor uses.
39
+ const fileKey = (raw) => canonical(raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
40
+ const holders = Object.entries(roles)
41
+ .filter(([, r]) => r.keyEntries?.some((k) => {
42
+ if (k.kind === "file") {
43
+ const held = fileKey(k.raw);
44
+ return file === held || file.startsWith(held + sep);
45
+ }
46
+ if (k.kind !== "ref" || k.scheme !== "file") return false;
47
+ // Match the builtin resolver's last-fragment rule. A reference delivers
48
+ // the contents even though it opens no filesystem read for the role.
49
+ const hash = k.ref.lastIndexOf("#");
50
+ return canonical(hash === -1 ? k.ref : k.ref.slice(0, hash)) === file;
51
+ }))
52
+ .map(([n]) => n);
53
+ if (holders.length)
54
+ throw new Error(
55
+ `${path}: [notify] url_file "${urlFile}" is declared as a key by ${holders.join(", ")} — ` +
56
+ `that role could read it and send the person a fake "grant this". No role may hold it.`);
57
+ return { urlFile, format };
58
+ }