seisin 0.1.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/src/render.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Everything that decides how output looks, and nothing that decides anything else.
3
+ *
4
+ * This exists because the commands used to do both. A function that computes a
5
+ * verdict and paints it in the same breath can only be tested by running the
6
+ * binary and matching a regular expression against its stdout — which is what
7
+ * the suite was doing eight times, and which tests the renderer as much as the
8
+ * logic. Split apart, the decision returns data and the test reads a field.
9
+ *
10
+ * Nothing here reads config, touches the filesystem, or exits. If a change to
11
+ * this file could alter what seisin allows, it is in the wrong file.
12
+ */
13
+
14
+ /**
15
+ * ANSI codes, or empty strings when nobody is watching.
16
+ *
17
+ * Resolved once at import: a pipe does not become a terminal halfway through a
18
+ * command. `NO_COLOR` is honoured because this prints into logs and CI.
19
+ */
20
+ export const C =
21
+ process.stdout.isTTY && !process.env.NO_COLOR
22
+ ? {
23
+ dim: "\x1b[2m", b: "\x1b[1m", off: "\x1b[0m",
24
+ red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m",
25
+ }
26
+ : { dim: "", b: "", off: "", red: "", green: "", yellow: "", blue: "" };
27
+
28
+ export const out = (s) => process.stdout.write(s);
29
+ export const err = (s) => process.stderr.write(s);
30
+
31
+ /* ── the map ──────────────────────────────────────────────────────────── */
32
+
33
+ /** The `check` report, as text. Takes the object `inspect()` returns. */
34
+ export function renderReport(report) {
35
+ const lines = [`\n${C.b}${report.where}${C.off}\n\n`];
36
+ const width = Math.max(...report.roles.map((r) => r.name.length), 4);
37
+
38
+ for (const r of report.roles) {
39
+ const writes = r.writes.join(" ") || `${C.dim}nothing${C.off}`;
40
+ const keys = r.keys.length ? r.keys.join(" ") : `${C.dim}none${C.off}`;
41
+ lines.push(` ${C.b}${r.name.padEnd(width)}${C.off} ${C.blue}writes${C.off} ${writes}\n`);
42
+ lines.push(` ${" ".repeat(width)} ${C.green}keys${C.off} ${keys}\n\n`);
43
+ }
44
+
45
+ for (const w of report.warnings) {
46
+ lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
47
+ if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);
48
+ lines.push("\n");
49
+ }
50
+
51
+ // Dim, and last: a standing limit is not an alarm, but it is not a footnote
52
+ // either — it is the part of the map that is blank.
53
+ for (const l of report.limits ?? []) {
54
+ lines.push(` ${C.dim}${l.headline}${C.off}\n`);
55
+ if (l.detail) lines.push(` ${C.dim}${l.detail}${C.off}\n`);
56
+ lines.push("\n");
57
+ }
58
+ return lines.join("");
59
+ }
60
+
61
+ /* ── one verdict ──────────────────────────────────────────────────────── */
62
+
63
+ /** The `explain` answer, as text. Takes what `owners.explain()` returns. */
64
+ export function renderVerdict(role, action, target, verdict) {
65
+ const head = verdict.allowed ? `${C.green}allowed${C.off}` : `${C.yellow}denied${C.off}`;
66
+ return `\n ${head} ${C.b}${role}${C.off} ${action} ${target}\n ${C.dim}${verdict.reason}${C.off}\n\n`;
67
+ }
68
+
69
+ /* ── the log ──────────────────────────────────────────────────────────── */
70
+
71
+ /**
72
+ * One log entry, one line, always the same shape.
73
+ *
74
+ * Denials are amber and not red on purpose: a role stopped at its own border is
75
+ * the system working, and painting routine correctness as an error teaches
76
+ * people to stop reading the colour.
77
+ */
78
+ export function renderEntry(e) {
79
+ const mark =
80
+ e.verdict === "denied" ? `${C.yellow}denied ${C.off}`
81
+ : e.verdict === "observed" ? `${C.dim}seen ${C.off}`
82
+ : `${C.green}allowed${C.off}`;
83
+ const owners =
84
+ e.owners?.length && e.verdict === "denied" ? ` ${C.dim}→ ${e.owners.join(", ")}${C.off}` : "";
85
+ // Which writer saw this. The hook reports an attempt and can be talked
86
+ // around; the kernel reports a refusal and cannot. When the two disagree
87
+ // about a run, a reader needs to know which line is which without going to
88
+ // the file — so the one that carries more weight is the one that is marked.
89
+ const from = e.source === "kernel" ? ` ${C.dim}[kernel]${C.off}` : "";
90
+ return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${e.action} ${e.target}${owners}${from}\n`;
91
+ }
92
+
93
+ /* ── the scan ─────────────────────────────────────────────────────────── */
94
+
95
+ /**
96
+ * Two lists, never one.
97
+ *
98
+ * A provider-issued string and a line that merely mentions a password are
99
+ * different claims. Merging them is how a scanner earns a reputation for crying
100
+ * wolf, and the first real run of this one returned 200 findings of which 2
101
+ * mattered. The split is the whole reason the output is readable.
102
+ */
103
+ export function renderScan(result, keyDirs) {
104
+ const { certain, review, skipped, truncated } = result;
105
+ const links = result.links ?? [];
106
+ const where = keyDirs.length ? keyDirs.join(", ") : `${C.yellow}nowhere — [keys] dir is unset${C.off}`;
107
+ const lines = [`\n ${C.dim}protected: ${where}${C.off}\n\n`];
108
+
109
+ if (certain.length === 0 && review.length === 0 && links.length === 0) {
110
+ lines.push(` ${C.green}nothing credential-shaped outside the declared directories${C.off}\n\n`);
111
+ return lines.join("");
112
+ }
113
+
114
+ if (certain.length) {
115
+ lines.push(` ${C.red}${certain.length} credential(s)${C.off} — these shapes are issued, not written by accident\n\n`);
116
+ for (const h of certain) lines.push(` ${C.b}${h.file}${C.off}${C.dim}:${h.line}${C.off} ${h.shape}\n`);
117
+ lines.push("\n");
118
+ }
119
+
120
+ if (review.length) {
121
+ const byFile = new Map();
122
+ for (const h of review) byFile.set(h.file, (byFile.get(h.file) ?? 0) + 1);
123
+ lines.push(` ${C.yellow}${review.length} line(s) to look at${C.off} in ${byFile.size} file(s) — a secret-shaped name with a literal value\n\n`);
124
+ for (const [file, n] of [...byFile].slice(0, 15))
125
+ lines.push(` ${file}${C.dim}${n > 1 ? ` ×${n}` : ""}${C.off}\n`);
126
+ if (byFile.size > 15) lines.push(` ${C.dim}… and ${byFile.size - 15} more file(s)${C.off}\n`);
127
+ lines.push("\n");
128
+ }
129
+
130
+ // Links are their own finding, not a line with a secret-shaped name in it.
131
+ // What is reported is that the link leaves the repo — the destination is
132
+ // named and never opened, because the point is that it exists.
133
+ if (links.length) {
134
+ lines.push(` ${C.yellow}${links.length} symlink(s) out of the repo${C.off} — present in the tree, stored somewhere else\n\n`);
135
+ for (const h of links.slice(0, 15)) lines.push(` ${C.b}${h.file}${C.off} ${C.dim}→${C.off} ${h.shape}\n`);
136
+ lines.push("\n");
137
+ }
138
+
139
+ // What was thrown away matters as much as what was kept: it is the only way
140
+ // to tell a quiet scan from a broken one.
141
+ const quiet = [];
142
+ if (skipped.reference) quiet.push(`${skipped.reference} value(s) read from the environment`);
143
+ if (skipped.placeholder) quiet.push(`${skipped.placeholder} placeholder(s)`);
144
+ if (skipped.ignored) quiet.push(`${skipped.ignored} ignored path(s)`);
145
+ if (skipped.protectedDirs) quiet.push(`${skipped.protectedDirs} inside declared key dir(s)`);
146
+ if (quiet.length) lines.push(` ${C.dim}not reported: ${quiet.join(" · ")}${C.off}\n`);
147
+ if (truncated) lines.push(` ${C.yellow}stopped at the limit — there are more${C.off}\n`);
148
+ lines.push(` ${C.dim}seisin does not move these. Where a credential lives is your call.${C.off}\n\n`);
149
+ return lines.join("");
150
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * The queue of permissions an agent asked for and cannot have yet.
3
+ *
4
+ * ── Two words, kept apart on purpose ──
5
+ * **Refused** is what the boundary does. **Declined** is what a person does to
6
+ * a request. They used to be one word in one command: an entry read `first
7
+ * refused on src/api/x.ts` and `seisin deny` answered `refused frontend ✕
8
+ * src/api/**`. One means the kernel said no, the other means you did, and a
9
+ * reader had nothing to tell them apart by.
10
+ *
11
+ * The entry says **asked** now, because refused was not always true. Asking for
12
+ * a permission before reaching for it is reasonable, and nothing required a
13
+ * denial to have happened first — so the queue could print a sentence about an
14
+ * event that never occurred, in front of a person about to approve something.
15
+ * `seisin run` drops any request the policy does not actually refuse; what is
16
+ * left is "you cannot have this, and you asked", which holds whether the agent
17
+ * tried or simply asked.
18
+ *
19
+ * A denial already contains everything a request needs — the hook knows the
20
+ * role, the path and the owner — so the refusal leaves something behind that a
21
+ * person can act on in one command instead of in an editor. See
22
+ * `docs/permission-requests.md` for why approving is deliberately not something
23
+ * an agent can do.
24
+ *
25
+ * Append-only, beside the log, for the same reason: a decision that can be
26
+ * rewritten is not evidence. A request that is granted or refused is not
27
+ * deleted — it is followed by a line saying what happened to it.
28
+ */
29
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
30
+ import { dirname, join } from "node:path";
31
+ import { STATE_DIR, tomlString } from "./layout.js";
32
+ import { send } from "./spool.js";
33
+
34
+ export const REQUESTS_NAME = "requests.jsonl";
35
+
36
+ export function requestsPath(root) {
37
+ return join(root, STATE_DIR, REQUESTS_NAME);
38
+ }
39
+
40
+ /**
41
+ * Identity of a request: the same role wanting the same kind of access to the
42
+ * same place is one request asked twice, not two requests.
43
+ *
44
+ * The path is generalised to its directory on purpose. An agent denied on
45
+ * `src/api/a.ts` and then on `src/api/b.ts` is not asking two questions, and a
46
+ * queue that says it is becomes a queue nobody reads.
47
+ */
48
+ export function keyOf({ role, action, target }) {
49
+ // A line can arrive malformed — a killed process, or something inside the
50
+ // sandbox writing to the queue on purpose. Reading the queue must not be the
51
+ // thing that fails: `seisin run` prints it on the way out and the console
52
+ // polls it, so a crash here takes down the half of the tool a person uses.
53
+ const t = typeof target === "string" ? target : "";
54
+ const dir = action === "read" ? t : t.split("/").slice(0, -1).join("/") || ".";
55
+ return `${role}:${action}:${dir}`;
56
+ }
57
+
58
+ /** The glob a grant would add, derived from what was asked. */
59
+ export function grantFor({ action, target }) {
60
+ const t = typeof target === "string" ? target : "";
61
+ // A key keeps its directory when it has one. Stripping it turned a request
62
+ // for `shared/api.txt` into a grant of `api.txt`, which settingsFor then
63
+ // resolves against the FIRST key directory — so the person approves one file
64
+ // and another one of the same name is what gets read.
65
+ if (action === "read") return t.includes("/") ? t : t.replace(/^.*\//, "");
66
+ const dir = t.split("/").slice(0, -1).join("/");
67
+ return dir ? `${dir}/**` : t;
68
+ }
69
+
70
+ function write(file, entry) {
71
+ if (send("requests", entry)) return true;
72
+ try {
73
+ mkdirSync(dirname(file), { recursive: true });
74
+ appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
75
+ return true;
76
+ } catch {
77
+ // Same rule as the log: never take the agent down over bookkeeping.
78
+ return false;
79
+ }
80
+ }
81
+
82
+ /** Records that a role asked for something the policy denies it. Safe to call
83
+ * on every denial; `seisin run` drops anything the policy does not refuse. */
84
+ export function record(file, { role, action, target, owners }) {
85
+ return write(file, { kind: "asked", key: keyOf({ role, action, target }), role, action, target, owners: owners ?? [] });
86
+ }
87
+
88
+ /** Records a decision. `by` is always a person; there is no other caller. */
89
+ export function settle(file, key, decision, reason = "") {
90
+ return write(file, { kind: decision, key, reason });
91
+ }
92
+
93
+ /**
94
+ * Records what the execution plane last tried to do about a request, which is a
95
+ * different question from what a person decided about it.
96
+ *
97
+ * `settle` answers "did the authority change?". This answers "what happened to
98
+ * the work?" — handed to the owner, stopped by a cycle, held back by a budget.
99
+ * They are kept apart deliberately: a request whose handoff was throttled is
100
+ * still `pending`, because nobody has decided anything about it. Writing the
101
+ * outcome into `state` would make admission look like a verdict.
102
+ *
103
+ * It exists so that a refusal to run cannot be invisible. Admission may
104
+ * postpone work; it may not make work disappear from the queue a person reads.
105
+ */
106
+ export function recordHandoff(file, key, decision = {}) {
107
+ const { type, role, limit, max, reason, depth, chainId, revision } = decision;
108
+ return write(file, {
109
+ kind: "handoff", key, outcome: type ?? "unknown",
110
+ ...(role !== undefined && { role }),
111
+ ...(limit !== undefined && { limit }),
112
+ ...(max !== undefined && { max }),
113
+ ...(reason !== undefined && { reason }),
114
+ ...(depth !== undefined && { depth }),
115
+ ...(chainId != null && { chainId }),
116
+ ...(revision != null && { revision }),
117
+ });
118
+ }
119
+
120
+ /**
121
+ * The queue as it stands: one entry per distinct request, with how many times
122
+ * it was asked and what was decided.
123
+ *
124
+ * Reduced from the whole file rather than kept as state, so the file stays the
125
+ * only thing that has to be correct.
126
+ */
127
+ export function pending(file, { includeSettled = false } = {}) {
128
+ if (!existsSync(file)) return [];
129
+ const byKey = new Map();
130
+
131
+ for (const line of readFileSync(file, "utf8").split("\n")) {
132
+ if (!line.trim()) continue;
133
+ let e;
134
+ try {
135
+ e = JSON.parse(line);
136
+ } catch {
137
+ continue; // a half-written line from a killed process is not an error
138
+ }
139
+ if (!e.key) continue;
140
+
141
+ if (e.kind === "asked") {
142
+ const seen = byKey.get(e.key);
143
+ if (seen) { seen.times++; seen.last = e.at; continue; }
144
+ byKey.set(e.key, {
145
+ key: e.key, role: e.role, action: e.action, target: e.target,
146
+ owners: e.owners ?? [], grant: grantFor(e),
147
+ times: 1, first: e.at, last: e.at, state: "pending", reason: "",
148
+ });
149
+ continue;
150
+ }
151
+ const seen = byKey.get(e.key);
152
+ if (!seen) continue;
153
+
154
+ /**
155
+ * Two lifecycles in one file, and only one of them is `state`.
156
+ *
157
+ * Without this branch the catch-all below would set state = "handoff" and a
158
+ * throttled attempt would drop out of the pending queue — the exact silent
159
+ * disappearance the attempt is recorded to prevent.
160
+ */
161
+ if (e.kind === "handoff") {
162
+ seen.handoff = {
163
+ outcome: e.outcome, role: e.role, limit: e.limit, max: e.max,
164
+ reason: e.reason, depth: e.depth, chainId: e.chainId, at: e.at,
165
+ };
166
+ continue;
167
+ }
168
+
169
+ seen.state = e.kind; seen.reason = e.reason ?? ""; seen.decided = e.at;
170
+ }
171
+
172
+ const all = [...byKey.values()];
173
+ return includeSettled ? all : all.filter((r) => r.state === "pending");
174
+ }
175
+
176
+ /**
177
+ * Adds a granted glob to a role in the config text, with its provenance.
178
+ *
179
+ * Edits the text rather than re-emitting the file, because a config someone
180
+ * wrote has comments and an order that mean something, and a tool that
181
+ * reformats it on every grant is a tool people stop letting near it.
182
+ */
183
+ /**
184
+ * Adds a granted glob to a role in the config text, with its provenance.
185
+ *
186
+ * Edits the text rather than re-emitting the file, because a config someone
187
+ * wrote has comments and an order that mean something, and a tool that
188
+ * reformats it on every grant is a tool people stop letting near it.
189
+ *
190
+ * The edit is bounded to the role's own section, and that is not tidiness. The
191
+ * first version searched from the role header to the next `writes =` anywhere
192
+ * in the file, so approving for a role that had no `writes` line wrote the
193
+ * grant into the NEXT role — with a comment saying who it was for. A human
194
+ * approved one thing and the file recorded another. In a permission tool that
195
+ * is the worst possible bug, and it is not exotic: a role with only `keys`
196
+ * declared is an ordinary config.
197
+ */
198
+ export function applyGrant(toml, request, note = "") {
199
+ const field = request.action === "read" ? "keys" : "writes";
200
+ const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
201
+ const at = header.exec(toml);
202
+ if (!at) throw new Error(`no [roles.${request.role}] section to grant into`);
203
+
204
+ // The section runs from its header to the next table header, or to the end.
205
+ const from = at.index + at[0].length;
206
+ const next = /^\[[^\]]+\]\s*$/m.exec(toml.slice(from));
207
+ const to = next ? from + next.index : toml.length;
208
+ const section = toml.slice(from, to);
209
+
210
+ const line = new RegExp(`^(${field}\\s*=\\s*)\\[([^\\]]*)\\]`, "m");
211
+ const m = line.exec(section);
212
+ if (!m)
213
+ throw new Error(
214
+ `[roles.${request.role}] has no ${field} list to grant into. ` +
215
+ `Add \`${field} = []\` to that section first — seisin will not write it into another role's block.`
216
+ );
217
+
218
+ // The subset has no escapes, so a value carrying a quote or a newline cannot
219
+ // be written down at all. The strict parser would reject the result rather
220
+ // than widen anything — but leaving someone with a config that no longer
221
+ // loads, after they approved something, is its own kind of broken.
222
+ tomlString(request.grant); // refuses what the format cannot hold
223
+
224
+ const items = (m[2].match(/"[^"]*"/g) ?? []).map((s) => s.slice(1, -1));
225
+ if (items.includes(request.grant)) return { toml, changed: false };
226
+
227
+ const stamp = `# granted ${new Date().toISOString().slice(0, 10)} · asked ${request.times}×` +
228
+ `${note ? ` · "${cleanReason(note)}"` : ""}`;
229
+ const body = [...items, request.grant]
230
+ .map((v) => ` "${v}"${v === request.grant ? `, ${stamp}` : ","}`)
231
+ .join("\n")
232
+ .replace(/,(\s*#[^\n]*)?$/, "$1");
233
+
234
+ const edited = section.replace(line, `$1[\n${body}\n]`);
235
+ return { toml: toml.slice(0, from) + edited + toml.slice(to), changed: true };
236
+ }
237
+
238
+ /**
239
+ * The approver's own words, made safe to put in a config file.
240
+ *
241
+ * A reason is free text typed by a person, and a person can be talked into
242
+ * typing something — "paste this as the reason" is a plausible thing for an
243
+ * agent to suggest. Newlines in a comment would end the comment, and what
244
+ * follows is parsed as TOML. The strict parser rejects the result rather than
245
+ * widening anything, so the worst case is a config that no longer loads, but a
246
+ * permission file that can be broken by a sentence is still broken.
247
+ */
248
+ export function cleanReason(s) {
249
+ return String(s ?? "")
250
+ .replace(/[\r\n\t]/g, " ")
251
+ .replace(/["\\]/g, "'")
252
+ .replace(/\s+/g, " ")
253
+ .trim()
254
+ .slice(0, 240);
255
+ }
256
+
257
+ function escapeRe(s) {
258
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
259
+ }
package/src/review.js ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * What the log says about the policy.
3
+ *
4
+ * Everything here is arithmetic over `.seisin/log.jsonl` and the config. No
5
+ * model, no network, no heuristics that could be wrong in an interesting way —
6
+ * and that is deliberate rather than modest. This project's whole argument is
7
+ * that interpreting text is the wrong way to decide things; adding something
8
+ * that reads the log and forms an opinion would contradict it on the way in.
9
+ *
10
+ * Three questions, and the second is the one nothing else can answer.
11
+ *
12
+ * 1. What is a role being stopped by, over and over?
13
+ * Forty denials on one directory is not an agent misbehaving. It is a
14
+ * policy that is wrong, and today it reads as forty tidy amber lines
15
+ * nobody adds up.
16
+ *
17
+ * 2. What was granted and never used?
18
+ * Every permission file only ever grows, everywhere, and the reason is
19
+ * always the same: nobody can prove a line is dead, so the safe move is to
20
+ * leave it. Here the log can prove it. This is the only direction that
21
+ * makes a policy smaller.
22
+ *
23
+ * 3. What does nobody own?
24
+ * `explain` says it one path at a time. Added up, it is the shape of the
25
+ * hole in the map.
26
+ *
27
+ * Every answer carries the window it was computed over, because "never used"
28
+ * means nothing without "…in how long".
29
+ */
30
+ import { read, logPath } from "./log.js";
31
+ import { covers, ownersOf } from "./owners.js";
32
+
33
+ /**
34
+ * One reading of the log against the policy.
35
+ *
36
+ * `minDenials` is the point at which repetition stops being an accident. Three
37
+ * is low on purpose: the cost of mentioning it is a line, and the cost of
38
+ * missing it is a role that quietly cannot do its job.
39
+ */
40
+ export function review(config, { minDenials = 3 } = {}) {
41
+ const entries = read(logPath(config.root), { limit: 0 });
42
+
43
+ if (entries.length === 0)
44
+ return { window: null, entries: 0, friction: [], guarded: [], unused: [], unusedKnowable: false, unowned: [] };
45
+
46
+ const at = entries.map((e) => e.at).filter(Boolean).sort();
47
+ const window = { from: at[0], to: at[at.length - 1], entries: entries.length };
48
+
49
+ return { window, entries: entries.length, ...findings(config, entries, minDenials) };
50
+ }
51
+
52
+ function findings(config, entries, minDenials) {
53
+ /* ── 1. what keeps stopping a role ──────────────────────────────────── */
54
+
55
+ /**
56
+ * Two piles, because they are two different decisions and only one of them is
57
+ * "grant it".
58
+ *
59
+ * A role stopped at another role's territory is a policy question: either the
60
+ * territory is drawn wrong or the work belongs to somebody else. That is what
61
+ * the advice *grant, or move the territory* is for.
62
+ *
63
+ * A role stopped at a `[keys] dir` is **the policy working**. A key directory
64
+ * is closed to everyone — nobody holds it, so there is nobody to hand the work
65
+ * to — and it is the one place where a grant is never the answer.
66
+ *
67
+ * Counting them together was not cosmetic. Measured against a real repository:
68
+ * six of the eight lines at the top of this report were key directories, so
69
+ * the strongest recommendation this tool made about that repo was to grant two
70
+ * roles the credential directory. The distinction sat in the config the whole
71
+ * time and this report was the only thing not using it.
72
+ */
73
+ const stopped = new Map(); // role + directory -> how often
74
+ for (const e of entries) {
75
+ if (e.verdict !== "denied" || !e.target) continue;
76
+ const dir = e.action === "read" ? e.target : dirOf(e.target);
77
+ const key = `${e.role} ${e.action} ${dir}`;
78
+ const seen = stopped.get(key) ?? {
79
+ role: e.role, action: e.action, where: dir, times: 0,
80
+ owners: e.owners ?? [],
81
+ // Carried by the log from both writers — the hook sets it from the config
82
+ // and so does the parent for a kernel denial. Recomputing it from the path
83
+ // here would be a second opinion on a settled question.
84
+ kind: e.kind === "key" ? "key" : "file",
85
+ };
86
+ seen.times++;
87
+ stopped.set(key, seen);
88
+ }
89
+
90
+ const repeated = [...stopped.values()]
91
+ .filter((f) => f.times >= minDenials)
92
+ .sort((a, b) => b.times - a.times);
93
+
94
+ const friction = repeated.filter((f) => f.kind !== "key");
95
+ const guarded = repeated.filter((f) => f.kind === "key");
96
+
97
+ /* ── 2. what was granted and never used ─────────────────────────────── */
98
+
99
+ /**
100
+ * This question refuses to answer when the log cannot support an answer, and
101
+ * that refusal is the whole of the second finding.
102
+ *
103
+ * "Never used" is read off `allowed` lines, and **only the hook writes those**.
104
+ * The kernel reports what it refused; it has nothing to say about what went
105
+ * through. So a log with no hook behind it holds denials and nothing else,
106
+ * every grant falls through as unused, and this section reports that the whole
107
+ * policy is dead — under a heading calling itself the only evidence anyone
108
+ * will ever have for making a permission file smaller.
109
+ *
110
+ * Measured on a real repository with the hook not installed: every write
111
+ * permission of every role listed as never used, while those roles were
112
+ * working.
113
+ *
114
+ * It became reachable the day the kernel started writing here. Before that an
115
+ * unwired repo had an empty log and this section said nothing, which was
116
+ * accidentally right. A partial input is more dangerous than no input, because
117
+ * it looks like an answer.
118
+ *
119
+ * So: no `allowed` line in the window, no answer. `unused` stays empty rather
120
+ * than full — a consumer that has never heard of `unusedKnowable` then reports
121
+ * nothing instead of everything, which is the direction this has to fail in.
122
+ */
123
+ const knowable = entries.some((e) => e.verdict === "allowed");
124
+
125
+ // Only writes. A key that is declared and not read is ordinary — most roles
126
+ // hold a credential for the one turn a month that needs it — but a folder a
127
+ // role owns and has never written to in the whole log is a line nobody would
128
+ // miss, and the only evidence anyone will ever have for removing it.
129
+ const used = new Set();
130
+ for (const e of entries) {
131
+ if (e.verdict !== "allowed" || e.action !== "write" || !e.target) continue;
132
+ for (const role of Object.values(config.roles))
133
+ for (const glob of role.writes)
134
+ if (role.name === e.role && covers(glob, e.target)) used.add(`${role.name} ${glob}`);
135
+ }
136
+
137
+ const unused = [];
138
+ if (knowable)
139
+ for (const role of Object.values(config.roles))
140
+ for (const glob of role.writes)
141
+ if (!used.has(`${role.name} ${glob}`)) unused.push({ role: role.name, glob });
142
+
143
+ /* ── 3. what nobody owns ────────────────────────────────────────────── */
144
+
145
+ const unowned = new Map();
146
+ for (const e of entries) {
147
+ if (!e.target || e.kind === "key") continue;
148
+ if (ownersOf(config, e.target).length) continue;
149
+ const dir = dirOf(e.target);
150
+ unowned.set(dir, (unowned.get(dir) ?? 0) + 1);
151
+ }
152
+
153
+ return {
154
+ friction,
155
+ guarded,
156
+ unused,
157
+ unusedKnowable: knowable,
158
+ unowned: [...unowned.entries()]
159
+ .map(([where, times]) => ({ where, times }))
160
+ .sort((a, b) => b.times - a.times),
161
+ };
162
+ }
163
+
164
+ /** The directory a path sits in, or "." for a bare filename. */
165
+ function dirOf(p) {
166
+ return p.split("/").slice(0, -1).join("/") || ".";
167
+ }