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/toml.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TOML subset seisin.toml is written in: `[table.name]` headers, and
|
|
3
|
+
* `key = ["a", "b"]` / `key = "a"` values, with a line number on every error.
|
|
4
|
+
*
|
|
5
|
+
* Kept in-house so installing seisin pulls in no parser. If a config outgrows
|
|
6
|
+
* this subset, the config is doing too much.
|
|
7
|
+
*/
|
|
8
|
+
import { CONFIG_NAME } from "./layout.js";
|
|
9
|
+
|
|
10
|
+
const RE_TABLE = /^\[([A-Za-z0-9_.\-]+)\]$/;
|
|
11
|
+
const RE_PAIR = /^([A-Za-z0-9_\-]+)\s*=\s*(.+)$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Names a table or key may not have.
|
|
15
|
+
*
|
|
16
|
+
* `[roles.__proto__]` does not show up in `Object.keys(roles)`, so `check`
|
|
17
|
+
* prints the roles that exist and says nothing — while every other role
|
|
18
|
+
* inherits whatever it declared. A config reading `[roles.frontend]` with no
|
|
19
|
+
* `writes` came out of the parser owning the entire repo and holding
|
|
20
|
+
* GITHUB_TOKEN, and no amount of reading the file would show it.
|
|
21
|
+
*
|
|
22
|
+
* The tables below are made with a null prototype, which stops the inheritance
|
|
23
|
+
* on its own. The names are refused as well, because a config containing them
|
|
24
|
+
* is not a config with a mistake in it.
|
|
25
|
+
*/
|
|
26
|
+
export const FORBIDDEN = new Set(["__proto__", "prototype", "constructor"]);
|
|
27
|
+
|
|
28
|
+
/** A table with no prototype, so nothing is inherited into it. */
|
|
29
|
+
export function dict() {
|
|
30
|
+
return Object.create(null);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const isTable = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
34
|
+
|
|
35
|
+
/** Read a property only if the object actually has it. */
|
|
36
|
+
export function own(obj, key) {
|
|
37
|
+
return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Where each table and key of a parsed file was written, by dotted name.
|
|
42
|
+
*
|
|
43
|
+
* Kept beside the result rather than in it, so the object stays exactly the
|
|
44
|
+
* data that was written; loadConfig reads it to put a line number on a name it
|
|
45
|
+
* does not know.
|
|
46
|
+
*/
|
|
47
|
+
export const LINES = new WeakMap();
|
|
48
|
+
|
|
49
|
+
/** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
|
|
50
|
+
export function parseToml(text) {
|
|
51
|
+
const out = dict();
|
|
52
|
+
const where = new Map();
|
|
53
|
+
LINES.set(out, where);
|
|
54
|
+
let table = out;
|
|
55
|
+
let tableName = "";
|
|
56
|
+
// "Last one wins" is TOML-ish and wrong for a permission file. A policy can
|
|
57
|
+
// look restrictive at the top and be cancelled forty lines down, and the
|
|
58
|
+
// person reviewing it reads the first block. Both shapes are refused.
|
|
59
|
+
const seenTables = new Set();
|
|
60
|
+
const seenKeys = new Set();
|
|
61
|
+
const lines = text.split(/\r?\n/);
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < lines.length; i++) {
|
|
64
|
+
const raw = lines[i];
|
|
65
|
+
const line = stripComment(raw).trim();
|
|
66
|
+
if (!line) continue;
|
|
67
|
+
|
|
68
|
+
const header = RE_TABLE.exec(line);
|
|
69
|
+
if (header) {
|
|
70
|
+
tableName = header[1];
|
|
71
|
+
for (const part of tableName.split("."))
|
|
72
|
+
if (FORBIDDEN.has(part))
|
|
73
|
+
throw new Error(
|
|
74
|
+
`${CONFIG_NAME}:${i + 1}: "[${tableName}]" uses the reserved name "${part}". ` +
|
|
75
|
+
`A table named that would not appear as a role and would hand its settings to ` +
|
|
76
|
+
`every other one.`);
|
|
77
|
+
if (seenTables.has(tableName))
|
|
78
|
+
throw new Error(
|
|
79
|
+
`${CONFIG_NAME}:${i + 1}: [${tableName}] appears twice. ` +
|
|
80
|
+
`A later table would silently override the earlier one — put every setting for ` +
|
|
81
|
+
`${tableName} in one place.`);
|
|
82
|
+
seenTables.add(tableName);
|
|
83
|
+
tableName.split(".").forEach((_, k, parts) => {
|
|
84
|
+
const name = parts.slice(0, k + 1).join(".");
|
|
85
|
+
if (!where.has(name)) where.set(name, i + 1);
|
|
86
|
+
});
|
|
87
|
+
table = tableName.split(".").reduce((node, key, k, parts) => {
|
|
88
|
+
if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
|
|
89
|
+
else if (!isTable(node[key]))
|
|
90
|
+
throw new Error(
|
|
91
|
+
`${CONFIG_NAME}:${i + 1}: [${tableName}] — "${parts.slice(0, k + 1).join(".")}" is already ` +
|
|
92
|
+
`a value, not a table. One name cannot be both.`);
|
|
93
|
+
return node[key];
|
|
94
|
+
}, out);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const pair = RE_PAIR.exec(line);
|
|
99
|
+
if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
|
|
100
|
+
|
|
101
|
+
let [, key, value] = pair;
|
|
102
|
+
const at = i + 1;
|
|
103
|
+
// An array may span lines; keep pulling until the brackets balance. Only
|
|
104
|
+
// the new line is searched: searching the whole growing value made an
|
|
105
|
+
// unclosed array of 100k lines take 20 s to be refused. A `]` inside a
|
|
106
|
+
// quoted item does not close it — `["a]b",` used to end the array there.
|
|
107
|
+
if (value.startsWith("[") && !closesArray(value)) {
|
|
108
|
+
const parts = [value];
|
|
109
|
+
let closed = false;
|
|
110
|
+
while (!closed && i + 1 < lines.length) {
|
|
111
|
+
const next = stripComment(lines[++i]).trim();
|
|
112
|
+
parts.push(next);
|
|
113
|
+
closed = closesArray(next);
|
|
114
|
+
}
|
|
115
|
+
value = parts.join(" ");
|
|
116
|
+
}
|
|
117
|
+
if (FORBIDDEN.has(key))
|
|
118
|
+
throw new Error(`${CONFIG_NAME}:${i + 1}: "${key}" is a reserved name and cannot be a setting.`);
|
|
119
|
+
const seen = `${tableName}.${key}`;
|
|
120
|
+
if (seenKeys.has(seen))
|
|
121
|
+
throw new Error(
|
|
122
|
+
`${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
|
|
123
|
+
`The second would win, which is not a thing a permission file should do quietly.`);
|
|
124
|
+
seenKeys.add(seen);
|
|
125
|
+
where.set(tableName ? `${tableName}.${key}` : key, at);
|
|
126
|
+
/**
|
|
127
|
+
* A key may not be a table that is declared elsewhere, in either order.
|
|
128
|
+
*
|
|
129
|
+
* `[roles.a]` with `network = []`, then `[roles]` and `a = true` forty lines
|
|
130
|
+
* down, used to replace the whole role with `true`: its `network = []` came
|
|
131
|
+
* back as "not set", which means the global allow list. `check` said
|
|
132
|
+
* nothing. It is the "last one wins" this parser refuses everywhere else,
|
|
133
|
+
* arriving by a different spelling.
|
|
134
|
+
*/
|
|
135
|
+
if (Object.prototype.hasOwnProperty.call(table, key))
|
|
136
|
+
throw new Error(
|
|
137
|
+
`${CONFIG_NAME}:${i + 1}: "${key}" in [${tableName || "the root"}] is already the table ` +
|
|
138
|
+
`[${tableName ? tableName + "." : ""}${key}]. One name cannot be both — the value would replace ` +
|
|
139
|
+
`everything that table says.`);
|
|
140
|
+
table[key] = readValue(value, i + 1);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Is there a `]` on this line outside a quoted string? Strings do not span lines. */
|
|
146
|
+
export function closesArray(line) {
|
|
147
|
+
let quoted = false;
|
|
148
|
+
for (let i = 0; i < line.length; i++) {
|
|
149
|
+
const c = line[i];
|
|
150
|
+
if (c === '"') quoted = !quoted;
|
|
151
|
+
else if (c === "]" && !quoted) return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Strips a trailing `#` comment, but not one inside a quoted string. */
|
|
157
|
+
export function stripComment(line) {
|
|
158
|
+
let quoted = false;
|
|
159
|
+
for (let i = 0; i < line.length; i++) {
|
|
160
|
+
const c = line[i];
|
|
161
|
+
if (c === '"') quoted = !quoted;
|
|
162
|
+
else if (c === "#" && !quoted) return line.slice(0, i);
|
|
163
|
+
}
|
|
164
|
+
return line;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One value, read strictly.
|
|
169
|
+
*
|
|
170
|
+
* Strict is the whole point, and it was not before. The array reader used to
|
|
171
|
+
* pick quoted runs out of the line with a regex and ignore everything between
|
|
172
|
+
* them, so `["a" GARBAGE "b"]` parsed as `["a","b"]` and an unterminated string
|
|
173
|
+
* parsed as `""`. A permission file that is half-understood is worse than one
|
|
174
|
+
* that is rejected: the half that was dropped is the half you meant.
|
|
175
|
+
*
|
|
176
|
+
* So this scans rather than matches, and anything it does not recognise is an
|
|
177
|
+
* error with a line number. There are no escapes — a `"` ends the string. If a
|
|
178
|
+
* path of yours needs a quote in it, that is outside this subset and the
|
|
179
|
+
* message says so instead of guessing.
|
|
180
|
+
*/
|
|
181
|
+
function readValue(value, lineNo) {
|
|
182
|
+
const v = value.trim();
|
|
183
|
+
const bad = (msg) => { throw new Error(`${CONFIG_NAME}:${lineNo}: ${msg}`); };
|
|
184
|
+
|
|
185
|
+
if (v === "true") return true;
|
|
186
|
+
if (v === "false") return false;
|
|
187
|
+
|
|
188
|
+
if (v.startsWith('"')) {
|
|
189
|
+
const end = v.indexOf('"', 1);
|
|
190
|
+
if (end === -1) bad(`unterminated string — no closing quote in ${v}`);
|
|
191
|
+
const rest = v.slice(end + 1).trim();
|
|
192
|
+
if (rest) bad(`unexpected ${JSON.stringify(rest)} after the string`);
|
|
193
|
+
return v.slice(1, end);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (v.startsWith("[")) {
|
|
197
|
+
if (!v.endsWith("]")) bad("unterminated array — no closing bracket");
|
|
198
|
+
const items = [];
|
|
199
|
+
let i = 1;
|
|
200
|
+
const body = v.slice(0, -1); // everything up to the closing bracket
|
|
201
|
+
let expectItem = true; // a list alternates item, comma, item…
|
|
202
|
+
|
|
203
|
+
while (i < body.length) {
|
|
204
|
+
const c = body[i];
|
|
205
|
+
if (c === " " || c === "\t") { i++; continue; }
|
|
206
|
+
|
|
207
|
+
if (c === ",") {
|
|
208
|
+
if (expectItem) bad("empty array slot — two commas in a row, or a leading comma");
|
|
209
|
+
expectItem = true; i++; continue;
|
|
210
|
+
}
|
|
211
|
+
if (!expectItem) bad(`missing comma before ${JSON.stringify(body.slice(i, i + 12))}`);
|
|
212
|
+
// Whole numbers, for `local_ports`. Digits only: no sign, no decimal, no
|
|
213
|
+
// underscore — a port is none of those, and every other key still
|
|
214
|
+
// refuses a number through asArray.
|
|
215
|
+
const digits = /^\d+/.exec(body.slice(i));
|
|
216
|
+
if (digits) {
|
|
217
|
+
const after = body[i + digits[0].length];
|
|
218
|
+
if (after !== undefined && after !== "," && after !== " " && after !== "\t")
|
|
219
|
+
bad(`${JSON.stringify(body.slice(i, i + 12))} is not a whole number`);
|
|
220
|
+
items.push(Number(digits[0]));
|
|
221
|
+
i += digits[0].length;
|
|
222
|
+
expectItem = false;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (c !== '"') bad(`array items must be double-quoted, found ${JSON.stringify(body.slice(i, i + 12))}`);
|
|
226
|
+
|
|
227
|
+
const end = body.indexOf('"', i + 1);
|
|
228
|
+
if (end === -1) bad("unterminated string inside the array");
|
|
229
|
+
// A backslash before the closing quote means somebody wrote `\"` and
|
|
230
|
+
// expected an escape. There are none — the quote just ended the string,
|
|
231
|
+
// and the error they get otherwise is about a missing comma somewhere in
|
|
232
|
+
// the middle of a shell pipeline, which names the symptom and hides the
|
|
233
|
+
// cause. Measured while writing the docs: every attempt at a one-line
|
|
234
|
+
// provider command landed here.
|
|
235
|
+
if (body[end - 1] === "\\")
|
|
236
|
+
bad(`there are no escapes in this format, so the \\" in ` +
|
|
237
|
+
`${JSON.stringify(body.slice(i, Math.min(end + 2, i + 40)))} ended the string early.\n` +
|
|
238
|
+
` A value cannot contain a double quote. If this is a shell command, put it in a ` +
|
|
239
|
+
`script and name the script instead — which is also how it stops being unreadable.`);
|
|
240
|
+
items.push(body.slice(i + 1, end));
|
|
241
|
+
i = end + 1;
|
|
242
|
+
expectItem = false; // a trailing comma is fine; a trailing item is not
|
|
243
|
+
}
|
|
244
|
+
return items;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (/^\d+$/.test(v)) return Number(v);
|
|
248
|
+
|
|
249
|
+
bad("value must be a string, an array of strings, a whole number, or a boolean");
|
|
250
|
+
}
|
package/src/violations.js
CHANGED
|
@@ -42,9 +42,13 @@
|
|
|
42
42
|
* per run is a line nobody can act on — and `seisin check` says it, with the
|
|
43
43
|
* platform's other standing limits.
|
|
44
44
|
*/
|
|
45
|
-
import { spawn
|
|
45
|
+
import { spawn } from "node:child_process";
|
|
46
46
|
import { statSync } from "node:fs";
|
|
47
47
|
import { basename } from "node:path";
|
|
48
|
+
import { createDenialAttribution, processTree, TREE_MS } from "./denial-attribution.js";
|
|
49
|
+
|
|
50
|
+
// Keep the existing helper imports available to callers and tests.
|
|
51
|
+
export { shellSplit, isOurs, descends, processTree, underAnotherRun } from "./denial-attribution.js";
|
|
48
52
|
|
|
49
53
|
/** Only sandbox-runtime tags its violations this way. Everything else on the
|
|
50
54
|
* machine — Safari, mDNSResponder, Spotlight — is filtered out by the OS
|
|
@@ -197,134 +201,6 @@ function decode(b64) {
|
|
|
197
201
|
}
|
|
198
202
|
}
|
|
199
203
|
|
|
200
|
-
/**
|
|
201
|
-
* Split a POSIX shell word list, the way a shell would.
|
|
202
|
-
*
|
|
203
|
-
* This reads the runtime's attribution tag, which carries the wrapped command
|
|
204
|
-
* re-quoted for `sh -c` and truncated to 100 characters. Parsing it beats
|
|
205
|
-
* re-generating it: any correct quoting of the same argument list parses back to
|
|
206
|
-
* that list, so this keeps matching if the runtime changes how it quotes, which
|
|
207
|
-
* re-implementing its `quote()` would not.
|
|
208
|
-
*
|
|
209
|
-
* Truncation means the last word is usually a fragment and the closing quote is
|
|
210
|
-
* usually missing. That is expected, not an error — an unterminated word is
|
|
211
|
-
* returned as far as it got, and {@link isOurs} treats the final word as a
|
|
212
|
-
* prefix.
|
|
213
|
-
*/
|
|
214
|
-
export function shellSplit(line) {
|
|
215
|
-
const words = [];
|
|
216
|
-
let word = null;
|
|
217
|
-
let quote = null;
|
|
218
|
-
for (let i = 0; i < line.length; i++) {
|
|
219
|
-
const c = line[i];
|
|
220
|
-
if (quote) {
|
|
221
|
-
if (c === quote) quote = null;
|
|
222
|
-
else word += c;
|
|
223
|
-
continue;
|
|
224
|
-
}
|
|
225
|
-
if (c === "'" || c === '"') { quote = c; word ??= ""; continue; }
|
|
226
|
-
if (c === "\\" && i + 1 < line.length) { word = (word ?? "") + line[++i]; continue; }
|
|
227
|
-
if (/\s/.test(c)) { if (word !== null) { words.push(word); word = null; } continue; }
|
|
228
|
-
word = (word ?? "") + c;
|
|
229
|
-
}
|
|
230
|
-
if (word !== null) words.push(word);
|
|
231
|
-
return words;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Was this tagged command the one this run launched?
|
|
236
|
-
*
|
|
237
|
-
* The second of two anchors, and it exists because the first one is racy: a
|
|
238
|
-
* refused `sh -c` can be dead before `ps` runs, and then a process tree proves
|
|
239
|
-
* nothing. This one does not depend on the process still existing.
|
|
240
|
-
*
|
|
241
|
-
* Compared as a *prefix*, because the tag holds only the first 100 characters:
|
|
242
|
-
* every complete word must match exactly, and the last word — the one the
|
|
243
|
-
* truncation cut — only has to start the same way. A command that agrees with
|
|
244
|
-
* ours for 100 characters and then differs would be credited to us, which is a
|
|
245
|
-
* wrong attribution we accept: it requires another sandbox on the same machine
|
|
246
|
-
* launching a command that matches ours to the byte for 100 bytes.
|
|
247
|
-
*/
|
|
248
|
-
export function isOurs(tagged, argv) {
|
|
249
|
-
if (!tagged) return false;
|
|
250
|
-
const words = shellSplit(tagged);
|
|
251
|
-
if (words.length === 0 || words.length > argv.length) return false;
|
|
252
|
-
for (let i = 0; i < words.length - 1; i++) if (words[i] !== argv[i]) return false;
|
|
253
|
-
const last = words[words.length - 1];
|
|
254
|
-
return argv[words.length - 1]?.startsWith(last) ?? false;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Is `pid` a descendant of `root`?
|
|
259
|
-
*
|
|
260
|
-
* This is the anchor for attribution, and it is the reason a denial from
|
|
261
|
-
* somebody else's sandbox never lands in this repo's log. The alternative was
|
|
262
|
-
* matching the decoded command against the one we launched, which would mean
|
|
263
|
-
* reimplementing the runtime's shell quoting and inheriting its bugs; a process
|
|
264
|
-
* tree is a fact the OS already holds.
|
|
265
|
-
*
|
|
266
|
-
* At most one `ps` per TREE_MS, and only until the session suffix is learned —
|
|
267
|
-
* after that the suffix alone is exact, because it is generated once per `srt`
|
|
268
|
-
* process. In practice that is one `ps` per run.
|
|
269
|
-
*/
|
|
270
|
-
export function descends(pid, root, snapshot) {
|
|
271
|
-
const parent = new Map(snapshot);
|
|
272
|
-
const seen = new Set();
|
|
273
|
-
let at = pid;
|
|
274
|
-
while (at && at !== 1 && !seen.has(at)) {
|
|
275
|
-
if (at === root) return true;
|
|
276
|
-
seen.add(at);
|
|
277
|
-
at = parent.get(at);
|
|
278
|
-
}
|
|
279
|
-
return false;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* pid → ppid for every process this user can see, and in `tree.roots` the
|
|
284
|
-
* pids that are a sandbox run's root: an `srt`, or a `seisin run`.
|
|
285
|
-
*
|
|
286
|
-
* The roots are what makes "not ours" provable. A process outside our tree is
|
|
287
|
-
* someone else's only if its ancestry reaches another run; one whose ancestry
|
|
288
|
-
* ends at init was orphaned — `cmd &`, a build daemon, git's fsmonitor — and
|
|
289
|
-
* may well be ours, reparented when the shell that started it exited.
|
|
290
|
-
*/
|
|
291
|
-
export function processTree(run = execFileSync) {
|
|
292
|
-
const tree = new Map();
|
|
293
|
-
tree.roots = new Set();
|
|
294
|
-
try {
|
|
295
|
-
const out = run("/bin/ps", ["-Ao", "pid=,ppid=,args="], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
296
|
-
for (const line of out.split("\n")) {
|
|
297
|
-
const m = line.trim().match(/^(\d+)\s+(\d+)(?:\s+(.*))?$/);
|
|
298
|
-
if (!m) continue;
|
|
299
|
-
tree.set(Number(m[1]), Number(m[2]));
|
|
300
|
-
if (m[3] && RUN_ROOT.test(m[3])) tree.roots.add(Number(m[1]));
|
|
301
|
-
}
|
|
302
|
-
} catch {
|
|
303
|
-
// No tree means nothing can be attributed, which the caller handles by
|
|
304
|
-
// recording nothing. A permission tool does not invent an owner.
|
|
305
|
-
}
|
|
306
|
-
return tree;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/** The command line of an `srt` (however it was installed) or of `seisin run`. */
|
|
310
|
-
const RUN_ROOT = /(?:^|[\s/])srt(?:\s|$)|sandbox-runtime\/dist\/cli\.js(?:\s|$)|(?:^|[\s/])(?:seisin|cli\.js)\s+run\s/;
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Does `pid` descend from a run root other than `root`?
|
|
314
|
-
*
|
|
315
|
-
* A table without command lines (`roots` absent) cannot tell an orphan from a
|
|
316
|
-
* stranger, and answers as before: alive and not ours is someone else's.
|
|
317
|
-
*/
|
|
318
|
-
export function underAnotherRun(pid, root, snapshot) {
|
|
319
|
-
if (!snapshot.roots) return true;
|
|
320
|
-
const seen = new Set();
|
|
321
|
-
for (let at = pid; at && at !== 1 && !seen.has(at); at = snapshot.get(at)) {
|
|
322
|
-
if (at !== root && snapshot.roots.has(at)) return true;
|
|
323
|
-
seen.add(at);
|
|
324
|
-
}
|
|
325
|
-
return false;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
204
|
/**
|
|
329
205
|
* Did this refusal reach for content, or just walk past the door?
|
|
330
206
|
*
|
|
@@ -442,143 +318,15 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
442
318
|
close: () => Promise.resolve(stats),
|
|
443
319
|
};
|
|
444
320
|
|
|
445
|
-
let suffix = null; // learned once, exact from then on
|
|
446
|
-
let tree = null; // refreshed only while still learning
|
|
447
|
-
let treeAt = -Infinity; // when `tree` was taken
|
|
448
|
-
let root = pid; // the srt pid; not known until after spawn
|
|
449
|
-
const mine = new Set(); // pids already proven to be ours
|
|
450
|
-
const held = []; // denials seen before they could be attributed
|
|
451
|
-
const foreignSuffixes = new Set(); // other sandboxes, proven while they were alive
|
|
452
321
|
let recheck = null; // a deferred look, when a snapshot was refused
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
* process may be younger than the last snapshot is not decided on it; it is
|
|
462
|
-
* held, and looked at again once the window has passed.
|
|
463
|
-
*/
|
|
464
|
-
const snapshot = () => { tree = treeFn(); treeAt = now(); };
|
|
465
|
-
const later = () => {
|
|
466
|
-
if (recheck) return;
|
|
467
|
-
recheck = setTimeout(() => { recheck = null; if (!suffix && held.length) revisit(); }, TREE_MS);
|
|
468
|
-
recheck.unref?.();
|
|
469
|
-
};
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Two anchors, and either one is enough.
|
|
473
|
-
*
|
|
474
|
-
* Neither works alone. The process tree is exact while the process exists and
|
|
475
|
-
* proves nothing once it has exited — which, for the short commands that get
|
|
476
|
-
* refused most, is by the time the line arrives. The command tag survives the
|
|
477
|
-
* process but depends on the runtime's quoting staying recognisable.
|
|
478
|
-
*
|
|
479
|
-
* Whichever answers first hands over the session suffix, and from that point
|
|
480
|
-
* attribution is one string comparison: the suffix is generated once per `srt`
|
|
481
|
-
* process, so it separates this run from every other sandbox on the machine
|
|
482
|
-
* exactly. In practice the tag settles it on the first denial and the tree is
|
|
483
|
-
* never consulted.
|
|
484
|
-
*/
|
|
485
|
-
const attribute = (d) => {
|
|
486
|
-
if (suffix) return d.suffix === suffix;
|
|
487
|
-
if (argv && isOurs(d.command, argv)) {
|
|
488
|
-
if (d.suffix) suffix = d.suffix;
|
|
489
|
-
return true;
|
|
490
|
-
}
|
|
491
|
-
if (root === null) return false; // nothing to descend from yet
|
|
492
|
-
if (mine.has(d.pid)) return true;
|
|
493
|
-
// A suffix already proven foreign needs no `ps`: the suffix is per `srt`.
|
|
494
|
-
if (d.suffix && foreignSuffixes.has(d.suffix)) return false;
|
|
495
|
-
if (tree === null) snapshot();
|
|
496
|
-
if (!descends(d.pid, root, tree)) {
|
|
497
|
-
// A pid the snapshot does not have may simply be younger than it.
|
|
498
|
-
// Refresh once, then believe the answer — unless the last refresh was
|
|
499
|
-
// moments ago, and then look again later rather than refresh now. A pid
|
|
500
|
-
// it does have is answered: its parents do not change into ours.
|
|
501
|
-
if (treeAt < d.seen && !tree.has(d.pid)) {
|
|
502
|
-
if (now() - treeAt < TREE_MS) { later(); return LATER; }
|
|
503
|
-
snapshot();
|
|
504
|
-
}
|
|
505
|
-
if (!descends(d.pid, root, tree)) {
|
|
506
|
-
// Alive, not ours by either anchor, and under another run — the tag
|
|
507
|
-
// did not match above. Every later line from that `srt` is someone
|
|
508
|
-
// else's without asking. Alive but under no run at all is an orphan
|
|
509
|
-
// (`cmd &`, a daemon): possibly ours, so its suffix stays undecided
|
|
510
|
-
// rather than turning this run's own later lines into strangers.
|
|
511
|
-
if (d.suffix && tree.has(d.pid) && underAnotherRun(d.pid, root, tree)) foreignSuffixes.add(d.suffix);
|
|
512
|
-
return false;
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
mine.add(d.pid);
|
|
516
|
-
// The first proven denial hands us the suffix for every later one.
|
|
517
|
-
if (d.suffix) suffix = d.suffix;
|
|
518
|
-
return true;
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
/**
|
|
522
|
-
* The same refusal, reported twice.
|
|
523
|
-
*
|
|
524
|
-
* macOS emits a violation from `kernel` and, for some of them, a second
|
|
525
|
-
* extended report from `sandboxd` carrying the identical `Sandbox:` line.
|
|
526
|
-
* They are one event and the log has to say so — `review` counts lines, and a
|
|
527
|
-
* denial that shows as two is a policy that looks twice as wrong as it is.
|
|
528
|
-
* Measured on the first real run: 31 doubled events out of 730.
|
|
529
|
-
*
|
|
530
|
-
* Keyed on what the kernel said, not on when we read it: two deliveries of one
|
|
531
|
-
* event agree on pid, operation and path and disagree on arrival time.
|
|
532
|
-
*/
|
|
533
|
-
const recent = new Map();
|
|
534
|
-
const isRepeat = (d) => {
|
|
535
|
-
const key = `${d.pid}|${d.operation}|${d.path}`;
|
|
536
|
-
const now = Date.now();
|
|
537
|
-
for (const [k, t] of recent) if (now - t > REPEAT_MS) recent.delete(k); else break;
|
|
538
|
-
if (recent.has(key)) return true;
|
|
539
|
-
recent.set(key, now);
|
|
540
|
-
return false;
|
|
541
|
-
};
|
|
542
|
-
|
|
543
|
-
const consider = (d) => {
|
|
544
|
-
const verdict = attribute(d);
|
|
545
|
-
if (verdict === true) {
|
|
546
|
-
stats.attributed++;
|
|
547
|
-
onDeny(d);
|
|
548
|
-
// Learning the suffix makes every held line decidable. Without this,
|
|
549
|
-
// denials that arrived while the first process was being identified —
|
|
550
|
-
// which on a fast command is all of them — are lost.
|
|
551
|
-
if (suffix && held.length) revisit();
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
// Not ours *yet*. Two reasons, both temporary: the run has not spawned the
|
|
555
|
-
// child whose pid anchors attribution, or the process died before `ps`
|
|
556
|
-
// could see it. Either way a foreign sandbox and one of ours look the same
|
|
557
|
-
// from here, so hold the line rather than credit or discard it.
|
|
558
|
-
// A line put off to the next snapshot is held with or without a suffix:
|
|
559
|
-
// the look it is waiting for is the only thing that can decide it.
|
|
560
|
-
if (verdict === LATER || (!suffix && d.suffix && !foreignSuffixes.has(d.suffix))) {
|
|
561
|
-
stats.unattributed++;
|
|
562
|
-
held.push(d);
|
|
563
|
-
// Bounded, because the common case on a busy machine is a run that is
|
|
564
|
-
// never refused anything: it never learns its own suffix, so every
|
|
565
|
-
// denial from every other sandbox lands here and nothing ever drains it.
|
|
566
|
-
// Several cells running at once for hours is exactly that shape, and an
|
|
567
|
-
// unbounded array is how an instrument becomes the leak.
|
|
568
|
-
//
|
|
569
|
-
// Dropping the oldest is the right end to drop. What is still worth
|
|
570
|
-
// replaying is whatever arrived near the run's own first refusal; a
|
|
571
|
-
// denial from an hour ago that is still unattributed is not ours.
|
|
572
|
-
while (held.length > HELD_MAX) { held.shift(); stats.unattributed--; stats.foreign++; }
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
stats.foreign++;
|
|
576
|
-
};
|
|
577
|
-
|
|
578
|
-
/** Re-examine what was held, now that something new is known. */
|
|
579
|
-
const revisit = () => {
|
|
580
|
-
for (const h of held.splice(0)) { stats.unattributed--; consider(h); }
|
|
581
|
-
};
|
|
322
|
+
const attribution = createDenialAttribution(onDeny, {
|
|
323
|
+
pid, argv, treeFn, now, stats,
|
|
324
|
+
requestRecheck() {
|
|
325
|
+
if (recheck) return;
|
|
326
|
+
recheck = setTimeout(() => { recheck = null; attribution.revisitPending(); }, TREE_MS);
|
|
327
|
+
recheck.unref?.();
|
|
328
|
+
},
|
|
329
|
+
});
|
|
582
330
|
|
|
583
331
|
let child;
|
|
584
332
|
try {
|
|
@@ -607,12 +355,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
607
355
|
// in `buf`, which close() counts.
|
|
608
356
|
stats.dropped += droppedIn(chunk);
|
|
609
357
|
const d = parseChunk(chunk);
|
|
610
|
-
if (d)
|
|
611
|
-
// Deduplicated here and not in consider(), because a held denial goes
|
|
612
|
-
// through consider() a second time when it is finally attributed. Doing
|
|
613
|
-
// this there would drop every line that arrived before the child had a
|
|
614
|
-
// pid — which, on a short command, is all of them.
|
|
615
|
-
if (d && !isRepeat(d)) consider(d);
|
|
358
|
+
if (d) attribution.accept(d);
|
|
616
359
|
}
|
|
617
360
|
// A stream we cannot parse is not a leak. A flooded one is mostly markers
|
|
618
361
|
// with no tag between them, so they are counted before they go.
|
|
@@ -627,19 +370,8 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
627
370
|
reason: null,
|
|
628
371
|
stats,
|
|
629
372
|
|
|
630
|
-
/**
|
|
631
|
-
* Name the process whose descendants count as ours.
|
|
632
|
-
*
|
|
633
|
-
* Separate from the constructor because the stream has to be listening
|
|
634
|
-
* *before* the child starts. A short command — the kind an agent runs
|
|
635
|
-
* hundreds of times — is refused and gone in single-digit milliseconds, so
|
|
636
|
-
* a monitor started after the spawn misses precisely the denials it exists
|
|
637
|
-
* to catch. Measured: with the stream started afterwards, a one-line `sh -c`
|
|
638
|
-
* refusal recorded nothing at all.
|
|
639
|
-
*/
|
|
640
373
|
attributeTo(childPid) {
|
|
641
|
-
|
|
642
|
-
if (held.length) revisit();
|
|
374
|
+
attribution.attributeTo(childPid);
|
|
643
375
|
},
|
|
644
376
|
|
|
645
377
|
/**
|
|
@@ -652,6 +384,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
652
384
|
* nothing pending — costs one tick rather than the full window.
|
|
653
385
|
*/
|
|
654
386
|
close({ drain = 0 } = {}) {
|
|
387
|
+
// Cancel before draining without resetting the handle: preserve replay scheduling.
|
|
655
388
|
clearTimeout(recheck);
|
|
656
389
|
const stop = () => ended(child).then(() => { stats.dropped += droppedIn(buf); buf = ""; return stats; });
|
|
657
390
|
if (drain <= 0) return stop();
|
|
@@ -698,38 +431,6 @@ const END_MS = 1000;
|
|
|
698
431
|
*/
|
|
699
432
|
const QUIET_MS = 60;
|
|
700
433
|
|
|
701
|
-
/**
|
|
702
|
-
* The shortest time between two `ps` snapshots while attribution is learning.
|
|
703
|
-
*
|
|
704
|
-
* Long enough that a burst of foreign denials costs one snapshot, not one
|
|
705
|
-
* each; short enough that a line held for a younger process is decided before
|
|
706
|
-
* anyone reads the log.
|
|
707
|
-
*/
|
|
708
|
-
const TREE_MS = 250;
|
|
709
|
-
|
|
710
|
-
/** What `attribute` answers for a line it has put off to the next snapshot. */
|
|
711
|
-
const LATER = "later";
|
|
712
|
-
|
|
713
|
-
/**
|
|
714
|
-
* How many undecided denials to keep while waiting to learn our own suffix.
|
|
715
|
-
*
|
|
716
|
-
* Generous enough to cover the startup window — where every denial arrives
|
|
717
|
-
* before there is anything to attribute it to — and small enough that a run
|
|
718
|
-
* which is never refused anything cannot accumulate another sandbox's traffic
|
|
719
|
-
* for the length of an agent session.
|
|
720
|
-
*/
|
|
721
|
-
const HELD_MAX = 500;
|
|
722
|
-
|
|
723
|
-
/**
|
|
724
|
-
* How long two reports of one refusal can be apart and still be one event.
|
|
725
|
-
*
|
|
726
|
-
* The second copy — `sandboxd`'s extended report — follows the kernel's within
|
|
727
|
-
* milliseconds. Kept short so that a role genuinely retrying the same path a
|
|
728
|
-
* second later is still recorded as a second attempt, which is what makes
|
|
729
|
-
* `asked 3×` mean anything.
|
|
730
|
-
*/
|
|
731
|
-
const REPEAT_MS = 1000;
|
|
732
|
-
|
|
733
434
|
/**
|
|
734
435
|
* Is the system log dropping messages right now, and who is writing the most?
|
|
735
436
|
*
|
package/src/walls.js
CHANGED
|
@@ -138,10 +138,12 @@ export function hitsOf(file, role, action, target, run = null) {
|
|
|
138
138
|
// the cut, so a cut on a boundary keeps it). Comparing the file's byte size
|
|
139
139
|
// with the text's UTF-16 length used to decide that, and with any multibyte
|
|
140
140
|
// character in the tail it kept the fragment of a line or dropped a whole one.
|
|
141
|
-
|
|
141
|
+
// The canary carries the run's label: the launcher's --run-id whole, or the
|
|
142
|
+
// first 8 characters of seisin's own id (older runs exported the whole id).
|
|
143
|
+
const id = run ? String(run) : null;
|
|
142
144
|
let total = 0, inRun = null;
|
|
143
145
|
for (const e of readEntries(file, { tail: TAIL_BYTES })) {
|
|
144
|
-
if (id && e.event === "canary" && e.role === role && e.run === id) inRun = 0;
|
|
146
|
+
if (id && e.event === "canary" && e.role === role && (e.run === id || e.run === id.slice(0, 8))) inRun = 0;
|
|
145
147
|
if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) {
|
|
146
148
|
total++;
|
|
147
149
|
if (inRun !== null) inRun++;
|