seisin 0.2.0 → 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 +72 -10
- package/package.json +7 -6
- package/src/cli.js +3 -2
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +7 -2
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +112 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +8 -2
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +150 -2
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +12 -3
- package/src/inspect.js +178 -5
- package/src/intake.js +215 -0
- package/src/keys.js +108 -6
- package/src/log.js +96 -2
- package/src/mcp.js +28 -10
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +29 -0
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +131 -21
- package/src/spool.js +98 -20
- package/src/srt.js +123 -225
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +19 -5
- package/ui/favicon.svg +1 -1
- package/ui/index.html +221 -28
package/src/notify.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tell a person when a request is filed.
|
|
3
|
+
*
|
|
4
|
+
* The queue is where a refusal becomes a handoff, and a queue only works if
|
|
5
|
+
* somebody looks at it. It was asynchronous with nothing to say it had moved:
|
|
6
|
+
* the handoff existed and the person did not know. This sends one message per
|
|
7
|
+
* new request — the role, the path, whose it is, and the command that answers
|
|
8
|
+
* it — to wherever the operator reads things (ntfy, Slack, a webhook).
|
|
9
|
+
*
|
|
10
|
+
* Approving is still a person at a terminal. This only notifies; it does not
|
|
11
|
+
* accept an answer, so it changes nothing about who decides. (Accepting one —
|
|
12
|
+
* nono's `approval-webhook-v1` — would, and is a separate decision.)
|
|
13
|
+
*
|
|
14
|
+
* Two rules, both from the review that found a role approving its own request
|
|
15
|
+
* through the console:
|
|
16
|
+
* - the POST leaves from the parent, never from the role: the role's process
|
|
17
|
+
* never holds the URL, and `SEISIN_NOTIFY_URL` is dropped from its
|
|
18
|
+
* environment even if the role names it;
|
|
19
|
+
* - the URL is not readable from any territory. A ntfy topic or a webhook is
|
|
20
|
+
* a credential — whoever can read it can send the person a fake "approve
|
|
21
|
+
* #3". So it lives in a key directory no role declares (`url_file`), or in
|
|
22
|
+
* the parent's environment, and never as a plain value in seisin.toml,
|
|
23
|
+
* which every role can read.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { basename, join } from "node:path";
|
|
27
|
+
import { keyOf, pending, requestsPath, grantFor } from "./requests.js";
|
|
28
|
+
|
|
29
|
+
export const FORMATS = ["text", "json", "slack"];
|
|
30
|
+
export const ENV_URL = "SEISIN_NOTIFY_URL";
|
|
31
|
+
|
|
32
|
+
/** The URL to notify, or null. Read in the parent only. */
|
|
33
|
+
export function notifyUrl(config, env = process.env) {
|
|
34
|
+
if (env[ENV_URL]) return env[ENV_URL].trim();
|
|
35
|
+
if (!config.notify?.urlFile) return null;
|
|
36
|
+
try {
|
|
37
|
+
const url = readFileSync(join(config.root, config.notify.urlFile), "utf8").trim();
|
|
38
|
+
return url || null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The body for one request, in the chosen format. */
|
|
45
|
+
export function message(config, req, number, format = "text") {
|
|
46
|
+
const owner = req.owners?.length ? `It belongs to ${req.owners.join(", ")}.` : "Nobody owns it.";
|
|
47
|
+
const text =
|
|
48
|
+
`seisin (${basename(config.root)}): ${req.role} was refused ${req.action} on ${req.target}. ${owner} ` +
|
|
49
|
+
`Approve: seisin grant ${number} · decline: seisin decline ${number}`;
|
|
50
|
+
if (format === "slack") return { type: "application/json", body: JSON.stringify({ text }) };
|
|
51
|
+
if (format === "json")
|
|
52
|
+
return {
|
|
53
|
+
type: "application/json",
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
text, repo: basename(config.root), number, role: req.role, action: req.action,
|
|
56
|
+
target: req.target, owners: req.owners ?? [], grant: grantFor(req),
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
return { type: "text/plain; charset=utf-8", body: text };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One per run. `maybe(req)` after a request is recorded; `settle()` before the
|
|
64
|
+
* run exits, so a message still in flight is not cut off.
|
|
65
|
+
*
|
|
66
|
+
* Only the first time a request appears — the queue already counts repeats,
|
|
67
|
+
* and a person pinged on every retry stops reading the pings.
|
|
68
|
+
*/
|
|
69
|
+
export function notifier(config, { env = process.env, fetchImpl = globalThis.fetch, timeoutMs = 3000 } = {}) {
|
|
70
|
+
const url = notifyUrl(config, env);
|
|
71
|
+
const format = env.SEISIN_NOTIFY_FORMAT && FORMATS.includes(env.SEISIN_NOTIFY_FORMAT)
|
|
72
|
+
? env.SEISIN_NOTIFY_FORMAT
|
|
73
|
+
: config.notify?.format ?? "text";
|
|
74
|
+
const file = requestsPath(config.root);
|
|
75
|
+
const known = url ? new Set(pending(file).map((p) => p.key)) : new Set();
|
|
76
|
+
const inflight = [];
|
|
77
|
+
const failures = [];
|
|
78
|
+
|
|
79
|
+
function maybe(req) {
|
|
80
|
+
if (!url) return false;
|
|
81
|
+
const key = keyOf(req);
|
|
82
|
+
if (known.has(key)) return false;
|
|
83
|
+
known.add(key);
|
|
84
|
+
const queue = pending(file);
|
|
85
|
+
const number = queue.findIndex((p) => p.key === key) + 1;
|
|
86
|
+
if (number < 1) return false;
|
|
87
|
+
const { type, body } = message(config, { ...req, owners: req.owners ?? queue[number - 1].owners }, number, format);
|
|
88
|
+
inflight.push(
|
|
89
|
+
fetchImpl(url, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "content-type": type },
|
|
92
|
+
body,
|
|
93
|
+
// A redirect would send the request to a place nobody configured.
|
|
94
|
+
redirect: "error",
|
|
95
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
96
|
+
})
|
|
97
|
+
.then((r) => { if (!r.ok) failures.push(`HTTP ${r.status}`); })
|
|
98
|
+
.catch((e) => failures.push(e.name === "TimeoutError" ? "timed out" : e.message)),
|
|
99
|
+
);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function settle(capMs = timeoutMs + 500) {
|
|
104
|
+
if (!inflight.length) return failures;
|
|
105
|
+
await Promise.race([Promise.allSettled(inflight), new Promise((ok) => setTimeout(ok, capMs))]);
|
|
106
|
+
return failures;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { enabled: !!url, maybe, settle };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `[notify]` from seisin.toml, refused rather than guessed when it would leak.
|
|
114
|
+
* Called by the config loader.
|
|
115
|
+
*/
|
|
116
|
+
export function readNotify(table, { path, root, keyDirs, roles, own }) {
|
|
117
|
+
if (table === undefined) return null;
|
|
118
|
+
if (own(table, "url") !== undefined)
|
|
119
|
+
throw new Error(
|
|
120
|
+
`${path}: [notify] url is readable by every role — seisin.toml is not a secret. ` +
|
|
121
|
+
`Put the URL in a file inside a key directory no role declares and name it with url_file, ` +
|
|
122
|
+
`or set ${ENV_URL} in the environment that starts seisin.`);
|
|
123
|
+
const urlFile = own(table, "url_file");
|
|
124
|
+
const format = own(table, "format") ?? "text";
|
|
125
|
+
if (!FORMATS.includes(format))
|
|
126
|
+
throw new Error(`${path}: [notify] format = "${format}" is not one of ${FORMATS.join(", ")}`);
|
|
127
|
+
if (urlFile === undefined) return { urlFile: null, format };
|
|
128
|
+
if (typeof urlFile !== "string" || !urlFile.trim() || urlFile.startsWith("/") || urlFile.split("/").includes(".."))
|
|
129
|
+
throw new Error(`${path}: [notify] url_file must be a path inside the repo`);
|
|
130
|
+
const inKeyDir = keyDirs.some((d) => urlFile.startsWith(d.replace(/\/+$/, "") + "/"));
|
|
131
|
+
if (!inKeyDir)
|
|
132
|
+
throw new Error(
|
|
133
|
+
`${path}: [notify] url_file "${urlFile}" is not inside a key directory, so every role can read ` +
|
|
134
|
+
`it. Move it under ${keyDirs[0] ?? "a [keys] dir"}.`);
|
|
135
|
+
// A file key is written bare (`netlify.txt`, meaning the first key directory)
|
|
136
|
+
// or with its directory (`shared/api.txt`) — the same resolution settingsFor uses.
|
|
137
|
+
const resolve = (raw) => (raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
|
|
138
|
+
const holders = Object.entries(roles)
|
|
139
|
+
.filter(([, r]) => r.keyEntries?.some((k) => k.kind === "file" && resolve(k.raw) === urlFile))
|
|
140
|
+
.map(([n]) => n);
|
|
141
|
+
if (holders.length)
|
|
142
|
+
throw new Error(
|
|
143
|
+
`${path}: [notify] url_file "${urlFile}" is declared as a key by ${holders.join(", ")} — ` +
|
|
144
|
+
`that role could read it and send the person a fake "approve". No role may hold it.`);
|
|
145
|
+
return { urlFile, format };
|
|
146
|
+
}
|
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
|
@@ -65,6 +65,18 @@ export function renderReport(report) {
|
|
|
65
65
|
const writes = collapseSidecars(r.writes).join(" ") || `${C.dim}nothing${C.off}`;
|
|
66
66
|
const keys = r.keys.length ? r.keys.join(" ") : `${C.dim}none${C.off}`;
|
|
67
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`);
|
|
68
80
|
lines.push(` ${" ".repeat(width)} ${C.green}keys${C.off} ${keys}\n\n`);
|
|
69
81
|
}
|
|
70
82
|
|
|
@@ -75,6 +87,23 @@ export function renderReport(report) {
|
|
|
75
87
|
lines.push("\n");
|
|
76
88
|
}
|
|
77
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
|
+
|
|
78
107
|
for (const w of report.warnings) {
|
|
79
108
|
lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
|
|
80
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()]
|