seisin 0.6.4 → 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.
@@ -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/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
@@ -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/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/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
+ }
package/src/notify.js CHANGED
@@ -23,13 +23,13 @@
23
23
  * which every role can read.
24
24
  */
25
25
  import { readFileSync } from "node:fs";
26
- import { basename, join, resolve, sep } from "node:path";
26
+ import { basename, join } from "node:path";
27
27
  import { keyOf, pending, requestsPath, grantFor, shellId, queueOf, withHandoffs } from "./requests.js";
28
28
  import { describeHandoff } from "./handoff.js";
29
- import { realAncestor } from "./paths.js";
30
29
 
31
- const FORMATS = ["text", "json", "slack"];
32
- const ENV_URL = "SEISIN_NOTIFY_URL";
30
+ import { ENV_URL, FORMATS } from "./notify-config.js";
31
+
32
+ export { readNotify } from "./notify-config.js";
33
33
 
34
34
  /** The URL to notify, or null. Read in the parent only. */
35
35
  function notifyUrl(config, env = process.env) {
@@ -142,51 +142,3 @@ function notificationFailure(error) {
142
142
  if (code === "ECONNREFUSED") return "connection unavailable";
143
143
  return "request failed";
144
144
  }
145
-
146
- /**
147
- * `[notify]` from seisin.toml, refused rather than guessed when it would leak.
148
- * Called by the config loader.
149
- */
150
- export function readNotify(table, { path, root, keyDirs, roles, own }) {
151
- if (table === undefined) return null;
152
- if (own(table, "url") !== undefined)
153
- throw new Error(
154
- `${path}: [notify] url is readable by every role — seisin.toml is not a secret. ` +
155
- `Put the URL in a file inside a key directory no role declares and name it with url_file, ` +
156
- `or set ${ENV_URL} in the environment that starts seisin.`);
157
- const urlFile = own(table, "url_file");
158
- const format = own(table, "format") ?? "text";
159
- if (!FORMATS.includes(format))
160
- throw new Error(`${path}: [notify] format = "${format}" is not one of ${FORMATS.join(", ")}`);
161
- if (urlFile === undefined) return { urlFile: null, format };
162
- if (typeof urlFile !== "string" || !urlFile.trim() || urlFile.startsWith("/") || urlFile.split("/").includes(".."))
163
- throw new Error(`${path}: [notify] url_file must be a path inside the repo`);
164
- const canonical = (p) => realAncestor(resolve(root, p));
165
- const file = canonical(urlFile);
166
- const inKeyDir = keyDirs.some((d) => file.startsWith(canonical(d) + sep));
167
- if (!inKeyDir)
168
- throw new Error(
169
- `${path}: [notify] url_file "${urlFile}" is not inside a key directory, so every role can read ` +
170
- `it. Move it under ${keyDirs[0] ?? "a [keys] dir"}.`);
171
- // A file key is written bare (`netlify.txt`, meaning the first key directory)
172
- // or with its directory (`shared/api.txt`) — the same resolution settingsFor uses.
173
- const fileKey = (raw) => canonical(raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
174
- const holders = Object.entries(roles)
175
- .filter(([, r]) => r.keyEntries?.some((k) => {
176
- if (k.kind === "file") {
177
- const held = fileKey(k.raw);
178
- return file === held || file.startsWith(held + sep);
179
- }
180
- if (k.kind !== "ref" || k.scheme !== "file") return false;
181
- // Match the builtin resolver's last-fragment rule. A reference delivers
182
- // the contents even though it opens no filesystem read for the role.
183
- const hash = k.ref.lastIndexOf("#");
184
- return canonical(hash === -1 ? k.ref : k.ref.slice(0, hash)) === file;
185
- }))
186
- .map(([n]) => n);
187
- if (holders.length)
188
- throw new Error(
189
- `${path}: [notify] url_file "${urlFile}" is declared as a key by ${holders.join(", ")} — ` +
190
- `that role could read it and send the person a fake "grant this". No role may hold it.`);
191
- return { urlFile, format };
192
- }
package/src/owners.js CHANGED
@@ -23,7 +23,7 @@ import { keptFor, shutByTerritory } from "./territory.js";
23
23
  * Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
24
24
  *
25
25
  * A wildcard-free pattern is a **prefix**, because that is what the kernel is
26
- * given. `toWritePath` in srt.js hands `src/api` to `allowWrite` unchanged, and
26
+ * given. `toWritePath` in srt-settings.js hands `src/api` to `allowWrite` unchanged, and
27
27
  * a sandbox grants a path and everything under it — so `src/api`, `src/api/`
28
28
  * and `src/api/**` are one grant to the OS, and reading the first of them as
29
29
  * "that path only" made seisin describe a boundary that was not there.
@@ -246,7 +246,7 @@ export function ownersOf(config, path) {
246
246
  path = path.slice(root.length);
247
247
  }
248
248
  // A private folder has one owner, whatever else covers it: every other role
249
- // is given a denyWrite on it (srt.js), and denyWrite wins over any territory.
249
+ // is given a denyWrite on it (srt-settings.js), and denyWrite wins over any territory.
250
250
  const mine = privateOwner(config, path);
251
251
  return Object.values(config.roles)
252
252
  .filter((r) => r.writes.some((g) => covers(g, path)) && !neverWrites(r, path, config))
package/src/rundir.js CHANGED
@@ -31,10 +31,10 @@ export const RUNS_NAME = "snr";
31
31
  /**
32
32
  * Where a run's temp directory lives when the role may write no other: beside
33
33
  * the runs root, not inside it. The runs root is denied to writes as a whole
34
- * (srt.js), and a deny wins over any grant beneath it.
34
+ * (srt-settings.js), and a deny wins over any grant beneath it.
35
35
  */
36
36
  export const TMP_NAME = "snt";
37
- /** The settings file inside a run's directory. srt.js denies it to the run by this name. */
37
+ /** The settings file inside a run's directory. srt-settings.js denies it to the run by this name. */
38
38
  export const SETTINGS_NAME = "settings.json";
39
39
 
40
40
  /**
@@ -72,8 +72,6 @@ export function assessCheck(argv, file, label) {
72
72
  return { unmet: [`${label} says it cannot hold this policy: ${why}`], warnings };
73
73
  }
74
74
 
75
- /** Only the answer: `[]` for yes, the reason for no. */
76
- export const askCheck = (argv, file, label) => assessCheck(argv, file, label).unmet;
77
75
 
78
76
  export function commandSandbox(name, command, check = null, { tagsDenials = false } = {}) {
79
77
  const assess = (_settings, { file } = {}) => (check ? assessCheck(check, file, `its check (${check[0]})`) : { unmet: [], warnings: [] });
@@ -2,7 +2,7 @@
2
2
  * `srt`: @anthropic-ai/sandbox-runtime, the sandbox seisin was built on.
3
3
  *
4
4
  * The default, and the reference every other provider is measured against:
5
- * the settings seisin generates (srt.js, one level up) ARE this runtime's
5
+ * the settings seisin generates (srt-settings.js, one level up) ARE this runtime's
6
6
  * settings file, so it enforces all of them by construction and `unmet` is
7
7
  * always empty. What a run hands it is the file and the command, nothing else.
8
8
  */
@@ -28,7 +28,7 @@ export const TARTAROS_PACKAGE = "@carlostapiacl/tartaros";
28
28
 
29
29
  /**
30
30
  * The tartaros seisin depends on first, one on PATH second, and nothing third:
31
- * the order srt is found in (srt.js), for the same reason. A tartaros built
31
+ * the order srt is found in (resolveSrt in sandboxes/srt.js), for the same reason. A tartaros built
32
32
  * once and left on PATH goes on answering after the pinned one has its fixes.
33
33
  *
34
34
  * The pinned one is an optional dependency: a package per platform with the