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.
- package/README.md +143 -686
- package/package.json +2 -2
- package/src/canary.js +1 -1
- package/src/cli.js +47 -3
- package/src/commands/check.js +68 -12
- package/src/commands/hook.js +1 -1
- 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 +331 -245
- package/src/commands/walls.js +12 -5
- package/src/config-fields.js +305 -0
- package/src/config.js +114 -534
- 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/help.js +6 -4
- 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 +2 -2
- package/src/kinds.js +19 -0
- package/src/layout.js +1 -1
- package/src/log.js +3 -2
- 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/requests.js +9 -4
- package/src/rundir.js +2 -2
- package/src/runs.js +29 -10
- package/src/sandboxes/command.js +0 -2
- package/src/sandboxes/index.js +15 -2
- package/src/sandboxes/srt.js +1 -1
- package/src/sandboxes/tartaros.js +34 -6
- package/src/serve.js +11 -22
- package/src/spool.js +1 -1
- package/src/{srt.js → srt-settings.js} +5 -2
- package/src/state.js +25 -0
- package/src/suggest.js +26 -1
- package/src/surface.js +3 -2
- package/src/territory.js +20 -1
- package/src/toml.js +250 -0
- package/src/violations.js +16 -315
- package/src/walls.js +4 -2
- 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.
|
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
|
@@ -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 {
|
|
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,
|
|
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
|
+
}
|