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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it blocks it tells you whose file it was.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "license": "MIT",
19
19
  "author": "Carlos Tapia",
20
- "homepage": "https://github.com/carlostapiaolguin3-stack/seisin#readme",
20
+ "homepage": "https://carlostapiaolguin3-stack.github.io/seisin/",
21
21
  "repository": {
22
22
  "type": "git",
23
23
  "url": "git+https://github.com/carlostapiaolguin3-stack/seisin.git"
package/src/cli.js CHANGED
@@ -30,6 +30,7 @@ import { hook } from "./commands/hook.js";
30
30
  import { ui } from "./commands/ui.js";
31
31
  import { requests, grant, deny } from "./commands/requests.js";
32
32
  import { whose } from "./commands/whose.js";
33
+ import { wallsCommand } from "./commands/walls.js";
33
34
  import { reviewCommand } from "./commands/review.js";
34
35
  import { wire } from "./commands/wire.js";
35
36
  import { serveMcp } from "./mcp.js";
@@ -47,10 +48,11 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
47
48
  seisin whose <path> who owns it — safe to call from inside the box
48
49
  seisin scan find secrets outside the declared key dirs
49
50
  seisin review what the log says about the policy
51
+ seisin walls <role> what that role keeps being denied, and what it cost
50
52
  seisin wire let the agent record what it does
51
- seisin requests what the agents asked for and could not do
53
+ seisin requests what the agents asked for and cannot have
52
54
  seisin grant <n> [--reason "…"] approve one, with its provenance
53
- seisin deny <n> [--reason "…"] refuse one, and record why
55
+ seisin decline <n> [--reason "…"] turn one down, and record why
54
56
  seisin log [--role r] [--verdict denied]
55
57
  seisin watch follow the log live
56
58
  seisin init [--from-observations] propose a ${CONFIG_NAME} for this repo
@@ -90,8 +92,19 @@ const COMMANDS = {
90
92
  whose: () => void whose(config(), argv),
91
93
  wire: () => void wire(config()),
92
94
  review: () => (reviewCommand(config(), argv).friction.length ? 1 : 0),
95
+ walls: () => (wallsCommand(config(), argv).length ? 1 : 0),
93
96
  requests: () => (requests(config()).length ? 1 : 0),
94
97
  grant: () => void grant(config(), argv),
98
+ /**
99
+ * A person DECLINES a request; the boundary DENIES a write. One word each,
100
+ * because they are opposite events and `deny` used to name both — a queue
101
+ * entry said `first refused on …` (the kernel) and the answer said
102
+ * `refused frontend ✕ …` (you).
103
+ *
104
+ * `deny` stays and is not documented. Renaming a published command over a
105
+ * word is not worth breaking somebody's script; teaching the new one is.
106
+ */
107
+ decline: () => void deny(config(), argv),
95
108
  deny: () => void deny(config(), argv),
96
109
  mcp: async () => {
97
110
  await serveMcp(version());
@@ -126,7 +126,7 @@ export function renderObserved(config, entries) {
126
126
  const declared = config.roles[name];
127
127
  const seen = roles.get(name);
128
128
 
129
- const from = declared ? declared.writes : [];
129
+ const from = declared ? declared.writesDeclared ?? declared.writes : [];
130
130
  const found = seen ? generalise([...seen.writes]).filter((w) => !from.includes(w)) : [];
131
131
  const keys = [
132
132
  ...(declared ? declared.keys : []),
@@ -73,7 +73,7 @@ export function renderQueue(queue) {
73
73
  const note = handoffNote(r.handoff);
74
74
  if (note) lines.push(` ${C.yellow}${note}${C.off}\n`);
75
75
  });
76
- lines.push(`\n ${C.dim}seisin grant <n> [--reason "…"] · seisin deny <n> [--reason "…"]${C.off}\n\n`);
76
+ lines.push(`\n ${C.dim}seisin grant <n> [--reason "…"] · seisin decline <n> [--reason "…"]${C.off}\n\n`);
77
77
  return lines.join("");
78
78
  }
79
79
 
@@ -13,6 +13,8 @@ import { join, dirname, delimiter } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { settingsFor, roleHome } from "../srt.js";
15
15
  import { buildEnv } from "../env.js";
16
+ import { resolveKeys } from "../keys.js";
17
+ import { nestedSandboxWarning } from "../nested.js";
16
18
  import { spool, spoolPath, SOCK_ENV } from "../spool.js";
17
19
  import { secretsOf, redactor } from "../redact.js";
18
20
  import { STATE_DIR } from "../layout.js";
@@ -202,6 +204,54 @@ export async function run(config, argv) {
202
204
  env.SEISIN_CONFIG = config.path;
203
205
  env[SOCK_ENV] = sockPath;
204
206
 
207
+ /**
208
+ * Reference keys: resolved here, by the parent, before anything is spawned.
209
+ *
210
+ * The ordering is the security property, not a convenience. The provider
211
+ * command holds the vault's own credential — the keychain prompt, the
212
+ * 1Password session — and running it inside the box would mean putting that
213
+ * credential in there too, which is the thing this feature exists to avoid.
214
+ * The confined side cannot reach the parent; the parent can reach the vault;
215
+ * so the parent resolves and hands over the result.
216
+ *
217
+ * A failure here stops the run. Not an empty value, not the file of the same
218
+ * name, not a skipped key: a role that starts without a credential it
219
+ * declared fails later, somewhere else, looking like something it is not.
220
+ */
221
+ const resolved = resolveKeys(config, config.roles[role]);
222
+ const scratchKeys = join(dirname(sockPath), "keys");
223
+ if (resolved.some((r) => r.mode === "scratch")) mkdirSync(scratchKeys, { recursive: true, mode: 0o700 });
224
+ for (const { entry, mode, value } of resolved) {
225
+ if (mode === "env") { env[entry.name] = value; continue; }
226
+ // `scratch`: the value lands in the per-run directory this process already
227
+ // makes for the spool and already removes on exit, so its lifetime is the
228
+ // turn's by construction rather than by a cleanup somebody has to remember.
229
+ // The variable carries the PATH, which is the convention every tool that
230
+ // wants a secret from a file already reads (`_FILE`), and it means the
231
+ // value itself is not in the environment of a process that may print it.
232
+ const file = join(scratchKeys, entry.name);
233
+ writeFileSync(file, value, { mode: 0o600 });
234
+ /**
235
+ * Both names, because the world has two conventions and neither is ours.
236
+ *
237
+ * `<NAME>_FILE` is the Docker-secrets shape, read by tools that normally
238
+ * take the value in `<NAME>`. But a large family of tools already expects
239
+ * a PATH in the plain variable — `KUBECONFIG`,
240
+ * `GOOGLE_APPLICATION_CREDENTIALS`, `AWS_SHARED_CREDENTIALS_FILE` — and
241
+ * for those, `KUBECONFIG_FILE` is a name nothing reads.
242
+ *
243
+ * Found by running `kubectl` against it rather than reasoning about it:
244
+ * the file was there and correct, and the variable it was announced under
245
+ * was one kubectl has never heard of.
246
+ *
247
+ * Setting both costs nothing and leaks nothing: in `scratch` mode the
248
+ * value is deliberately not in the environment, so both variables hold a
249
+ * path and neither holds a secret.
250
+ */
251
+ env[entry.name] = file;
252
+ env[`${entry.name}_FILE`] = file;
253
+ }
254
+
205
255
  /**
206
256
  * Isolated mode: point the toolchain at a home of this role's own.
207
257
  *
@@ -247,7 +297,15 @@ export async function run(config, argv) {
247
297
  const canRedact = wantRedact && !process.stdout.isTTY;
248
298
  if (wantRedact && !canRedact) err(`${C.dim}seisin: redaction off — interactive terminal${C.off}\n`);
249
299
 
250
- const secrets = canRedact ? secretsOf(settings, readFileSync) : [];
300
+ // Resolved references are masked alongside the key files. They never touched
301
+ // the disk under a path `secretsOf` could find, so without this the one kind
302
+ // of key that is supposed to be better looked after would be the one kind
303
+ // that sails through the redactor into a log.
304
+ const secrets = canRedact
305
+ ? [...secretsOf(settings, readFileSync), ...resolved.map((r) => r.value)]
306
+ .filter((v) => v.length >= 8)
307
+ .sort((a, b) => b.length - a.length)
308
+ : [];
251
309
  const outStream = secrets.length ? redactor(secrets) : null;
252
310
  const errStream = secrets.length ? redactor(secrets) : null;
253
311
  if (outStream) { outStream.pipe(process.stdout); errStream.pipe(process.stderr); }
@@ -327,6 +385,16 @@ export async function run(config, argv) {
327
385
  // platform, so it belongs with the other standing limits, in `seisin check`.
328
386
  void denials.available;
329
387
 
388
+ /**
389
+ * An agent that sandboxes itself has to be told about, before it starts.
390
+ *
391
+ * Said here and not by `check`, because `check` never sees the command. And
392
+ * said rather than fixed: passing somebody's bypass flag for them is exactly
393
+ * the kind of quiet widening this tool exists to refuse.
394
+ */
395
+ const nested = nestedSandboxWarning(cmd);
396
+ if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
397
+
330
398
  const child = spawn(srt, ["--settings", file, "--", ...cmd], {
331
399
  stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
332
400
  env,
@@ -0,0 +1,24 @@
1
+ /** `seisin walls <role>` — what that role keeps being refused, and what it cost. */
2
+ import { walls, render } from "../walls.js";
3
+ import { logPath } from "../log.js";
4
+ import { out } from "../render.js";
5
+
6
+ export function wallsCommand(config, argv = []) {
7
+ const role = argv[0];
8
+ if (!role) throw new Error("usage: seisin walls <role> [--since <iso>] [--min <n>]");
9
+ if (!config.roles[role])
10
+ throw new Error(`unknown role "${role}". Known: ${Object.keys(config.roles).join(", ")}`);
11
+ const at = (flag, fallback) => {
12
+ const i = argv.indexOf(flag);
13
+ return i === -1 ? fallback : argv[i + 1];
14
+ };
15
+ const list = walls(config, role, {
16
+ file: logPath(config.root),
17
+ min: Number(at("--min", 2)),
18
+ since: at("--since", null),
19
+ });
20
+ // Nothing to say prints nothing, the way `scan` does when a repo is clean.
21
+ // A command that always speaks is one whose output stops being read.
22
+ if (list.length) out(render(list));
23
+ return list;
24
+ }
package/src/config.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { readFileSync, existsSync } from "node:fs";
12
12
  import { resolve, dirname, join } from "node:path";
13
13
  import { CONFIG_NAME } from "./layout.js";
14
+ import { parseKey, MODES, checkNames, BUILTIN } from "./keys.js";
14
15
 
15
16
  export { CONFIG_NAME } from "./layout.js";
16
17
 
@@ -174,6 +175,17 @@ function readValue(value, lineNo) {
174
175
 
175
176
  const end = body.indexOf('"', i + 1);
176
177
  if (end === -1) bad("unterminated string inside the array");
178
+ // A backslash before the closing quote means somebody wrote `\"` and
179
+ // expected an escape. There are none — the quote just ended the string,
180
+ // and the error they get otherwise is about a missing comma somewhere in
181
+ // the middle of a shell pipeline, which names the symptom and hides the
182
+ // cause. Measured while writing the docs: every attempt at a one-line
183
+ // provider command landed here.
184
+ if (body[end - 1] === "\\")
185
+ bad(`there are no escapes in this format, so the \\" in ` +
186
+ `${JSON.stringify(body.slice(i, Math.min(end + 2, i + 40)))} ended the string early.\n` +
187
+ ` A value cannot contain a double quote. If this is a shell command, put it in a ` +
188
+ `script and name the script instead — which is also how it stops being unreadable.`);
177
189
  items.push(body.slice(i + 1, end));
178
190
  i = end + 1;
179
191
  expectItem = false; // a trailing comma is fine; a trailing item is not
@@ -273,6 +285,7 @@ export function loadConfig(path) {
273
285
  // is how a tool gets kept out.
274
286
  const keyDir = own(own(parsed, "keys"), "dir");
275
287
  const keyDirs = keyDir === undefined ? [] : asArray(keyDir, "keys.dir");
288
+ const keyProviders = readProviders(own(own(parsed, "keys"), "providers"), path);
276
289
  // `undefined` means "use the defaults"; an explicit empty array means "none".
277
290
  // The difference matters: one is a user who has not thought about it, the
278
291
  // other is a user who has.
@@ -280,7 +293,7 @@ export function loadConfig(path) {
280
293
  const runtimeWrites = own(runtime, "writes");
281
294
 
282
295
  const out = {
283
- root: dirname(path), path, keyDirs,
296
+ root: dirname(path), path, keyDirs, keyProviders,
284
297
  allowedDomains: own(own(parsed, "network"), "allow") ?? [],
285
298
  runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
286
299
  // `[runtime] isolate` — how much of your home a role stops reaching. See
@@ -296,12 +309,69 @@ export function loadConfig(path) {
296
309
  const r = own(roles, name);
297
310
  const writes = asArray(own(r, "writes"), `roles.${name}.writes`);
298
311
  const keys = asArray(own(r, "keys"), `roles.${name}.keys`);
299
- if (keys.length && keyDirs.length === 0)
312
+ // Read once, here, so every consumer sees the same answer about what an
313
+ // entry is. `keys` itself stays the array of strings that was written: it
314
+ // is what `render`, `serve` and `init` print, and the whole point of a
315
+ // reference is that the written form is the reviewable one.
316
+ const keyEntries = keys.map((k) => {
317
+ try {
318
+ return parseKey(k);
319
+ } catch (e) {
320
+ throw new Error(`${path}: roles.${name}: ${e.message}`);
321
+ }
322
+ });
323
+ // Only a path needs somewhere to live. A reference does not, and demanding
324
+ // `[keys] dir` from a config whose secrets are all in a vault would be the
325
+ // tool insisting on the arrangement it was built for.
326
+ if (keyEntries.some((k) => k.kind === "file") && keyDirs.length === 0)
300
327
  throw new Error(`${path}: roles.${name} lists keys, but [keys] dir is not set`);
328
+ /**
329
+ * An unknown scheme is refused, not ignored.
330
+ *
331
+ * Same rule as everywhere else here: a `keys = ["vault://x"]` with no
332
+ * `[keys.providers.vault]` is a configuration error, and the alternative —
333
+ * treating it as a filename, or as a key that quietly never arrives — is a
334
+ * policy that reads as protecting something it is not.
335
+ */
336
+ for (const k of keyEntries) {
337
+ // `file://` ships with seisin, so it needs no declaration. It is the one
338
+ // builtin, and the reason is in keys.js: a secret in a plain file is the
339
+ // case this exists for, and making that case declare a provider that
340
+ // runs `cat` is a papercut on the only path most people take.
341
+ if (k.kind !== "ref" || BUILTIN.has(k.scheme) || own(keyProviders, k.scheme)) continue;
342
+ const known = Object.keys(keyProviders);
343
+ throw new Error(
344
+ `${path}: roles.${name}: key "${k.raw}" uses the "${k.scheme}" scheme, and no ` +
345
+ `[keys.providers.${k.scheme}] is declared.\n` +
346
+ ` ${known.length ? `Declared providers: ${known.join(", ")}.` : "No providers are declared."}\n` +
347
+ ` A provider is a command with a placeholder:\n` +
348
+ ` [keys.providers.${k.scheme}]\n` +
349
+ ` command = ["your-cli", "read", "{ref}"]`);
350
+ }
351
+ checkNames({ name, keyEntries });
352
+ const keyMode = own(r, "key_mode");
353
+ if (keyMode !== undefined && !MODES.includes(keyMode))
354
+ throw new Error(
355
+ `${path}: roles.${name}: key_mode = "${keyMode}" is not a delivery mode. ` +
356
+ `Known: ${MODES.join(", ")}.`);
301
357
  out.roles[name] = {
302
358
  name,
303
- writes,
359
+ /**
360
+ * `writes` is what is GRANTED; `writesDeclared` is what was written.
361
+ *
362
+ * Expanded here and not at emit time, because two answers is how the
363
+ * document ends up looser or tighter than the boundary. Everything
364
+ * downstream — `ownersOf`, `explain`, `whose`, the settings, the console
365
+ * — reads `writes`, so the owner of a database is the owner of its `-wal`
366
+ * everywhere, and the kernel and the sentence agree. The declared form is
367
+ * kept so that regenerating a config writes back what a person typed
368
+ * rather than the expansion.
369
+ */
370
+ writes: withSidecars(writes),
371
+ writesDeclared: writes,
304
372
  keys,
373
+ keyEntries,
374
+ keyMode: keyMode ?? null,
305
375
  env: asArray(own(r, "env"), `roles.${name}.env`),
306
376
  network: own(r, "network") === undefined ? null : asArray(own(r, "network"), `roles.${name}.network`),
307
377
  };
@@ -309,6 +379,93 @@ export function loadConfig(path) {
309
379
  return out;
310
380
  }
311
381
 
382
+ /**
383
+ * `[keys.providers.<name>]` — the commands that turn a reference into a value.
384
+ *
385
+ * Validated here and not at resolution time, because the whole argument for
386
+ * references is that a broken policy should be visible in `seisin check`,
387
+ * without asking anybody's keychain for a password to find out.
388
+ *
389
+ * `{ref}` is required. A command without it resolves every reference to the
390
+ * same thing, which is the kind of mistake that looks like it works: the first
391
+ * key comes back, so does the second, and they are both the first one.
392
+ */
393
+ function readProviders(table, path) {
394
+ const out = Object.create(null);
395
+ if (table === undefined) return out;
396
+ for (const name of Object.keys(table)) {
397
+ const p = own(table, name);
398
+ // Redefining `file://` is refused rather than honoured. Two configs where
399
+ // the same scheme means different things is the failure this whole feature
400
+ // is supposed to remove — and it would be invisible, because the policy
401
+ // reads identically in both.
402
+ if (BUILTIN.has(name))
403
+ throw new Error(
404
+ `${path}: [keys.providers.${name}] — "${name}://" is built in and cannot be redefined.\n` +
405
+ ` It reads a file, optionally one KEY out of it: "file://.secrets/all.env#TOKEN".\n` +
406
+ ` For different behaviour, give your provider another name.`);
407
+ const command = own(p, "command");
408
+ if (!Array.isArray(command) || command.length === 0 || command.some((c) => typeof c !== "string"))
409
+ throw new Error(
410
+ `${path}: [keys.providers.${name}] needs a command, as an array of strings.\n` +
411
+ ` e.g. command = ["security", "find-generic-password", "-w", "-s", "{ref}"]`);
412
+ if (!command.some((c) => c.includes("{ref}")))
413
+ throw new Error(
414
+ `${path}: [keys.providers.${name}] has no {ref} in its command, so every key of this ` +
415
+ `provider would resolve to the same value.\n` +
416
+ ` Put {ref} where the reference goes: command = [${command.map((c) => `"${c}"`).join(", ")}, "{ref}"]`);
417
+ const mode = own(p, "mode");
418
+ if (mode !== undefined && !MODES.includes(mode))
419
+ throw new Error(
420
+ `${path}: [keys.providers.${name}] mode = "${mode}" is not a delivery mode. ` +
421
+ `Known: ${MODES.join(", ")}.`);
422
+ out[name] = { name, command, mode: mode ?? null };
423
+ }
424
+ return out;
425
+ }
426
+
427
+ /**
428
+ * A SQLite database is four files, and a policy should say it once.
429
+ *
430
+ * `bitacora/x/equipo.sqlite` is not the database — it is one of the files the
431
+ * database is made of. Writing to it in WAL mode also writes `-wal` and
432
+ * `-shm`; in rollback mode, `-journal`. So "this role writes this database"
433
+ * takes four lines, and a policy with eight databases and thirty-one roles
434
+ * takes nine hundred and ninety-two of them. Measured on a real one: 744 of
435
+ * 1,248 write lines — 60% — were sidecars, and every single one had its
436
+ * `.sqlite` declared beside it. Not one case wanted the `-wal` without the
437
+ * database.
438
+ *
439
+ * **This grants no authority that was not already there**, which is the whole
440
+ * reason it is safe to do implicitly. The `-wal` holds that database's pending
441
+ * transactions: whoever can write the database can already empty it, replace
442
+ * it, or corrupt it. The sidecar is not a second resource — it is the same one.
443
+ *
444
+ * It is also not a glob. The kernel grants a path and everything under it, so
445
+ * `equipo.sqlite*` is unenforceable and `seisin check` refuses it; these are
446
+ * emitted as literal paths, exactly as if they had been typed.
447
+ *
448
+ * **The suffix list is closed on purpose** and this is the line that keeps the
449
+ * idea from becoming the exception list it could be: three suffixes, and only
450
+ * after a name that says SQLite. `.db` is deliberately not in it — plenty of
451
+ * things are called `.db` and a grant to create `whatever.db-wal` inside a
452
+ * directory somebody else owns would be new authority, arriving quietly. A
453
+ * database with another name declares its sidecars by hand, as before.
454
+ */
455
+ const SIDECARS = ["-wal", "-shm", "-journal"];
456
+ const IS_SQLITE = /\.sqlite3?$/;
457
+
458
+ export function withSidecars(paths) {
459
+ const out = [];
460
+ for (const p of paths) {
461
+ out.push(p);
462
+ if (IS_SQLITE.test(p)) for (const s of SIDECARS) out.push(p + s);
463
+ }
464
+ // A policy written before this existed already lists them. Keeping the first
465
+ // occurrence keeps the order somebody chose.
466
+ return [...new Set(out)];
467
+ }
468
+
312
469
  function asArray(value, where) {
313
470
  if (value === undefined) return [];
314
471
  if (typeof value === "string") return [value];
package/src/hook.js CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { explain, normalize } from "./owners.js";
20
20
  import { append, logPath } from "./log.js";
21
+ import { timesHit } from "./walls.js";
21
22
  import { record, requestsPath } from "./requests.js";
22
23
 
23
24
  /** Tools whose input names a file directly. */
@@ -118,6 +119,12 @@ function kindOf(config, path) {
118
119
  */
119
120
  const QUEUED = "Already queued for a person to answer — retrying or waiting will not move it.";
120
121
 
122
+ /** 4 -> "fourth". Past a handful the numeral reads better than the word. */
123
+ function ordinal(n) {
124
+ const words = ["", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth"];
125
+ return words[n] ?? `${n}th`;
126
+ }
127
+
121
128
  /**
122
129
  * Decides and records one tool call.
123
130
  *
@@ -164,7 +171,12 @@ export function decide(config, role, event, { observe = false, now = append, ask
164
171
  if (!observe && !v.allowed)
165
172
  ask(queue, { role, action: kind === "key" ? "read" : t.action, target: rel, owners: v.owners ?? [] });
166
173
 
167
- verdicts.push({ ...v, target: rel, kind });
174
+ // The action as it was RECORDED, not as the tool named it: a read of a key
175
+ // is logged as "read" and a write is "write", and the counter below looks
176
+ // the entry up by that pair. Carrying the tool's word here instead would
177
+ // make every lookup miss, silently — the refusal would simply never
178
+ // mention that it had been given before.
179
+ verdicts.push({ ...v, target: rel, kind, action: kind === "key" ? "read" : t.action });
168
180
  }
169
181
 
170
182
  if (observe) return { decision: null, logged: verdicts.length };
@@ -173,6 +185,25 @@ export function decide(config, role, event, { observe = false, now = append, ask
173
185
  if (!denied) return { decision: null, logged: verdicts.length };
174
186
  const wasRead = denied.kind === "key";
175
187
 
188
+ /**
189
+ * How many times this exact denial has already been handed to this role.
190
+ *
191
+ * The sentence below is correct every time and that has not been enough:
192
+ * measured on a real team, 25% of all denials were a repeat of something the
193
+ * same role had already been refused, one of them nineteen times. Correct
194
+ * and *heard* are different properties, and only the first was being
195
+ * measured. So the refusal carries its own history: the second time, it says
196
+ * it is the second time.
197
+ *
198
+ * Counted from the log that was just written, so the count includes this
199
+ * attempt. Silent on the first — a counter that says "1×" on every first
200
+ * refusal is noise on the turn where the sentence is already doing its job.
201
+ */
202
+ const before = timesHit(logPath(config.root), role, denied.action, denied.target);
203
+ const again = before > 1
204
+ ? ` You have been denied this ${before} times now; it is not going to work on the ${ordinal(before + 1)} try.`
205
+ : "";
206
+
176
207
  // The hook advises; it does not enforce. Returning `deny` here stops the call
177
208
  // early and — the part that matters — hands the agent a sentence it can act
178
209
  // on, instead of an errno it can only retry.
@@ -189,7 +220,7 @@ export function decide(config, role, event, { observe = false, now = append, ask
189
220
  ? wasRead
190
221
  ? `${denied.target} is declared for ${denied.owners.join(", ")}, not ${role}. Ask for what you need from it rather than reading the key.`
191
222
  : `${denied.target} belongs to ${denied.owners.join(", ")}. It is not ${role}'s to change — hand it over rather than working around it.`
192
- : `${denied.reason} (seisin)`) + " " + QUEUED,
223
+ : `${denied.reason} (seisin)`) + again + " " + QUEUED,
193
224
  },
194
225
  };
195
226
  }
package/src/index.js CHANGED
@@ -25,6 +25,18 @@ export { covers, ownersOf, keyHolders, explain } from "./owners.js";
25
25
  export { settingsFor, RUNTIME_WRITES, expand } from "./srt.js";
26
26
  export { buildEnv, BASE as BASE_ENV, DEFAULTS as DEFAULT_ENV } from "./env.js";
27
27
 
28
+ /**
29
+ * Keys that are references, and how a resolved value reaches a role.
30
+ *
31
+ * `parseKey` and `MODES` are here because reading somebody's policy means
32
+ * knowing whether an entry is a path or a reference; `resolveRef` is not,
33
+ * because resolving is something seisin does and not something you ask it.
34
+ */
35
+ export { parseKey, defaultName, entriesOf, MODES, RESERVED_ENV } from "./keys.js";
36
+
37
+ /** What a role keeps being refused, recomputed against the policy as it stands. */
38
+ export { walls, wasted, MIN_HITS } from "./walls.js";
39
+
28
40
  /** The report `check` prints, without the printing. */
29
41
  export { inspect, sharedPaths } from "./inspect.js";
30
42