seisin 0.4.2 → 0.5.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/README.md +142 -54
- package/package.json +3 -3
- package/src/cli.js +145 -46
- package/src/commands/check.js +3 -2
- package/src/commands/explain.js +34 -13
- package/src/commands/hook.js +14 -4
- package/src/commands/init.js +126 -38
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +109 -32
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +159 -37
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +25 -6
- package/src/commands/whose.js +7 -6
- package/src/commands/wire.js +156 -17
- package/src/config.js +187 -25
- package/src/controls.js +244 -0
- package/src/diagnose.js +35 -33
- package/src/env.js +11 -8
- package/src/grants.js +15 -7
- package/src/help.js +159 -0
- package/src/hook.js +49 -19
- package/src/index.js +4 -4
- package/src/inspect.js +137 -71
- package/src/intake.js +20 -15
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +236 -51
- package/src/mcp.js +42 -28
- package/src/notify.js +13 -8
- package/src/owners.js +198 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +68 -10
- package/src/requests.js +173 -72
- package/src/review.js +30 -17
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +377 -229
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +191 -44
- package/src/uilink.js +10 -4
- package/src/views.js +289 -0
- package/src/violations.js +104 -16
- package/src/walls.js +19 -42
- package/src/worktree.js +3 -11
- package/ui/index.html +1356 -537
package/src/views.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the console and the MCP server both say about the log, computed once.
|
|
3
|
+
*
|
|
4
|
+
* The two readers are different — a person opens the console, an agent calls
|
|
5
|
+
* the MCP — and they must not disagree about the same file. This held the
|
|
6
|
+
* shared arithmetic inside serve.js, so the MCP server imported node:http to
|
|
7
|
+
* group denials. Nothing here opens a socket or writes a file.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { explain, standingOf } from "./owners.js";
|
|
12
|
+
import { kindsOf, KINDS } from "./kinds.js";
|
|
13
|
+
import { walls } from "./walls.js";
|
|
14
|
+
import { pending, requestsPath, markStale } from "./requests.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An ISO date from what a person or an agent typed, or null for no filter.
|
|
18
|
+
* Anything that does not parse is ignored rather than refused: a bad filter
|
|
19
|
+
* should show everything, not an error.
|
|
20
|
+
*/
|
|
21
|
+
export function parseSince(raw) {
|
|
22
|
+
const t = raw ? Date.parse(raw) : NaN;
|
|
23
|
+
return Number.isNaN(t) ? null : new Date(t).toISOString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `explain`, remembered while the policy file stays the same.
|
|
28
|
+
*
|
|
29
|
+
* Causes and walls ask the policy about every refused path, and a real log has
|
|
30
|
+
* two thousand of them: ~0.75 ms each, most of the console's two-second poll.
|
|
31
|
+
* The answer depends on the policy text, so that is the key — and a little on
|
|
32
|
+
* the disk (on Linux a control file is protected only once it exists), so the
|
|
33
|
+
* memory is dropped after a minute too, as standingOf does. A config that was
|
|
34
|
+
* not read from a file gets a memory for this call only.
|
|
35
|
+
*/
|
|
36
|
+
const VERDICT_TTL = 60_000;
|
|
37
|
+
let verdictMemo = { key: null, at: 0, map: new Map() };
|
|
38
|
+
|
|
39
|
+
export function verdicts(cfg, { now = Date.now() } = {}) {
|
|
40
|
+
let key = null;
|
|
41
|
+
try {
|
|
42
|
+
if (cfg.path) key = `${cfg.path}\u0000${createHash("sha256").update(readFileSync(cfg.path)).digest("hex")}`;
|
|
43
|
+
} catch {}
|
|
44
|
+
let map;
|
|
45
|
+
if (key === null) map = new Map();
|
|
46
|
+
else {
|
|
47
|
+
if (verdictMemo.key !== key || now - verdictMemo.at > VERDICT_TTL) verdictMemo = { key, at: now, map: new Map() };
|
|
48
|
+
map = verdictMemo.map;
|
|
49
|
+
}
|
|
50
|
+
return (c, role, action, target) => {
|
|
51
|
+
const k = `${role}\u0000${action}\u0000${target}`;
|
|
52
|
+
let v = map.get(k);
|
|
53
|
+
if (!v) map.set(k, (v = explain(c, role, action, target)));
|
|
54
|
+
return v;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Today's denials, grouped by what was denied rather than by who asked.
|
|
60
|
+
*
|
|
61
|
+
* Called `causes` and not `friction`: `seisin review` already has a `friction`
|
|
62
|
+
* that counts something narrower — repeated denials that are not about keys —
|
|
63
|
+
* and two screens of one product reporting different numbers under one label
|
|
64
|
+
* is the kind of thing nobody notices until they are compared.
|
|
65
|
+
*
|
|
66
|
+
* The console used to show them per role, which is the same list a hundred
|
|
67
|
+
* times over: measured on one deployment, 1,080 of 1,422 refusals were a
|
|
68
|
+
* single lock file across six repositories, and per-role that reads as six
|
|
69
|
+
* hundred identical rows instead of one sentence. Grouped, the shape of the
|
|
70
|
+
* day is visible at a glance and it is almost never a territory dispute.
|
|
71
|
+
*
|
|
72
|
+
* `stillRefused` is recomputed against the policy as it stands, not read out
|
|
73
|
+
* of the log — a cause that has been granted since is history, not friction,
|
|
74
|
+
* and leaving it on the page sends somebody to fix what is already fixed.
|
|
75
|
+
*/
|
|
76
|
+
export function causesOf(cfg, entries, { ask = explain, limit = 12 } = {}) {
|
|
77
|
+
const by = new Map();
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
if (e.verdict !== "denied" || !e.target || !e.action) continue;
|
|
80
|
+
const k = `${e.action}\u0000${e.target}`;
|
|
81
|
+
const g = by.get(k) ?? { action: e.action, target: e.target, kind: e.kind, times: 0, roles: new Set(), ownersThen: e.owners ?? [] };
|
|
82
|
+
g.times++;
|
|
83
|
+
g.roles.add(e.role);
|
|
84
|
+
by.set(k, g);
|
|
85
|
+
}
|
|
86
|
+
const total = [...by.values()].reduce((n, g) => n + g.times, 0);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The same grouping again, one level coarser: by the NAME at the end of the
|
|
90
|
+
* path rather than the path.
|
|
91
|
+
*
|
|
92
|
+
* Grouping by path alone gets the most important reading backwards. Measured
|
|
93
|
+
* here: six of the top seven causes were `.git/index.lock` in six different
|
|
94
|
+
* repositories, no single one above 17% — so "no cause dominates" is what the
|
|
95
|
+
* arithmetic says and the opposite of what is true. Three quarters of the
|
|
96
|
+
* window was one *kind* of thing, and that is a tooling problem with a
|
|
97
|
+
* mechanical fix, not a territory question anybody needs to rule on.
|
|
98
|
+
*
|
|
99
|
+
* By last segment, and nothing cleverer. A regex that recognised lock files,
|
|
100
|
+
* caches and build outputs would be a list of guesses about other people's
|
|
101
|
+
* toolchains that quietly goes stale; a repeated filename is a fact about
|
|
102
|
+
* this log.
|
|
103
|
+
*/
|
|
104
|
+
const fams = new Map();
|
|
105
|
+
for (const g of by.values()) {
|
|
106
|
+
const name = g.target.split("/").filter(Boolean).pop() ?? g.target;
|
|
107
|
+
const f = fams.get(name) ?? { name, times: 0, paths: 0, where: [], roles: new Set() };
|
|
108
|
+
f.times += g.times;
|
|
109
|
+
f.paths++;
|
|
110
|
+
// The places, so the console can show what a name is made of instead of
|
|
111
|
+
// asserting a percentage the reader has to take on faith.
|
|
112
|
+
f.where.push({ target: g.target, times: g.times });
|
|
113
|
+
for (const r of g.roles) f.roles.add(r);
|
|
114
|
+
fams.set(name, f);
|
|
115
|
+
}
|
|
116
|
+
const families = [...fams.values()]
|
|
117
|
+
.sort((a, b) => b.times - a.times)
|
|
118
|
+
.map((f) => ({
|
|
119
|
+
name: f.name, times: f.times, paths: f.paths,
|
|
120
|
+
share: total ? f.times / total : 0,
|
|
121
|
+
roles: [...f.roles].sort(),
|
|
122
|
+
where: f.where.sort((a, b) => b.times - a.times).slice(0, 20),
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What a `grep` over the log cannot tell you: where each refused path stands
|
|
127
|
+
* against the policy as it is now.
|
|
128
|
+
*
|
|
129
|
+
* The headline used to be "74% of it is one name", and a field review
|
|
130
|
+
* applied this project's own test to it: *did the number tell you something
|
|
131
|
+
* you did not know?* For somebody who reads the raw log, no. So the console
|
|
132
|
+
* added how many causes are on paths no role owns — a decision rather than a
|
|
133
|
+
* number — and then computed it from the `owners` each log line carried,
|
|
134
|
+
* which is the one thing the log already had. It said the policy and read
|
|
135
|
+
* the past: every grant made since left its refusals counted as "nobody's",
|
|
136
|
+
* and protected surfaces, ports and paths outside the repository, which
|
|
137
|
+
* nobody can ever own, were counted as waiting for somebody to claim them.
|
|
138
|
+
*
|
|
139
|
+
* Now every cause is classified by `standing` (owners.js), and the four
|
|
140
|
+
* piles are reported in paths and in refusals. `unowned` keeps its name and
|
|
141
|
+
* now means what the page always said it meant. The owners the log recorded
|
|
142
|
+
* are kept on each cause as `ownersThen`: evidence of what was true, not
|
|
143
|
+
* authority over what is.
|
|
144
|
+
*/
|
|
145
|
+
const standOf = standingOf(cfg);
|
|
146
|
+
const piles = Object.fromEntries(["unowned", "owned", "protected", "outside"].map((k) => [k, { paths: 0, denials: 0 }]));
|
|
147
|
+
for (const g of by.values()) {
|
|
148
|
+
const s = standOf({ kind: g.kind, target: g.target });
|
|
149
|
+
g.standing = s;
|
|
150
|
+
piles[s.kind].paths++;
|
|
151
|
+
piles[s.kind].denials += g.times;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* And for the unowned pile, what kind of thing each path is (kinds.js).
|
|
156
|
+
*
|
|
157
|
+
* Suggesting an owner was measured first and was right about one time in
|
|
158
|
+
* twenty; the rest widened a role over git's lock files, a test's scratch
|
|
159
|
+
* directories and a credential. What the reader needs is the kind and the
|
|
160
|
+
* move that fits it, and `territory` — the part that really is a decision —
|
|
161
|
+
* is what is left. Over every unowned cause, not the twelve shown: one rule
|
|
162
|
+
* needs the whole set (the same name made unique per run in many places).
|
|
163
|
+
*/
|
|
164
|
+
const unowned = [...by.values()].filter((g) => g.standing.kind === "unowned");
|
|
165
|
+
const natures = kindsOf(unowned.map((g) => g.target), { keyDirs: cfg.keyDirs ?? [] });
|
|
166
|
+
piles.unowned.kinds = Object.fromEntries(Object.keys(KINDS).map((k) => [k, { paths: 0, denials: 0 }]));
|
|
167
|
+
for (const g of unowned) {
|
|
168
|
+
g.nature = natures.get(g.target);
|
|
169
|
+
piles.unowned.kinds[g.nature.kind].paths++;
|
|
170
|
+
piles.unowned.kinds[g.nature.kind].denials += g.times;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* What each refusal was about — a file, a key, a network destination or an
|
|
175
|
+
* MCP tool — counted over every cause, not the ones listed. The console
|
|
176
|
+
* shows keys, network and tools apart from files: "no role owns this" is a
|
|
177
|
+
* sentence about paths, and said of a port or a tool it is simply wrong.
|
|
178
|
+
*/
|
|
179
|
+
const about = Object.fromEntries(ABOUT.map((k) => [k, { paths: 0, denials: 0 }]));
|
|
180
|
+
for (const g of by.values()) {
|
|
181
|
+
const a = aboutOf(g.kind);
|
|
182
|
+
about[a].paths++;
|
|
183
|
+
about[a].denials += g.times;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
total,
|
|
188
|
+
unowned: piles.unowned.paths,
|
|
189
|
+
about,
|
|
190
|
+
standing: piles,
|
|
191
|
+
// The real number of distinct causes, not the length of the list below.
|
|
192
|
+
// The page says "over N distinct paths" and the list is capped at twelve,
|
|
193
|
+
// so taking N from the list reported the cap as if it were the count —
|
|
194
|
+
// a wrong number stated confidently, which is worse than no number.
|
|
195
|
+
distinct: by.size,
|
|
196
|
+
families: families.slice(0, 8),
|
|
197
|
+
causes: [...by.values()]
|
|
198
|
+
.sort((a, b) => b.times - a.times)
|
|
199
|
+
.slice(0, limit)
|
|
200
|
+
.map((g) => ({
|
|
201
|
+
about: aboutOf(g.kind),
|
|
202
|
+
action: g.action,
|
|
203
|
+
target: g.target,
|
|
204
|
+
times: g.times,
|
|
205
|
+
share: total ? g.times / total : 0,
|
|
206
|
+
roles: [...g.roles].sort(),
|
|
207
|
+
standing: g.standing.kind,
|
|
208
|
+
...(g.standing.why && { why: g.standing.why }),
|
|
209
|
+
// Only on an unowned cause: what kind of thing it is, and what to do.
|
|
210
|
+
...(g.nature && { kind: g.nature.kind, hint: g.nature.hint }),
|
|
211
|
+
owners: g.standing.owners,
|
|
212
|
+
ownersThen: g.ownersThen,
|
|
213
|
+
// How many of the roles that hit this would still hit it. `some` and
|
|
214
|
+
// not `every`: two roles out of three still blocked is still friction,
|
|
215
|
+
// and requiring all of them would quietly retire a live cause the day
|
|
216
|
+
// one role got a grant.
|
|
217
|
+
stillRefused: [...g.roles].filter((r) => cfg.roles[r] && !ask(cfg, r, g.action, g.target).allowed).length,
|
|
218
|
+
})),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** What a log line was about. Lines from before the field existed are files. */
|
|
223
|
+
const ABOUT = ["file", "key", "network", "tool"];
|
|
224
|
+
function aboutOf(kind) {
|
|
225
|
+
return ABOUT.includes(kind) ? kind : "file";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Walls as the console shows them: each with what it was about and, when no
|
|
230
|
+
* role owns the path, the same kind and hint the Denied screen gives it.
|
|
231
|
+
*
|
|
232
|
+
* Without this the two screens contradicted each other about one path: Walls
|
|
233
|
+
* said `.env.local` "has no owner — no role can write it until one claims it",
|
|
234
|
+
* an invitation to give it one, while Denied said it is a credential and never
|
|
235
|
+
* to grant a write to it. The hint is kinds.js's, so it is the same sentence.
|
|
236
|
+
*
|
|
237
|
+
* A copy: the MCP server hands out walls() as it is, and this adds to it.
|
|
238
|
+
*/
|
|
239
|
+
export function describeWalls(cfg, byRole, entries) {
|
|
240
|
+
const kindOfLine = new Map();
|
|
241
|
+
for (const e of entries) if (e.verdict === "denied" && e.target) kindOfLine.set(`${e.role}\u0000${e.action}\u0000${e.target}`, e.kind);
|
|
242
|
+
const standOf = standingOf(cfg);
|
|
243
|
+
const out = {};
|
|
244
|
+
const unowned = [];
|
|
245
|
+
for (const [role, list] of Object.entries(byRole)) {
|
|
246
|
+
out[role] = list.map((w) => {
|
|
247
|
+
const about = aboutOf(kindOfLine.get(`${role}\u0000${w.action}\u0000${w.target}`));
|
|
248
|
+
const x = { ...w, about, retried: w.times - 1 };
|
|
249
|
+
// Every file wall, owned or not: a protected path can also lie inside
|
|
250
|
+
// someone's territory, and "owned" must not hide "never granted". The
|
|
251
|
+
// owners are today's, as Denied's are.
|
|
252
|
+
if (about === "file") {
|
|
253
|
+
const s = standOf({ kind: "file", target: w.target });
|
|
254
|
+
x.standing = s.kind;
|
|
255
|
+
x.owners = s.owners ?? [];
|
|
256
|
+
if (s.why) x.why = s.why;
|
|
257
|
+
if (s.kind === "unowned") unowned.push(x);
|
|
258
|
+
}
|
|
259
|
+
return x;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const natures = kindsOf(unowned.map((w) => w.target), { keyDirs: cfg.keyDirs ?? [] });
|
|
263
|
+
for (const w of unowned) Object.assign(w, natures.get(w.target));
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Every role's walls, from one read of the log.
|
|
269
|
+
*
|
|
270
|
+
* `entries` are log lines of any verdict (walls keeps the denied ones); only
|
|
271
|
+
* roles with at least one wall appear. The MCP read the log once per role — 32
|
|
272
|
+
* reads on a real policy — to answer the same question.
|
|
273
|
+
*/
|
|
274
|
+
export function wallsByRole(cfg, entries, { ask } = {}) {
|
|
275
|
+
const denied = entries.filter((e) => e.verdict === "denied");
|
|
276
|
+
return Object.fromEntries(
|
|
277
|
+
Object.keys(cfg.roles)
|
|
278
|
+
.map((r) => [r, walls(cfg, r, { entries: denied, ...(ask && { ask }) })])
|
|
279
|
+
.filter(([, w]) => w.length));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The request queue as a person should read it: pending, with the requests
|
|
284
|
+
* the role has stopped asking for marked. `history` is the whole log, every
|
|
285
|
+
* verdict — a run that was only allowed things is still a run.
|
|
286
|
+
*/
|
|
287
|
+
export function queue(root, history, keyDirs = null) {
|
|
288
|
+
return markStale(pending(requestsPath(root), { keyDirs }), history);
|
|
289
|
+
}
|
package/src/violations.js
CHANGED
|
@@ -98,7 +98,7 @@ export function actionOf(operation) {
|
|
|
98
98
|
* - `remote:*:<port>` — a TCP dial the profile refused. The kernel does not
|
|
99
99
|
* name the host, so the target is the port and nothing more.
|
|
100
100
|
* - a unix socket path — except under /var/run, where the system daemons
|
|
101
|
-
* live: every DNS lookup inside the
|
|
101
|
+
* live: every DNS lookup inside the sandbox is a refused connect to
|
|
102
102
|
* mDNSResponder, and recording those would bury everything else.
|
|
103
103
|
* - anything else (an empty detail, which the kernel also writes) — dropped.
|
|
104
104
|
*/
|
|
@@ -219,8 +219,8 @@ export function isOurs(tagged, argv) {
|
|
|
219
219
|
* reimplementing the runtime's shell quoting and inheriting its bugs; a process
|
|
220
220
|
* tree is a fact the OS already holds.
|
|
221
221
|
*
|
|
222
|
-
*
|
|
223
|
-
* that the suffix alone is exact, because it is generated once per `srt`
|
|
222
|
+
* At most one `ps` per TREE_MS, and only until the session suffix is learned —
|
|
223
|
+
* after that the suffix alone is exact, because it is generated once per `srt`
|
|
224
224
|
* process. In practice that is one `ps` per run.
|
|
225
225
|
*/
|
|
226
226
|
export function descends(pid, root, snapshot) {
|
|
@@ -235,14 +235,25 @@ export function descends(pid, root, snapshot) {
|
|
|
235
235
|
return false;
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* pid → ppid for every process this user can see, and in `tree.roots` the
|
|
240
|
+
* pids that are a sandbox run's root: an `srt`, or a `seisin run`.
|
|
241
|
+
*
|
|
242
|
+
* The roots are what makes "not ours" provable. A process outside our tree is
|
|
243
|
+
* someone else's only if its ancestry reaches another run; one whose ancestry
|
|
244
|
+
* ends at init was orphaned — `cmd &`, a build daemon, git's fsmonitor — and
|
|
245
|
+
* may well be ours, reparented when the shell that started it exited.
|
|
246
|
+
*/
|
|
239
247
|
export function processTree(run = execFileSync) {
|
|
240
248
|
const tree = new Map();
|
|
249
|
+
tree.roots = new Set();
|
|
241
250
|
try {
|
|
242
|
-
const out = run("/bin/ps", ["-Ao", "pid=,ppid="], { encoding: "utf8" });
|
|
251
|
+
const out = run("/bin/ps", ["-Ao", "pid=,ppid=,args="], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
243
252
|
for (const line of out.split("\n")) {
|
|
244
|
-
const m = line.trim().match(/^(\d+)\s+(\d+)
|
|
245
|
-
if (m)
|
|
253
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)(?:\s+(.*))?$/);
|
|
254
|
+
if (!m) continue;
|
|
255
|
+
tree.set(Number(m[1]), Number(m[2]));
|
|
256
|
+
if (m[3] && RUN_ROOT.test(m[3])) tree.roots.add(Number(m[1]));
|
|
246
257
|
}
|
|
247
258
|
} catch {
|
|
248
259
|
// No tree means nothing can be attributed, which the caller handles by
|
|
@@ -251,6 +262,25 @@ export function processTree(run = execFileSync) {
|
|
|
251
262
|
return tree;
|
|
252
263
|
}
|
|
253
264
|
|
|
265
|
+
/** The command line of an `srt` (however it was installed) or of `seisin run`. */
|
|
266
|
+
const RUN_ROOT = /(?:^|[\s/])srt(?:\s|$)|sandbox-runtime\/dist\/cli\.js(?:\s|$)|(?:^|[\s/])(?:seisin|cli\.js)\s+run\s/;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Does `pid` descend from a run root other than `root`?
|
|
270
|
+
*
|
|
271
|
+
* A table without command lines (`roots` absent) cannot tell an orphan from a
|
|
272
|
+
* stranger, and answers as before: alive and not ours is someone else's.
|
|
273
|
+
*/
|
|
274
|
+
export function underAnotherRun(pid, root, snapshot) {
|
|
275
|
+
if (!snapshot.roots) return true;
|
|
276
|
+
const seen = new Set();
|
|
277
|
+
for (let at = pid; at && at !== 1 && !seen.has(at); at = snapshot.get(at)) {
|
|
278
|
+
if (at !== root && snapshot.roots.has(at)) return true;
|
|
279
|
+
seen.add(at);
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
254
284
|
/**
|
|
255
285
|
* Did this refusal reach for content, or just walk past the door?
|
|
256
286
|
*
|
|
@@ -336,7 +366,7 @@ export function scopeOf(settings, root) {
|
|
|
336
366
|
* without `log` looks like — and the caller says so once rather than silently
|
|
337
367
|
* logging less than it used to.
|
|
338
368
|
*/
|
|
339
|
-
export function watchDenials(onDeny, { pid = null, argv = null, platform = process.platform, spawnFn = spawn, treeFn = processTree } = {}) {
|
|
369
|
+
export function watchDenials(onDeny, { pid = null, argv = null, platform = process.platform, spawnFn = spawn, treeFn = processTree, now = () => performance.now() } = {}) {
|
|
340
370
|
const stats = { attributed: 0, foreign: 0, unattributed: 0 };
|
|
341
371
|
|
|
342
372
|
/**
|
|
@@ -370,9 +400,29 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
370
400
|
|
|
371
401
|
let suffix = null; // learned once, exact from then on
|
|
372
402
|
let tree = null; // refreshed only while still learning
|
|
403
|
+
let treeAt = -Infinity; // when `tree` was taken
|
|
373
404
|
let root = pid; // the srt pid; not known until after spawn
|
|
374
405
|
const mine = new Set(); // pids already proven to be ours
|
|
375
406
|
const held = []; // denials seen before they could be attributed
|
|
407
|
+
const foreignSuffixes = new Set(); // other sandboxes, proven while they were alive
|
|
408
|
+
let recheck = null; // a deferred look, when a snapshot was refused
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* One `ps` per TREE_MS at most, and a later look instead of a skipped one.
|
|
412
|
+
*
|
|
413
|
+
* `ps` is synchronous and it was taken for every denial from another sandbox
|
|
414
|
+
* while this run had not learned its own suffix — which, for a run that is
|
|
415
|
+
* never refused anything, is the whole run. Several cells at once is exactly
|
|
416
|
+
* that: 83 ms of blocked event loop per foreign line, measured. A line whose
|
|
417
|
+
* process may be younger than the last snapshot is not decided on it; it is
|
|
418
|
+
* held, and looked at again once the window has passed.
|
|
419
|
+
*/
|
|
420
|
+
const snapshot = () => { tree = treeFn(); treeAt = now(); };
|
|
421
|
+
const later = () => {
|
|
422
|
+
if (recheck) return;
|
|
423
|
+
recheck = setTimeout(() => { recheck = null; if (!suffix && held.length) revisit(); }, TREE_MS);
|
|
424
|
+
recheck.unref?.();
|
|
425
|
+
};
|
|
376
426
|
|
|
377
427
|
/**
|
|
378
428
|
* Two anchors, and either one is enough.
|
|
@@ -396,12 +446,27 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
396
446
|
}
|
|
397
447
|
if (root === null) return false; // nothing to descend from yet
|
|
398
448
|
if (mine.has(d.pid)) return true;
|
|
399
|
-
|
|
449
|
+
// A suffix already proven foreign needs no `ps`: the suffix is per `srt`.
|
|
450
|
+
if (d.suffix && foreignSuffixes.has(d.suffix)) return false;
|
|
451
|
+
if (tree === null) snapshot();
|
|
400
452
|
if (!descends(d.pid, root, tree)) {
|
|
401
|
-
// A pid
|
|
402
|
-
// Refresh once, then believe the answer
|
|
403
|
-
|
|
404
|
-
|
|
453
|
+
// A pid the snapshot does not have may simply be younger than it.
|
|
454
|
+
// Refresh once, then believe the answer — unless the last refresh was
|
|
455
|
+
// moments ago, and then look again later rather than refresh now. A pid
|
|
456
|
+
// it does have is answered: its parents do not change into ours.
|
|
457
|
+
if (treeAt < d.seen && !tree.has(d.pid)) {
|
|
458
|
+
if (now() - treeAt < TREE_MS) { later(); return LATER; }
|
|
459
|
+
snapshot();
|
|
460
|
+
}
|
|
461
|
+
if (!descends(d.pid, root, tree)) {
|
|
462
|
+
// Alive, not ours by either anchor, and under another run — the tag
|
|
463
|
+
// did not match above. Every later line from that `srt` is someone
|
|
464
|
+
// else's without asking. Alive but under no run at all is an orphan
|
|
465
|
+
// (`cmd &`, a daemon): possibly ours, so its suffix stays undecided
|
|
466
|
+
// rather than turning this run's own later lines into strangers.
|
|
467
|
+
if (d.suffix && tree.has(d.pid) && underAnotherRun(d.pid, root, tree)) foreignSuffixes.add(d.suffix);
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
405
470
|
}
|
|
406
471
|
mine.add(d.pid);
|
|
407
472
|
// The first proven denial hands us the suffix for every later one.
|
|
@@ -432,7 +497,8 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
432
497
|
};
|
|
433
498
|
|
|
434
499
|
const consider = (d) => {
|
|
435
|
-
|
|
500
|
+
const verdict = attribute(d);
|
|
501
|
+
if (verdict === true) {
|
|
436
502
|
stats.attributed++;
|
|
437
503
|
onDeny(d);
|
|
438
504
|
// Learning the suffix makes every held line decidable. Without this,
|
|
@@ -445,7 +511,9 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
445
511
|
// child whose pid anchors attribution, or the process died before `ps`
|
|
446
512
|
// could see it. Either way a foreign sandbox and one of ours look the same
|
|
447
513
|
// from here, so hold the line rather than credit or discard it.
|
|
448
|
-
|
|
514
|
+
// A line put off to the next snapshot is held with or without a suffix:
|
|
515
|
+
// the look it is waiting for is the only thing that can decide it.
|
|
516
|
+
if (verdict === LATER || (!suffix && d.suffix && !foreignSuffixes.has(d.suffix))) {
|
|
449
517
|
stats.unattributed++;
|
|
450
518
|
held.push(d);
|
|
451
519
|
// Bounded, because the common case on a busy machine is a run that is
|
|
@@ -473,7 +541,13 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
473
541
|
child = spawnFn("/usr/bin/log", ["stream", "--predicate", PREDICATE, "--style", "compact"],
|
|
474
542
|
{ stdio: ["ignore", "pipe", "ignore"] });
|
|
475
543
|
} catch (e) {
|
|
476
|
-
|
|
544
|
+
// The same shape as the unavailable watcher above, for the same reason:
|
|
545
|
+
// `run` calls attributeTo() and awaits close() unconditionally.
|
|
546
|
+
return {
|
|
547
|
+
available: false, reason: `could not start the log stream: ${e.message}`, stats,
|
|
548
|
+
attributeTo() {},
|
|
549
|
+
close: () => Promise.resolve(stats),
|
|
550
|
+
};
|
|
477
551
|
}
|
|
478
552
|
|
|
479
553
|
let buf = "";
|
|
@@ -486,6 +560,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
486
560
|
const chunk = buf.slice(0, cut + 4);
|
|
487
561
|
buf = buf.slice(cut + 4);
|
|
488
562
|
const d = parseChunk(chunk);
|
|
563
|
+
if (d) d.seen = now();
|
|
489
564
|
// Deduplicated here and not in consider(), because a held denial goes
|
|
490
565
|
// through consider() a second time when it is finally attributed. Doing
|
|
491
566
|
// this there would drop every line that arrived before the child had a
|
|
@@ -528,6 +603,7 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
528
603
|
* nothing pending — costs one tick rather than the full window.
|
|
529
604
|
*/
|
|
530
605
|
close({ drain = 0 } = {}) {
|
|
606
|
+
clearTimeout(recheck);
|
|
531
607
|
const stop = () => { try { child.kill("SIGTERM"); } catch { /* already gone */ } };
|
|
532
608
|
if (drain <= 0) { stop(); return Promise.resolve(stats); }
|
|
533
609
|
return new Promise((resolve) => {
|
|
@@ -551,6 +627,18 @@ export function watchDenials(onDeny, { pid = null, argv = null, platform = proce
|
|
|
551
627
|
*/
|
|
552
628
|
const QUIET_MS = 60;
|
|
553
629
|
|
|
630
|
+
/**
|
|
631
|
+
* The shortest time between two `ps` snapshots while attribution is learning.
|
|
632
|
+
*
|
|
633
|
+
* Long enough that a burst of foreign denials costs one snapshot, not one
|
|
634
|
+
* each; short enough that a line held for a younger process is decided before
|
|
635
|
+
* anyone reads the log.
|
|
636
|
+
*/
|
|
637
|
+
const TREE_MS = 250;
|
|
638
|
+
|
|
639
|
+
/** What `attribute` answers for a line it has put off to the next snapshot. */
|
|
640
|
+
const LATER = "later";
|
|
641
|
+
|
|
554
642
|
/**
|
|
555
643
|
* How many undecided denials to keep while waiting to learn our own suffix.
|
|
556
644
|
*
|
package/src/walls.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* seisin answers *whose is this* at the moment of the refusal, once, in the
|
|
5
5
|
* middle of a turn, and then the sentence is gone. Nothing carries it forward,
|
|
6
6
|
* so the agent tries again — and the measurement that made this module exist
|
|
7
|
-
* says how often: of 345 denials
|
|
8
|
-
* something that same role had already been
|
|
7
|
+
* says how often: of 345 denials in a multi-repo workspace, **88 (25%) were a repeat of
|
|
8
|
+
* something that same role had already been denied**. One role spent 37 calls
|
|
9
9
|
* on two walls, hitting the same one nineteen times.
|
|
10
10
|
*
|
|
11
11
|
* A block that repeats nineteen times is not a boundary working. It is a
|
|
@@ -33,8 +33,7 @@
|
|
|
33
33
|
* A window is still accepted, and still useful for a different reason: a
|
|
34
34
|
* refusal from three weeks ago is noise even if it is still refused today.
|
|
35
35
|
*/
|
|
36
|
-
import {
|
|
37
|
-
import { read } from "./log.js";
|
|
36
|
+
import { read, readEntries } from "./log.js";
|
|
38
37
|
import { explain } from "./owners.js";
|
|
39
38
|
import { STALE_RUNS, RUN_GAP_MS, runsAfter } from "./requests.js";
|
|
40
39
|
|
|
@@ -49,7 +48,7 @@ export const MIN_HITS = 2;
|
|
|
49
48
|
* different repositories are two walls, and merging them hides which one was
|
|
50
49
|
* hit.
|
|
51
50
|
*/
|
|
52
|
-
export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false } = {}) {
|
|
51
|
+
export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false, ask = explain } = {}) {
|
|
53
52
|
// `entries`: the log already read, for a caller asking about every role at
|
|
54
53
|
// once (the console asks for 32 on a real policy) — one read instead of 32.
|
|
55
54
|
const denied = entries
|
|
@@ -70,7 +69,9 @@ export function walls(config, role, { file, entries = null, min = MIN_HITS, sinc
|
|
|
70
69
|
if (w.times < min) continue;
|
|
71
70
|
// The policy as it stands, not as it stood. A granted wall is not a wall,
|
|
72
71
|
// and the whole point of saying this to an agent is that it is true now.
|
|
73
|
-
|
|
72
|
+
// `ask` is explain, or a remembered one (views.js) for a caller asking
|
|
73
|
+
// about every role at once.
|
|
74
|
+
const verdict = ask(config, role, w.action, w.target);
|
|
74
75
|
if (verdict.allowed) continue;
|
|
75
76
|
out.push({ ...w, reason: verdict.reason, owners: verdict.owners });
|
|
76
77
|
}
|
|
@@ -89,17 +90,18 @@ export function walls(config, role, { file, entries = null, min = MIN_HITS, sinc
|
|
|
89
90
|
/**
|
|
90
91
|
* How much of the log `timesHit` will look at.
|
|
91
92
|
*
|
|
92
|
-
* The log is append-only and
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* call, which is nothing, and 300 ms at 37 MB, which is not, on every
|
|
96
|
-
* of every
|
|
93
|
+
* The log is append-only and a segment only rotates at 8 MiB, so "read the
|
|
94
|
+
* file" is O(everything since the last rotation), on a path that runs inside
|
|
95
|
+
* the hook. Measured on a 372 KB log: 3 ms per
|
|
96
|
+
* call, which is nothing, and 300 ms at 37 MB, which is not, on every denial
|
|
97
|
+
* of every session forever.
|
|
97
98
|
*
|
|
98
99
|
* So it reads the tail. That makes the count **recent repetition** rather than
|
|
99
100
|
* lifetime repetition, and recent is the one the sentence is about anyway: an
|
|
100
101
|
* agent being told "you have hit this three times" means this session, not
|
|
101
102
|
* last month. 4 MB is thousands of entries — far more than a turn produces,
|
|
102
|
-
* and bounded.
|
|
103
|
+
* and bounded. Right after a rotation the tail is short, and so is the count:
|
|
104
|
+
* the current segment only, on purpose.
|
|
103
105
|
*/
|
|
104
106
|
const TAIL_BYTES = 4 * 1024 * 1024;
|
|
105
107
|
|
|
@@ -113,41 +115,16 @@ const TAIL_BYTES = 4 * 1024 * 1024;
|
|
|
113
115
|
* call.
|
|
114
116
|
*/
|
|
115
117
|
export function timesHit(file, role, action, target) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const fd = openSync(file, "r");
|
|
121
|
-
try {
|
|
122
|
-
const buf = Buffer.alloc(size - from);
|
|
123
|
-
readSync(fd, buf, 0, buf.length, from);
|
|
124
|
-
text = buf.toString("utf8");
|
|
125
|
-
} finally {
|
|
126
|
-
closeSync(fd);
|
|
127
|
-
}
|
|
128
|
-
} catch {
|
|
129
|
-
return 0; // no log yet is not an error; it is a first turn
|
|
130
|
-
}
|
|
131
|
-
const lines = text.split("\n");
|
|
132
|
-
// The first line of a tail read is usually half an entry. Dropping it costs
|
|
133
|
-
// one count at most and keeps a truncated line from being parsed as if it
|
|
134
|
-
// were whole.
|
|
135
|
-
if (from0(text, file)) lines.shift();
|
|
118
|
+
// The line the tail cut lands in is dropped (readLines checks the byte before
|
|
119
|
+
// the cut, so a cut on a boundary keeps it). Comparing the file's byte size
|
|
120
|
+
// with the text's UTF-16 length used to decide that, and with any multibyte
|
|
121
|
+
// character in the tail it kept the fragment of a line or dropped a whole one.
|
|
136
122
|
let n = 0;
|
|
137
|
-
for (const
|
|
138
|
-
if (!line.trim()) continue;
|
|
139
|
-
let e;
|
|
140
|
-
try { e = JSON.parse(line); } catch { continue; }
|
|
123
|
+
for (const e of readEntries(file, { tail: TAIL_BYTES }))
|
|
141
124
|
if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) n++;
|
|
142
|
-
}
|
|
143
125
|
return n;
|
|
144
126
|
}
|
|
145
127
|
|
|
146
|
-
/** Did we start mid-file? Then the first line is a fragment. */
|
|
147
|
-
function from0(text, file) {
|
|
148
|
-
try { return statSync(file).size > text.length; } catch { return false; }
|
|
149
|
-
}
|
|
150
|
-
|
|
151
128
|
/**
|
|
152
129
|
* The calls that went into hitting the same wall twice or more.
|
|
153
130
|
*
|
package/src/worktree.js
CHANGED
|
@@ -30,9 +30,10 @@
|
|
|
30
30
|
* and no dependency — a tool people install to shrink their attack surface
|
|
31
31
|
* should not shell out to find out where it is.
|
|
32
32
|
*/
|
|
33
|
-
import { statSync, readFileSync, readdirSync
|
|
33
|
+
import { statSync, readFileSync, readdirSync } from "node:fs";
|
|
34
34
|
import { dirname, join, resolve, relative, isAbsolute } from "node:path";
|
|
35
35
|
import { findConfig } from "./config.js";
|
|
36
|
+
import { realOrSelf } from "./grants.js";
|
|
36
37
|
|
|
37
38
|
const GITDIR = /^gitdir:\s*(.+?)\s*$/;
|
|
38
39
|
const WORKTREE = /^(.+)\/\.git\/worktrees\/[^/]+$/;
|
|
@@ -63,18 +64,9 @@ function statOf(p) {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
/** The path with symlinks followed, or the path itself when it is not on disk. */
|
|
67
|
-
function real(p) {
|
|
68
|
-
try {
|
|
69
|
-
return realpathSync(p);
|
|
70
|
-
} catch {
|
|
71
|
-
return p;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
67
|
/** Two spellings of one place — `/tmp` and `/private/tmp` — compare equal. */
|
|
76
68
|
function same(a, b) {
|
|
77
|
-
return
|
|
69
|
+
return realOrSelf(a) === realOrSelf(b);
|
|
78
70
|
}
|
|
79
71
|
|
|
80
72
|
/**
|