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.
- package/README.md +143 -686
- package/package.json +1 -1
- package/src/canary.js +1 -1
- package/src/cli.js +2 -2
- package/src/commands/check.js +19 -11
- package/src/commands/log.js +21 -14
- package/src/commands/requests.js +10 -15
- package/src/commands/review.js +2 -1
- package/src/commands/run.js +275 -248
- package/src/commands/walls.js +12 -5
- package/src/config-fields.js +305 -0
- package/src/config.js +13 -550
- package/src/controls.js +1 -1
- package/src/decide-request.js +37 -0
- package/src/denial-attribution.js +338 -0
- package/src/grants.js +1 -1
- package/src/index.js +1 -1
- package/src/inspect/enforce.js +1 -1
- package/src/inspect/files.js +1 -1
- package/src/inspect/home.js +1 -1
- package/src/inspect/inject.js +1 -1
- package/src/intake.js +3 -2
- package/src/keys.js +1 -1
- package/src/layout.js +1 -1
- package/src/mcp.js +1 -1
- package/src/notify-config.js +58 -0
- package/src/notify.js +4 -52
- package/src/owners.js +2 -2
- package/src/rundir.js +2 -2
- package/src/sandboxes/command.js +0 -2
- package/src/sandboxes/srt.js +1 -1
- package/src/sandboxes/tartaros.js +1 -1
- package/src/serve.js +11 -22
- package/src/spool.js +1 -1
- package/src/{srt.js → srt-settings.js} +1 -1
- package/src/suggest.js +26 -1
- package/src/territory.js +12 -2
- package/src/toml.js +250 -0
- package/src/violations.js +16 -315
- package/src/walls.js +4 -2
- package/ui/index.html +27 -5
|
@@ -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.
|
package/src/inspect/enforce.js
CHANGED
package/src/inspect/files.js
CHANGED
|
@@ -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.
|
package/src/inspect/home.js
CHANGED
package/src/inspect/inject.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
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
|
/**
|
package/src/sandboxes/command.js
CHANGED
|
@@ -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: [] });
|
package/src/sandboxes/srt.js
CHANGED
|
@@ -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
|