seisin 0.1.1 → 0.2.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/mcp.js CHANGED
@@ -32,6 +32,8 @@ import { explain, ownersOf } from "./owners.js";
32
32
  import { settingsFor } from "./srt.js";
33
33
  import { pending, requestsPath, grantFor } from "./requests.js";
34
34
  import { read, logPath } from "./log.js";
35
+ import { causesOf } from "./serve.js";
36
+ import { walls, wasted } from "./walls.js";
35
37
 
36
38
  /**
37
39
  * Versions this server will agree to.
@@ -93,13 +95,35 @@ const TOOLS = [
93
95
  },
94
96
  },
95
97
  },
98
+ {
99
+ name: "seisin_causes",
100
+ description:
101
+ "Denials grouped by what was denied — by path, and one level coarser by the name at " +
102
+ "the end of it — with how many are on paths no role owns. This is the question " +
103
+ "`seisin_activity` cannot answer: the raw log has the events, this has them counted " +
104
+ "against the policy as it stands, so a cause that has been granted since stops " +
105
+ "counting. Reach for it before proposing a change: a day that is three quarters one " +
106
+ "filename is a tooling problem, and the same volume spread across unrelated paths is " +
107
+ "a question about who owns what. Read-only.",
108
+ inputSchema: { type: "object", properties: { limit: { type: "number" } } },
109
+ },
110
+ {
111
+ name: "seisin_walls",
112
+ description:
113
+ "What a role keeps being denied AND would still be denied today, with the calls it " +
114
+ "spent retrying. Recomputed against the policy rather than read out of the log, so " +
115
+ "something granted since is not listed. Use it to find out whether an agent is stuck " +
116
+ "rather than slow. Omit `role` for every role that has one. Read-only.",
117
+ inputSchema: { type: "object", properties: { role: { type: "string" } } },
118
+ },
96
119
  {
97
120
  name: "seisin_draft_grant",
98
121
  description:
99
122
  "Draft the change a pending request would make, as text, plus the command a person " +
100
123
  "runs to apply it. Reach for this after a refusal: the denial is already in " +
101
124
  "seisin_requests, and this turns it into a concrete proposal someone can approve. " +
102
- "Writes nothing: the approval is a human action in another channel.",
125
+ "Read-only, and it is the tool where that matters most: the name says grant and it " +
126
+ "does not grant. The approval is a human action in another channel.",
103
127
  inputSchema: {
104
128
  type: "object",
105
129
  properties: { number: { type: "number", description: "position in seisin_requests" } },
@@ -166,6 +190,40 @@ const HANDLERS = {
166
190
  };
167
191
  },
168
192
 
193
+ /**
194
+ * The same arithmetic the console's front page does.
195
+ *
196
+ * It lives here as well as there because the two readers are different and
197
+ * neither can do the other's work: a person opens the console, an agent
198
+ * calls this. Giving the agent only `seisin_activity` hands it the raw log
199
+ * and asks it to re-derive the grouping — without the policy, which is the
200
+ * half that makes the grouping mean anything.
201
+ */
202
+ seisin_causes({ limit }) {
203
+ const cfg = config();
204
+ const c = causesOf(cfg, read(logPath(cfg.root), { limit: 4000 }));
205
+ return {
206
+ total: c.total,
207
+ distinct: c.distinct,
208
+ unowned: c.unowned,
209
+ families: c.families,
210
+ causes: typeof limit === "number" ? c.causes.slice(0, limit) : c.causes,
211
+ };
212
+ },
213
+
214
+ seisin_walls({ role }) {
215
+ const cfg = config();
216
+ const file = logPath(cfg.root);
217
+ const names = role ? [role] : Object.keys(cfg.roles);
218
+ if (role && !cfg.roles[role]) throw new Error(`unknown role "${role}"`);
219
+ const out = {};
220
+ for (const r of names) {
221
+ const w = walls(cfg, r, { file });
222
+ if (w.length) out[r] = { walls: w, spentRetrying: wasted(w) };
223
+ }
224
+ return { roles: out, spentRetrying: Object.values(out).reduce((n, x) => n + x.spentRetrying, 0) };
225
+ },
226
+
169
227
  seisin_activity({ role, verdict, limit }) {
170
228
  if (verdict !== undefined && !["allowed", "denied", "observed"].includes(verdict))
171
229
  throw new Error(`verdict must be allowed, denied or observed, got ${JSON.stringify(verdict)}`);
package/src/nested.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Agents that sandbox themselves, and why seisin has to say so first.
3
+ *
4
+ * `codex` confines every command it runs with its own `sandbox-exec` profile.
5
+ * macOS will not apply a second Seatbelt profile to a process that already has
6
+ * one, so inside `seisin run` it fails with:
7
+ *
8
+ * sandbox-exec: sandbox_apply: Operation not permitted
9
+ *
10
+ * Measured, twice: the README has it from the original run and the lab
11
+ * reproduced it — exit 71, and the inner command never produced a byte.
12
+ *
13
+ * Two things make it a special case in a tool that otherwise has none.
14
+ *
15
+ * **It does not fail at the start.** The agent launches, reads, thinks, and
16
+ * dies on the first command it tries to confine. So the operator sees a turn
17
+ * that did nothing, which reads exactly like an agent with nothing to do.
18
+ *
19
+ * **And the error explains nothing.** It names neither seisin, nor the agent,
20
+ * nor the fix. For a tool whose whole claim is that a refusal tells you what
21
+ * happened, shipping the one message that says nothing is the worst available
22
+ * outcome — so this is checked before the spawn and said in words.
23
+ *
24
+ * It is a closed list, not a detector. Guessing which binaries sandbox
25
+ * themselves from their name would be wrong in both directions, and being
26
+ * wrong here means printing a warning about a thing that is not happening.
27
+ */
28
+
29
+ /**
30
+ * What each agent does by default, and what turns it off.
31
+ *
32
+ * `on: true` means the agent confines by default, so the warning is the
33
+ * normal case and silence needs a flag. codex is that. gemini is the other
34
+ * way round: off unless asked. Getting this backwards means warning about a
35
+ * thing that is not happening, which trains people to skip the line.
36
+ *
37
+ * `opencode` is absent. It has been run under seisin and the
38
+ * README describes its behaviour, but nothing here has measured whether it
39
+ * confines its own commands, and a closed list holds only what was checked.
40
+ */
41
+ const SELF_SANDBOXING = {
42
+ codex: {
43
+ on: true,
44
+ off: ["--dangerously-bypass-approvals-and-sandbox"],
45
+ // The subcommand whose entire job is to confine. No flag turns that off.
46
+ never: ["sandbox"],
47
+ },
48
+ gemini: { on: false, onWith: ["-s", "--sandbox"], off: ["--sandbox=false"] },
49
+ };
50
+
51
+ /** The basename of a command, without its path. */
52
+ const bare = (c) => String(c ?? "").split("/").pop();
53
+
54
+ /**
55
+ * The warning for this command line, or null.
56
+ *
57
+ * Takes the argv `run` is about to spawn, and looks only at the first token
58
+ * and its flags. Anything cleverer would be guessing at a shell it cannot
59
+ * parse, and a wrong warning is worse than no warning.
60
+ */
61
+ export function nestedSandboxWarning(cmd = []) {
62
+ const name = bare(cmd[0]);
63
+ const spec = Object.prototype.hasOwnProperty.call(SELF_SANDBOXING, name) ? SELF_SANDBOXING[name] : null;
64
+ if (!spec) return null;
65
+
66
+ const rest = cmd.slice(1);
67
+ if ((spec.never ?? []).includes(rest[0]))
68
+ return (
69
+ `\`${name} ${rest[0]}\` exists to run a command inside a sandbox, so it cannot run inside ` +
70
+ `one. The OS will not apply a second profile to a process that already has one.\n` +
71
+ ` Run the command directly — seisin is already the boundary here.`
72
+ );
73
+
74
+ const turnedOff = (spec.off ?? []).some((f) => rest.some((a) => a === f || a.startsWith(f + "=")));
75
+ if (turnedOff) return null;
76
+ const confining = spec.on || (spec.onWith ?? []).some((f) => rest.includes(f));
77
+ if (!confining) return null;
78
+
79
+ return (
80
+ `${name} confines the commands it runs with its own sandbox, and the OS will not apply a ` +
81
+ `second profile to a process that already has one.\n` +
82
+ ` This does not fail here — it fails on the first command ${name} tries to run, which ` +
83
+ `looks like an agent that did nothing.\n` +
84
+ ` Run it with its own sandbox off: \`${name} … ${(spec.off ?? ["--no-sandbox"])[0]}\`.`
85
+ );
86
+ }
package/src/render.js CHANGED
@@ -30,18 +30,51 @@ 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`);
42
68
  lines.push(` ${" ".repeat(width)} ${C.green}keys${C.off} ${keys}\n\n`);
43
69
  }
44
70
 
71
+ if (report.providers?.length) {
72
+ lines.push(` ${C.b}key providers${C.off} ${C.dim}— seisin runs these itself, outside the sandbox, as you${C.off}\n`);
73
+ for (const p of report.providers)
74
+ lines.push(` ${C.green}${p.name}${C.off} ${p.command.join(" ")}${p.mode ? ` ${C.dim}(${p.mode})${C.off}` : ""}\n`);
75
+ lines.push("\n");
76
+ }
77
+
45
78
  for (const w of report.warnings) {
46
79
  lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
47
80
  if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);
package/src/serve.js CHANGED
@@ -19,6 +19,8 @@ import { loadConfig } from "./config.js";
19
19
  import { read, logPath } from "./log.js";
20
20
  import { settingsFor } from "./srt.js";
21
21
  import { pending, requestsPath, settle, applyGrant } from "./requests.js";
22
+ import { walls } from "./walls.js";
23
+ import { explain } from "./owners.js";
22
24
 
23
25
  const HERE = dirname(fileURLToPath(import.meta.url));
24
26
 
@@ -29,9 +31,124 @@ const HERE = dirname(fileURLToPath(import.meta.url));
29
31
  * page is a view of it — someone editing seisin.toml in an editor should see
30
32
  * the console change, not wonder why it disagrees.
31
33
  */
34
+ /**
35
+ * Today's denials, grouped by what was denied rather than by who asked.
36
+ *
37
+ * Called `causes` and not `friction`: `seisin review` already has a `friction`
38
+ * that counts something narrower — repeated denials that are not about keys —
39
+ * and two screens of one product reporting different numbers under one label
40
+ * is the kind of thing nobody notices until they are compared.
41
+ *
42
+ * The console used to show them per role, which is the same list a hundred
43
+ * times over: measured on one deployment, 1,080 of 1,422 refusals were a
44
+ * single lock file across six repositories, and per-role that reads as six
45
+ * hundred identical rows instead of one sentence. Grouped, the shape of the
46
+ * day is visible at a glance and it is almost never a territory dispute.
47
+ *
48
+ * `stillRefused` is recomputed against the policy as it stands, not read out
49
+ * of the log — a cause that has been granted since is history, not friction,
50
+ * and leaving it on the page sends somebody to fix what is already fixed.
51
+ */
52
+ export function causesOf(cfg, entries) {
53
+ const by = new Map();
54
+ for (const e of entries) {
55
+ if (e.verdict !== "denied" || !e.target || !e.action) continue;
56
+ const k = `${e.action}\u0000${e.target}`;
57
+ const g = by.get(k) ?? { action: e.action, target: e.target, times: 0, roles: new Set(), owners: e.owners ?? [] };
58
+ g.times++;
59
+ g.roles.add(e.role);
60
+ by.set(k, g);
61
+ }
62
+ const total = [...by.values()].reduce((n, g) => n + g.times, 0);
63
+
64
+ /**
65
+ * The same grouping again, one level coarser: by the NAME at the end of the
66
+ * path rather than the path.
67
+ *
68
+ * Grouping by path alone gets the most important reading backwards. Measured
69
+ * here: six of the top seven causes were `.git/index.lock` in six different
70
+ * repositories, no single one above 17% — so "no cause dominates" is what the
71
+ * arithmetic says and the opposite of what is true. Three quarters of the
72
+ * window was one *kind* of thing, and that is a tooling problem with a
73
+ * mechanical fix, not a territory question anybody needs to rule on.
74
+ *
75
+ * By last segment, and nothing cleverer. A regex that recognised lock files,
76
+ * caches and build outputs would be a list of guesses about other people's
77
+ * toolchains that quietly goes stale; a repeated filename is a fact about
78
+ * this log.
79
+ */
80
+ const fams = new Map();
81
+ for (const g of by.values()) {
82
+ const name = g.target.split("/").filter(Boolean).pop() ?? g.target;
83
+ const f = fams.get(name) ?? { name, times: 0, paths: 0, where: [], roles: new Set() };
84
+ f.times += g.times;
85
+ f.paths++;
86
+ // The places, so the console can show what a name is made of instead of
87
+ // asserting a percentage the reader has to take on faith.
88
+ f.where.push({ target: g.target, times: g.times });
89
+ for (const r of g.roles) f.roles.add(r);
90
+ fams.set(name, f);
91
+ }
92
+ const families = [...fams.values()]
93
+ .sort((a, b) => b.times - a.times)
94
+ .map((f) => ({
95
+ name: f.name, times: f.times, paths: f.paths,
96
+ share: total ? f.times / total : 0,
97
+ roles: [...f.roles].sort(),
98
+ where: f.where.sort((a, b) => b.times - a.times).slice(0, 20),
99
+ }));
100
+
101
+ /**
102
+ * What a `grep` over the log cannot tell you.
103
+ *
104
+ * The headline used to be "74% of it is one name", and a field review
105
+ * applied this project's own test to it: *did the number tell you something
106
+ * you did not know?* For somebody who reads the raw log, no — they had
107
+ * already counted that. The console was repeating the log back.
108
+ *
109
+ * These two are different, because they need the **policy** and the log does
110
+ * not contain it. `unowned` is the count of causes on paths no role claims:
111
+ * no grant resolves those until somebody decides who owns them, which is a
112
+ * decision rather than a number. `settled` is friction that has since been
113
+ * granted — real yesterday, noise today.
114
+ */
115
+ const live = [...by.values()];
116
+ const unowned = live.filter((g) => !(g.owners ?? []).length).length;
117
+
118
+ return {
119
+ total,
120
+ unowned,
121
+ // The real number of distinct causes, not the length of the list below.
122
+ // The page says "over N distinct paths" and the list is capped at twelve,
123
+ // so taking N from the list reported the cap as if it were the count —
124
+ // a wrong number stated confidently, which is worse than no number.
125
+ distinct: by.size,
126
+ families: families.slice(0, 8),
127
+ causes: [...by.values()]
128
+ .sort((a, b) => b.times - a.times)
129
+ .slice(0, 12)
130
+ .map((g) => ({
131
+ action: g.action,
132
+ target: g.target,
133
+ times: g.times,
134
+ share: total ? g.times / total : 0,
135
+ roles: [...g.roles].sort(),
136
+ owners: g.owners,
137
+ // How many of the roles that hit this would still hit it. `some` and
138
+ // not `every`: two roles out of three still blocked is still friction,
139
+ // and requiring all of them would quietly retire a live cause the day
140
+ // one role got a grant.
141
+ stillRefused: [...g.roles].filter((r) => cfg.roles[r] && !explain(cfg, r, g.action, g.target).allowed).length,
142
+ })),
143
+ };
144
+ }
145
+
32
146
  function state(configPath) {
33
147
  const cfg = loadConfig(configPath);
34
148
  const entries = read(logPath(cfg.root), { limit: 200 });
149
+ // A wider window than the activity tail, because grouping by cause is an
150
+ // arithmetic question and 200 lines of a busy day is one role's morning.
151
+ const forShape = read(logPath(cfg.root), { limit: 4000 });
35
152
 
36
153
  const roles = Object.values(cfg.roles).map((r) => ({
37
154
  name: r.name,
@@ -54,6 +171,13 @@ function state(configPath) {
54
171
  keyDirs: cfg.keyDirs,
55
172
  allowedDomains: cfg.allowedDomains,
56
173
  roles,
174
+ causes: causesOf(cfg, forShape),
175
+ // What each role keeps being refused AND would still be refused today.
176
+ // Empty for a role that has hit nothing twice, which is most of them.
177
+ walls: Object.fromEntries(
178
+ Object.keys(cfg.roles)
179
+ .map((r) => [r, walls(cfg, r, { file: logPath(cfg.root) })])
180
+ .filter(([, w]) => w.length)),
57
181
  log: entries.slice(-60).reverse(),
58
182
  live: true,
59
183
  };
@@ -114,8 +238,18 @@ export function serve(configPath, port = 4178) {
114
238
  */
115
239
  const token = randomBytes(24).toString("hex");
116
240
 
241
+ /**
242
+ * The path, without whatever came after `?`.
243
+ *
244
+ * `req.url` is the raw request target, so a query string made every route
245
+ * miss: `http://localhost:4178/?anything` answered 404 on the one page this
246
+ * server has. Harmless until a launcher appends a parameter or a link is
247
+ * shared with a suffix on it, and then the console just looks broken.
248
+ */
249
+ const pathOf = (u) => (u ?? "/").split("?")[0];
250
+
117
251
  const server = createServer(async (req, res) => {
118
- if (req.method === "POST" && req.url === "/api/decide") {
252
+ if (req.method === "POST" && pathOf(req.url) === "/api/decide") {
119
253
  if (req.headers["x-seisin-token"] !== token) {
120
254
  res.writeHead(403, { "content-type": "application/json" });
121
255
  return res.end(JSON.stringify({ error: "bad or missing token" }));
@@ -133,7 +267,7 @@ export function serve(configPath, port = 4178) {
133
267
  res.writeHead(405).end("method not allowed");
134
268
  return;
135
269
  }
136
- if (req.url === "/api/state") {
270
+ if (pathOf(req.url) === "/api/state") {
137
271
  let body;
138
272
  try {
139
273
  body = JSON.stringify(state(configPath));
@@ -145,7 +279,7 @@ export function serve(configPath, port = 4178) {
145
279
  res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
146
280
  return res.end(body);
147
281
  }
148
- if (req.url === "/" || req.url === "/index.html") {
282
+ if (pathOf(req.url) === "/" || pathOf(req.url) === "/index.html") {
149
283
  // Inlined rather than put in the URL: a fragment survives in history, in
150
284
  // a screenshot, and in whatever the operator pastes into a chat.
151
285
  const html = readFileSync(page, "utf8")
package/src/srt.js CHANGED
@@ -16,6 +16,7 @@
16
16
  import { join, dirname } from "node:path";
17
17
  import { STATE_DIR, CONFIG_NAME } from "./layout.js";
18
18
  import { homedir, tmpdir } from "node:os";
19
+ import { entriesOf } from "./keys.js";
19
20
  import { realpathSync, lstatSync } from "node:fs";
20
21
  import { createHash } from "node:crypto";
21
22
 
@@ -283,7 +284,17 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
283
284
  denyRead.push(realOrSelf(roleHomeRoot(config)));
284
285
  allowRead.push(realOrSelf(home));
285
286
  }
286
- for (const key of role.keys) {
287
+ /**
288
+ * Only a key that IS a file becomes a read grant.
289
+ *
290
+ * A reference has no path, so there is nothing here to allow: it is resolved
291
+ * by the parent before the sandbox starts and handed over as an environment
292
+ * variable or as a scratch file. See keys.js. Filtering here rather than
293
+ * upstream keeps the two kinds from having to know about each other — the
294
+ * settings this function emits are still, entirely, about the filesystem.
295
+ */
296
+ for (const entry of entriesOf(role).filter((e) => e.kind === "file")) {
297
+ const key = entry.raw;
287
298
  const path = key.includes("/") ? abs(key) : abs(join(dirs[0] ?? ".", key));
288
299
  // A key has to live in a declared key directory. Without this check a
289
300
  // slash in the name made the path relative to the repo root instead, so
@@ -373,11 +384,47 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
373
384
  // Connecting is `network-outbound` and both of those are
374
385
  // `file-write-unlink`, so denying them leaves the first working.
375
386
  ...(spool ? [spool, dirname(spool)] : []),
387
+ /**
388
+ * Whatever the key providers execute.
389
+ *
390
+ * `seisin.toml` is denied because a role that can rewrite the policy
391
+ * has no policy. A provider command is the same thing one level out:
392
+ * the parent runs it, unsandboxed, as you — so a role that can rewrite
393
+ * `bin/open-vault.sh` decides what runs outside the box. That is not a
394
+ * wider boundary, it is no boundary, and it arrives disguised as an
395
+ * ordinary file in somebody's territory.
396
+ *
397
+ * Found by writing the documentation: every worked example ended up
398
+ * using a script, because the config language has no escapes and a
399
+ * one-line shell pipeline cannot be spelled. So the shape this protects
400
+ * is not a corner case — it is the shape the tool pushes you into.
401
+ *
402
+ * Only a command that resolves to a path. A bare name like `security`
403
+ * or `op` is found on PATH, which is not this policy's to reason about.
404
+ */
405
+ ...providerPaths(config),
376
406
  ],
377
407
  },
378
408
  };
379
409
  }
380
410
 
411
+ /**
412
+ * The provider executables that live at a path, absolute.
413
+ *
414
+ * A command with no separator (`security`, `op`, `gpg`) is resolved by the OS
415
+ * from PATH and is deliberately left alone: denying "wherever gpg happens to
416
+ * be" would mean writing a rule about a machine rather than about a repo.
417
+ */
418
+ export function providerPaths(config) {
419
+ const out = [];
420
+ for (const p of Object.values(config.keyProviders ?? {})) {
421
+ const cmd = p.command?.[0];
422
+ if (!cmd || !cmd.includes("/")) continue;
423
+ out.push(cmd.startsWith("/") ? cmd : join(config.root ?? ".", cmd));
424
+ }
425
+ return [...new Set(out)];
426
+ }
427
+
381
428
  /**
382
429
  * `allowWrite` takes directories, not globs: the OS grants a subtree, it does
383
430
  * not pattern-match. `src/api/**` and `src/api/` mean the same thing to the
package/src/walls.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The walls a role has already hit.
3
+ *
4
+ * seisin answers *whose is this* at the moment of the refusal, once, in the
5
+ * middle of a turn, and then the sentence is gone. Nothing carries it forward,
6
+ * so the agent tries again — and the measurement that made this module exist
7
+ * says how often: of 345 denials on a real team, **88 (25%) were a repeat of
8
+ * something that same role had already been refused**. One role spent 37 calls
9
+ * on two walls, hitting the same one nineteen times.
10
+ *
11
+ * A block that repeats nineteen times is not a boundary working. It is a
12
+ * boundary that failed to communicate. The refusal was correct every time and
13
+ * still cost thirty-seven calls, because *correct* and *heard* are different
14
+ * properties and only one of them was being measured.
15
+ *
16
+ * So this reads what the role has already been told and hands it back, with
17
+ * the reason — the reason is the part that lets it look for the other way in.
18
+ *
19
+ * ## Why it recomputes instead of trusting the log
20
+ *
21
+ * The obvious version of this is a time window: only count the last two days,
22
+ * because an old wall may have been granted since and telling an agent not to
23
+ * do something it can now do is worse than saying nothing. That is what the
24
+ * prior art does, and the window is a proxy for the thing you actually want to
25
+ * know.
26
+ *
27
+ * seisin can ask the real question instead. The policy is right here, so every
28
+ * candidate is checked against it **now**: if the role would be allowed today,
29
+ * it is not a wall, whatever the log says. Same move as `owners` and the
30
+ * request queue — recompute rather than believe — and it means a grant makes
31
+ * its wall disappear on the next turn instead of two days later.
32
+ *
33
+ * A window is still accepted, and still useful for a different reason: a
34
+ * refusal from three weeks ago is noise even if it is still refused today.
35
+ */
36
+ import { openSync, readSync, closeSync, statSync } from "node:fs";
37
+ import { read } from "./log.js";
38
+ import { explain } from "./owners.js";
39
+
40
+ /** A wall is something you hit more than once. Once is information; twice is a pattern. */
41
+ export const MIN_HITS = 2;
42
+
43
+ /**
44
+ * What `role` keeps being denied, most-repeated first.
45
+ *
46
+ * Each entry is `{ action, target, times, reason, owners, firstAt, lastAt }`.
47
+ * The grain is action+target, not the directory above it: two writes into two
48
+ * different repositories are two walls, and merging them hides which one was
49
+ * hit.
50
+ */
51
+ export function walls(config, role, { file, min = MIN_HITS, since = null, limit = 6 } = {}) {
52
+ const denied = read(file, { role, verdict: "denied", ...(since ? { since } : {}) });
53
+
54
+ const byKey = new Map();
55
+ for (const e of denied) {
56
+ if (!e.target || !e.action) continue;
57
+ const key = `${e.action}\u0000${e.target}`;
58
+ const seen = byKey.get(key);
59
+ if (seen) { seen.times++; seen.lastAt = e.at; continue; }
60
+ byKey.set(key, { action: e.action, target: e.target, times: 1, firstAt: e.at, lastAt: e.at });
61
+ }
62
+
63
+ const out = [];
64
+ for (const w of byKey.values()) {
65
+ if (w.times < min) continue;
66
+ // The policy as it stands, not as it stood. A granted wall is not a wall,
67
+ // and the whole point of saying this to an agent is that it is true now.
68
+ const verdict = explain(config, role, w.action, w.target);
69
+ if (verdict.allowed) continue;
70
+ out.push({ ...w, reason: verdict.reason, owners: verdict.owners });
71
+ }
72
+ out.sort((a, b) => b.times - a.times || a.target.localeCompare(b.target));
73
+ return limit > 0 ? out.slice(0, limit) : out;
74
+ }
75
+
76
+ /**
77
+ * How much of the log `timesHit` will look at.
78
+ *
79
+ * The log is append-only and never rotates — that is the storage design and it
80
+ * is the right one — so "read the file" is O(everything you have ever done),
81
+ * on a path that runs inside the hook. Measured on a 372 KB log: 3 ms per
82
+ * call, which is nothing, and 300 ms at 37 MB, which is not, on every refusal
83
+ * of every turn forever.
84
+ *
85
+ * So it reads the tail. That makes the count **recent repetition** rather than
86
+ * lifetime repetition, and recent is the one the sentence is about anyway: an
87
+ * agent being told "you have hit this three times" means this session, not
88
+ * last month. 4 MB is thousands of entries — far more than a turn produces,
89
+ * and bounded.
90
+ */
91
+ const TAIL_BYTES = 4 * 1024 * 1024;
92
+
93
+ /**
94
+ * How many times this exact denial is already in the recent log for this role.
95
+ *
96
+ * Deliberately NOT the same question as `walls()`: no policy recomputation, no
97
+ * threshold. The hook calls this while deciding, and the only thing it needs is
98
+ * a count — the policy has just been consulted, so asking it again would be
99
+ * asking a question already answered, in the one place that runs on every tool
100
+ * call.
101
+ */
102
+ export function timesHit(file, role, action, target) {
103
+ let text;
104
+ try {
105
+ const { size } = statSync(file);
106
+ const from = Math.max(0, size - TAIL_BYTES);
107
+ const fd = openSync(file, "r");
108
+ try {
109
+ const buf = Buffer.alloc(size - from);
110
+ readSync(fd, buf, 0, buf.length, from);
111
+ text = buf.toString("utf8");
112
+ } finally {
113
+ closeSync(fd);
114
+ }
115
+ } catch {
116
+ return 0; // no log yet is not an error; it is a first turn
117
+ }
118
+ const lines = text.split("\n");
119
+ // The first line of a tail read is usually half an entry. Dropping it costs
120
+ // one count at most and keeps a truncated line from being parsed as if it
121
+ // were whole.
122
+ if (from0(text, file)) lines.shift();
123
+ let n = 0;
124
+ for (const line of lines) {
125
+ if (!line.trim()) continue;
126
+ let e;
127
+ try { e = JSON.parse(line); } catch { continue; }
128
+ if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) n++;
129
+ }
130
+ return n;
131
+ }
132
+
133
+ /** Did we start mid-file? Then the first line is a fragment. */
134
+ function from0(text, file) {
135
+ try { return statSync(file).size > text.length; } catch { return false; }
136
+ }
137
+
138
+ /**
139
+ * The calls that went into hitting the same wall twice or more.
140
+ *
141
+ * `times - 1` per wall, because the first one is the agent finding out. Every
142
+ * one after that is the cost of not having been able to remember.
143
+ */
144
+ export function wasted(list) {
145
+ return list.reduce((n, w) => n + w.times - 1, 0);
146
+ }
147
+
148
+ /**
149
+ * The block an agent reads, or `""` when there is nothing to say.
150
+ *
151
+ * Empty rather than "no walls found", because this goes into a prompt: a line
152
+ * that says nothing happened, every turn, is how an agent learns to skip the
153
+ * whole section — and then the one turn it matters, it is skipped too.
154
+ */
155
+ export function render(list) {
156
+ if (!list.length) return "";
157
+ const lines = [
158
+ "WALLS — you have already been denied these, and the policy still denies them.",
159
+ "Do not retry; the reason is where the other way in is.",
160
+ ];
161
+ for (const w of list) {
162
+ lines.push(` ${w.times}× ${w.action} ${w.target}`);
163
+ lines.push(` ${w.reason}`);
164
+ }
165
+ const n = wasted(list);
166
+ // Said to the agent, not about it. One that can see what retrying cost has a
167
+ // reason to stop; one told "do not retry" has an instruction.
168
+ if (n > 0) lines.push(` (${n} of your calls went into retrying these.)`);
169
+ return lines.join("\n") + "\n";
170
+ }