seisin 0.3.0 → 0.4.2
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 +12 -16
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/commands/init.js +13 -5
- package/src/commands/log.js +11 -4
- package/src/commands/requests.js +26 -18
- package/src/commands/run.js +15 -2
- package/src/commands/ui.js +59 -7
- package/src/config.js +79 -7
- package/src/hook.js +50 -0
- package/src/intake.js +32 -1
- package/src/log.js +194 -16
- package/src/mcp.js +63 -24
- package/src/notify.js +3 -3
- package/src/owners.js +140 -15
- package/src/render.js +13 -2
- package/src/requests.js +90 -13
- package/src/review.js +15 -7
- package/src/serve.js +68 -24
- package/src/surface.js +15 -1
- package/src/uilink.js +55 -0
- package/ui/index.html +30 -12
package/src/owners.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* agents have to read each other's code to do anything useful. Keys are the
|
|
11
11
|
* exception, and they are handled separately below.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
15
|
import { join } from "node:path";
|
|
15
16
|
import { protectedBy } from "./surface.js";
|
|
16
17
|
|
|
@@ -45,7 +46,7 @@ export function covers(glob, path) {
|
|
|
45
46
|
// The path itself, or anything beneath it. The `/` is load-bearing: without
|
|
46
47
|
// it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
|
|
47
48
|
if (m.prefix !== null) return p === m.prefix || p.startsWith(m.prefix + "/");
|
|
48
|
-
return m.
|
|
49
|
+
return matchGlob(m.glob, p);
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/**
|
|
@@ -68,7 +69,7 @@ function matcherOf(glob) {
|
|
|
68
69
|
if (m) return m;
|
|
69
70
|
let g = normalize(glob);
|
|
70
71
|
if (glob.endsWith("/")) g += "/**";
|
|
71
|
-
m = WILD.test(g) ? { prefix: null,
|
|
72
|
+
m = WILD.test(g) ? { prefix: null, glob: g } : { prefix: g, glob: null };
|
|
72
73
|
if (GLOBS.size >= MAX_CACHED) GLOBS.clear();
|
|
73
74
|
GLOBS.set(glob, m);
|
|
74
75
|
return m;
|
|
@@ -126,18 +127,66 @@ export function normalize(s) {
|
|
|
126
127
|
* must not: covering one file too many costs an argument, covering one too few
|
|
127
128
|
* is the hole. When the two readings disagree, this one takes the wider set.
|
|
128
129
|
*/
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Does `glob` cover `path`? A linear matcher, not a regular expression.
|
|
132
|
+
*
|
|
133
|
+
* The regex form (`**` → `.*`, `**\/` → `(?:.*\/)?`) put several unbounded `.*`
|
|
134
|
+
* next to literals, and on a long path that does not match, the engine
|
|
135
|
+
* backtracks catastrophically: measured on a crafted glob, 9 `**` took ~26 s
|
|
136
|
+
* and 11 hung for over a minute. Since a request's target — which an agent
|
|
137
|
+
* chooses — becomes a glob here (`grantFor`), that is a denial-of-service on the
|
|
138
|
+
* hook and on `explain`. This matcher is memoised on (glob index, path index),
|
|
139
|
+
* so its cost is bounded by their product and no input backtracks.
|
|
140
|
+
*
|
|
141
|
+
* Same wildcards as before: `?` one non-`/`, `*` a run of non-`/`, `**` any run
|
|
142
|
+
* including `/`, `**\/` zero or more whole directories, a trailing `/**` the
|
|
143
|
+
* subtree. Characters are compared literally; nothing is treated as regex.
|
|
144
|
+
*/
|
|
145
|
+
function matchGlob(glob, path) {
|
|
146
|
+
const G = glob.length, P = path.length;
|
|
147
|
+
// memo[gi * (P+1) + pi]: 0 unknown, 1 true, 2 false. One flat array, reused.
|
|
148
|
+
const memo = new Uint8Array((G + 1) * (P + 1));
|
|
149
|
+
const at = (gi, pi) => {
|
|
150
|
+
for (;;) {
|
|
151
|
+
const key = gi * (P + 1) + pi;
|
|
152
|
+
const seen = memo[key];
|
|
153
|
+
if (seen) return seen === 1;
|
|
154
|
+
let res;
|
|
155
|
+
if (gi === G) { res = pi === P; }
|
|
156
|
+
else if (glob[gi] === "*" && glob[gi + 1] === "*") {
|
|
157
|
+
if (glob[gi + 2] === "/") {
|
|
158
|
+
// `**/` = zero or more whole directories: empty, or any chars ending
|
|
159
|
+
// at a `/` then the rest.
|
|
160
|
+
if (at(gi + 3, pi)) res = true;
|
|
161
|
+
else if (pi < P) {
|
|
162
|
+
if (path[pi] === "/" && at(gi + 3, pi + 1)) res = true;
|
|
163
|
+
else { memo[key] = 2; pi++; continue; } // consume one char, stay on `**/`
|
|
164
|
+
} else res = false;
|
|
165
|
+
} else {
|
|
166
|
+
// `**` = any run including `/` (also covers `**` at the very end,
|
|
167
|
+
// where `at(gi+2, pi)` requires pi to reach the end).
|
|
168
|
+
if (at(gi + 2, pi)) res = true;
|
|
169
|
+
else if (pi < P) { memo[key] = 2; pi++; continue; } // consume one char, stay on `**`
|
|
170
|
+
else res = false;
|
|
171
|
+
}
|
|
172
|
+
} else if (glob[gi] === "/" && glob[gi + 1] === "*" && glob[gi + 2] === "*" && gi + 3 === G) {
|
|
173
|
+
// trailing `/**`: the directory itself, or anything beneath it.
|
|
174
|
+
res = pi === P || path[pi] === "/";
|
|
175
|
+
} else if (glob[gi] === "*") {
|
|
176
|
+
// `*` = a run of non-`/`.
|
|
177
|
+
if (at(gi + 1, pi)) res = true;
|
|
178
|
+
else if (pi < P && path[pi] !== "/") { memo[key] = 2; pi++; continue; }
|
|
179
|
+
else res = false;
|
|
180
|
+
} else if (glob[gi] === "?") {
|
|
181
|
+
res = pi < P && path[pi] !== "/" && at(gi + 1, pi + 1);
|
|
182
|
+
} else {
|
|
183
|
+
res = pi < P && glob[gi] === path[pi] && at(gi + 1, pi + 1);
|
|
184
|
+
}
|
|
185
|
+
memo[key] = res ? 1 : 2;
|
|
186
|
+
return res;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
return at(0, 0);
|
|
141
190
|
}
|
|
142
191
|
|
|
143
192
|
/** Is this path inside a `.git` directory (git's metadata, not a working file)? */
|
|
@@ -166,6 +215,82 @@ export function ownersOf(config, path) {
|
|
|
166
215
|
.map((r) => r.name);
|
|
167
216
|
}
|
|
168
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Where a refused target stands against the policy as it is now.
|
|
220
|
+
*
|
|
221
|
+
* One of four, and only the last one is a decision somebody has to make:
|
|
222
|
+
*
|
|
223
|
+
* outside not a place in the map at all — a port, a key directory, or a
|
|
224
|
+
* path outside the repository, which no `writes` reaches
|
|
225
|
+
* protected closed to every role on purpose (surface.js); the boundary
|
|
226
|
+
* working, with nothing to grant
|
|
227
|
+
* owned some role's territory today
|
|
228
|
+
* unowned in the repository, ownable, and nobody claims it
|
|
229
|
+
*
|
|
230
|
+
* Computed from the policy, never from the `owners` a log line carries. That
|
|
231
|
+
* value is what was true the day of the refusal; every grant made since makes
|
|
232
|
+
* it more wrong, so a screen reading it gets worse the more seisin is used the
|
|
233
|
+
* way it is meant to be. Measured on one deployment: the console said 1,178 of
|
|
234
|
+
* 1,486 refused paths had no owner; against the policy of that day it was 316.
|
|
235
|
+
* The rest had been claimed since, were protected, or were not paths at all.
|
|
236
|
+
*/
|
|
237
|
+
export function standing(config, entry) {
|
|
238
|
+
const target = entry.target;
|
|
239
|
+
if (entry.kind === "network")
|
|
240
|
+
return { kind: "outside", owners: [], why: "a port or a socket, which no role owns" };
|
|
241
|
+
if (entry.kind === "key")
|
|
242
|
+
return { kind: "outside", owners: [], why: "a key directory, closed on purpose" };
|
|
243
|
+
const guard = protectedBy(config, target);
|
|
244
|
+
if (guard) return { kind: "protected", owners: [], why: guard.why };
|
|
245
|
+
if (target.startsWith("/")) {
|
|
246
|
+
const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
|
|
247
|
+
if (!config.root || !target.startsWith(root))
|
|
248
|
+
return { kind: "outside", owners: [], why: "outside the repository, where no role's territory reaches" };
|
|
249
|
+
}
|
|
250
|
+
const owners = ownersOf(config, target);
|
|
251
|
+
return { kind: owners.length ? "owned" : "unowned", owners };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* `standing`, remembered while the policy stays the same.
|
|
256
|
+
*
|
|
257
|
+
* The console asks for its whole state every two seconds, and a real log has
|
|
258
|
+
* well over a thousand distinct refused paths: classifying all of them costs
|
|
259
|
+
* most of a second each time (measured: ~400 ms in protectedBy, ~335 ms in
|
|
260
|
+
* ownersOf), on a call that already took about that long. What a path's
|
|
261
|
+
* standing depends on is the policy file, so that is the key. It also depends,
|
|
262
|
+
* a little, on the disk — on Linux a control file is protected only once it
|
|
263
|
+
* exists — so the memory is dropped after a minute as well, which bounds how
|
|
264
|
+
* stale an answer can be without paying for it on every poll.
|
|
265
|
+
*
|
|
266
|
+
* A config that was not read from a file (tests, embedders) gets a memory for
|
|
267
|
+
* this call only.
|
|
268
|
+
*/
|
|
269
|
+
const STANDING_TTL = 60_000;
|
|
270
|
+
let standingMemo = { key: null, at: 0, map: new Map() };
|
|
271
|
+
|
|
272
|
+
export function standingOf(config, { now = Date.now() } = {}) {
|
|
273
|
+
let map;
|
|
274
|
+
let key = null;
|
|
275
|
+
try {
|
|
276
|
+
if (config.path) key = `${config.path}\u0000${createHash("sha256").update(readFileSync(config.path)).digest("hex")}`;
|
|
277
|
+
} catch {
|
|
278
|
+
key = null;
|
|
279
|
+
}
|
|
280
|
+
if (key === null) map = new Map();
|
|
281
|
+
else {
|
|
282
|
+
if (standingMemo.key !== key || now - standingMemo.at > STANDING_TTL)
|
|
283
|
+
standingMemo = { key, at: now, map: new Map() };
|
|
284
|
+
map = standingMemo.map;
|
|
285
|
+
}
|
|
286
|
+
return (entry) => {
|
|
287
|
+
const k = `${entry.kind ?? ""}\u0000${entry.target}`;
|
|
288
|
+
let s = map.get(k);
|
|
289
|
+
if (!s) map.set(k, (s = standing(config, entry)));
|
|
290
|
+
return s;
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
169
294
|
/**
|
|
170
295
|
* The `never_writes` entries the kernel will actually be given for this role.
|
|
171
296
|
*
|
package/src/render.js
CHANGED
|
@@ -25,6 +25,17 @@ export const C =
|
|
|
25
25
|
}
|
|
26
26
|
: { dim: "", b: "", off: "", red: "", green: "", yellow: "", blue: "" };
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A value chosen by an agent (a target, a grant, an id) made safe to print.
|
|
30
|
+
*
|
|
31
|
+
* `target` and the id built from it are strings the agent picked, and they were
|
|
32
|
+
* written to the terminal raw. Terminal escape sequences (ESC, CR, other C0
|
|
33
|
+
* controls) let such a string repaint the line a person reads before approving.
|
|
34
|
+
* On disk they are already JSON-escaped; this is for the screen. Stripped, not
|
|
35
|
+
* rendered, so what shows is the literal path with the control bytes removed.
|
|
36
|
+
*/
|
|
37
|
+
export const safe = (s) => String(s ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
|
38
|
+
|
|
28
39
|
export const out = (s) => process.stdout.write(s);
|
|
29
40
|
export const err = (s) => process.stderr.write(s);
|
|
30
41
|
|
|
@@ -125,7 +136,7 @@ export function renderReport(report) {
|
|
|
125
136
|
/** The `explain` answer, as text. Takes what `owners.explain()` returns. */
|
|
126
137
|
export function renderVerdict(role, action, target, verdict) {
|
|
127
138
|
const head = verdict.allowed ? `${C.green}allowed${C.off}` : `${C.yellow}denied${C.off}`;
|
|
128
|
-
return `\n ${head} ${C.b}${role}${C.off} ${action} ${target}\n ${C.dim}${verdict.reason}${C.off}\n\n`;
|
|
139
|
+
return `\n ${head} ${C.b}${role}${C.off} ${action} ${safe(target)}\n ${C.dim}${safe(verdict.reason)}${C.off}\n\n`;
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
/* ── the log ──────────────────────────────────────────────────────────── */
|
|
@@ -149,7 +160,7 @@ export function renderEntry(e) {
|
|
|
149
160
|
// about a run, a reader needs to know which line is which without going to
|
|
150
161
|
// the file — so the one that carries more weight is the one that is marked.
|
|
151
162
|
const from = e.source === "kernel" ? ` ${C.dim}[kernel]${C.off}` : "";
|
|
152
|
-
return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${e.action} ${e.target}${owners}${from}\n`;
|
|
163
|
+
return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${e.action} ${safe(e.target)}${owners}${from}\n`;
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
/* ── the scan ─────────────────────────────────────────────────────────── */
|
package/src/requests.js
CHANGED
|
@@ -27,10 +27,11 @@
|
|
|
27
27
|
* deleted — it is followed by a line saying what happened to it.
|
|
28
28
|
*/
|
|
29
29
|
import { neverWrites, isGitMetadata, covers } from "./owners.js";
|
|
30
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
30
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
31
31
|
import { dirname, join } from "node:path";
|
|
32
32
|
import { STATE_DIR, tomlString } from "./layout.js";
|
|
33
33
|
import { send } from "./spool.js";
|
|
34
|
+
import { withLock } from "./log.js";
|
|
34
35
|
|
|
35
36
|
export const REQUESTS_NAME = "requests.jsonl";
|
|
36
37
|
|
|
@@ -57,6 +58,17 @@ export function keyOf({ role, action, target }) {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** The glob a grant would add, derived from what was asked. */
|
|
61
|
+
/**
|
|
62
|
+
* A request's id as one shell word, for the commands seisin suggests.
|
|
63
|
+
*
|
|
64
|
+
* The id carries a path an agent chose, so it is single-quoted whole and any
|
|
65
|
+
* quote inside it is closed and escaped: pasting the suggestion runs `seisin
|
|
66
|
+
* grant` on that request and nothing else.
|
|
67
|
+
*/
|
|
68
|
+
export function shellId(key) {
|
|
69
|
+
return `'${String(key).replace(/'/g, `'\\''`)}'`;
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
export function grantFor({ action, target }) {
|
|
61
73
|
const t = typeof target === "string" ? target : "";
|
|
62
74
|
// A key keeps its directory when it has one. Stripping it turned a request
|
|
@@ -283,6 +295,32 @@ export function refuseIfBarred(config, request) {
|
|
|
283
295
|
`rewrite another owner's history. Decline it; a role that needs to commit gets a worktree of its own.`);
|
|
284
296
|
}
|
|
285
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Apply one policy edit under a lock, and write it atomically.
|
|
300
|
+
*
|
|
301
|
+
* `grant` and the console both did read → applyGrant → write with no lock, so
|
|
302
|
+
* two approvals racing (a person at the CLI and one on the console, or two
|
|
303
|
+
* terminals) lost one edit: the queue marked both granted while `seisin.toml`
|
|
304
|
+
* kept only the last writer's change (measured: 39 of 40 concurrent pairs). The
|
|
305
|
+
* lock serialises them — it already survives a writer that died holding it
|
|
306
|
+
* (see log.js) — and the write goes to a temp file then renames over the config,
|
|
307
|
+
* so a crash mid-write cannot leave a half-written, partly-privileged policy.
|
|
308
|
+
*
|
|
309
|
+
* `mutate(toml)` returns `{ toml, changed }`. Nothing is written when unchanged.
|
|
310
|
+
*/
|
|
311
|
+
export function editPolicy(config, mutate, { waitMs = 10000 } = {}) {
|
|
312
|
+
return withLock(config.path, () => {
|
|
313
|
+
const before = readFileSync(config.path, "utf8");
|
|
314
|
+
const result = mutate(before);
|
|
315
|
+
if (result.changed) {
|
|
316
|
+
const tmp = `${config.path}.tmp-${process.pid}`;
|
|
317
|
+
writeFileSync(tmp, result.toml);
|
|
318
|
+
renameSync(tmp, config.path);
|
|
319
|
+
}
|
|
320
|
+
return result;
|
|
321
|
+
}, { waitMs });
|
|
322
|
+
}
|
|
323
|
+
|
|
286
324
|
export function applyGrant(toml, request, note = "") {
|
|
287
325
|
const field = request.action === "read" ? "keys" : "writes";
|
|
288
326
|
const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
|
|
@@ -295,9 +333,8 @@ export function applyGrant(toml, request, note = "") {
|
|
|
295
333
|
const to = next ? from + next.index : toml.length;
|
|
296
334
|
const section = toml.slice(from, to);
|
|
297
335
|
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
if (!m)
|
|
336
|
+
const open = new RegExp(`^${field}\\s*=\\s*\\[`, "m").exec(section);
|
|
337
|
+
if (!open)
|
|
301
338
|
throw new Error(
|
|
302
339
|
`[roles.${request.role}] has no ${field} list to grant into. ` +
|
|
303
340
|
`Add \`${field} = []\` to that section first — seisin will not write it into another role's block.`
|
|
@@ -309,20 +346,60 @@ export function applyGrant(toml, request, note = "") {
|
|
|
309
346
|
// loads, after they approved something, is its own kind of broken.
|
|
310
347
|
tomlString(request.grant); // refuses what the format cannot hold
|
|
311
348
|
|
|
312
|
-
const
|
|
313
|
-
|
|
349
|
+
const start = open.index + open[0].length;
|
|
350
|
+
const list = scanList(section, start);
|
|
351
|
+
if (list.items.includes(request.grant)) return { toml, changed: false };
|
|
314
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Appended, never rebuilt.
|
|
355
|
+
*
|
|
356
|
+
* The list used to be re-written from every quoted string between its
|
|
357
|
+
* brackets — comments included. `"src/web/**", # was "src/**"` came back as
|
|
358
|
+
* two grants, so approving anything also granted what a comment mentioned,
|
|
359
|
+
* and the reason an approver typed (quoted in the provenance comment) became
|
|
360
|
+
* a path on the next approval. Every earlier provenance comment was lost on
|
|
361
|
+
* the way. Now the items are the strings outside comments, what is there is
|
|
362
|
+
* left as it is, and the new entry goes last. The reason is set off with
|
|
363
|
+
* «», never a double quote, so no older reader can take it for a value.
|
|
364
|
+
*/
|
|
365
|
+
let inner = section.slice(start, list.end).replace(/\s+$/, "");
|
|
366
|
+
if (list.lastItemEnd !== null && !list.commaAfterLast) {
|
|
367
|
+
const at = list.lastItemEnd - start;
|
|
368
|
+
inner = inner.slice(0, at) + "," + inner.slice(at);
|
|
369
|
+
}
|
|
315
370
|
const stamp = `# granted ${new Date().toISOString().slice(0, 10)} · asked ${request.times}×` +
|
|
316
|
-
`${note ? ` ·
|
|
317
|
-
const
|
|
318
|
-
.map((v) => ` "${v}"${v === request.grant ? `, ${stamp}` : ","}`)
|
|
319
|
-
.join("\n")
|
|
320
|
-
.replace(/,(\s*#[^\n]*)?$/, "$1");
|
|
321
|
-
|
|
322
|
-
const edited = section.replace(line, `$1[\n${body}\n]`);
|
|
371
|
+
`${note ? ` · «${cleanReason(note)}»` : ""}`;
|
|
372
|
+
const edited = section.slice(0, start) + `${inner}\n "${request.grant}" ${stamp}\n]` + section.slice(list.end + 1);
|
|
323
373
|
return { toml: toml.slice(0, from) + edited + toml.slice(to), changed: true };
|
|
324
374
|
}
|
|
325
375
|
|
|
376
|
+
/**
|
|
377
|
+
* The strings of a TOML array that starts at `start` (just past its `[`),
|
|
378
|
+
* skipping comments, and where it closes. The subset has no escapes, so a
|
|
379
|
+
* string runs to the next double quote.
|
|
380
|
+
*/
|
|
381
|
+
function scanList(text, start) {
|
|
382
|
+
const items = [];
|
|
383
|
+
let lastItemEnd = null;
|
|
384
|
+
let commaAfterLast = false;
|
|
385
|
+
for (let i = start; i < text.length; i++) {
|
|
386
|
+
const c = text[i];
|
|
387
|
+
if (c === "#") { const nl = text.indexOf("\n", i); if (nl === -1) break; i = nl; continue; }
|
|
388
|
+
if (c === '"') {
|
|
389
|
+
const close = text.indexOf('"', i + 1);
|
|
390
|
+
if (close === -1) break;
|
|
391
|
+
items.push(text.slice(i + 1, close));
|
|
392
|
+
lastItemEnd = close + 1;
|
|
393
|
+
commaAfterLast = false;
|
|
394
|
+
i = close;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (c === ",") { commaAfterLast = true; continue; }
|
|
398
|
+
if (c === "]") return { items, end: i, lastItemEnd, commaAfterLast };
|
|
399
|
+
}
|
|
400
|
+
throw new Error(`the ${text.slice(0, 40).trim()}… list never closes; fix the config by hand before granting`);
|
|
401
|
+
}
|
|
402
|
+
|
|
326
403
|
/**
|
|
327
404
|
* The approver's own words, made safe to put in a config file.
|
|
328
405
|
*
|
package/src/review.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* means nothing without "…in how long".
|
|
29
29
|
*/
|
|
30
30
|
import { read, logPath } from "./log.js";
|
|
31
|
-
import { covers,
|
|
31
|
+
import { covers, standingOf } from "./owners.js";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* One reading of the log against the policy.
|
|
@@ -122,7 +122,10 @@ function findings(config, entries, minDenials) {
|
|
|
122
122
|
* than full — a consumer that has never heard of `unusedKnowable` then reports
|
|
123
123
|
* nothing instead of everything, which is the direction this has to fail in.
|
|
124
124
|
*/
|
|
125
|
-
|
|
125
|
+
// A disputed line is the parent saying the verdict did not match the policy
|
|
126
|
+
// — often a forged line. It is not evidence of legitimate use, so it neither
|
|
127
|
+
// makes the window knowable nor marks a territory used.
|
|
128
|
+
const knowable = entries.some((e) => e.verdict === "allowed" && !e.disputed);
|
|
126
129
|
|
|
127
130
|
// Only writes. A key that is declared and not read is ordinary — most roles
|
|
128
131
|
// hold a credential for the one turn a month that needs it — but a folder a
|
|
@@ -130,7 +133,7 @@ function findings(config, entries, minDenials) {
|
|
|
130
133
|
// miss, and the only evidence anyone will ever have for removing it.
|
|
131
134
|
const used = new Set();
|
|
132
135
|
for (const e of entries) {
|
|
133
|
-
if (e.verdict !== "allowed" || e.action !== "write" || !e.target) continue;
|
|
136
|
+
if (e.verdict !== "allowed" || e.disputed || e.action !== "write" || !e.target) continue;
|
|
134
137
|
for (const role of Object.values(config.roles))
|
|
135
138
|
for (const glob of role.writes)
|
|
136
139
|
if (role.name === e.role && covers(glob, e.target)) used.add(`${role.name} ${glob}`);
|
|
@@ -144,12 +147,17 @@ function findings(config, entries, minDenials) {
|
|
|
144
147
|
|
|
145
148
|
/* ── 3. what nobody owns ────────────────────────────────────────────── */
|
|
146
149
|
|
|
150
|
+
// The same classification the console and `seisin_causes` use, so the word
|
|
151
|
+
// means one thing everywhere. A key directory is closed on purpose, a port is
|
|
152
|
+
// not a path, a protected file is closed to every role and a path outside
|
|
153
|
+
// the repository is outside every territory: none of them is a hole in the
|
|
154
|
+
// map. This used to skip keys and ports and count the rest, protected ones
|
|
155
|
+
// included, while the console counted all four — one word, two numbers.
|
|
147
156
|
const unowned = new Map();
|
|
157
|
+
const standOf = standingOf(config);
|
|
148
158
|
for (const e of entries) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (!e.target || e.kind === "key" || e.kind === "network") continue;
|
|
152
|
-
if (ownersOf(config, e.target).length) continue;
|
|
159
|
+
if (!e.target) continue;
|
|
160
|
+
if (standOf(e).kind !== "unowned") continue;
|
|
153
161
|
const dir = dirOf(e.target);
|
|
154
162
|
unowned.set(dir, (unowned.get(dir) ?? 0) + 1);
|
|
155
163
|
}
|
package/src/serve.js
CHANGED
|
@@ -11,16 +11,16 @@
|
|
|
11
11
|
* key each role can read, which is a map of where the credentials live.
|
|
12
12
|
*/
|
|
13
13
|
import { createServer } from "node:http";
|
|
14
|
-
import { randomBytes } from "node:crypto";
|
|
15
|
-
import { readFileSync,
|
|
14
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
16
16
|
import { join, dirname } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { loadConfig } from "./config.js";
|
|
19
19
|
import { read, logPath } from "./log.js";
|
|
20
20
|
import { settingsFor } from "./srt.js";
|
|
21
|
-
import { pending, requestsPath, settle, applyGrant, refuseIfBarred, markStale } from "./requests.js";
|
|
21
|
+
import { pending, requestsPath, settle, applyGrant, refuseIfBarred, markStale, editPolicy } from "./requests.js";
|
|
22
22
|
import { walls } from "./walls.js";
|
|
23
|
-
import { explain } from "./owners.js";
|
|
23
|
+
import { explain, standingOf } from "./owners.js";
|
|
24
24
|
|
|
25
25
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
26
|
|
|
@@ -54,7 +54,7 @@ export function causesOf(cfg, entries) {
|
|
|
54
54
|
for (const e of entries) {
|
|
55
55
|
if (e.verdict !== "denied" || !e.target || !e.action) continue;
|
|
56
56
|
const k = `${e.action}\u0000${e.target}`;
|
|
57
|
-
const g = by.get(k) ?? { action: e.action, target: e.target, times: 0, roles: new Set(),
|
|
57
|
+
const g = by.get(k) ?? { action: e.action, target: e.target, kind: e.kind, times: 0, roles: new Set(), ownersThen: e.owners ?? [] };
|
|
58
58
|
g.times++;
|
|
59
59
|
g.roles.add(e.role);
|
|
60
60
|
by.set(k, g);
|
|
@@ -99,25 +99,38 @@ export function causesOf(cfg, entries) {
|
|
|
99
99
|
}));
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
* What a `grep` over the log cannot tell you
|
|
102
|
+
* What a `grep` over the log cannot tell you: where each refused path stands
|
|
103
|
+
* against the policy as it is now.
|
|
103
104
|
*
|
|
104
105
|
* The headline used to be "74% of it is one name", and a field review
|
|
105
106
|
* applied this project's own test to it: *did the number tell you something
|
|
106
|
-
* you did not know?* For somebody who reads the raw log, no
|
|
107
|
-
*
|
|
107
|
+
* you did not know?* For somebody who reads the raw log, no. So the console
|
|
108
|
+
* added how many causes are on paths no role owns — a decision rather than a
|
|
109
|
+
* number — and then computed it from the `owners` each log line carried,
|
|
110
|
+
* which is the one thing the log already had. It said the policy and read
|
|
111
|
+
* the past: every grant made since left its refusals counted as "nobody's",
|
|
112
|
+
* and protected surfaces, ports and paths outside the repository, which
|
|
113
|
+
* nobody can ever own, were counted as waiting for somebody to claim them.
|
|
108
114
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
115
|
+
* Now every cause is classified by `standing` (owners.js), and the four
|
|
116
|
+
* piles are reported in paths and in refusals. `unowned` keeps its name and
|
|
117
|
+
* now means what the page always said it meant. The owners the log recorded
|
|
118
|
+
* are kept on each cause as `ownersThen`: evidence of what was true, not
|
|
119
|
+
* authority over what is.
|
|
114
120
|
*/
|
|
115
|
-
const
|
|
116
|
-
const
|
|
121
|
+
const standOf = standingOf(cfg);
|
|
122
|
+
const piles = Object.fromEntries(["unowned", "owned", "protected", "outside"].map((k) => [k, { paths: 0, denials: 0 }]));
|
|
123
|
+
for (const g of by.values()) {
|
|
124
|
+
const s = standOf({ kind: g.kind, target: g.target });
|
|
125
|
+
g.standing = s;
|
|
126
|
+
piles[s.kind].paths++;
|
|
127
|
+
piles[s.kind].denials += g.times;
|
|
128
|
+
}
|
|
117
129
|
|
|
118
130
|
return {
|
|
119
131
|
total,
|
|
120
|
-
unowned,
|
|
132
|
+
unowned: piles.unowned.paths,
|
|
133
|
+
standing: piles,
|
|
121
134
|
// The real number of distinct causes, not the length of the list below.
|
|
122
135
|
// The page says "over N distinct paths" and the list is capped at twelve,
|
|
123
136
|
// so taking N from the list reported the cap as if it were the count —
|
|
@@ -133,7 +146,10 @@ export function causesOf(cfg, entries) {
|
|
|
133
146
|
times: g.times,
|
|
134
147
|
share: total ? g.times / total : 0,
|
|
135
148
|
roles: [...g.roles].sort(),
|
|
136
|
-
|
|
149
|
+
standing: g.standing.kind,
|
|
150
|
+
...(g.standing.why && { why: g.standing.why }),
|
|
151
|
+
owners: g.standing.owners,
|
|
152
|
+
ownersThen: g.ownersThen,
|
|
137
153
|
// How many of the roles that hit this would still hit it. `some` and
|
|
138
154
|
// not `every`: two roles out of three still blocked is still friction,
|
|
139
155
|
// and requiring all of them would quietly retire a live cause the day
|
|
@@ -220,11 +236,22 @@ export function state(configPath, { since = null } = {}) {
|
|
|
220
236
|
* an exception for loopback. That is what lets the console hold the half the
|
|
221
237
|
* MCP server deliberately does not.
|
|
222
238
|
*/
|
|
223
|
-
function decide(configPath, {
|
|
239
|
+
function decide(configPath, { key, decision, reason }) {
|
|
224
240
|
const cfg = loadConfig(configPath);
|
|
225
241
|
const file = requestsPath(cfg.root);
|
|
226
|
-
|
|
227
|
-
|
|
242
|
+
/**
|
|
243
|
+
* By the request's id, never by its place in the queue.
|
|
244
|
+
*
|
|
245
|
+
* It took a row number, counted against the queue as it stood when the
|
|
246
|
+
* request arrived — not when the person read the page. Agents keep writing
|
|
247
|
+
* to that queue, and another channel can settle an entry in between, so
|
|
248
|
+
* "approve #2" could approve whatever had moved into second place, with the
|
|
249
|
+
* reason typed for something else. Re-sending the POST approved the next one.
|
|
250
|
+
* The CLI stopped doing this long ago; the console had not.
|
|
251
|
+
*/
|
|
252
|
+
if (typeof key !== "string" || !key) throw new Error("key is required — the id of the request on screen");
|
|
253
|
+
const req = pending(file).find((r) => r.key === key);
|
|
254
|
+
if (!req) throw new Error(`request ${key} is no longer pending — reload to see the queue as it is now`);
|
|
228
255
|
if (decision !== "granted" && decision !== "denied")
|
|
229
256
|
throw new Error(`decision must be granted or denied`);
|
|
230
257
|
|
|
@@ -233,8 +260,14 @@ function decide(configPath, { number, decision, reason }) {
|
|
|
233
260
|
// before the queue is settled: if this throws, the request is still open
|
|
234
261
|
// rather than marked done against a file that never changed.
|
|
235
262
|
refuseIfBarred(cfg, req);
|
|
236
|
-
|
|
237
|
-
|
|
263
|
+
// Locked and atomic, so a grant from another channel is serialised with
|
|
264
|
+
// this one instead of one silently overwriting the other. See editPolicy.
|
|
265
|
+
try {
|
|
266
|
+
editPolicy(cfg, (before) => applyGrant(before, req, reason));
|
|
267
|
+
} catch (e) {
|
|
268
|
+
if (e.code === "ELOCKED") throw new Error("another grant is in progress — try again in a moment");
|
|
269
|
+
throw e;
|
|
270
|
+
}
|
|
238
271
|
}
|
|
239
272
|
settle(file, req.key, decision, reason ?? "");
|
|
240
273
|
return { ok: true, role: req.role, grant: req.grant, decision };
|
|
@@ -315,7 +348,16 @@ export function serve(configPath, port = 4178) {
|
|
|
315
348
|
* from there, keeps it for the tab, and removes it from the address bar and
|
|
316
349
|
* the history entry. Anything that fetches `/` gets a page with no token in it.
|
|
317
350
|
*/
|
|
318
|
-
|
|
351
|
+
// Constant-time compare: `===` on a secret leaks its length and a prefix
|
|
352
|
+
// through timing. Loopback with a Host check makes it near-theoretical, but a
|
|
353
|
+
// role with local_binding can reach this port, so it is not free.
|
|
354
|
+
const tokenBuf = Buffer.from(token);
|
|
355
|
+
const authorized = (req) => {
|
|
356
|
+
const got = req.headers["x-seisin-token"];
|
|
357
|
+
if (typeof got !== "string") return false;
|
|
358
|
+
const gotBuf = Buffer.from(got);
|
|
359
|
+
return gotBuf.length === tokenBuf.length && timingSafeEqual(gotBuf, tokenBuf);
|
|
360
|
+
};
|
|
319
361
|
|
|
320
362
|
/**
|
|
321
363
|
* The Host header has to name this server.
|
|
@@ -406,7 +448,9 @@ export function serve(configPath, port = 4178) {
|
|
|
406
448
|
});
|
|
407
449
|
server.on("error", (e) =>
|
|
408
450
|
fail(e.code === "EADDRINUSE"
|
|
409
|
-
|
|
451
|
+
// Keep the code on the rejection: `seisin ui` reads it to tell "a
|
|
452
|
+
// console is already here, reopen its link" from any other failure.
|
|
453
|
+
? Object.assign(new Error(`port ${port} is busy — pass another with: seisin ui --port <n>`), { code: "EADDRINUSE" })
|
|
410
454
|
: e));
|
|
411
455
|
});
|
|
412
456
|
}
|
package/src/surface.js
CHANGED
|
@@ -235,8 +235,13 @@ function dedupe(entries) {
|
|
|
235
235
|
*/
|
|
236
236
|
export function parentInputs(config, { env = process.env } = {}) {
|
|
237
237
|
const abs = (p) => (isAbsolute(p) ? p : join(config.root, p));
|
|
238
|
+
const policy = abs(config.path ?? CONFIG_NAME);
|
|
238
239
|
const out = [
|
|
239
|
-
{ path:
|
|
240
|
+
{ path: policy, why: "the policy" },
|
|
241
|
+
// `seisin.toml.observed` is the proposal `init --from-observations` writes
|
|
242
|
+
// for a person to diff and move onto the policy; a role that could write it
|
|
243
|
+
// could plant a policy the operator then promotes in good faith.
|
|
244
|
+
{ path: policy + ".observed", why: "the policy" },
|
|
240
245
|
{ path: abs(STATE_DIR), why: "seisin's log and request queue" },
|
|
241
246
|
...(config.keyDirs ?? []).map((d) => ({ path: abs(d), why: "a key directory" })),
|
|
242
247
|
];
|
|
@@ -447,6 +452,15 @@ function computeDenies(config, role, { env, platform, observe }) {
|
|
|
447
452
|
? repoDirs.flatMap((d) => [
|
|
448
453
|
`${d}/**/.claude`, `${d}/**/.claude/**`,
|
|
449
454
|
`${d}/**/.git/hooks/**`, `${d}/**/.git/config`,
|
|
455
|
+
// What git runs that lives beside, not in, `.git/config`: a submodule's
|
|
456
|
+
// own config and hooks, and the files that redirect a worktree to
|
|
457
|
+
// another gitdir or config. Ordinary commits never write these, so
|
|
458
|
+
// denying them costs a working role nothing; a role that could write
|
|
459
|
+
// one would leave a command for the next `git` outside the box. The
|
|
460
|
+
// subtree of `.git` stays writable on purpose, so the agent can commit.
|
|
461
|
+
`${d}/**/.git/config.worktree`,
|
|
462
|
+
`${d}/**/.git/modules/**/config`, `${d}/**/.git/modules/**/hooks/**`,
|
|
463
|
+
`${d}/**/.git/worktrees/**/config.worktree`, `${d}/**/.git/worktrees/**/commondir`,
|
|
450
464
|
`${d}/**/.mcp.json`, `${d}/**/.envrc`, `${d}/**/.codex/config.toml`,
|
|
451
465
|
]).map((path) => ({ path, why: "any project's control files in this territory, by pattern" }))
|
|
452
466
|
: [];
|