seisin 0.1.1 → 0.3.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 +287 -339
- package/package.json +7 -6
- package/src/cli.js +17 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/init.js +1 -1
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +8 -3
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +180 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +30 -0
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +310 -5
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +45 -5
- package/src/index.js +12 -0
- package/src/inspect.js +332 -9
- package/src/intake.js +215 -0
- package/src/keys.js +585 -0
- package/src/log.js +96 -2
- package/src/mcp.js +87 -11
- package/src/nested.js +86 -0
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +63 -1
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +263 -19
- package/src/spool.js +98 -20
- package/src/srt.js +148 -203
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +184 -0
- package/ui/favicon.svg +1 -1
- package/ui/index.html +585 -56
package/src/owners.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
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 { existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { protectedBy } from "./surface.js";
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
|
|
@@ -37,13 +40,47 @@
|
|
|
37
40
|
* kernel is already enforcing it. What was missing was seisin saying so.
|
|
38
41
|
*/
|
|
39
42
|
export function covers(glob, path) {
|
|
40
|
-
const p =
|
|
41
|
-
|
|
42
|
-
if (glob.endsWith("/")) g += "/**";
|
|
43
|
+
const p = normalized(path);
|
|
44
|
+
const m = matcherOf(glob);
|
|
43
45
|
// The path itself, or anything beneath it. The `/` is load-bearing: without
|
|
44
46
|
// it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
|
|
45
|
-
if (
|
|
46
|
-
return
|
|
47
|
+
if (m.prefix !== null) return p === m.prefix || p.startsWith(m.prefix + "/");
|
|
48
|
+
return m.re.test(p);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The same glob and the same path, prepared once.
|
|
53
|
+
*
|
|
54
|
+
* `covers` is the inner loop of everything that asks "whose is this": owners of
|
|
55
|
+
* a path is every role's every glob against it, and the console asks that for
|
|
56
|
+
* every path of every role. On a real 32-role policy that was ~1.7M calls per
|
|
57
|
+
* refresh, each normalizing the glob again and compiling its regex again — 8 s
|
|
58
|
+
* of a console that polls, measured with a CPU profile (normalize alone 5 s).
|
|
59
|
+
* The answer never changes for the same string, so it is kept. Bounded: a
|
|
60
|
+
* server that runs for days sees paths from the log, and those keep coming.
|
|
61
|
+
*/
|
|
62
|
+
const MAX_CACHED = 50_000;
|
|
63
|
+
const GLOBS = new Map();
|
|
64
|
+
const PATHS = new Map();
|
|
65
|
+
|
|
66
|
+
function matcherOf(glob) {
|
|
67
|
+
let m = GLOBS.get(glob);
|
|
68
|
+
if (m) return m;
|
|
69
|
+
let g = normalize(glob);
|
|
70
|
+
if (glob.endsWith("/")) g += "/**";
|
|
71
|
+
m = WILD.test(g) ? { prefix: null, re: toRegExp(g) } : { prefix: g, re: null };
|
|
72
|
+
if (GLOBS.size >= MAX_CACHED) GLOBS.clear();
|
|
73
|
+
GLOBS.set(glob, m);
|
|
74
|
+
return m;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalized(path) {
|
|
78
|
+
let p = PATHS.get(path);
|
|
79
|
+
if (p !== undefined) return p;
|
|
80
|
+
p = normalize(path);
|
|
81
|
+
if (PATHS.size >= MAX_CACHED) PATHS.clear();
|
|
82
|
+
PATHS.set(path, p);
|
|
83
|
+
return p;
|
|
47
84
|
}
|
|
48
85
|
|
|
49
86
|
/** The four characters toRegExp() treats as wildcards. */
|
|
@@ -103,13 +140,64 @@ function toRegExp(glob) {
|
|
|
103
140
|
return new RegExp("^" + out + "$");
|
|
104
141
|
}
|
|
105
142
|
|
|
143
|
+
/** Is this path inside a `.git` directory (git's metadata, not a working file)? */
|
|
144
|
+
export function isGitMetadata(path) {
|
|
145
|
+
return /(^|\/)\.git\//.test(normalize(path));
|
|
146
|
+
}
|
|
147
|
+
|
|
106
148
|
/** Every role whose territory covers `path`. Usually one; zero is a finding. */
|
|
107
149
|
export function ownersOf(config, path) {
|
|
150
|
+
/**
|
|
151
|
+
* An absolute path is outside the repo, or it is made relative first.
|
|
152
|
+
*
|
|
153
|
+
* The kernel reports paths as it meets them, and toRepoRelative leaves the
|
|
154
|
+
* ones outside the repo absolute on purpose. They then met `writes = ["**"]`,
|
|
155
|
+
* which matches any string, so a refused write to ~/.claude/plugins was
|
|
156
|
+
* logged as "owned by dev, wide" — two roles that own nothing outside the
|
|
157
|
+
* repo. Measured on the first run of Claude Code under the new denies.
|
|
158
|
+
*/
|
|
159
|
+
if (path.startsWith("/")) {
|
|
160
|
+
const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
|
|
161
|
+
if (!config.root || !path.startsWith(root)) return [];
|
|
162
|
+
path = path.slice(root.length);
|
|
163
|
+
}
|
|
108
164
|
return Object.values(config.roles)
|
|
109
|
-
.filter((r) => r.writes.some((g) => covers(g, path)))
|
|
165
|
+
.filter((r) => r.writes.some((g) => covers(g, path)) && !neverWrites(r, path, config))
|
|
110
166
|
.map((r) => r.name);
|
|
111
167
|
}
|
|
112
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The `never_writes` entries the kernel will actually be given for this role.
|
|
171
|
+
*
|
|
172
|
+
* On macOS, all of them. On Linux, only the ones that exist on disk right now,
|
|
173
|
+
* and that is the runtime's shape rather than a choice: bubblewrap denies a
|
|
174
|
+
* path by mounting over it, and to mount over a path that does not exist it
|
|
175
|
+
* creates it on the host — an empty, read-only file that stays there while the
|
|
176
|
+
* role runs and forever if the process is killed. For `.git/index.lock`, the
|
|
177
|
+
* case the key exists for, that locks every other user of the repo out.
|
|
178
|
+
* Measured in Docker (Debian 12, bwrap 0.8.0) on 2026-09-23: present during the
|
|
179
|
+
* run, gone after a clean exit, still there after SIGKILL.
|
|
180
|
+
*
|
|
181
|
+
* `explain` asks this same function, so the sentence and the kernel agree: a
|
|
182
|
+
* document stricter than the boundary is the direction this project refuses.
|
|
183
|
+
* `check` names the entries that are not enforced.
|
|
184
|
+
*/
|
|
185
|
+
export function enforcedNeverWrites(config, role) {
|
|
186
|
+
const all = role?.neverWrites ?? [];
|
|
187
|
+
if (process.platform !== "linux" || !config?.root) return all;
|
|
188
|
+
return all.filter((g) => existsSync(join(config.root, g.replace(/\/\*\*$/, ""))));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The `never_writes` entry of `role` that covers `path`, or null.
|
|
193
|
+
*
|
|
194
|
+
* `?? []` because a caller embedding the library builds role objects itself,
|
|
195
|
+
* and one built before the key existed must keep meaning what it meant.
|
|
196
|
+
*/
|
|
197
|
+
export function neverWrites(role, path, config = null) {
|
|
198
|
+
return enforcedNeverWrites(config, role).find((g) => covers(g, path)) ?? null;
|
|
199
|
+
}
|
|
200
|
+
|
|
113
201
|
/** Every role allowed to read `key`, matched with or without its extension. */
|
|
114
202
|
export function keyHolders(config, key) {
|
|
115
203
|
const dirs = config.keyDirs ?? [];
|
|
@@ -149,7 +237,72 @@ export function keyHolders(config, key) {
|
|
|
149
237
|
* not a permission problem, it is a hole in the map. Saying so is more useful
|
|
150
238
|
* than denying quietly, and it is the only way the hole ever gets fixed.
|
|
151
239
|
*/
|
|
240
|
+
/**
|
|
241
|
+
* A connection the kernel refused: `tcp:<port>` or a unix socket path.
|
|
242
|
+
*
|
|
243
|
+
* The kernel does not say which host — its line is `remote:*:<port>` — so the
|
|
244
|
+
* sentence says the port and what the policy gives for it, and nothing it
|
|
245
|
+
* cannot know. Three answers, because they send a person to three places:
|
|
246
|
+
*
|
|
247
|
+
* - the port is not in `local_ports`: a policy question, add it or do not;
|
|
248
|
+
* - it is, and was refused anyway: the client dialled directly instead of
|
|
249
|
+
* through the proxy — a MySQL driver, Chromium, a raw socket — and adding
|
|
250
|
+
* the port again will not change that;
|
|
251
|
+
* - a unix socket: seisin grants none, on purpose. The one that comes up is
|
|
252
|
+
* Docker's, and a role that can reach Docker can mount the whole disk.
|
|
253
|
+
*
|
|
254
|
+
* Never an owner and never a request: a port is not anybody's territory.
|
|
255
|
+
*/
|
|
256
|
+
function explainConnect(config, role, target) {
|
|
257
|
+
const r = config.roles[role];
|
|
258
|
+
const port = /^tcp:(\d+)$/.exec(target)?.[1];
|
|
259
|
+
if (port) {
|
|
260
|
+
const n = Number(port);
|
|
261
|
+
if (r?.localPorts?.includes(n))
|
|
262
|
+
return {
|
|
263
|
+
allowed: false, owners: [], network: true, listed: true,
|
|
264
|
+
reason: `port ${n} is in ${role}'s local_ports, but this client skipped the proxy. ` +
|
|
265
|
+
`curl, fetch and urllib use HTTP_PROXY on their own; a database driver or a browser ` +
|
|
266
|
+
`has to be pointed at it`,
|
|
267
|
+
};
|
|
268
|
+
// The kernel names no host, and every direct dial is refused — to an
|
|
269
|
+
// outside host as much as to localhost. So both readings, not a guess:
|
|
270
|
+
// advising `local_ports = [443]` for a client that skipped the proxy on
|
|
271
|
+
// its way to the internet opens a port and fixes nothing.
|
|
272
|
+
return {
|
|
273
|
+
allowed: false, owners: [], network: true,
|
|
274
|
+
reason: `port ${n}, host unknown. Local service: a person adds it to local_ports` +
|
|
275
|
+
`${r?.localPorts?.length ? ` (has ${r.localPorts.join(", ")})` : ""}. ` +
|
|
276
|
+
`Outside host: the client skipped HTTP_PROXY`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
allowed: false, owners: [], network: true,
|
|
281
|
+
reason: `${target} is a unix socket, and roles get none` +
|
|
282
|
+
(/docker\.sock$/.test(target)
|
|
283
|
+
? `. Docker's socket can mount any directory on this machine: start containers outside ` +
|
|
284
|
+
`the role and reach them by port`
|
|
285
|
+
: ""),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** May this role load the MCP server `name`? A declaration, not something the kernel sees. */
|
|
290
|
+
function explainMcp(config, role, name) {
|
|
291
|
+
const list = config.roles[role]?.mcp;
|
|
292
|
+
if (list == null)
|
|
293
|
+
return { allowed: true, owners: [], mcp: true, declared: false,
|
|
294
|
+
reason: `${role} has no mcp list, so the policy does not limit its MCP servers` };
|
|
295
|
+
if (list.includes(name))
|
|
296
|
+
return { allowed: true, owners: [], mcp: true, declared: true, reason: `${role} declares mcp = [${list.map((n) => `"${n}"`).join(", ")}]` };
|
|
297
|
+
return { allowed: false, owners: [], mcp: true, declared: true,
|
|
298
|
+
reason: list.length
|
|
299
|
+
? `${role} may load only ${list.join(", ")}. Adding ${name} is a change to its mcp list`
|
|
300
|
+
: `${role} declares mcp = [], no MCP servers` };
|
|
301
|
+
}
|
|
302
|
+
|
|
152
303
|
export function explain(config, role, action, target) {
|
|
304
|
+
if (action === "connect") return explainConnect(config, role, target);
|
|
305
|
+
if (action === "mcp") return explainMcp(config, role, target);
|
|
153
306
|
if (action === "read") {
|
|
154
307
|
const holders = keyHolders(config, target);
|
|
155
308
|
if (holders.includes(role)) return { allowed: true, owners: holders, reason: `${role} declares ${target}` };
|
|
@@ -159,7 +312,56 @@ export function explain(config, role, action, target) {
|
|
|
159
312
|
}
|
|
160
313
|
|
|
161
314
|
const owners = ownersOf(config, target);
|
|
315
|
+
/**
|
|
316
|
+
* Protected before anything else, because no territory reaches it.
|
|
317
|
+
*
|
|
318
|
+
* The policy, seisin's state, the key directories, what the parent executes
|
|
319
|
+
* or reads for a key, and the files that make git or Claude Code run
|
|
320
|
+
* something outside the box. The kernel denies all of them to every role —
|
|
321
|
+
* surface.js has the list and the reasons — so saying "belongs to dev" about
|
|
322
|
+
* `seisin.toml` sent a request to a person that no grant could satisfy.
|
|
323
|
+
*/
|
|
324
|
+
const guard = protectedBy(config, target);
|
|
325
|
+
if (guard)
|
|
326
|
+
return {
|
|
327
|
+
allowed: false, owners, protected: guard.why,
|
|
328
|
+
reason: `${target} is protected (${guard.why}) — no role writes it, so there is nothing to grant`,
|
|
329
|
+
};
|
|
330
|
+
/**
|
|
331
|
+
* Named for what it is, and checked before "no owner".
|
|
332
|
+
*
|
|
333
|
+
* A path the role gave up through `never_writes` is not a permission it is
|
|
334
|
+
* missing. Said as "belongs to nobody", the agent files a request, a person
|
|
335
|
+
* approves it, and the subtraction is undone from the other side without
|
|
336
|
+
* anyone deciding to undo it.
|
|
337
|
+
*/
|
|
338
|
+
const r = config.roles[role];
|
|
339
|
+
const barred = r && r.writes.some((g) => covers(g, target)) ? neverWrites(r, target, config) : null;
|
|
340
|
+
if (barred)
|
|
341
|
+
return {
|
|
342
|
+
allowed: false, owners, neverWrites: barred,
|
|
343
|
+
reason: `${target} is denied by never_writes of ${role} ("${barred}") — ` +
|
|
344
|
+
`a subtraction written into the policy, not a missing permission`,
|
|
345
|
+
};
|
|
162
346
|
if (owners.includes(role)) return { allowed: true, owners, reason: `${target} is inside ${role}'s territory` };
|
|
347
|
+
/**
|
|
348
|
+
* Git's own bookkeeping in a repository this role does not write.
|
|
349
|
+
*
|
|
350
|
+
* Lock files, FETCH_HEAD, objects, refs, packed-refs: never a territory to
|
|
351
|
+
* hand over, because granting them is letting the role rewrite somebody
|
|
352
|
+
* else's history. Codex keeps `.git` read-only under a writable root for the
|
|
353
|
+
* same reason; nono and Gemini grant the whole of it. Here it stays refused
|
|
354
|
+
* and is said for what it is — not "belongs to X, ask them", which filed a
|
|
355
|
+
* request per lock file (327 of them for one index.lock on one portfolio)
|
|
356
|
+
* that nobody could sensibly approve. The requests side skips it too.
|
|
357
|
+
*/
|
|
358
|
+
if (isGitMetadata(target))
|
|
359
|
+
return {
|
|
360
|
+
allowed: false, owners, gitMetadata: true,
|
|
361
|
+
reason: `${target} is git's own bookkeeping in a repository ${role} does not write — ` +
|
|
362
|
+
`not a permission to ask for. Read what you need (git log, git show, git ls-remote) ` +
|
|
363
|
+
`or work in a worktree of your own`,
|
|
364
|
+
};
|
|
163
365
|
if (owners.length === 0)
|
|
164
366
|
return { allowed: false, owners: [], reason: `${target} has no owner — no role can write it until one claims it` };
|
|
165
367
|
return { allowed: false, owners, reason: `${target} belongs to ${owners.join(", ")}` };
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One way to turn whatever path a caller holds into the repo-relative path the
|
|
3
|
+
* policy is written in.
|
|
4
|
+
*
|
|
5
|
+
* It was copied by hand into four places (explain, whose, the hook, run) and a
|
|
6
|
+
* fifth, the MCP server, never had it: `seisin_explain` answered "has no owner"
|
|
7
|
+
* for an absolute path the CLI called allowed — the same defect decisions.md
|
|
8
|
+
* records as closed, closed in one surface only. And none of the copies
|
|
9
|
+
* resolved symlinks, so on macOS a path under `/tmp` (a link to `/private/tmp`)
|
|
10
|
+
* was refused by the sentence while the kernel, which sees the real path,
|
|
11
|
+
* allowed it: the document tighter than the boundary, the direction this
|
|
12
|
+
* project refuses. Found by the read-only review of 2026-09-22.
|
|
13
|
+
*/
|
|
14
|
+
import { realpathSync } from "node:fs";
|
|
15
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `p` with its deepest existing ancestor resolved through symlinks and the rest
|
|
19
|
+
* appended as written. realpath alone fails on a path that does not exist yet,
|
|
20
|
+
* which is the shape of every file about to be created — a lock file included.
|
|
21
|
+
*/
|
|
22
|
+
export function realAncestor(p) {
|
|
23
|
+
let head = p, tail = "";
|
|
24
|
+
while (head !== dirname(head)) {
|
|
25
|
+
try {
|
|
26
|
+
const real = realpathSync(head);
|
|
27
|
+
return tail ? join(real, tail) : real;
|
|
28
|
+
} catch {
|
|
29
|
+
tail = tail ? join(basename(head), tail) : basename(head);
|
|
30
|
+
head = dirname(head);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Repo-relative when `target` is inside the repo, spelled either way; exactly as
|
|
38
|
+
* written when it is relative already or lies outside. A path outside keeps its
|
|
39
|
+
* honest "no owner" — it is a question about somewhere else.
|
|
40
|
+
*/
|
|
41
|
+
export function toRepoRelative(config, target) {
|
|
42
|
+
if (typeof target !== "string" || !isAbsolute(target)) return target;
|
|
43
|
+
const root = config.root;
|
|
44
|
+
const inside = (r, t) => t === r || t.startsWith(r + sep);
|
|
45
|
+
if (inside(root, target)) return relative(root, target) || ".";
|
|
46
|
+
const realRoot = realAncestor(root);
|
|
47
|
+
const realTarget = realAncestor(target);
|
|
48
|
+
if (inside(realRoot, realTarget)) return relative(realRoot, realTarget) || ".";
|
|
49
|
+
return target;
|
|
50
|
+
}
|
package/src/render.js
CHANGED
|
@@ -30,18 +30,80 @@ export const err = (s) => process.stderr.write(s);
|
|
|
30
30
|
|
|
31
31
|
/* ── the map ──────────────────────────────────────────────────────────── */
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A database's sidecars, shown as the database.
|
|
35
|
+
*
|
|
36
|
+
* `writes` carries the expansion because the grant and the sentence have to
|
|
37
|
+
* agree — see config.js. Printing all four is then honest and unreadable: on a
|
|
38
|
+
* real policy it turned eight databases into thirty-two rows of the same name
|
|
39
|
+
* with three suffixes, which is what the expansion existed to remove.
|
|
40
|
+
*
|
|
41
|
+
* So the list is collapsed back for DISPLAY only, and only where the base is
|
|
42
|
+
* present, and it says the sidecars are there rather than hiding them. A
|
|
43
|
+
* sidecar declared without its database still prints on its own line — that is
|
|
44
|
+
* unusual enough to be worth seeing.
|
|
45
|
+
*/
|
|
46
|
+
const SIDECAR_RE = /-(wal|shm|journal)$/;
|
|
47
|
+
|
|
48
|
+
export function collapseSidecars(paths) {
|
|
49
|
+
const have = new Set(paths);
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const p of paths) {
|
|
52
|
+
const base = p.replace(SIDECAR_RE, "");
|
|
53
|
+
if (SIDECAR_RE.test(p) && have.has(base)) continue; // shown on its base
|
|
54
|
+
out.push(SIDECAR_RE.test(p) || !have.has(p + "-wal") ? p : `${p}${C.dim}+wal+shm+journal${C.off}`);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
33
59
|
/** The `check` report, as text. Takes the object `inspect()` returns. */
|
|
34
60
|
export function renderReport(report) {
|
|
35
61
|
const lines = [`\n${C.b}${report.where}${C.off}\n\n`];
|
|
36
62
|
const width = Math.max(...report.roles.map((r) => r.name.length), 4);
|
|
37
63
|
|
|
38
64
|
for (const r of report.roles) {
|
|
39
|
-
const writes = r.writes.join(" ") || `${C.dim}nothing${C.off}`;
|
|
65
|
+
const writes = collapseSidecars(r.writes).join(" ") || `${C.dim}nothing${C.off}`;
|
|
40
66
|
const keys = r.keys.length ? r.keys.join(" ") : `${C.dim}none${C.off}`;
|
|
41
67
|
lines.push(` ${C.b}${r.name.padEnd(width)}${C.off} ${C.blue}writes${C.off} ${writes}\n`);
|
|
68
|
+
// Only when present: absent is the default, and a "never_writes nothing" line
|
|
69
|
+
// on every role is noise that teaches the reader to skip the one that matters.
|
|
70
|
+
if (r.neverWrites?.length)
|
|
71
|
+
lines.push(` ${" ".repeat(width)} ${C.yellow}never${C.off} ${collapseSidecars(r.neverWrites).join(" ")}\n`);
|
|
72
|
+
if (r.trustd)
|
|
73
|
+
lines.push(` ${" ".repeat(width)} ${C.yellow}trustd${C.off} TLS through the system verifier (trustd)\n`);
|
|
74
|
+
if (r.localBinding)
|
|
75
|
+
lines.push(` ${" ".repeat(width)} ${C.yellow}listen${C.off} local ports (local_binding)\n`);
|
|
76
|
+
if (r.mcp)
|
|
77
|
+
lines.push(` ${" ".repeat(width)} ${C.yellow}mcp${C.off} ${r.mcp.length ? r.mcp.join(" ") : "none"} ${C.dim}(declared; the launcher enforces it)${C.off}\n`);
|
|
78
|
+
if (r.localPorts?.length)
|
|
79
|
+
lines.push(` ${" ".repeat(width)} ${C.yellow}reach${C.off} localhost ${r.localPorts.join(" ")} ${C.dim}(local_ports, through the proxy)${C.off}\n`);
|
|
42
80
|
lines.push(` ${" ".repeat(width)} ${C.green}keys${C.off} ${keys}\n\n`);
|
|
43
81
|
}
|
|
44
82
|
|
|
83
|
+
if (report.providers?.length) {
|
|
84
|
+
lines.push(` ${C.b}key providers${C.off} ${C.dim}— seisin runs these itself, outside the sandbox, as you${C.off}\n`);
|
|
85
|
+
for (const p of report.providers)
|
|
86
|
+
lines.push(` ${C.green}${p.name}${C.off} ${p.command.join(" ")}${p.mode ? ` ${C.dim}(${p.mode})${C.off}` : ""}\n`);
|
|
87
|
+
lines.push("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// What the kernel refuses inside a territory, and why. Shown because each one
|
|
91
|
+
// takes something from a territory as written; capped because a monorepo has
|
|
92
|
+
// a hooks directory per package and the reader needs the shape, not all 200.
|
|
93
|
+
if (report.protected?.length) {
|
|
94
|
+
const root = report.where ? report.where.replace(/\/[^/]*$/, "/") : "";
|
|
95
|
+
const show = (p) => (root && p.startsWith(root) ? p.slice(root.length) : p.replace(/^\/Users\/[^/]+|^\/home\/[^/]+/, "~"));
|
|
96
|
+
lines.push(` ${C.b}protected${C.off} ${C.dim}— inside a territory, refused anyway: something outside the box runs or reads it${C.off}\n`);
|
|
97
|
+
const LIMIT = 12;
|
|
98
|
+
for (const e of report.protected.slice(0, LIMIT)) {
|
|
99
|
+
const who = e.roles.length > 3 ? `${e.roles.slice(0, 3).join(", ")} +${e.roles.length - 3}` : e.roles.join(", ");
|
|
100
|
+
lines.push(` ${show(e.path)} ${C.dim}${e.why} · ${who}${C.off}\n`);
|
|
101
|
+
}
|
|
102
|
+
if (report.protected.length > LIMIT)
|
|
103
|
+
lines.push(` ${C.dim}… and ${report.protected.length - LIMIT} more${C.off}\n`);
|
|
104
|
+
lines.push("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
45
107
|
for (const w of report.warnings) {
|
|
46
108
|
lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
|
|
47
109
|
if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);
|
package/src/requests.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* rewritten is not evidence. A request that is granted or refused 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";
|
|
29
30
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
30
31
|
import { dirname, join } from "node:path";
|
|
31
32
|
import { STATE_DIR, tomlString } from "./layout.js";
|
|
@@ -173,6 +174,68 @@ export function pending(file, { includeSettled = false } = {}) {
|
|
|
173
174
|
return includeSettled ? all : all.filter((r) => r.state === "pending");
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
/**
|
|
178
|
+
* How many runs of the role, after the last time it asked, make a request old.
|
|
179
|
+
*
|
|
180
|
+
* A request is recorded again on every refusal, so one the role still needs
|
|
181
|
+
* keeps a fresh `last`. One whose need went away — the work moved somewhere the
|
|
182
|
+
* role may write, or the task ended — stops being asked while the role keeps
|
|
183
|
+
* running. Three runs is enough to say "not since" and few enough that a queue
|
|
184
|
+
* does not carry a dead request for a week.
|
|
185
|
+
*/
|
|
186
|
+
export const STALE_RUNS = 3;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Two log lines of a role this far apart belong to different runs — for lines
|
|
190
|
+
* written before the log had a run marker. Lines with `run` are counted by it.
|
|
191
|
+
*/
|
|
192
|
+
export const RUN_GAP_MS = 10 * 60_000;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* How many runs produced these lines, strictly after `since` (ms).
|
|
196
|
+
*
|
|
197
|
+
* By the `run` id when a line has one (intake.js writes it on every line since
|
|
198
|
+
* 2026-09-23), by the gap otherwise. The gap guessed: three runs started a
|
|
199
|
+
* minute apart by an orchestrator counted as one, and one run idle for eleven
|
|
200
|
+
* minutes counted as two.
|
|
201
|
+
*/
|
|
202
|
+
export function runsAfter(entries, since, gapMs = RUN_GAP_MS) {
|
|
203
|
+
const ids = new Set();
|
|
204
|
+
const untagged = [];
|
|
205
|
+
for (const e of entries) {
|
|
206
|
+
const t = Date.parse(e.at);
|
|
207
|
+
if (Number.isNaN(t) || t <= since) continue;
|
|
208
|
+
if (e.run) ids.add(e.run);
|
|
209
|
+
else untagged.push(t);
|
|
210
|
+
}
|
|
211
|
+
untagged.sort((a, b) => a - b);
|
|
212
|
+
let n = 0, prev = -Infinity;
|
|
213
|
+
for (const t of untagged) { if (t - prev > gapMs) n++; prev = t; }
|
|
214
|
+
return ids.size + n;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Marks the requests the role has stopped asking for, read off the log.
|
|
219
|
+
*
|
|
220
|
+
* Marked, never settled and never moved. Declining is a person's decision, and
|
|
221
|
+
* reordering would shift the numbers `grant <n>` is typed against while
|
|
222
|
+
* nobody decided anything. The mark says what is true — the role ran N times
|
|
223
|
+
* since and did not ask again — and leaves the rest to whoever reads it.
|
|
224
|
+
* `entries` are log lines (`read(logPath(root))`); `now` is for the tests.
|
|
225
|
+
*/
|
|
226
|
+
export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_MS } = {}) {
|
|
227
|
+
const byRole = new Map(); // role -> its log lines
|
|
228
|
+
for (const e of entries) {
|
|
229
|
+
if (!e.role) continue;
|
|
230
|
+
(byRole.get(e.role) ?? byRole.set(e.role, []).get(e.role)).push(e);
|
|
231
|
+
}
|
|
232
|
+
for (const r of queue) {
|
|
233
|
+
const n = runsAfter(byRole.get(r.role) ?? [], Date.parse(r.last), gapMs);
|
|
234
|
+
if (n >= runs) r.stale = { runs: n, since: r.last };
|
|
235
|
+
}
|
|
236
|
+
return queue;
|
|
237
|
+
}
|
|
238
|
+
|
|
176
239
|
/**
|
|
177
240
|
* Adds a granted glob to a role in the config text, with its provenance.
|
|
178
241
|
*
|
|
@@ -195,6 +258,31 @@ export function pending(file, { includeSettled = false } = {}) {
|
|
|
195
258
|
* is the worst possible bug, and it is not exotic: a role with only `keys`
|
|
196
259
|
* declared is an ordinary config.
|
|
197
260
|
*/
|
|
261
|
+
/**
|
|
262
|
+
* Refuses a grant that `never_writes` would cancel, before anything is written.
|
|
263
|
+
*
|
|
264
|
+
* A request filed before the subtraction existed can still be in the queue.
|
|
265
|
+
* Approving it would add the path to `writes` and change nothing — denyWrite
|
|
266
|
+
* wins — so the person would believe they granted something the kernel still
|
|
267
|
+
* refuses. The two keys contradict each other, and which one should win is the
|
|
268
|
+
* decision; seisin will not make it by writing one of them silently.
|
|
269
|
+
*/
|
|
270
|
+
export function refuseIfBarred(config, request) {
|
|
271
|
+
if (request.action === "read") return;
|
|
272
|
+
const role = config.roles?.[request.role];
|
|
273
|
+
const hit = role && neverWrites(role, request.target, config);
|
|
274
|
+
if (hit)
|
|
275
|
+
throw new Error(
|
|
276
|
+
`${request.target} is under never_writes of ${request.role} ("${hit}"), which wins over ` +
|
|
277
|
+
`writes. Granting it would change nothing. If the subtraction is wrong, remove that ` +
|
|
278
|
+
`entry from [roles.${request.role}]; otherwise decline this request.`);
|
|
279
|
+
// Only a repository the role does not write: its own `.git` is its own business.
|
|
280
|
+
if (isGitMetadata(request.target) && !(role?.writes ?? []).some((g) => covers(g, request.target)))
|
|
281
|
+
throw new Error(
|
|
282
|
+
`${request.target} is git's own bookkeeping, not a territory: granting it lets ${request.role} ` +
|
|
283
|
+
`rewrite another owner's history. Decline it; a role that needs to commit gets a worktree of its own.`);
|
|
284
|
+
}
|
|
285
|
+
|
|
198
286
|
export function applyGrant(toml, request, note = "") {
|
|
199
287
|
const field = request.action === "read" ? "keys" : "writes";
|
|
200
288
|
const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
|
package/src/review.js
CHANGED
|
@@ -73,7 +73,7 @@ function findings(config, entries, minDenials) {
|
|
|
73
73
|
const stopped = new Map(); // role + directory -> how often
|
|
74
74
|
for (const e of entries) {
|
|
75
75
|
if (e.verdict !== "denied" || !e.target) continue;
|
|
76
|
-
const dir = e.action === "read" ? e.target : dirOf(e.target);
|
|
76
|
+
const dir = e.action === "read" || e.action === "connect" ? e.target : dirOf(e.target);
|
|
77
77
|
const key = `${e.role} ${e.action} ${dir}`;
|
|
78
78
|
const seen = stopped.get(key) ?? {
|
|
79
79
|
role: e.role, action: e.action, where: dir, times: 0,
|
|
@@ -81,7 +81,7 @@ function findings(config, entries, minDenials) {
|
|
|
81
81
|
// Carried by the log from both writers — the hook sets it from the config
|
|
82
82
|
// and so does the parent for a kernel denial. Recomputing it from the path
|
|
83
83
|
// here would be a second opinion on a settled question.
|
|
84
|
-
kind: e.kind === "key"
|
|
84
|
+
kind: e.kind === "key" || e.kind === "network" ? e.kind : "file",
|
|
85
85
|
};
|
|
86
86
|
seen.times++;
|
|
87
87
|
stopped.set(key, seen);
|
|
@@ -91,8 +91,10 @@ function findings(config, entries, minDenials) {
|
|
|
91
91
|
.filter((f) => f.times >= minDenials)
|
|
92
92
|
.sort((a, b) => b.times - a.times);
|
|
93
93
|
|
|
94
|
-
const friction = repeated.filter((f) => f.kind !== "key");
|
|
94
|
+
const friction = repeated.filter((f) => f.kind !== "key" && f.kind !== "network");
|
|
95
95
|
const guarded = repeated.filter((f) => f.kind === "key");
|
|
96
|
+
// Ports and sockets: repeated, and not a thing `grant` can answer.
|
|
97
|
+
const connects = repeated.filter((f) => f.kind === "network");
|
|
96
98
|
|
|
97
99
|
/* ── 2. what was granted and never used ─────────────────────────────── */
|
|
98
100
|
|
|
@@ -144,7 +146,9 @@ function findings(config, entries, minDenials) {
|
|
|
144
146
|
|
|
145
147
|
const unowned = new Map();
|
|
146
148
|
for (const e of entries) {
|
|
147
|
-
|
|
149
|
+
// A key directory is closed on purpose; a port or a socket is not a path
|
|
150
|
+
// anybody could own. Neither is a hole in the map.
|
|
151
|
+
if (!e.target || e.kind === "key" || e.kind === "network") continue;
|
|
148
152
|
if (ownersOf(config, e.target).length) continue;
|
|
149
153
|
const dir = dirOf(e.target);
|
|
150
154
|
unowned.set(dir, (unowned.get(dir) ?? 0) + 1);
|
|
@@ -153,6 +157,7 @@ function findings(config, entries, minDenials) {
|
|
|
153
157
|
return {
|
|
154
158
|
friction,
|
|
155
159
|
guarded,
|
|
160
|
+
connects,
|
|
156
161
|
unused,
|
|
157
162
|
unusedKnowable: knowable,
|
|
158
163
|
unowned: [...unowned.entries()]
|
package/src/rundir.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One private directory per run, for everything that belongs to that run only:
|
|
3
|
+
* the audit socket, the sandbox settings, and `scratch` keys.
|
|
4
|
+
*
|
|
5
|
+
* ── Why ──
|
|
6
|
+
* These lived in two places and both leaked. The settings were written to
|
|
7
|
+
* `.seisin/<role>.json`, one file per ROLE, so two runs of the same role — an
|
|
8
|
+
* orchestrator does that all day — wrote the same file with different socket
|
|
9
|
+
* paths, and one could start with the other's. The socket and the scratch keys
|
|
10
|
+
* lived in `$TMPDIR/seisin-XXXX/`, private by mode, but every role runs as the
|
|
11
|
+
* same user and every role can read the temp dir: measured on 2026-09-23, one
|
|
12
|
+
* role read another's `scratch` key while it ran (`cat …/keys/S` → the value).
|
|
13
|
+
*
|
|
14
|
+
* ── How ──
|
|
15
|
+
* All runs live under one root, `$TMPDIR/snr/`, and each role's profile denies
|
|
16
|
+
* reading that root and allows reading its own run's directory — the shape the
|
|
17
|
+
* key directories and the isolated homes already use. The root is denied for
|
|
18
|
+
* writing too, so no role can swap it for a symlink between runs.
|
|
19
|
+
*
|
|
20
|
+
* Short names on purpose: the socket path is capped near 104 bytes on macOS.
|
|
21
|
+
*/
|
|
22
|
+
import { mkdirSync, mkdtempSync, lstatSync, chmodSync, writeFileSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
23
|
+
import { join, basename, dirname } from "node:path";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { randomUUID } from "node:crypto";
|
|
26
|
+
import { realOrSelf } from "./grants.js";
|
|
27
|
+
import { channelName } from "./spool.js";
|
|
28
|
+
|
|
29
|
+
export const RUNS_NAME = "snr";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The root every run lives under, created private, and checked.
|
|
33
|
+
*
|
|
34
|
+
* Real path, because the kernel enforces on real paths and on macOS the temp
|
|
35
|
+
* dir sits behind /var → /private/var. Refused rather than used when it is not
|
|
36
|
+
* a directory of this user's with nobody else's bits on it: it holds keys.
|
|
37
|
+
*/
|
|
38
|
+
export function runsRoot(base = tmpdir()) {
|
|
39
|
+
const root = join(realOrSelf(base), RUNS_NAME);
|
|
40
|
+
try { mkdirSync(root, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; }
|
|
41
|
+
const st = lstatSync(root);
|
|
42
|
+
const mine = typeof process.getuid !== "function" || st.uid === process.getuid();
|
|
43
|
+
if (st.isSymbolicLink() || !st.isDirectory() || !mine)
|
|
44
|
+
throw new Error(
|
|
45
|
+
`${root} is not a directory of yours (${st.isSymbolicLink() ? "it is a symlink" : "wrong owner or type"}).\n` +
|
|
46
|
+
` It holds each run's socket and keys, so seisin will not use it. Remove it and run again.`);
|
|
47
|
+
if (st.mode & 0o077) chmodSync(root, 0o700);
|
|
48
|
+
return root;
|
|
49
|
+
}
|
|
50
|
+
|
|
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
|
+
/**
|
|
57
|
+
* Removes the directories of runs whose process is gone.
|
|
58
|
+
*
|
|
59
|
+
* A run cleans up after itself on exit. A run that is killed does not, and
|
|
60
|
+
* its directory keeps its `scratch` keys on disk indefinitely — measured by
|
|
61
|
+
* SIGKILL on a keyed run. Swept at the start of the next one; a directory
|
|
62
|
+
* without a pid file is younger than the write that creates it, and is left.
|
|
63
|
+
*/
|
|
64
|
+
function sweep(root) {
|
|
65
|
+
for (const name of readdirSync(root)) {
|
|
66
|
+
const dir = join(root, name);
|
|
67
|
+
let pid;
|
|
68
|
+
try { pid = Number(readFileSync(join(dir, "pid"), "utf8")); } catch { continue; }
|
|
69
|
+
if (pid && !alive(pid)) rmSync(dir, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Opens a run: its id, its directory, and the paths inside it.
|
|
75
|
+
*
|
|
76
|
+
* The id is the one the rest of the run uses — the nonce in the kernel's
|
|
77
|
+
* command tag, the `run` field on every log line — so one value ties the
|
|
78
|
+
* socket, the settings, the log and the kernel's reports together.
|
|
79
|
+
*/
|
|
80
|
+
export function openRun({ base = tmpdir(), id = randomUUID() } = {}) {
|
|
81
|
+
const root = runsRoot(base);
|
|
82
|
+
try { sweep(root); } catch { /* a sweep that fails is not a reason not to run */ }
|
|
83
|
+
const dir = mkdtempSync(join(root, `${id.slice(0, 6)}-`));
|
|
84
|
+
writeFileSync(join(dir, "pid"), String(process.pid), { mode: 0o600 });
|
|
85
|
+
return {
|
|
86
|
+
id,
|
|
87
|
+
dir,
|
|
88
|
+
root,
|
|
89
|
+
// A socket on macOS, a FIFO on Linux, where the runtime blocks every unix
|
|
90
|
+
// socket inside the box (spool.js).
|
|
91
|
+
sock: join(dir, channelName()),
|
|
92
|
+
keys: join(dir, "keys"),
|
|
93
|
+
/**
|
|
94
|
+
* `wx`: created by this call or not at all. A settings file that already
|
|
95
|
+
* exists is not this run's, and the runtime would read whatever it says.
|
|
96
|
+
*/
|
|
97
|
+
writeSettings(settings) {
|
|
98
|
+
const file = join(dir, "settings.json");
|
|
99
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
100
|
+
return file;
|
|
101
|
+
},
|
|
102
|
+
close() {
|
|
103
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The runs root a socket path belongs to, or null when it is not one of ours. */
|
|
109
|
+
export function runsRootOf(sock) {
|
|
110
|
+
if (!sock) return null;
|
|
111
|
+
const root = dirname(dirname(sock));
|
|
112
|
+
return basename(root) === RUNS_NAME ? root : null;
|
|
113
|
+
}
|