seisin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +52 -0
- package/src/cli.js +125 -0
- package/src/commands/check.js +9 -0
- package/src/commands/explain.js +87 -0
- package/src/commands/hook.js +42 -0
- package/src/commands/init.js +181 -0
- package/src/commands/log.js +68 -0
- package/src/commands/requests.js +145 -0
- package/src/commands/review.js +94 -0
- package/src/commands/run.js +395 -0
- package/src/commands/scan.js +21 -0
- package/src/commands/ui.js +31 -0
- package/src/commands/whose.js +94 -0
- package/src/commands/wire.js +74 -0
- package/src/config.js +317 -0
- package/src/env.js +61 -0
- package/src/handoff.js +111 -0
- package/src/hook.js +195 -0
- package/src/index.js +52 -0
- package/src/inspect.js +477 -0
- package/src/layout.js +47 -0
- package/src/log.js +124 -0
- package/src/mcp.js +290 -0
- package/src/owners.js +166 -0
- package/src/redact.js +82 -0
- package/src/render.js +150 -0
- package/src/requests.js +259 -0
- package/src/review.js +167 -0
- package/src/scan.js +175 -0
- package/src/serve.js +168 -0
- package/src/spool.js +164 -0
- package/src/srt.js +424 -0
- package/src/violations.js +540 -0
- package/src/worktree.js +216 -0
- package/ui/favicon.svg +1 -0
- package/ui/index.html +937 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The other half of the record: what the KERNEL refused, not what the hook saw.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this exists ──
|
|
5
|
+
* The hook runs one layer above the boundary and reports the attempt before it
|
|
6
|
+
* happens. That is most of the log, and it is deliberately imperfect — a hook
|
|
7
|
+
* that misreads a shell command costs an explanation, never a boundary. But it
|
|
8
|
+
* means the log only ever contains what the hook understood. Everything that
|
|
9
|
+
* escaped it went **unexplained**, and an escape looks exactly like nothing
|
|
10
|
+
* happening: the agent gets a bare `Operation not permitted` on its stderr, with
|
|
11
|
+
* no path and no reason, and the record does not grow.
|
|
12
|
+
*
|
|
13
|
+
* That failure mode has a shape, and it has cost real time. Four defects found
|
|
14
|
+
* while wrapping a live cell all presented the same way — a file that stopped
|
|
15
|
+
* growing — because nothing anywhere said which path had been refused.
|
|
16
|
+
*
|
|
17
|
+
* macOS already writes the missing line. Every Seatbelt denial lands in the
|
|
18
|
+
* system log with the operation, the absolute path, and the sandbox runtime's
|
|
19
|
+
* own attribution tag attached:
|
|
20
|
+
*
|
|
21
|
+
* Sandbox: bash(65518) deny(1) file-write-create /repo/src/api/orders.ts
|
|
22
|
+
* CMD64_c2ggLWMgJ2VjaG8g…_END__qlvxb5sax_SBX
|
|
23
|
+
*
|
|
24
|
+
* This module reads that stream and hands each denial to the parent, which
|
|
25
|
+
* already owns the log file. Nothing here enforces anything: the boundary held
|
|
26
|
+
* before this existed and holds the same afterwards. It is an instrument.
|
|
27
|
+
*
|
|
28
|
+
* ── Why it does not go through the runtime ──
|
|
29
|
+
* The runtime collects these itself — `startMacOSSandboxLogMonitor` — but only
|
|
30
|
+
* for a caller that embeds the library AND passes `enableLogMonitor: true`. The
|
|
31
|
+
* `srt` binary omits that argument, so on the CLI path the collector is never
|
|
32
|
+
* constructed. Filed upstream: docs/upstream/cli-violations.md.
|
|
33
|
+
*
|
|
34
|
+
* ── Why macOS only, stated rather than papered over ──
|
|
35
|
+
* There is no Linux equivalent to read. bubblewrap does not log refusals; the
|
|
36
|
+
* runtime synthesises them by observing write-intent syscalls through its own
|
|
37
|
+
* `apply-seccomp` stub, which reports over a socket the runtime creates and
|
|
38
|
+
* reads paths out of the traced process's memory — its own comment calls those
|
|
39
|
+
* events attacker-controlled and racy. That is not a stream an outside process
|
|
40
|
+
* can attach to, and it is not one to reimplement. On Linux this returns an
|
|
41
|
+
* unavailable watcher with the reason, and `seisin run` says so once.
|
|
42
|
+
*/
|
|
43
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
44
|
+
import { statSync } from "node:fs";
|
|
45
|
+
|
|
46
|
+
/** Only sandbox-runtime tags its violations this way. Everything else on the
|
|
47
|
+
* machine — Safari, mDNSResponder, Spotlight — is filtered out by the OS
|
|
48
|
+
* before a byte reaches us, which is why the predicate is not a grep. */
|
|
49
|
+
const PREDICATE = 'eventMessage ENDSWITH "_SBX"';
|
|
50
|
+
|
|
51
|
+
/** `Sandbox: <proc>(<pid>) deny(<n>) <operation> <detail>` */
|
|
52
|
+
const DENY = /Sandbox:\s+(\S+)\((\d+)\)\s+deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/;
|
|
53
|
+
|
|
54
|
+
/** `CMD64_<base64 of the first 100 chars>_END_<per-srt-process suffix>` */
|
|
55
|
+
const TAG = /CMD64_(.*?)_END_(\S*_SBX)\s*$/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A Seatbelt operation, as one of the two verbs the log speaks.
|
|
59
|
+
*
|
|
60
|
+
* Anything that is not a file operation — `mach-lookup`, `network-outbound`,
|
|
61
|
+
* `sysctl-read` — returns null and is dropped. Those are the sandbox doing its
|
|
62
|
+
* job against the machine, not a role reaching into someone else's territory,
|
|
63
|
+
* and the network half already has its own path through the proxy.
|
|
64
|
+
*
|
|
65
|
+
* **`file-read-metadata` is dropped too, and that one was learned the hard
|
|
66
|
+
* way.** It is a refused `stat()`, which is what any directory walk produces
|
|
67
|
+
* against a denied path — `rg`, `find`, a glob, a tool listing the repo. It is
|
|
68
|
+
* not an attempt to read a secret and recording it as one is actively harmful:
|
|
69
|
+
* the first real run against a policy with a populated key directory wrote
|
|
70
|
+
* **721 of them in two runs**, against 4 genuine `file-read-data` attempts. That
|
|
71
|
+
* buried the nine lines that mattered, told `review` that the policy was
|
|
72
|
+
* catastrophically wrong about a directory where nothing had happened, and filed
|
|
73
|
+
* a separate permission request per file — 327 of them — in a queue whose whole
|
|
74
|
+
* promise is that many refusals in one place are one question.
|
|
75
|
+
*
|
|
76
|
+
* `file-read-data` is the operation that means content was reached for. That is
|
|
77
|
+
* the one worth a line.
|
|
78
|
+
*/
|
|
79
|
+
export function actionOf(operation) {
|
|
80
|
+
if (operation.startsWith("file-write")) return "write";
|
|
81
|
+
if (operation === "file-read-data") return "read";
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One chunk of `log stream` output into a denial, or null.
|
|
87
|
+
*
|
|
88
|
+
* Kept pure and exported so the parsing is testable without a kernel: every
|
|
89
|
+
* fixture in the test suite is a real chunk captured from a real refusal.
|
|
90
|
+
*/
|
|
91
|
+
export function parseChunk(chunk) {
|
|
92
|
+
let deny = null;
|
|
93
|
+
let tag = null;
|
|
94
|
+
for (const line of chunk.split("\n")) {
|
|
95
|
+
// "3 duplicate reports for Sandbox: …" is the OS collapsing a repeat. The
|
|
96
|
+
// first one was already reported on its own line, so counting it again
|
|
97
|
+
// would inflate `review`, which exists to do arithmetic over this file.
|
|
98
|
+
if (!deny && !/\bduplicate reports? for\b/.test(line)) {
|
|
99
|
+
const m = line.match(DENY);
|
|
100
|
+
if (m) deny = { proc: m[1], pid: Number(m[2]), operation: m[3], detail: m[4] };
|
|
101
|
+
}
|
|
102
|
+
if (!tag) {
|
|
103
|
+
const m = line.match(TAG);
|
|
104
|
+
if (m) tag = { command: decode(m[1]), suffix: m[2] };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!deny) return null;
|
|
108
|
+
const action = actionOf(deny.operation);
|
|
109
|
+
if (!action) return null;
|
|
110
|
+
// Only an absolute path is a territory question. A relative or malformed
|
|
111
|
+
// detail means the line was not what it looked like; dropping beats guessing.
|
|
112
|
+
if (!deny.detail.startsWith("/")) return null;
|
|
113
|
+
return { ...deny, action, path: deny.detail, suffix: tag?.suffix ?? null, command: tag?.command ?? null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function decode(b64) {
|
|
117
|
+
try {
|
|
118
|
+
return Buffer.from(b64, "base64").toString("utf8");
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Split a POSIX shell word list, the way a shell would.
|
|
126
|
+
*
|
|
127
|
+
* This reads the runtime's attribution tag, which carries the wrapped command
|
|
128
|
+
* re-quoted for `sh -c` and truncated to 100 characters. Parsing it beats
|
|
129
|
+
* re-generating it: any correct quoting of the same argument list parses back to
|
|
130
|
+
* that list, so this keeps matching if the runtime changes how it quotes, which
|
|
131
|
+
* re-implementing its `quote()` would not.
|
|
132
|
+
*
|
|
133
|
+
* Truncation means the last word is usually a fragment and the closing quote is
|
|
134
|
+
* usually missing. That is expected, not an error — an unterminated word is
|
|
135
|
+
* returned as far as it got, and {@link isOurs} treats the final word as a
|
|
136
|
+
* prefix.
|
|
137
|
+
*/
|
|
138
|
+
export function shellSplit(line) {
|
|
139
|
+
const words = [];
|
|
140
|
+
let word = null;
|
|
141
|
+
let quote = null;
|
|
142
|
+
for (let i = 0; i < line.length; i++) {
|
|
143
|
+
const c = line[i];
|
|
144
|
+
if (quote) {
|
|
145
|
+
if (c === quote) quote = null;
|
|
146
|
+
else word += c;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (c === "'" || c === '"') { quote = c; word ??= ""; continue; }
|
|
150
|
+
if (c === "\\" && i + 1 < line.length) { word = (word ?? "") + line[++i]; continue; }
|
|
151
|
+
if (/\s/.test(c)) { if (word !== null) { words.push(word); word = null; } continue; }
|
|
152
|
+
word = (word ?? "") + c;
|
|
153
|
+
}
|
|
154
|
+
if (word !== null) words.push(word);
|
|
155
|
+
return words;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Was this tagged command the one this run launched?
|
|
160
|
+
*
|
|
161
|
+
* The second of two anchors, and it exists because the first one is racy: a
|
|
162
|
+
* refused `sh -c` can be dead before `ps` runs, and then a process tree proves
|
|
163
|
+
* nothing. This one does not depend on the process still existing.
|
|
164
|
+
*
|
|
165
|
+
* Compared as a *prefix*, because the tag holds only the first 100 characters:
|
|
166
|
+
* every complete word must match exactly, and the last word — the one the
|
|
167
|
+
* truncation cut — only has to start the same way. A command that agrees with
|
|
168
|
+
* ours for 100 characters and then differs would be credited to us, which is a
|
|
169
|
+
* wrong attribution we accept: it requires another sandbox on the same machine
|
|
170
|
+
* launching a command that matches ours to the byte for 100 bytes.
|
|
171
|
+
*/
|
|
172
|
+
export function isOurs(tagged, argv) {
|
|
173
|
+
if (!tagged) return false;
|
|
174
|
+
const words = shellSplit(tagged);
|
|
175
|
+
if (words.length === 0 || words.length > argv.length) return false;
|
|
176
|
+
for (let i = 0; i < words.length - 1; i++) if (words[i] !== argv[i]) return false;
|
|
177
|
+
const last = words[words.length - 1];
|
|
178
|
+
return argv[words.length - 1]?.startsWith(last) ?? false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Is `pid` a descendant of `root`?
|
|
183
|
+
*
|
|
184
|
+
* This is the anchor for attribution, and it is the reason a denial from
|
|
185
|
+
* somebody else's sandbox never lands in this repo's log. The alternative was
|
|
186
|
+
* matching the decoded command against the one we launched, which would mean
|
|
187
|
+
* reimplementing the runtime's shell quoting and inheriting its bugs; a process
|
|
188
|
+
* tree is a fact the OS already holds.
|
|
189
|
+
*
|
|
190
|
+
* One `ps` per unseen pid, and only until the session suffix is learned — after
|
|
191
|
+
* that the suffix alone is exact, because it is generated once per `srt`
|
|
192
|
+
* process. In practice that is one `ps` per run.
|
|
193
|
+
*/
|
|
194
|
+
export function descends(pid, root, snapshot) {
|
|
195
|
+
const parent = new Map(snapshot);
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
let at = pid;
|
|
198
|
+
while (at && at !== 1 && !seen.has(at)) {
|
|
199
|
+
if (at === root) return true;
|
|
200
|
+
seen.add(at);
|
|
201
|
+
at = parent.get(at);
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** pid → ppid for every process this user can see. */
|
|
207
|
+
export function processTree(run = execFileSync) {
|
|
208
|
+
const tree = new Map();
|
|
209
|
+
try {
|
|
210
|
+
const out = run("/bin/ps", ["-Ao", "pid=,ppid="], { encoding: "utf8" });
|
|
211
|
+
for (const line of out.split("\n")) {
|
|
212
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)$/);
|
|
213
|
+
if (m) tree.set(Number(m[1]), Number(m[2]));
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// No tree means nothing can be attributed, which the caller handles by
|
|
217
|
+
// recording nothing. A permission tool does not invent an owner.
|
|
218
|
+
}
|
|
219
|
+
return tree;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Did this refusal reach for content, or just walk past the door?
|
|
224
|
+
*
|
|
225
|
+
* `file-read-data` on a *file* is a read. On a *directory* it is `opendir` —
|
|
226
|
+
* what any recursive search produces when it reaches a denied path. `rg`, `find`
|
|
227
|
+
* and `grep -r` from the repo root hit every key directory on every invocation,
|
|
228
|
+
* and the kernel refuses each one.
|
|
229
|
+
*
|
|
230
|
+
* This is the same lesson as `file-read-metadata`, one layer up, and it was
|
|
231
|
+
* missed the first time because the operation name is identical: the difference
|
|
232
|
+
* is not in what the kernel said, it is in what was on the other end. Measured
|
|
233
|
+
* on the real log after the metadata fix: **78 of 116 lines** were directory
|
|
234
|
+
* opens of a key directory, and **not one of them named a file inside it**. A
|
|
235
|
+
* role going for a secret names the file.
|
|
236
|
+
*
|
|
237
|
+
* It matters more than volume. Those lines carry no owner — a key directory is
|
|
238
|
+
* closed to everyone, not held by someone — so they arrive at `review` as
|
|
239
|
+
* repeated friction with nobody to hand the work to, and its advice for
|
|
240
|
+
* repeated friction is *grant it, or move the territory*. Left alone, the
|
|
241
|
+
* strongest recommendation the tool made about a real repository was to grant
|
|
242
|
+
* two roles the credential directory.
|
|
243
|
+
*
|
|
244
|
+
* Writes are not filtered this way. A refused `file-write-create` names
|
|
245
|
+
* something that does not exist yet, and there is nothing to stat.
|
|
246
|
+
*/
|
|
247
|
+
export function reachedForContent(denial, stat = statSync) {
|
|
248
|
+
if (denial.action !== "read") return true;
|
|
249
|
+
try {
|
|
250
|
+
return !stat(denial.path).isDirectory();
|
|
251
|
+
} catch {
|
|
252
|
+
// Gone, or never there. A read of something that is not on disk is not a
|
|
253
|
+
// directory walk, so it keeps its line rather than being filtered on a
|
|
254
|
+
// guess.
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Is this path one the policy has anything to say about?
|
|
261
|
+
*
|
|
262
|
+
* The kernel refuses plenty that is not a territory question: `/dev/tty` on
|
|
263
|
+
* every command a CLI runs, a font cache, a dtrace helper. Those are the
|
|
264
|
+
* sandbox holding the line against the machine, they repeat on every single
|
|
265
|
+
* invocation, and recording them would bury the one line that matters under
|
|
266
|
+
* thousands that never do — in a file whose whole purpose is that `review` can
|
|
267
|
+
* do arithmetic over it.
|
|
268
|
+
*
|
|
269
|
+
* So a denial is recorded when it lands inside the repo, or inside a path this
|
|
270
|
+
* role's policy actually named: its territory, its scratch space, its key
|
|
271
|
+
* directories, the state directory, the denied credential locations under
|
|
272
|
+
* `isolate`. Everything else is counted and reported as a number when the run
|
|
273
|
+
* ends, so that filtering them is visible rather than silent.
|
|
274
|
+
*
|
|
275
|
+
* This is a reporting filter and nothing else. It cannot widen or narrow a
|
|
276
|
+
* boundary; by the time a line exists here the kernel has already refused.
|
|
277
|
+
*/
|
|
278
|
+
export function inScope(path, prefixes) {
|
|
279
|
+
return prefixes.some((p) => path === p || path.startsWith(p.endsWith("/") ? p : p + "/"));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Every path the role's settings mention, plus the repo itself.
|
|
284
|
+
*
|
|
285
|
+
* Read off the generated settings rather than off the config, so it covers
|
|
286
|
+
* exactly what was handed to the kernel — including the paths seisin adds on
|
|
287
|
+
* the role's behalf, which are the ones a reader would not think to look for.
|
|
288
|
+
*/
|
|
289
|
+
export function scopeOf(settings, root) {
|
|
290
|
+
const fs = settings.filesystem ?? {};
|
|
291
|
+
return [root, ...(fs.allowWrite ?? []), ...(fs.denyWrite ?? []),
|
|
292
|
+
...(fs.allowRead ?? []), ...(fs.denyRead ?? [])];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Follows the kernel's refusals for one `seisin run`.
|
|
297
|
+
*
|
|
298
|
+
* `onDeny` receives `{ action, path, operation, proc, pid }` for each denial
|
|
299
|
+
* attributable to this run. `pid` is the pid of the `srt` process; denials from
|
|
300
|
+
* anything that is not its descendant belong to someone else and are dropped.
|
|
301
|
+
*
|
|
302
|
+
* Returns `{ available, reason, close, stats }`. `available: false` is a normal
|
|
303
|
+
* outcome, not a failure — it is what Linux looks like, and what a machine
|
|
304
|
+
* without `log` looks like — and the caller says so once rather than silently
|
|
305
|
+
* logging less than it used to.
|
|
306
|
+
*/
|
|
307
|
+
export function watchDenials(onDeny, { pid = null, argv = null, platform = process.platform, spawnFn = spawn, treeFn = processTree } = {}) {
|
|
308
|
+
const stats = { attributed: 0, foreign: 0, unattributed: 0 };
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Unavailable is a watcher that does nothing, NOT a different shape.
|
|
312
|
+
*
|
|
313
|
+
* This returned an object without `attributeTo`, and `seisin run` calls that
|
|
314
|
+
* unconditionally — so on Linux, where this branch is always taken, every
|
|
315
|
+
* single run died with `denials.attributeTo is not a function` before the
|
|
316
|
+
* agent started. The tool did not degrade on the platform it cannot watch;
|
|
317
|
+
* **it stopped working there.**
|
|
318
|
+
*
|
|
319
|
+
* It went unnoticed for a day because the suite could not see it either: the
|
|
320
|
+
* fourteen tests that run real commands decided whether the runtime was
|
|
321
|
+
* present with `command -v srt`, which misses the bundled one, so they
|
|
322
|
+
* skipped and the run came back green. Two defects covering for each other,
|
|
323
|
+
* and it took running the suite on the other platform to part them.
|
|
324
|
+
*
|
|
325
|
+
* So the contract is the object, not the flag. Everything a caller may invoke
|
|
326
|
+
* exists at both ends; `available` says whether it will find anything.
|
|
327
|
+
*/
|
|
328
|
+
if (platform !== "darwin")
|
|
329
|
+
return {
|
|
330
|
+
available: false,
|
|
331
|
+
reason: platform === "linux"
|
|
332
|
+
? "kernel denials are not readable from outside the runtime on Linux — see docs/upstream/cli-violations.md"
|
|
333
|
+
: `no kernel denial stream on ${platform}`,
|
|
334
|
+
stats,
|
|
335
|
+
attributeTo() {},
|
|
336
|
+
close: () => Promise.resolve(stats),
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
let suffix = null; // learned once, exact from then on
|
|
340
|
+
let tree = null; // refreshed only while still learning
|
|
341
|
+
let root = pid; // the srt pid; not known until after spawn
|
|
342
|
+
const mine = new Set(); // pids already proven to be ours
|
|
343
|
+
const held = []; // denials seen before they could be attributed
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Two anchors, and either one is enough.
|
|
347
|
+
*
|
|
348
|
+
* Neither works alone. The process tree is exact while the process exists and
|
|
349
|
+
* proves nothing once it has exited — which, for the short commands that get
|
|
350
|
+
* refused most, is by the time the line arrives. The command tag survives the
|
|
351
|
+
* process but depends on the runtime's quoting staying recognisable.
|
|
352
|
+
*
|
|
353
|
+
* Whichever answers first hands over the session suffix, and from that point
|
|
354
|
+
* attribution is one string comparison: the suffix is generated once per `srt`
|
|
355
|
+
* process, so it separates this run from every other sandbox on the machine
|
|
356
|
+
* exactly. In practice the tag settles it on the first denial and the tree is
|
|
357
|
+
* never consulted.
|
|
358
|
+
*/
|
|
359
|
+
const attribute = (d) => {
|
|
360
|
+
if (suffix) return d.suffix === suffix;
|
|
361
|
+
if (argv && isOurs(d.command, argv)) {
|
|
362
|
+
if (d.suffix) suffix = d.suffix;
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
if (root === null) return false; // nothing to descend from yet
|
|
366
|
+
if (mine.has(d.pid)) return true;
|
|
367
|
+
if (tree === null) tree = treeFn();
|
|
368
|
+
if (!descends(d.pid, root, tree)) {
|
|
369
|
+
// A pid we have never seen may simply be younger than the snapshot.
|
|
370
|
+
// Refresh once, then believe the answer.
|
|
371
|
+
tree = treeFn();
|
|
372
|
+
if (!descends(d.pid, root, tree)) return false;
|
|
373
|
+
}
|
|
374
|
+
mine.add(d.pid);
|
|
375
|
+
// The first proven denial hands us the suffix for every later one.
|
|
376
|
+
if (d.suffix) suffix = d.suffix;
|
|
377
|
+
return true;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The same refusal, reported twice.
|
|
382
|
+
*
|
|
383
|
+
* macOS emits a violation from `kernel` and, for some of them, a second
|
|
384
|
+
* extended report from `sandboxd` carrying the identical `Sandbox:` line.
|
|
385
|
+
* They are one event and the log has to say so — `review` counts lines, and a
|
|
386
|
+
* denial that shows as two is a policy that looks twice as wrong as it is.
|
|
387
|
+
* Measured on the first real run: 31 doubled events out of 730.
|
|
388
|
+
*
|
|
389
|
+
* Keyed on what the kernel said, not on when we read it: two deliveries of one
|
|
390
|
+
* event agree on pid, operation and path and disagree on arrival time.
|
|
391
|
+
*/
|
|
392
|
+
const recent = new Map();
|
|
393
|
+
const isRepeat = (d) => {
|
|
394
|
+
const key = `${d.pid}|${d.operation}|${d.path}`;
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
for (const [k, t] of recent) if (now - t > REPEAT_MS) recent.delete(k); else break;
|
|
397
|
+
if (recent.has(key)) return true;
|
|
398
|
+
recent.set(key, now);
|
|
399
|
+
return false;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const consider = (d) => {
|
|
403
|
+
if (attribute(d)) {
|
|
404
|
+
stats.attributed++;
|
|
405
|
+
onDeny(d);
|
|
406
|
+
// Learning the suffix makes every held line decidable. Without this,
|
|
407
|
+
// denials that arrived while the first process was being identified —
|
|
408
|
+
// which on a fast command is all of them — are lost.
|
|
409
|
+
if (suffix && held.length) revisit();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// Not ours *yet*. Two reasons, both temporary: the run has not spawned the
|
|
413
|
+
// child whose pid anchors attribution, or the process died before `ps`
|
|
414
|
+
// could see it. Either way a foreign sandbox and one of ours look the same
|
|
415
|
+
// from here, so hold the line rather than credit or discard it.
|
|
416
|
+
if (!suffix && d.suffix) {
|
|
417
|
+
stats.unattributed++;
|
|
418
|
+
held.push(d);
|
|
419
|
+
// Bounded, because the common case on a busy machine is a run that is
|
|
420
|
+
// never refused anything: it never learns its own suffix, so every
|
|
421
|
+
// denial from every other sandbox lands here and nothing ever drains it.
|
|
422
|
+
// Several cells running at once for hours is exactly that shape, and an
|
|
423
|
+
// unbounded array is how an instrument becomes the leak.
|
|
424
|
+
//
|
|
425
|
+
// Dropping the oldest is the right end to drop. What is still worth
|
|
426
|
+
// replaying is whatever arrived near the run's own first refusal; a
|
|
427
|
+
// denial from an hour ago that is still unattributed is not ours.
|
|
428
|
+
while (held.length > HELD_MAX) { held.shift(); stats.unattributed--; stats.foreign++; }
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
stats.foreign++;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
/** Re-examine what was held, now that something new is known. */
|
|
435
|
+
const revisit = () => {
|
|
436
|
+
for (const h of held.splice(0)) { stats.unattributed--; consider(h); }
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
let child;
|
|
440
|
+
try {
|
|
441
|
+
child = spawnFn("/usr/bin/log", ["stream", "--predicate", PREDICATE, "--style", "compact"],
|
|
442
|
+
{ stdio: ["ignore", "pipe", "ignore"] });
|
|
443
|
+
} catch (e) {
|
|
444
|
+
return { available: false, reason: `could not start the log stream: ${e.message}`, stats, close() {} };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let buf = "";
|
|
448
|
+
child.stdout?.on("data", (data) => {
|
|
449
|
+
buf += data.toString();
|
|
450
|
+
// The OS emits one event per chunk, but a chunk can split across reads.
|
|
451
|
+
// Split on the tag, which always terminates an event.
|
|
452
|
+
let cut;
|
|
453
|
+
while ((cut = buf.indexOf("_SBX")) !== -1) {
|
|
454
|
+
const chunk = buf.slice(0, cut + 4);
|
|
455
|
+
buf = buf.slice(cut + 4);
|
|
456
|
+
const d = parseChunk(chunk);
|
|
457
|
+
// Deduplicated here and not in consider(), because a held denial goes
|
|
458
|
+
// through consider() a second time when it is finally attributed. Doing
|
|
459
|
+
// this there would drop every line that arrived before the child had a
|
|
460
|
+
// pid — which, on a short command, is all of them.
|
|
461
|
+
if (d && !isRepeat(d)) consider(d);
|
|
462
|
+
}
|
|
463
|
+
if (buf.length > 64_000) buf = ""; // a stream we cannot parse is not a leak
|
|
464
|
+
});
|
|
465
|
+
// A monitor that cannot run must never take the run down with it. Same rule
|
|
466
|
+
// as append(): the instrument is allowed to fail, the agent is not.
|
|
467
|
+
child.on("error", () => {});
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
available: true,
|
|
471
|
+
reason: null,
|
|
472
|
+
stats,
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Name the process whose descendants count as ours.
|
|
476
|
+
*
|
|
477
|
+
* Separate from the constructor because the stream has to be listening
|
|
478
|
+
* *before* the child starts. A short command — the kind an agent runs
|
|
479
|
+
* hundreds of times — is refused and gone in single-digit milliseconds, so
|
|
480
|
+
* a monitor started after the spawn misses precisely the denials it exists
|
|
481
|
+
* to catch. Measured: with the stream started afterwards, a one-line `sh -c`
|
|
482
|
+
* refusal recorded nothing at all.
|
|
483
|
+
*/
|
|
484
|
+
attributeTo(childPid) {
|
|
485
|
+
root = childPid;
|
|
486
|
+
if (held.length) revisit();
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Stop, after giving the kernel time to finish talking.
|
|
491
|
+
*
|
|
492
|
+
* `log stream` is a separate process and the last denial of a run can still
|
|
493
|
+
* be in flight when the child exits. The wait is per run and it is the only
|
|
494
|
+
* latency this whole mechanism adds; nothing here sits on the path of a tool
|
|
495
|
+
* call. Resolves early once the stream goes quiet, so the common case —
|
|
496
|
+
* nothing pending — costs one tick rather than the full window.
|
|
497
|
+
*/
|
|
498
|
+
close({ drain = 0 } = {}) {
|
|
499
|
+
const stop = () => { try { child.kill("SIGTERM"); } catch { /* already gone */ } };
|
|
500
|
+
if (drain <= 0) { stop(); return Promise.resolve(stats); }
|
|
501
|
+
return new Promise((resolve) => {
|
|
502
|
+
let quiet = null;
|
|
503
|
+
const settle = () => { clearTimeout(quiet); clearTimeout(cap); stop(); resolve(stats); };
|
|
504
|
+
const cap = setTimeout(settle, drain);
|
|
505
|
+
const bump = () => { clearTimeout(quiet); quiet = setTimeout(settle, QUIET_MS); };
|
|
506
|
+
child.stdout?.on("data", bump);
|
|
507
|
+
bump();
|
|
508
|
+
});
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* How long the stream has to stay silent before a drain gives up on it.
|
|
515
|
+
*
|
|
516
|
+
* Short, because the measured lag between a refusal and its line arriving is
|
|
517
|
+
* under a millisecond — the line had already landed before `srt` finished
|
|
518
|
+
* exiting. This is a floor against scheduling noise, not a guess at the lag.
|
|
519
|
+
*/
|
|
520
|
+
const QUIET_MS = 60;
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* How many undecided denials to keep while waiting to learn our own suffix.
|
|
524
|
+
*
|
|
525
|
+
* Generous enough to cover the startup window — where every denial arrives
|
|
526
|
+
* before there is anything to attribute it to — and small enough that a run
|
|
527
|
+
* which is never refused anything cannot accumulate another sandbox's traffic
|
|
528
|
+
* for the length of an agent session.
|
|
529
|
+
*/
|
|
530
|
+
const HELD_MAX = 500;
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* How long two reports of one refusal can be apart and still be one event.
|
|
534
|
+
*
|
|
535
|
+
* The second copy — `sandboxd`'s extended report — follows the kernel's within
|
|
536
|
+
* milliseconds. Kept short so that a role genuinely retrying the same path a
|
|
537
|
+
* second later is still recorded as a second attempt, which is what makes
|
|
538
|
+
* `asked 3×` mean anything.
|
|
539
|
+
*/
|
|
540
|
+
const REPEAT_MS = 1000;
|