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/src/intake.js ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The parent's intake: what arrives during a run, checked, and written down.
3
+ *
4
+ * Two sources, and they are not equally trustworthy. The hook runs inside the
5
+ * box, so everything it sends is a claim by the process being recorded. The
6
+ * kernel is the thing that actually refused. Both end up as log lines and,
7
+ * sometimes, as requests in front of a person; this module is where each is
8
+ * recomputed from the policy rather than believed.
9
+ *
10
+ * Every line written here carries two fields that make it evidence rather
11
+ * than a sentence:
12
+ *
13
+ * - `run`, the id of the run that produced it — the same id as the nonce in
14
+ * the kernel's command tag and the run's private directory (rundir.js).
15
+ * Before, the log had no run marker and "how many runs since" was guessed
16
+ * from 10-minute gaps.
17
+ * - `policy`, a hash of the policy file the run STARTED with. The kernel
18
+ * enforces that one for the whole run, whatever happens to the file
19
+ * afterwards, and `review` reading today's policy over last week's lines
20
+ * could not tell a grant made since from one that was always there.
21
+ *
22
+ * Pulled out of run.js, which held both handlers inline between the spawn and
23
+ * the signal handling — 180 lines of policy arithmetic inside the launcher.
24
+ */
25
+ import { createHash } from "node:crypto";
26
+ import { readFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { toRepoRelative } from "./paths.js";
29
+ import { explain, ownersOf } from "./owners.js";
30
+ import { append, logPath } from "./log.js";
31
+ import { record, requestsPath } from "./requests.js";
32
+ import { inScope, scopeOf, reachedForContent } from "./violations.js";
33
+
34
+ /** A hash of the policy file as it is now, short: it names a version, it proves nothing. */
35
+ export function policyId(config) {
36
+ try {
37
+ return createHash("sha256").update(readFileSync(config.path)).digest("hex").slice(0, 12);
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The handlers for one run.
45
+ *
46
+ * `fromHook(to, entry)` is the spool's sink; `fromKernel(denial)` is
47
+ * watchDenials' callback; `stats` counts what the kernel said that was left
48
+ * out on purpose, for the line at the end of the run.
49
+ */
50
+ export function intake({ config, role, runId, observe = false, settings, notify }) {
51
+ const stamp = { run: runId.slice(0, 8), policy: policyId(config) };
52
+ const log = (entry) => append(logPath(config.root), { ...entry, ...stamp });
53
+ const ask = (asked) => { record(requestsPath(config.root), asked); notify?.maybe(asked); };
54
+
55
+ const scope = scopeOf(settings, config.root);
56
+ const keyDirs = (config.keyDirs ?? []).map((d) => (d.startsWith("/") ? d : join(config.root, d)));
57
+ const connects = new Set(); // one line per refused target per run
58
+ const stats = { offPolicy: 0, walks: 0 };
59
+
60
+ /**
61
+ * Nothing from inside the box is taken at its word.
62
+ *
63
+ * The sender is the process being recorded, so every field it supplies is
64
+ * a claim. Two of them matter. `role` decides whose request this is — left
65
+ * alone, a frontend agent could file one as backend and wait for a human to
66
+ * approve it. `owners` decides who the queue says it belongs to, and the
67
+ * parent can work that out itself from the policy.
68
+ *
69
+ * So the role is overwritten with the role of this run, the owners are
70
+ * recomputed, and anything shaped wrong is dropped. Forging a *log* line is
71
+ * noise. Forging a *request* is a sentence placed in front of a person for
72
+ * approval, and that is a different thing entirely.
73
+ */
74
+ function fromHook(to, entry) {
75
+ if (!entry || typeof entry !== "object") return;
76
+ if (entry.action !== "read" && entry.action !== "write") return;
77
+ if (typeof entry.target !== "string" || !entry.target.trim()) return;
78
+
79
+ if (to === "log") {
80
+ // The same treatment the queue already got. The role was overwritten but
81
+ // everything else was passed through, so a process inside the box could
82
+ // write "allowed" lines for paths it never touched — which would not move
83
+ // the boundary, but would move `review`, and `init --from-observations`
84
+ // builds a policy out of exactly this.
85
+ if (!["allowed", "denied", "observed"].includes(entry.verdict)) return;
86
+ /**
87
+ * The verdict is the hook's account of what it did, so it stays — but it
88
+ * is checked. The parent works out what the policy this run started with
89
+ * says, and when the two differ the line carries `disputed` with the
90
+ * parent's answer. Two causes, both worth seeing: the policy file changed
91
+ * since the run started (the hook reads it fresh, the kernel does not), or
92
+ * the line was not written by the hook at all.
93
+ */
94
+ const expected = observe
95
+ ? "observed"
96
+ : explain(config, role, entry.action, entry.target).allowed ? "allowed" : "denied";
97
+ return void log({
98
+ at: entry.at,
99
+ role,
100
+ tool: String(entry.tool ?? "").slice(0, 40),
101
+ action: entry.action,
102
+ kind: entry.kind === "key" ? "key" : "file",
103
+ target: entry.target.slice(0, 1000),
104
+ verdict: entry.verdict,
105
+ owners: ownersOf(config, entry.target), // recomputed, never taken
106
+ reason: String(entry.reason ?? "").slice(0, 500),
107
+ ...(expected !== entry.verdict ? { disputed: expected } : {}),
108
+ });
109
+ }
110
+
111
+ /**
112
+ * The policy has to actually refuse it, or it does not belong in a queue.
113
+ *
114
+ * Nothing checked. A process inside the box could file a request for a path
115
+ * its own role already owns, and the queue would print *first refused on
116
+ * …* about something that was never refused — a sentence placed in front of
117
+ * a person for approval, describing an event that did not happen.
118
+ *
119
+ * Same treatment as `owners` above: the claim is recomputed here rather
120
+ * than believed. It is the policy answering a question about itself, which
121
+ * is arithmetic, and it is the parent asking — the one process the confined
122
+ * side cannot reach.
123
+ *
124
+ * This does not require the agent to have *tried*. Asking for a permission
125
+ * before reaching for it is a reasonable thing to do, and the wording says
126
+ * "asked" rather than "refused" for exactly that case. What it rules out is
127
+ * a request for something that is not refused at all — nor for a
128
+ * `never_writes` path, git's metadata, or a protected one, none of which a
129
+ * grant can open.
130
+ */
131
+ const v = explain(config, role, entry.action, entry.target);
132
+ if (v.allowed || v.neverWrites || v.gitMetadata || v.protected) return;
133
+ // The owners explain found: for a key that is who declares it, not who
134
+ // writes the directory it sits in — the queue said "owned by dev" about
135
+ // backend's key because dev writes `**`.
136
+ ask({ role, action: entry.action, target: entry.target, owners: v.owners });
137
+ }
138
+
139
+ function fromKernel(d) {
140
+ /**
141
+ * A refused connection: logged, never queued.
142
+ *
143
+ * Once per target per run, because the thing that dials a closed port is
144
+ * usually a readiness loop, and a loop polling a database every half second
145
+ * would otherwise write the log the size of the wait. And no request: a port
146
+ * is not a territory anyone owns, so there is nobody to hand it to — the
147
+ * sentence says what the policy would have to change instead.
148
+ */
149
+ if (d.action === "connect") {
150
+ if (connects.has(d.path)) return;
151
+ connects.add(d.path);
152
+ const v = explain(config, role, "connect", d.path);
153
+ log({
154
+ at: new Date().toISOString(), role, tool: "kernel", source: "kernel",
155
+ action: "connect", kind: "network", target: d.path, verdict: "denied",
156
+ owners: [], reason: d.operation, ...(v.listed ? { listed: true } : {}),
157
+ });
158
+ return;
159
+ }
160
+ if (!inScope(d.path, scope)) { stats.offPolicy++; return; }
161
+ if (!reachedForContent(d)) { stats.walks++; return; }
162
+ // Relative inside the repo, absolute outside it. A role can be refused at
163
+ // ~/.ssh under `isolate`, and "../../../.ssh/id_rsa" would be a worse
164
+ // answer to "what was refused" than the path itself.
165
+ const rel = toRepoRelative(config, d.path);
166
+
167
+ /**
168
+ * A refusal the hook never saw still leaves a request behind.
169
+ *
170
+ * Without this the two halves of the record disagree in the worst
171
+ * direction: `review` would show a role stopped repeatedly on a directory
172
+ * while the queue held nothing to approve, so the one refusal a person most
173
+ * needed to see — the one that escaped the hook — would be the one with no
174
+ * way to act on it.
175
+ *
176
+ * There is no double counting to avoid. When the hook catches something it
177
+ * denies the tool call outright and the command never runs, so the kernel
178
+ * never sees it. These are the ones that got past it, which is the whole
179
+ * reason this exists. Inside the repo only: `~/.ssh` is refused on purpose
180
+ * under `isolate` and is not a territory anyone is meant to ask for.
181
+ */
182
+ const verdict = rel !== d.path && d.action === "write"
183
+ ? explain(config, role, "write", rel)
184
+ : null;
185
+ const barred = verdict?.neverWrites ?? null;
186
+ const unaskable = verdict?.gitMetadata === true || verdict?.protected != null;
187
+ // A request for something the policy already grants cannot be approved into
188
+ // anything: it is misattribution or a kernel/policy mismatch, and either way
189
+ // the line in the log is the evidence, not a question for a person. The hook
190
+ // path has always had this check; the kernel path did not, which is how a
191
+ // role ended up asking for its own territory.
192
+ const granted = verdict?.allowed === true;
193
+ if (rel !== d.path && !barred && !unaskable && !granted)
194
+ ask({ role, action: d.action, target: rel, owners: ownersOf(config, rel) });
195
+
196
+ log({
197
+ at: new Date().toISOString(),
198
+ role,
199
+ tool: "kernel",
200
+ source: "kernel",
201
+ action: d.action,
202
+ kind: keyDirs.some((k) => d.path === k || d.path.startsWith(k + "/")) ? "key" : "file",
203
+ target: rel,
204
+ verdict: "denied",
205
+ owners: ownersOf(config, rel),
206
+ reason: d.operation,
207
+ // So the log can tell a subtraction doing its job from a missing permission,
208
+ // and a protected file from a territory somebody could be asked for.
209
+ ...(barred ? { neverWrites: barred } : {}),
210
+ ...(verdict?.protected ? { protected: verdict.protected } : {}),
211
+ });
212
+ }
213
+
214
+ return { fromHook, fromKernel, stats };
215
+ }
package/src/keys.js CHANGED
@@ -41,9 +41,21 @@
41
41
  */
42
42
  import { spawnSync } from "node:child_process";
43
43
  import { readFileSync } from "node:fs";
44
- import { resolve, isAbsolute } from "node:path";
44
+ import { resolve, isAbsolute, delimiter } from "node:path";
45
45
  import { BASE, DEFAULTS } from "./env.js";
46
46
  import { SOCK_ENV } from "./spool.js";
47
+ import { resolveExecutable, trustedPath } from "./surface.js";
48
+
49
+ /**
50
+ * How long a provider may take. A minute, because the slow case is a person:
51
+ * `op` and the keychain can put a prompt in front of someone who is not
52
+ * looking at the screen yet. Before this there was no limit, and a provider
53
+ * stuck on a prompt nobody could see held the run with no message at all.
54
+ */
55
+ export const PROVIDER_TIMEOUT_MS = 60_000;
56
+
57
+ /** A credential is bytes, not megabytes. Past this it is not a key. */
58
+ const PROVIDER_MAX_BYTES = 1024 * 1024;
47
59
 
48
60
  /**
49
61
  * `scheme://rest`, and nothing cleverer.
@@ -202,15 +214,78 @@ function fromJson(obj, key, ref) {
202
214
  return String(v);
203
215
  }
204
216
 
217
+ /**
218
+ * One `NAME=value` line's value, the way dotenv reads it — or a refusal.
219
+ *
220
+ * Compared against dotenv 16 on the same file (2026-09-23), three lines came
221
+ * back different, and a different value is a wrong credential handed over
222
+ * without a word:
223
+ *
224
+ * - `A=abc # comment` — dotenv drops the comment; this kept it.
225
+ * - a name defined twice — dotenv takes the last; this took the first.
226
+ * - a quoted value spanning lines — dotenv reads it whole; this returned
227
+ * the first line with its quote.
228
+ *
229
+ * The comment is read like dotenv, because there is one reading of it. The
230
+ * other two are refused, because loaders disagree about them and picking one
231
+ * is guessing which credential somebody meant.
232
+ */
233
+ function dotEnvValue(raw, ref, key) {
234
+ const v = raw.trim();
235
+ const q = v[0];
236
+ if (q === '"' || q === "'" || q === "`") {
237
+ // The closing quote is the first one not escaped with a backslash, as in
238
+ // dotenv's own pattern: `"a\"b"` is one value, `a\"b`, not `a\`.
239
+ let end = -1;
240
+ for (let i = 1; i < v.length; i++) {
241
+ if (v[i] === "\\") { i++; continue; }
242
+ if (v[i] === q) { end = i; break; }
243
+ }
244
+ if (end === -1)
245
+ throw new Error(
246
+ `key "${ref}": ${key}= opens a ${q} quote that does not close on its line.\n` +
247
+ ` A value across several lines is read differently by different loaders, so seisin ` +
248
+ `does not read it. Put the credential on one line, or in a file of its own.`);
249
+ // Only a comment may follow the closing quote. `A="a" b` is read by dotenv
250
+ // as the whole line, quotes included, and by others as `a`.
251
+ const rest = v.slice(end + 1).trim();
252
+ if (rest && !rest.startsWith("#"))
253
+ throw new Error(
254
+ `key "${ref}": ${key}= has text after its closing quote (${rest.slice(0, 20)}…).\n` +
255
+ ` Loaders disagree on what that value is. Quote the whole value.`);
256
+ const inner = v.slice(1, end);
257
+ // Inside double quotes dotenv turns \n and \r into the characters — the
258
+ // shape a PEM key takes in a .env. Handed over literally, the key has a
259
+ // backslash and an n where its line breaks go, and it does not parse.
260
+ return q === '"' ? inner.replace(/\\n/g, "\n").replace(/\\r/g, "\r") : inner;
261
+ }
262
+ const bare = v.replace(/\s+#.*$/, "");
263
+ // A `#` inside an unquoted value: dotenv 16 cuts the value there
264
+ // (`tok_#_123` → `tok_`), python-dotenv and docker compose keep it.
265
+ if (bare.includes("#"))
266
+ throw new Error(
267
+ `key "${ref}": the value of ${key}= contains a # without quotes.\n` +
268
+ ` Some loaders read everything after it as a comment and some do not, so the ` +
269
+ `credential depends on who reads the file. Quote it: ${key}="…".`);
270
+ return bare;
271
+ }
272
+
205
273
  function fromDotEnv(text, key, ref) {
206
274
  let sawAny = false;
275
+ let found = null;
207
276
  for (const line of text.split(/\r?\n/)) {
208
277
  const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
209
278
  if (!m) continue;
210
279
  sawAny = true;
211
280
  if (m[1] !== key) continue;
212
- return m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
281
+ if (found !== null)
282
+ throw new Error(
283
+ `key "${ref}": ${key}= is defined more than once in that file.\n` +
284
+ ` dotenv takes the last one and other loaders the first, so which credential is meant ` +
285
+ `is not written down. Keep one.`);
286
+ found = dotEnvValue(m[2], ref, key);
213
287
  }
288
+ if (found !== null) return found;
214
289
  /**
215
290
  * Two different failures, and telling them apart is the whole message.
216
291
  *
@@ -437,16 +512,43 @@ export function modeOf(config, role, entry) {
437
512
  * is — a provider that prints the secret and then exits non-zero would
438
513
  * otherwise put it in the terminal and the log.
439
514
  */
440
- export function resolveRef(entry, provider, { run = spawnSync, root = "." } = {}) {
515
+ export function resolveRef(entry, provider, { run = spawnSync, root = ".", config = null, env = process.env } = {}) {
441
516
  if (provider?.builtin === "file" || (provider === undefined && entry.scheme === "file"))
442
517
  return readFileRef(entry, root);
443
518
  const argv = provider.command.map((part) => part.replaceAll("{ref}", entry.ref));
444
- const r = run(argv[0], argv.slice(1), { encoding: "utf8" });
519
+ /**
520
+ * Found where no role can write, and handed the same PATH.
521
+ *
522
+ * `spawnSync("fakeprov")` searched the parent's PATH, and a PATH directory
523
+ * inside a territory made the next run execute whatever that role had put
524
+ * there — outside the box, as you. Measured on 2026-09-23 with a marker file.
525
+ * The provider's own PATH is narrowed the same way, because a provider that
526
+ * is a script starts with `#!/usr/bin/env bash` and looks its interpreter up
527
+ * again. Without a config (a caller embedding seisin), or with a `run` of the
528
+ * caller's own, the argv is handed over as given: finding the program is part
529
+ * of spawning it, and whoever replaced the spawn owns that too.
530
+ */
531
+ const opts = { encoding: "utf8", timeout: PROVIDER_TIMEOUT_MS, maxBuffer: PROVIDER_MAX_BYTES };
532
+ if (config && run === spawnSync) {
533
+ argv[0] = resolveExecutable(argv[0], config, env);
534
+ opts.env = { ...env, PATH: trustedPath(config, env).join(delimiter) };
535
+ }
536
+ const r = run(argv[0], argv.slice(1), opts);
537
+ if (r.error?.code === "ETIMEDOUT")
538
+ throw new Error(
539
+ `key "${entry.raw}": the ${entry.scheme} provider (${argv[0]}) did not answer in ` +
540
+ `${PROVIDER_TIMEOUT_MS / 1000} s and was stopped.\n` +
541
+ ` A provider that waits on a prompt nobody sees would otherwise hold the run forever.`);
445
542
  if (r.error)
446
543
  throw new Error(
447
544
  `key "${entry.raw}": could not run the ${entry.scheme} provider (${argv[0]}): ${r.error.message}`);
448
545
  if (r.status !== 0) {
449
- const said = (r.stderr ?? "").trim().split("\n").slice(0, 3).join("\n ");
546
+ // Whatever it printed on stdout is taken out of stderr before stderr is
547
+ // shown: a provider that echoes the secret to both and then fails would
548
+ // otherwise put it in the terminal through the error meant to explain it.
549
+ const leaked = (r.stdout ?? "").trim();
550
+ const clean = leaked.length >= 4 ? (r.stderr ?? "").split(leaked).join("‹redacted›") : (r.stderr ?? "");
551
+ const said = clean.trim().split("\n").slice(0, 3).map((l) => l.slice(0, 200)).join("\n ");
450
552
  throw new Error(
451
553
  `key "${entry.raw}": the ${entry.scheme} provider exited ${r.status ?? "on a signal"}.` +
452
554
  (said ? `\n ${said}` : "") +
@@ -473,7 +575,7 @@ export function resolveRef(entry, provider, { run = spawnSync, root = "." } = {}
473
575
  * should say so without asking anyone's keychain for a password first.
474
576
  */
475
577
  export function resolveKeys(config, role, opts = {}) {
476
- opts = { root: config.root ?? ".", ...opts };
578
+ opts = { root: config.root ?? ".", config, ...opts };
477
579
  const refs = role.keyEntries.filter((e) => e.kind === "ref");
478
580
  const plan = refs.map((entry) => ({ entry, mode: modeOf(config, role, entry) }));
479
581
  return plan.map((p) => ({
package/src/log.js CHANGED
@@ -13,7 +13,8 @@
13
13
  * tries the parent's socket first for exactly that reason; writing the file
14
14
  * directly is what happens when there is no parent, outside the box.
15
15
  */
16
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
16
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
17
+ import { createHash } from "node:crypto";
17
18
  import { dirname, join } from "node:path";
18
19
  import { STATE_DIR, LOG_NAME } from "./layout.js";
19
20
  import { send } from "./spool.js";
@@ -37,13 +38,106 @@ export function append(file, entry) {
37
38
  if (send("log", entry)) return true;
38
39
  try {
39
40
  mkdirSync(dirname(file), { recursive: true });
40
- appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
41
+ withLock(file, () => {
42
+ // `prev` goes last and is computed from the previous line exactly as it
43
+ // sits on disk, so verifying needs nothing but the file.
44
+ const line = JSON.stringify({ at: new Date().toISOString(), ...entry, prev: hashOf(lastLine(file)) });
45
+ appendFileSync(file, line + "\n");
46
+ });
41
47
  return true;
42
48
  } catch {
43
49
  return false;
44
50
  }
45
51
  }
46
52
 
53
+ /**
54
+ * The log is chained: every line carries `prev`, the hash of the line before it.
55
+ *
56
+ * The log is the record of who touched what and who was refused what, and it
57
+ * could be edited without a trace. A chain does not stop anyone with write
58
+ * access from rewriting it — nothing inside a file can — but it makes an edit,
59
+ * a deletion or a reordering show, and `seisin log verify` says where. Taken
60
+ * from nono's audit trail; the Merkle root and the signature it adds on top
61
+ * are left for when someone asks for them.
62
+ *
63
+ * The first chained line of a file points at GENESIS. A file started by
64
+ * rotation should point at the last line of the one before it; there is no
65
+ * rotation yet, and when there is, that is what it has to do.
66
+ */
67
+ export const GENESIS = "0".repeat(32);
68
+
69
+ export function hashOf(line) {
70
+ return line == null ? GENESIS : createHash("sha256").update(line).digest("hex").slice(0, 32);
71
+ }
72
+
73
+ /** The last complete line of the file, or null when there is none. */
74
+ function lastLine(file) {
75
+ if (!existsSync(file)) return null;
76
+ const { size } = statSync(file);
77
+ if (size === 0) return null;
78
+ const from = Math.max(0, size - 64 * 1024);
79
+ const fd = openSync(file, "r");
80
+ try {
81
+ const buf = Buffer.alloc(size - from);
82
+ readSync(fd, buf, 0, buf.length, from);
83
+ const lines = buf.toString("utf8").split("\n").filter((l) => l.trim());
84
+ return lines.length ? lines[lines.length - 1] : null;
85
+ } finally {
86
+ closeSync(fd);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * One writer at a time. Several roles of a round run at once and write this
92
+ * same file; two of them reading the same last line would fork the chain.
93
+ * A lock left by a killed process is taken over after a few seconds.
94
+ */
95
+ function withLock(file, fn) {
96
+ const lock = file + ".lock";
97
+ const deadline = Date.now() + 2000;
98
+ for (;;) {
99
+ try {
100
+ closeSync(openSync(lock, "wx"));
101
+ break;
102
+ } catch (e) {
103
+ if (e.code !== "EEXIST") throw e;
104
+ try { if (Date.now() - statSync(lock).mtimeMs > 5000) { unlinkSync(lock); continue; } } catch {}
105
+ if (Date.now() > deadline) break; // never block the run over bookkeeping
106
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
107
+ }
108
+ }
109
+ try { return fn(); } finally { try { unlinkSync(lock); } catch {} }
110
+ }
111
+
112
+ /**
113
+ * Walk the file and say where the chain breaks.
114
+ *
115
+ * Lines written before the chain existed have no `prev`; they are counted as an
116
+ * unchained prefix, not reported as tampering. After the first chained line,
117
+ * a line without `prev` is itself a break.
118
+ */
119
+ export function verifyChain(file) {
120
+ const out = { lines: 0, unchained: 0, chained: 0, breaks: [] };
121
+ if (!existsSync(file)) return out;
122
+ let previous = null;
123
+ let started = false;
124
+ for (const line of readFileSync(file, "utf8").split("\n")) {
125
+ if (!line.trim()) continue;
126
+ out.lines++;
127
+ let e = null;
128
+ try { e = JSON.parse(line); } catch {}
129
+ const prev = e?.prev;
130
+ if (!started && prev === undefined) { out.unchained++; previous = line; continue; }
131
+ started = true;
132
+ const expected = out.chained === 0 && out.unchained === 0 ? GENESIS : hashOf(previous);
133
+ const ok = prev === expected || (out.chained === 0 && prev === hashOf(previous));
134
+ if (!ok) out.breaks.push({ line: out.lines, expected, found: prev ?? null });
135
+ out.chained++;
136
+ previous = line;
137
+ }
138
+ return out;
139
+ }
140
+
47
141
  /** Reads entries, newest last. A malformed line is skipped, never fatal. */
48
142
  export function read(file, { role, verdict, since, limit = 0 } = {}) {
49
143
  if (!existsSync(file)) return [];
package/src/mcp.js CHANGED
@@ -26,11 +26,12 @@
26
26
  * The cost of this choice is written down under PROTOCOLS below: we track the
27
27
  * spec by hand, and the spec moves.
28
28
  */
29
+ import { toRepoRelative } from "./paths.js";
29
30
  import { loadConfig, findConfig } from "./config.js";
30
31
  import { inspect } from "./inspect.js";
31
32
  import { explain, ownersOf } from "./owners.js";
32
33
  import { settingsFor } from "./srt.js";
33
- import { pending, requestsPath, grantFor } from "./requests.js";
34
+ import { pending, requestsPath, grantFor, refuseIfBarred, markStale } from "./requests.js";
34
35
  import { read, logPath } from "./log.js";
35
36
  import { causesOf } from "./serve.js";
36
37
  import { walls, wasted } from "./walls.js";
@@ -62,14 +63,14 @@ const TOOLS = [
62
63
  {
63
64
  name: "seisin_explain",
64
65
  description:
65
- "Whether a role may read or write a path, and whose it is if not. " +
66
- "Use this before suggesting an agent touch a file. Read-only.",
66
+ "Whether a role may read or write a path, and whose it is if not; or, with action mcp, " +
67
+ "whether it may load an MCP server. Use this before suggesting an agent touch a file. Read-only.",
67
68
  inputSchema: {
68
69
  type: "object",
69
70
  properties: {
70
71
  role: { type: "string" },
71
- action: { type: "string", enum: ["read", "write"] },
72
- target: { type: "string", description: "path, or key filename for a read" },
72
+ action: { type: "string", enum: ["read", "write", "mcp"] },
73
+ target: { type: "string", description: "path; key filename for a read; server name for mcp" },
73
74
  },
74
75
  required: ["role", "action", "target"],
75
76
  },
@@ -163,24 +164,27 @@ const HANDLERS = {
163
164
  // that was not "read" fell into the write branch — including a typo, which
164
165
  // would answer the wrong question confidently. A declared schema that is
165
166
  // not enforced is documentation.
166
- if (action !== "read" && action !== "write")
167
- throw new Error(`action must be "read" or "write", got ${JSON.stringify(action)}`);
167
+ if (action !== "read" && action !== "write" && action !== "mcp")
168
+ throw new Error(`action must be "read", "write" or "mcp", got ${JSON.stringify(action)}`);
168
169
  if (typeof target !== "string" || !target)
169
170
  throw new Error("target must be a non-empty path");
170
171
  const cfg = config();
171
172
  if (!cfg.roles[role])
172
173
  return { error: `unknown role "${role}"`, known: Object.keys(cfg.roles) };
173
- const verdict = explain(cfg, role, action, target);
174
- return { ...verdict, alsoOwnedBy: action === "write" ? ownersOf(cfg, target) : undefined };
174
+ if (action === "mcp") return explain(cfg, role, "mcp", target);
175
+ const rel = toRepoRelative(cfg, target);
176
+ const verdict = explain(cfg, role, action, rel);
177
+ return { ...verdict, alsoOwnedBy: action === "write" ? ownersOf(cfg, rel) : undefined };
175
178
  },
176
179
 
177
180
  seisin_requests() {
178
181
  const cfg = config();
179
- const queue = pending(requestsPath(cfg.root));
182
+ const queue = markStale(pending(requestsPath(cfg.root)), read(logPath(cfg.root)));
180
183
  return {
181
184
  pending: queue.map((r, i) => ({
182
185
  number: i + 1, role: r.role, action: r.action,
183
186
  wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
187
+ ...(r.stale ? { stale: `not asked again in ${r.stale.runs} runs of ${r.role} since` } : {}),
184
188
  })),
185
189
  // Said in the payload and not only in the tool description, because a
186
190
  // model reading this is deciding what to do next.
@@ -240,6 +244,20 @@ const HANDLERS = {
240
244
  const req = pending(requestsPath(cfg.root))[Number(number) - 1];
241
245
  if (!req) throw new Error(`no pending request #${number}`);
242
246
 
247
+ // The same refusal `seisin grant` and the console apply, asked here first.
248
+ // Without it this drafted, as approvable, a request that `never_writes`
249
+ // cancels — and `grant` then refused what the draft had offered.
250
+ try {
251
+ refuseIfBarred(cfg, req);
252
+ } catch (e) {
253
+ return {
254
+ barred: e.message,
255
+ ownedBy: req.owners,
256
+ applyWith: null,
257
+ note: "Not approvable as it stands: never_writes wins over writes. Decline it with " +
258
+ `\`seisin decline ${number}\`, or remove the never_writes entry if the subtraction is wrong.`,
259
+ };
260
+ }
243
261
  const field = req.action === "read" ? "keys" : "writes";
244
262
  return {
245
263
  wouldAdd: { role: req.role, field, value: req.grant },