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/requests.js
CHANGED
|
@@ -1,39 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The queue of
|
|
2
|
+
* The queue of requests: what an agent asked for and cannot have yet.
|
|
3
3
|
*
|
|
4
4
|
* ── Two words, kept apart on purpose ──
|
|
5
|
-
* **
|
|
5
|
+
* **Denied** is what the boundary does. **Declined** is what a person does to
|
|
6
6
|
* a request. They used to be one word in one command: an entry read `first
|
|
7
7
|
* refused on src/api/x.ts` and `seisin deny` answered `refused frontend ✕
|
|
8
8
|
* src/api/**`. One means the kernel said no, the other means you did, and a
|
|
9
9
|
* reader had nothing to tell them apart by.
|
|
10
10
|
*
|
|
11
11
|
* The entry says **asked** now, because refused was not always true. Asking for
|
|
12
|
-
* a
|
|
12
|
+
* a path before reaching for it is reasonable, and nothing required a
|
|
13
13
|
* denial to have happened first — so the queue could print a sentence about an
|
|
14
|
-
* event that never occurred, in front of a person about to
|
|
15
|
-
* `seisin run` drops any request the policy does not actually
|
|
14
|
+
* event that never occurred, in front of a person about to grant something.
|
|
15
|
+
* `seisin run` drops any request the policy does not actually deny; what is
|
|
16
16
|
* left is "you cannot have this, and you asked", which holds whether the agent
|
|
17
17
|
* tried or simply asked.
|
|
18
18
|
*
|
|
19
19
|
* A denial already contains everything a request needs — the hook knows the
|
|
20
|
-
* role, the path and the owner — so the
|
|
20
|
+
* role, the path and the owner — so the denial leaves something behind that a
|
|
21
21
|
* person can act on in one command instead of in an editor. See
|
|
22
|
-
* `docs/permission-requests.md` for why
|
|
22
|
+
* `docs/permission-requests.md` for why granting is deliberately not something
|
|
23
23
|
* an agent can do.
|
|
24
24
|
*
|
|
25
25
|
* Append-only, beside the log, for the same reason: a decision that can be
|
|
26
|
-
* rewritten is not evidence. A request that is granted or
|
|
26
|
+
* rewritten is not evidence. A request that is granted or declined is not
|
|
27
27
|
* deleted — it is followed by a line saying what happened to it.
|
|
28
28
|
*/
|
|
29
|
-
import { neverWrites, isGitMetadata, covers } from "./owners.js";
|
|
30
|
-
import {
|
|
29
|
+
import { neverWrites, isGitMetadata, covers, keyHolders, inKeyDir } from "./owners.js";
|
|
30
|
+
import { protectedBy } from "./surface.js";
|
|
31
|
+
import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
|
|
32
|
+
import { randomBytes } from "node:crypto";
|
|
31
33
|
import { dirname, join } from "node:path";
|
|
32
34
|
import { STATE_DIR, tomlString } from "./layout.js";
|
|
33
35
|
import { send } from "./spool.js";
|
|
34
|
-
import { withLock } from "./log.js";
|
|
36
|
+
import { withLock, readEntries } from "./log.js";
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
const REQUESTS_NAME = "requests.jsonl";
|
|
37
39
|
|
|
38
40
|
export function requestsPath(root) {
|
|
39
41
|
return join(root, STATE_DIR, REQUESTS_NAME);
|
|
@@ -57,7 +59,6 @@ export function keyOf({ role, action, target }) {
|
|
|
57
59
|
return `${role}:${action}:${dir}`;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
/** The glob a grant would add, derived from what was asked. */
|
|
61
62
|
/**
|
|
62
63
|
* A request's id as one shell word, for the commands seisin suggests.
|
|
63
64
|
*
|
|
@@ -69,13 +70,23 @@ export function shellId(key) {
|
|
|
69
70
|
return `'${String(key).replace(/'/g, `'\\''`)}'`;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
/** The glob a grant would add, derived from what was asked. */
|
|
74
|
+
export function grantFor({ action, target }, keyDir = null) {
|
|
73
75
|
const t = typeof target === "string" ? target : "";
|
|
74
76
|
// A key keeps its directory when it has one. Stripping it turned a request
|
|
75
77
|
// for `shared/api.txt` into a grant of `api.txt`, which settingsFor then
|
|
76
|
-
// resolves against the FIRST key directory — so the person
|
|
78
|
+
// resolves against the FIRST key directory — so the person grants one file
|
|
77
79
|
// and another one of the same name is what gets read.
|
|
78
|
-
|
|
80
|
+
//
|
|
81
|
+
// Except the first key directory itself, which is exactly the one a bare
|
|
82
|
+
// name resolves against: `.secrets/stripe.txt` is written `stripe.txt`, the
|
|
83
|
+
// way every hand-written key is. Granting the long form put two spellings of
|
|
84
|
+
// one key in the same file.
|
|
85
|
+
if (action === "read") {
|
|
86
|
+
const first = keyDir ? String(keyDir).replace(/^\.\/+/, "").replace(/\/+$/, "") : null;
|
|
87
|
+
if (first && t.startsWith(first + "/") && !t.slice(first.length + 1).includes("/")) return t.slice(first.length + 1);
|
|
88
|
+
return t.includes("/") ? t : t.replace(/^.*\//, "");
|
|
89
|
+
}
|
|
79
90
|
const dir = t.split("/").slice(0, -1).join("/");
|
|
80
91
|
return dir ? `${dir}/**` : t;
|
|
81
92
|
}
|
|
@@ -84,7 +95,9 @@ function write(file, entry) {
|
|
|
84
95
|
if (send("requests", entry)) return true;
|
|
85
96
|
try {
|
|
86
97
|
mkdirSync(dirname(file), { recursive: true });
|
|
87
|
-
|
|
98
|
+
// The time is this process's, named after the spread so no entry sets it.
|
|
99
|
+
const now = new Date().toISOString();
|
|
100
|
+
appendFileSync(file, JSON.stringify({ at: now, ...entry, at: now }) + "\n");
|
|
88
101
|
return true;
|
|
89
102
|
} catch {
|
|
90
103
|
// Same rule as the log: never take the agent down over bookkeeping.
|
|
@@ -92,8 +105,27 @@ function write(file, entry) {
|
|
|
92
105
|
}
|
|
93
106
|
}
|
|
94
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Who a read request's key belongs to, from the policy as it is now.
|
|
110
|
+
*
|
|
111
|
+
* A key is declared, not owned: nobody writes `.secrets/`, so the owners a
|
|
112
|
+
* write-territory lookup finds for one are always none, and the queue said
|
|
113
|
+
* "(unowned)" about a key another role declares — right before a grant that
|
|
114
|
+
* would share it. Recomputed rather than read from the line, like every other
|
|
115
|
+
* field that decides what a person is shown.
|
|
116
|
+
*/
|
|
117
|
+
export function withDeclarers(config, queue) {
|
|
118
|
+
for (const r of queue) {
|
|
119
|
+
if (r.action !== "read" || typeof r.target !== "string") continue;
|
|
120
|
+
if (!inKeyDir(config, r.target) && r.target.includes("/")) continue;
|
|
121
|
+
r.owners = keyHolders(config, r.target);
|
|
122
|
+
r.declared = true;
|
|
123
|
+
}
|
|
124
|
+
return queue;
|
|
125
|
+
}
|
|
126
|
+
|
|
95
127
|
/** Records that a role asked for something the policy denies it. Safe to call
|
|
96
|
-
* on every denial; `seisin run` drops anything the policy does not
|
|
128
|
+
* on every denial; `seisin run` drops anything the policy does not deny. */
|
|
97
129
|
export function record(file, { role, action, target, owners }) {
|
|
98
130
|
return write(file, { kind: "asked", key: keyOf({ role, action, target }), role, action, target, owners: owners ?? [] });
|
|
99
131
|
}
|
|
@@ -115,6 +147,9 @@ export function settle(file, key, decision, reason = "") {
|
|
|
115
147
|
*
|
|
116
148
|
* It exists so that a refusal to run cannot be invisible. Admission may
|
|
117
149
|
* postpone work; it may not make work disappear from the queue a person reads.
|
|
150
|
+
*
|
|
151
|
+
* Nothing in src/ calls it today — handoff.js decides but nothing records the
|
|
152
|
+
* outcome; the tests do, and pending() keeps reading what it writes.
|
|
118
153
|
*/
|
|
119
154
|
export function recordHandoff(file, key, decision = {}) {
|
|
120
155
|
const { type, role, limit, max, reason, depth, chainId, revision } = decision;
|
|
@@ -135,20 +170,35 @@ export function recordHandoff(file, key, decision = {}) {
|
|
|
135
170
|
* it was asked and what was decided.
|
|
136
171
|
*
|
|
137
172
|
* Reduced from the whole file rather than kept as state, so the file stays the
|
|
138
|
-
* only thing that has to be correct.
|
|
173
|
+
* only thing that has to be correct. The reduction is remembered for as long as
|
|
174
|
+
* the file is unchanged (same inode, size and mtime): the console asks every
|
|
175
|
+
* two seconds, and re-parsing a 2 MB queue each time to find it had not moved
|
|
176
|
+
* was most of the cost of asking. Each call gets its own copies, so a caller
|
|
177
|
+
* marking entries (markStale) cannot mark the next caller's.
|
|
139
178
|
*/
|
|
140
|
-
|
|
141
|
-
|
|
179
|
+
let reduced = null; // { file, ino, size, mtimeMs, all }
|
|
180
|
+
|
|
181
|
+
export function pending(file, { includeSettled = false, keyDirs = null } = {}) {
|
|
182
|
+
let st;
|
|
183
|
+
try { st = statSync(file); } catch { return []; }
|
|
184
|
+
let all;
|
|
185
|
+
if (reduced && reduced.file === file && reduced.ino === st.ino && reduced.size === st.size && reduced.mtimeMs === st.mtimeMs) {
|
|
186
|
+
all = reduced.all;
|
|
187
|
+
} else {
|
|
188
|
+
all = reduce(file);
|
|
189
|
+
reduced = { file, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, all };
|
|
190
|
+
}
|
|
191
|
+
// The grant is re-derived with the policy's first key directory when the
|
|
192
|
+
// caller has one, so a key is proposed in the form hand-written keys use.
|
|
193
|
+
const first = keyDirs?.[0] ?? null;
|
|
194
|
+
const copies = all.map((r) => (first && r.action === "read" ? { ...r, grant: grantFor(r, first) } : { ...r }));
|
|
195
|
+
return includeSettled ? copies : copies.filter((r) => r.state === "pending");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function reduce(file) {
|
|
142
199
|
const byKey = new Map();
|
|
143
200
|
|
|
144
|
-
for (const
|
|
145
|
-
if (!line.trim()) continue;
|
|
146
|
-
let e;
|
|
147
|
-
try {
|
|
148
|
-
e = JSON.parse(line);
|
|
149
|
-
} catch {
|
|
150
|
-
continue; // a half-written line from a killed process is not an error
|
|
151
|
-
}
|
|
201
|
+
for (const e of readEntries(file)) {
|
|
152
202
|
if (!e.key) continue;
|
|
153
203
|
|
|
154
204
|
if (e.kind === "asked") {
|
|
@@ -169,7 +219,8 @@ export function pending(file, { includeSettled = false } = {}) {
|
|
|
169
219
|
*
|
|
170
220
|
* Without this branch the catch-all below would set state = "handoff" and a
|
|
171
221
|
* throttled attempt would drop out of the pending queue — the exact silent
|
|
172
|
-
* disappearance the attempt is recorded to prevent.
|
|
222
|
+
* disappearance the attempt is recorded to prevent. (Only the tests write
|
|
223
|
+
* handoff lines today — see recordHandoff.)
|
|
173
224
|
*/
|
|
174
225
|
if (e.kind === "handoff") {
|
|
175
226
|
seen.handoff = {
|
|
@@ -182,14 +233,13 @@ export function pending(file, { includeSettled = false } = {}) {
|
|
|
182
233
|
seen.state = e.kind; seen.reason = e.reason ?? ""; seen.decided = e.at;
|
|
183
234
|
}
|
|
184
235
|
|
|
185
|
-
|
|
186
|
-
return includeSettled ? all : all.filter((r) => r.state === "pending");
|
|
236
|
+
return [...byKey.values()];
|
|
187
237
|
}
|
|
188
238
|
|
|
189
239
|
/**
|
|
190
240
|
* How many runs of the role, after the last time it asked, make a request old.
|
|
191
241
|
*
|
|
192
|
-
* A request is recorded again on every
|
|
242
|
+
* A request is recorded again on every denial, so one the role still needs
|
|
193
243
|
* keeps a fresh `last`. One whose need went away — the work moved somewhere the
|
|
194
244
|
* role may write, or the task ended — stops being asked while the role keeps
|
|
195
245
|
* running. Three runs is enough to say "not since" and few enough that a queue
|
|
@@ -233,7 +283,7 @@ export function runsAfter(entries, since, gapMs = RUN_GAP_MS) {
|
|
|
233
283
|
* reordering would shift the numbers `grant <n>` is typed against while
|
|
234
284
|
* nobody decided anything. The mark says what is true — the role ran N times
|
|
235
285
|
* since and did not ask again — and leaves the rest to whoever reads it.
|
|
236
|
-
* `entries` are log lines (`read(logPath(root))`)
|
|
286
|
+
* `entries` are log lines (`read(logPath(root))`).
|
|
237
287
|
*/
|
|
238
288
|
export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_MS } = {}) {
|
|
239
289
|
const byRole = new Map(); // role -> its log lines
|
|
@@ -248,35 +298,13 @@ export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_M
|
|
|
248
298
|
return queue;
|
|
249
299
|
}
|
|
250
300
|
|
|
251
|
-
/**
|
|
252
|
-
* Adds a granted glob to a role in the config text, with its provenance.
|
|
253
|
-
*
|
|
254
|
-
* Edits the text rather than re-emitting the file, because a config someone
|
|
255
|
-
* wrote has comments and an order that mean something, and a tool that
|
|
256
|
-
* reformats it on every grant is a tool people stop letting near it.
|
|
257
|
-
*/
|
|
258
|
-
/**
|
|
259
|
-
* Adds a granted glob to a role in the config text, with its provenance.
|
|
260
|
-
*
|
|
261
|
-
* Edits the text rather than re-emitting the file, because a config someone
|
|
262
|
-
* wrote has comments and an order that mean something, and a tool that
|
|
263
|
-
* reformats it on every grant is a tool people stop letting near it.
|
|
264
|
-
*
|
|
265
|
-
* The edit is bounded to the role's own section, and that is not tidiness. The
|
|
266
|
-
* first version searched from the role header to the next `writes =` anywhere
|
|
267
|
-
* in the file, so approving for a role that had no `writes` line wrote the
|
|
268
|
-
* grant into the NEXT role — with a comment saying who it was for. A human
|
|
269
|
-
* approved one thing and the file recorded another. In a permission tool that
|
|
270
|
-
* is the worst possible bug, and it is not exotic: a role with only `keys`
|
|
271
|
-
* declared is an ordinary config.
|
|
272
|
-
*/
|
|
273
301
|
/**
|
|
274
302
|
* Refuses a grant that `never_writes` would cancel, before anything is written.
|
|
275
303
|
*
|
|
276
304
|
* A request filed before the subtraction existed can still be in the queue.
|
|
277
|
-
*
|
|
305
|
+
* Granting it would add the path to `writes` and change nothing — denyWrite
|
|
278
306
|
* wins — so the person would believe they granted something the kernel still
|
|
279
|
-
*
|
|
307
|
+
* denies. The two settings contradict each other, and which one should win is the
|
|
280
308
|
* decision; seisin will not make it by writing one of them silently.
|
|
281
309
|
*/
|
|
282
310
|
export function refuseIfBarred(config, request) {
|
|
@@ -288,6 +316,18 @@ export function refuseIfBarred(config, request) {
|
|
|
288
316
|
`${request.target} is under never_writes of ${request.role} ("${hit}"), which wins over ` +
|
|
289
317
|
`writes. Granting it would change nothing. If the subtraction is wrong, remove that ` +
|
|
290
318
|
`entry from [roles.${request.role}]; otherwise decline this request.`);
|
|
319
|
+
// A protected file stays denied whatever `writes` says: the kernel is given
|
|
320
|
+
// it as a deny, and explain answers the same. Granting `web/.vscode/**`
|
|
321
|
+
// used to add the line and change nothing — a grant that reads as done.
|
|
322
|
+
const guard = protectedBy(config, request.target, { role: request.role });
|
|
323
|
+
if (guard)
|
|
324
|
+
throw new Error(guard.family
|
|
325
|
+
? `${request.target} is protected (${guard.why}). A grant cannot open it: ${request.role} ` +
|
|
326
|
+
`writes it only if the policy hands it the family, control_files = ["${guard.family}"] ` +
|
|
327
|
+
`under [roles.${request.role}], inside its own territory. Edit the policy if that is ` +
|
|
328
|
+
`what you mean; otherwise decline this request.`
|
|
329
|
+
: `${request.target} is protected (${guard.why}) and no role can ever be granted it. ` +
|
|
330
|
+
`Decline this request.`);
|
|
291
331
|
// Only a repository the role does not write: its own `.git` is its own business.
|
|
292
332
|
if (isGitMetadata(request.target) && !(role?.writes ?? []).some((g) => covers(g, request.target)))
|
|
293
333
|
throw new Error(
|
|
@@ -307,20 +347,81 @@ export function refuseIfBarred(config, request) {
|
|
|
307
347
|
* so a crash mid-write cannot leave a half-written, partly-privileged policy.
|
|
308
348
|
*
|
|
309
349
|
* `mutate(toml)` returns `{ toml, changed }`. Nothing is written when unchanged.
|
|
350
|
+
* `after(result)` runs once the file is written, still under the lock — for
|
|
351
|
+
* the queue line that records the decision, so a decision and its edit are
|
|
352
|
+
* one step to anyone else taking this lock.
|
|
310
353
|
*/
|
|
311
|
-
export function editPolicy(config, mutate, { waitMs = 10000 } = {}) {
|
|
312
|
-
return withLock(config
|
|
354
|
+
export function editPolicy(config, mutate, { waitMs = 10000, after = null } = {}) {
|
|
355
|
+
return withLock(policyLockBase(config), () => {
|
|
313
356
|
const before = readFileSync(config.path, "utf8");
|
|
314
357
|
const result = mutate(before);
|
|
315
|
-
if (result.changed)
|
|
316
|
-
|
|
317
|
-
writeFileSync(tmp, result.toml);
|
|
318
|
-
renameSync(tmp, config.path);
|
|
319
|
-
}
|
|
358
|
+
if (result.changed) writePolicy(config, result.toml);
|
|
359
|
+
after?.(result);
|
|
320
360
|
return result;
|
|
321
361
|
}, { waitMs });
|
|
322
362
|
}
|
|
323
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Where the policy's lock lives: inside `.seisin/`, never beside the policy.
|
|
366
|
+
*
|
|
367
|
+
* It was `seisin.toml.lock`, next to the file. Any role that writes the repo
|
|
368
|
+
* root — every `--observe` run, any `writes = ["**"]` — could create that name
|
|
369
|
+
* holding a live pid and hold up every grant and every decline for as long as
|
|
370
|
+
* it liked. `.seisin/` is denied to every role (surface.js, parentInputs), so
|
|
371
|
+
* nothing confined can hold this one. withLock adds the `.lock`.
|
|
372
|
+
*/
|
|
373
|
+
export function policyLockBase(config) {
|
|
374
|
+
const dir = join(config.root, STATE_DIR);
|
|
375
|
+
mkdirSync(dir, { recursive: true });
|
|
376
|
+
return join(dir, "policy");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Write the policy through a temp file that nobody could have prepared.
|
|
381
|
+
*
|
|
382
|
+
* The temp was `seisin.toml.tmp-<pid>` beside the policy: a predictable name,
|
|
383
|
+
* in a directory roles can write, opened by writeFileSync — which follows a
|
|
384
|
+
* symlink. A role could plant that name as a link into its own territory, and
|
|
385
|
+
* the next grant made the policy itself a symlink the role could rewrite.
|
|
386
|
+
* Now the temp sits in `.seisin/`, has a random name, and is opened with `wx`
|
|
387
|
+
* (O_CREAT|O_EXCL), which refuses anything already there, a link included.
|
|
388
|
+
*
|
|
389
|
+
* A `.seisin/` on another filesystem (a symlink a person made) cannot be
|
|
390
|
+
* renamed across; then the temp goes beside the policy, still random and still
|
|
391
|
+
* exclusive, which is what defeats a planted name.
|
|
392
|
+
*/
|
|
393
|
+
function writePolicy(config, text) {
|
|
394
|
+
const name = `seisin.toml.tmp-${randomBytes(12).toString("hex")}`;
|
|
395
|
+
const stateTmp = join(config.root, STATE_DIR, name);
|
|
396
|
+
writeFileSync(stateTmp, text, { flag: "wx" });
|
|
397
|
+
try {
|
|
398
|
+
renameSync(stateTmp, config.path);
|
|
399
|
+
return;
|
|
400
|
+
} catch (e) {
|
|
401
|
+
try { unlinkSync(stateTmp); } catch {}
|
|
402
|
+
if (e.code !== "EXDEV") throw e;
|
|
403
|
+
}
|
|
404
|
+
const near = join(dirname(config.path), `.${name}`);
|
|
405
|
+
writeFileSync(near, text, { flag: "wx" });
|
|
406
|
+
try { renameSync(near, config.path); }
|
|
407
|
+
catch (e) { try { unlinkSync(near); } catch {} throw e; }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Adds a granted glob to a role in the config text, with its provenance.
|
|
412
|
+
*
|
|
413
|
+
* Edits the text rather than re-emitting the file, because a config someone
|
|
414
|
+
* wrote has comments and an order that mean something, and a tool that
|
|
415
|
+
* reformats it on every grant is a tool people stop letting near it.
|
|
416
|
+
*
|
|
417
|
+
* The edit is bounded to the role's own section, and that is not tidiness. The
|
|
418
|
+
* first version searched from the role header to the next `writes =` anywhere
|
|
419
|
+
* in the file, so granting for a role that had no `writes` line wrote the
|
|
420
|
+
* grant into the NEXT role — with a comment saying who it was for. A person
|
|
421
|
+
* granted one thing and the file recorded another. In a permission layer that
|
|
422
|
+
* is the worst possible bug, and it is not exotic: a role with only `keys`
|
|
423
|
+
* declared is an ordinary config.
|
|
424
|
+
*/
|
|
324
425
|
export function applyGrant(toml, request, note = "") {
|
|
325
426
|
const field = request.action === "read" ? "keys" : "writes";
|
|
326
427
|
const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
|
|
@@ -337,13 +438,13 @@ export function applyGrant(toml, request, note = "") {
|
|
|
337
438
|
if (!open)
|
|
338
439
|
throw new Error(
|
|
339
440
|
`[roles.${request.role}] has no ${field} list to grant into. ` +
|
|
340
|
-
`Add \`${field} = []\` to that section first — seisin will not write it into another role's
|
|
441
|
+
`Add \`${field} = []\` to that section first — seisin will not write it into another role's table.`
|
|
341
442
|
);
|
|
342
443
|
|
|
343
444
|
// The subset has no escapes, so a value carrying a quote or a newline cannot
|
|
344
445
|
// be written down at all. The strict parser would reject the result rather
|
|
345
446
|
// than widen anything — but leaving someone with a config that no longer
|
|
346
|
-
// loads, after they
|
|
447
|
+
// loads, after they granted something, is its own kind of broken.
|
|
347
448
|
tomlString(request.grant); // refuses what the format cannot hold
|
|
348
449
|
|
|
349
450
|
const start = open.index + open[0].length;
|
|
@@ -355,9 +456,9 @@ export function applyGrant(toml, request, note = "") {
|
|
|
355
456
|
*
|
|
356
457
|
* The list used to be re-written from every quoted string between its
|
|
357
458
|
* brackets — comments included. `"src/web/**", # was "src/**"` came back as
|
|
358
|
-
* two grants, so
|
|
359
|
-
* and the reason
|
|
360
|
-
* a path on the next
|
|
459
|
+
* two grants, so granting anything also granted what a comment mentioned,
|
|
460
|
+
* and the reason a person typed (quoted in the provenance comment) became
|
|
461
|
+
* a path on the next grant. Every earlier provenance comment was lost on
|
|
361
462
|
* the way. Now the items are the strings outside comments, what is there is
|
|
362
463
|
* left as it is, and the new entry goes last. The reason is set off with
|
|
363
464
|
* «», never a double quote, so no older reader can take it for a value.
|
|
@@ -401,14 +502,14 @@ function scanList(text, start) {
|
|
|
401
502
|
}
|
|
402
503
|
|
|
403
504
|
/**
|
|
404
|
-
* The
|
|
505
|
+
* The reason a person typed, made safe to put in a config file.
|
|
405
506
|
*
|
|
406
507
|
* A reason is free text typed by a person, and a person can be talked into
|
|
407
508
|
* typing something — "paste this as the reason" is a plausible thing for an
|
|
408
509
|
* agent to suggest. Newlines in a comment would end the comment, and what
|
|
409
510
|
* follows is parsed as TOML. The strict parser rejects the result rather than
|
|
410
511
|
* widening anything, so the worst case is a config that no longer loads, but a
|
|
411
|
-
*
|
|
512
|
+
* policy that can be broken by a sentence is still broken.
|
|
412
513
|
*/
|
|
413
514
|
export function cleanReason(s) {
|
|
414
515
|
return String(s ?? "")
|
package/src/review.js
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Three questions, and the second is the one nothing else can answer.
|
|
11
11
|
*
|
|
12
|
-
* 1. What is a role being
|
|
12
|
+
* 1. What is a role being denied, over and over?
|
|
13
13
|
* Forty denials on one directory is not an agent misbehaving. It is a
|
|
14
14
|
* policy that is wrong, and today it reads as forty tidy amber lines
|
|
15
15
|
* nobody adds up.
|
|
16
16
|
*
|
|
17
17
|
* 2. What was granted and never used?
|
|
18
|
-
* Every
|
|
18
|
+
* Every allowlist only ever grows, everywhere, and the reason is
|
|
19
19
|
* always the same: nobody can prove a line is dead, so the safe move is to
|
|
20
20
|
* leave it. Here the log can prove it. This is the only direction that
|
|
21
21
|
* makes a policy smaller.
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { read, logPath } from "./log.js";
|
|
31
31
|
import { covers, standingOf } from "./owners.js";
|
|
32
|
+
import { kindsOf } from "./kinds.js";
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* One reading of the log against the policy.
|
|
@@ -103,11 +104,11 @@ function findings(config, entries, minDenials) {
|
|
|
103
104
|
* that refusal is the whole of the second finding.
|
|
104
105
|
*
|
|
105
106
|
* "Never used" is read off `allowed` lines, and **only the hook writes those**.
|
|
106
|
-
* The kernel reports what it
|
|
107
|
+
* The kernel reports what it denied; it has nothing to say about what went
|
|
107
108
|
* through. So a log with no hook behind it holds denials and nothing else,
|
|
108
109
|
* every grant falls through as unused, and this section reports that the whole
|
|
109
110
|
* policy is dead — under a heading calling itself the only evidence anyone
|
|
110
|
-
* will ever have for making a
|
|
111
|
+
* will ever have for making a policy smaller.
|
|
111
112
|
*
|
|
112
113
|
* Measured on a real repository with the hook not installed: every write
|
|
113
114
|
* permission of every role listed as never used, while those roles were
|
|
@@ -128,15 +129,21 @@ function findings(config, entries, minDenials) {
|
|
|
128
129
|
const knowable = entries.some((e) => e.verdict === "allowed" && !e.disputed);
|
|
129
130
|
|
|
130
131
|
// Only writes. A key that is declared and not read is ordinary — most roles
|
|
131
|
-
// hold a credential for the one
|
|
132
|
+
// hold a credential for the one run a month that needs it — but a folder a
|
|
132
133
|
// role owns and has never written to in the whole log is a line nobody would
|
|
133
134
|
// miss, and the only evidence anyone will ever have for removing it.
|
|
135
|
+
// Straight to the line's own role, and each (role, target) once: walking
|
|
136
|
+
// every role's globs for every line to keep only one role's was the cost.
|
|
134
137
|
const used = new Set();
|
|
138
|
+
const seen = new Set();
|
|
135
139
|
for (const e of entries) {
|
|
136
140
|
if (e.verdict !== "allowed" || e.disputed || e.action !== "write" || !e.target) continue;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
141
|
+
if (!Object.prototype.hasOwnProperty.call(config.roles, e.role)) continue;
|
|
142
|
+
const k = `${e.role}\u0000${e.target}`;
|
|
143
|
+
if (seen.has(k)) continue;
|
|
144
|
+
seen.add(k);
|
|
145
|
+
for (const glob of config.roles[e.role].writes)
|
|
146
|
+
if (covers(glob, e.target)) used.add(`${e.role} ${glob}`);
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
const unused = [];
|
|
@@ -153,13 +160,21 @@ function findings(config, entries, minDenials) {
|
|
|
153
160
|
// the repository is outside every territory: none of them is a hole in the
|
|
154
161
|
// map. This used to skip keys and ports and count the rest, protected ones
|
|
155
162
|
// included, while the console counted all four — one word, two numbers.
|
|
156
|
-
|
|
163
|
+
//
|
|
164
|
+
// Each row also says what kind of thing it is (kinds.js), by directory and
|
|
165
|
+
// kind together: `.git` holding a lock and a folder holding a source file
|
|
166
|
+
// are different answers, and only `territory` is a hole somebody has to fill.
|
|
157
167
|
const standOf = standingOf(config);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
168
|
+
const loose = entries.filter((e) => e.target && standOf(e).kind === "unowned");
|
|
169
|
+
const natures = kindsOf(loose.map((e) => e.target), { keyDirs: config.keyDirs ?? [] });
|
|
170
|
+
const unowned = new Map();
|
|
171
|
+
for (const e of loose) {
|
|
172
|
+
const { kind, hint } = natures.get(e.target);
|
|
173
|
+
const where = dirOf(e.target);
|
|
174
|
+
const k = `${kind}\u0000${where}`;
|
|
175
|
+
const row = unowned.get(k) ?? { where, times: 0, kind, hint };
|
|
176
|
+
row.times++;
|
|
177
|
+
unowned.set(k, row);
|
|
163
178
|
}
|
|
164
179
|
|
|
165
180
|
return {
|
|
@@ -168,9 +183,7 @@ function findings(config, entries, minDenials) {
|
|
|
168
183
|
connects,
|
|
169
184
|
unused,
|
|
170
185
|
unusedKnowable: knowable,
|
|
171
|
-
unowned: [...unowned.
|
|
172
|
-
.map(([where, times]) => ({ where, times }))
|
|
173
|
-
.sort((a, b) => b.times - a.times),
|
|
186
|
+
unowned: [...unowned.values()].sort((a, b) => b.times - a.times),
|
|
174
187
|
};
|
|
175
188
|
}
|
|
176
189
|
|
package/src/rundir.js
CHANGED
|
@@ -25,6 +25,7 @@ import { tmpdir } from "node:os";
|
|
|
25
25
|
import { randomUUID } from "node:crypto";
|
|
26
26
|
import { realOrSelf } from "./grants.js";
|
|
27
27
|
import { channelName } from "./spool.js";
|
|
28
|
+
import { alive } from "./log.js";
|
|
28
29
|
|
|
29
30
|
export const RUNS_NAME = "snr";
|
|
30
31
|
|
|
@@ -48,11 +49,6 @@ export function runsRoot(base = tmpdir()) {
|
|
|
48
49
|
return root;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/** Is the process that owns a run directory still alive? */
|
|
52
|
-
function alive(pid) {
|
|
53
|
-
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
52
|
/**
|
|
57
53
|
* Removes the directories of runs whose process is gone.
|
|
58
54
|
*
|
|
@@ -66,6 +62,8 @@ function sweep(root) {
|
|
|
66
62
|
const dir = join(root, name);
|
|
67
63
|
let pid;
|
|
68
64
|
try { pid = Number(readFileSync(join(dir, "pid"), "utf8")); } catch { continue; }
|
|
65
|
+
// Only a process the kernel says is gone: a directory kept one sweep too
|
|
66
|
+
// long costs disk, one removed early takes a live run's socket and keys.
|
|
69
67
|
if (pid && !alive(pid)) rmSync(dir, { recursive: true, force: true });
|
|
70
68
|
}
|
|
71
69
|
}
|
|
@@ -87,7 +85,7 @@ export function openRun({ base = tmpdir(), id = randomUUID() } = {}) {
|
|
|
87
85
|
dir,
|
|
88
86
|
root,
|
|
89
87
|
// A socket on macOS, a FIFO on Linux, where the runtime blocks every unix
|
|
90
|
-
// socket inside the
|
|
88
|
+
// socket inside the sandbox (spool.js).
|
|
91
89
|
sock: join(dir, channelName()),
|
|
92
90
|
keys: join(dir, "keys"),
|
|
93
91
|
/**
|