seisin 0.1.1 → 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/inspect.js CHANGED
@@ -8,11 +8,15 @@
8
8
  * does — each one is a way the policy silently does not hold — so they belong
9
9
  * where they can be exercised directly.
10
10
  */
11
- import { resolve, dirname, basename, relative } from "node:path";
12
- import { realpathSync, lstatSync, readdirSync } from "node:fs";
13
- import { ownersOf } from "./owners.js";
11
+ import { resolve, dirname, basename, relative, join, delimiter } from "node:path";
12
+ import { realpathSync, lstatSync, readdirSync, existsSync, readFileSync, statSync } from "node:fs";
13
+ import { ownersOf, covers, enforcedNeverWrites } from "./owners.js";
14
+ import { ROLE_KEYS } from "./config.js";
15
+ import { entriesOf } from "./keys.js";
16
+ import { SHAPES } from "./scan.js";
14
17
  import { wired } from "./commands/wire.js";
15
18
  import { RUNTIME_WRITES, CREDENTIAL_HOMES, expand, settingsFor } from "./srt.js";
19
+ import { protections, resolveExecutable } from "./surface.js";
16
20
 
17
21
  /**
18
22
  * A report on one config: the roles, and every way the map lies.
@@ -27,8 +31,27 @@ export function inspect(config, only = null, where = config.path) {
27
31
 
28
32
  return {
29
33
  where,
30
- roles: roles.map((r) => ({ name: r.name, writes: r.writes, keys: r.keys })),
34
+ roles: roles.map((r) => ({ name: r.name, writes: r.writes, keys: r.keys, neverWrites: r.neverWrites ?? [], localBinding: r.localBinding === true, localPorts: r.localPorts ?? [], mcp: r.mcp ?? null, trustd: r.trustd === true })),
35
+ /**
36
+ * The provider commands this config would run, listed because they are the
37
+ * one thing in a `seisin.toml` that **executes**, and it executes in the
38
+ * parent, unsandboxed, as you. Everything else in the file only describes a
39
+ * boundary. `check` runs nothing, so this is where you see them first — and
40
+ * seeing them first is the whole point when the file came with a repo you
41
+ * cloned rather than one you wrote.
42
+ */
43
+ providers: Object.values(config.keyProviders ?? {}).map((p) => ({
44
+ name: p.name, command: p.command, mode: p.mode,
45
+ })),
31
46
  shared: sharedPaths(config, roles),
47
+ /**
48
+ * What the kernel will refuse a role inside its own territory, and why:
49
+ * programs installed where a role writes, the hooks and settings of each
50
+ * project, provider scripts and `file://` targets. Each takes something
51
+ * away from a territory as written, so it is shown rather than applied
52
+ * quietly. See surface.js for the family and the measurements.
53
+ */
54
+ protected: protections(config, roles),
32
55
  warnings: warningsFor(config, roles),
33
56
  limits: LIMITS,
34
57
  };
@@ -56,7 +79,7 @@ const LIMITS = [
56
79
  "equivalent to read here — bubblewrap does not log refusals, and the runtime's substitute " +
57
80
  "is not reachable from outside it. The boundary holds exactly as well; what you lose is " +
58
81
  "seeing it work. Run `seisin wire` so the hook records what it can. The ask is upstream: " +
59
- "github.com/carlostapiaolguin3-stack/seisin/blob/main/docs/upstream/cli-violations.md",
82
+ "github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
60
83
  }]),
61
84
  {
62
85
  kind: "keys-only-what-you-declared",
@@ -76,7 +99,25 @@ const LIMITS = [
76
99
  "library can read them; nothing reaches a caller that runs the binary, which is what " +
77
100
  "seisin does. So for an agent whose endpoints are not published, the options are to " +
78
101
  "find them another way or drop the network restriction for it. The ask is upstream: " +
79
- "github.com/carlostapiaolguin3-stack/seisin/blob/main/docs/upstream/cli-violations.md",
102
+ "github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
103
+ },
104
+ ...(process.platform === "linux" ? [{
105
+ kind: "control-files-linux",
106
+ headline: "a project's hooks and settings are protected only where they already exist",
107
+ detail:
108
+ "bubblewrap denies a path by mounting over it, and for a path that is not there it would " +
109
+ "create it on your disk. So on Linux a role can still create a .git/hooks entry or a " +
110
+ ".claude/settings.json in a project of its territory that had none. On macOS the kernel " +
111
+ "refuses creating them too.",
112
+ }] : []),
113
+ {
114
+ kind: "shared-scratch",
115
+ headline: "roles share ~/.cache, ~/.local/share and the temp dir unless isolate = \"home\"",
116
+ detail:
117
+ "programs installed there that are on your PATH are protected, and so are Claude Code's " +
118
+ "settings and plugins. Anything else a program outside the box reads from those places — a " +
119
+ "cache it executes, a tool not on PATH — is writable by every role. isolate = \"home\" " +
120
+ "gives each role its own.",
80
121
  },
81
122
  {
82
123
  kind: "unlink-uncovered",
@@ -86,10 +127,127 @@ const LIMITS = [
86
127
  // package, so for most readers it named a file they do not have.
87
128
  "writes and deletes are one permission to the kernel, so `rm` inside a " +
88
129
  "role's own folders succeeds. Closing it needs denyUnlink in the sandbox " +
89
- "runtime: github.com/carlostapiaolguin3-stack/seisin#how-it-holds",
130
+ "runtime: github.com/carlostapiacl/seisin#how-it-holds",
90
131
  },
91
132
  ];
92
133
 
134
+ /**
135
+ * What is wrong with a role table's keys, as opposed to its paths.
136
+ *
137
+ * Two things, both about `never_writes` and both ways a subtraction can look
138
+ * written and not be:
139
+ *
140
+ * - a key seisin does not know. It used to be dropped without a word, which
141
+ * for `writes` is a role with less than it thinks, and for a misspelt
142
+ * `never_writes` is a role with MORE than its owner thinks — the one
143
+ * direction a permission file must not fail in. Named with the nearest
144
+ * known key when there is one close enough to be the intended spelling.
145
+ * - a `never_writes` entry that no `writes` of the same role covers. It
146
+ * subtracts from nothing, so it is either a typo in the path or a rule
147
+ * that stopped applying when the territory moved. Either way it reads as
148
+ * protection and is not.
149
+ *
150
+ * Silent when `never_writes` is absent. Absent is valid; a check that asks for
151
+ * the key would turn an optional subtraction into a required migration.
152
+ */
153
+ function roleKeyWarnings(roles, config = null) {
154
+ const warnings = [];
155
+ for (const r of roles) {
156
+ const enforced = new Set(enforcedNeverWrites(config, r));
157
+ const skipped = (r.neverWritesDeclared ?? r.neverWrites ?? []).filter((g) => !enforced.has(g));
158
+ if (skipped.length)
159
+ warnings.push({
160
+ kind: "never-writes-not-enforced-here",
161
+ headline: `${r.name}: never_writes ${skipped.join(" ")} — not enforced on Linux until it exists`,
162
+ detail: "bubblewrap denies a path by mounting over it, and would create a missing one on the host " +
163
+ "for the whole run — for a lock file, locking everyone else out, and for good if the role is " +
164
+ "killed. seisin skips those, and explain says allowed for them, so the sentence matches the kernel.",
165
+ });
166
+ for (const k of r.unknownKeys ?? []) {
167
+ const near = nearest(k, ROLE_KEYS);
168
+ warnings.push({
169
+ kind: "unknown-role-key",
170
+ headline: `${r.name}: unknown key "${k}" in [roles.${r.name}] — ignored` +
171
+ (near ? `. Did you mean "${near}"?` : ""),
172
+ detail: `Known keys: ${ROLE_KEYS.join(", ")}. ` +
173
+ (near === "never_writes"
174
+ ? "As written this subtracts nothing: the role can still write every path it lists."
175
+ : "Nothing in it takes effect."),
176
+ });
177
+ }
178
+ // macOS only, and not by omission: on Linux the runtime removes the network
179
+ // namespace, so every role already has a loopback of its own — it can serve
180
+ // and reach its own server with or without the key, and reaches nothing on
181
+ // the host either way (measured in Docker, Debian 12, bwrap 0.8.0).
182
+ if (r.localBinding && process.platform === "darwin")
183
+ warnings.push({
184
+ kind: "local-binding-reaches-localhost",
185
+ headline: `${r.name}: local_binding lets it connect to every port on localhost, not only listen`,
186
+ detail: "The runtime grants bind on any interface, inbound, and outbound to localhost:*. " +
187
+ "Anything listening on this machine without authentication — a local control API, a " +
188
+ "database, a dev server — is within this role's reach, outside its territory.",
189
+ });
190
+ // Both at once is a config that says "these ports" and grants "every port".
191
+ // macOS only: on Linux local_binding reaches nothing on the host, so there
192
+ // local_ports is the only one of the two that does anything.
193
+ if (r.localBinding && r.localPorts?.length && process.platform === "darwin")
194
+ warnings.push({
195
+ kind: "local-ports-moot",
196
+ headline: `${r.name}: local_ports names ${r.localPorts.join(", ")}, but local_binding already opens every localhost port`,
197
+ detail: "local_binding grants outbound to localhost:* in the kernel profile, so the port list " +
198
+ "restricts nothing for this role. Drop local_binding if the role only needs to reach " +
199
+ "those ports; keep it only if the role has to listen.",
200
+ });
201
+ // trustd is a macOS service; on Linux the key changes nothing.
202
+ if (r.trustd && process.platform === "darwin")
203
+ warnings.push({
204
+ kind: "trustd-open",
205
+ headline: `${r.name}: trustd = true opens com.apple.trustd.agent, a path out that the domain list does not see`,
206
+ detail: "trustd verifies certificates for Go (before 1.27) and Dart on macOS, and to do it fetches, " +
207
+ "outside the sandbox, the URLs a certificate carries. The runtime calls it an exfiltration vector; " +
208
+ "no public demonstration exists. Go 1.27+ does not need it: seisin sets SSL_CERT_FILE.",
209
+ });
210
+ for (const g of r.neverWrites ?? []) {
211
+ if (r.writes.some((w) => covers(w, g) || covers(w, g.replace(/\/\*\*$/, ""))))
212
+ continue;
213
+ warnings.push({
214
+ kind: "never-writes-subtracts-nothing",
215
+ headline: `${r.name}: never_writes "${g}" is not inside any of its writes`,
216
+ detail: "It denies something that was already denied, so it protects nothing. " +
217
+ "Check the path, or drop the entry if the territory moved.",
218
+ });
219
+ }
220
+ }
221
+ return warnings;
222
+ }
223
+
224
+ /** The candidate within edit distance 3 of `word`, or null. */
225
+ function nearest(word, candidates) {
226
+ // Spellings of the same idea in another vocabulary, checked first because
227
+ // letters mislead here: `no_writes` is three edits from `writes`, and
228
+ // suggesting the grant for a misspelt subtraction is the worst answer.
229
+ if (/deny|never|no_?write/i.test(word)) return "never_writes";
230
+ let best = null, bestD = 4;
231
+ for (const c of candidates) {
232
+ const d = distance(word.toLowerCase(), c);
233
+ if (d < bestD) { best = c; bestD = d; }
234
+ }
235
+ return best;
236
+ }
237
+
238
+ function distance(a, b) {
239
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
240
+ for (let i = 1; i <= a.length; i++) {
241
+ let prev = row[0]; row[0] = i;
242
+ for (let j = 1; j <= b.length; j++) {
243
+ const cur = row[j];
244
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
245
+ prev = cur;
246
+ }
247
+ }
248
+ return row[b.length];
249
+ }
250
+
93
251
  /**
94
252
  * Paths more than one role claims.
95
253
  *
@@ -102,7 +260,21 @@ export function sharedPaths(config, roles = Object.values(config.roles)) {
102
260
  for (const r of roles)
103
261
  for (const glob of r.writes)
104
262
  if (ownersOf(config, glob.replace(/\/\*\*$/, "")).length > 1) seen.add(glob);
105
- return [...seen];
263
+ /**
264
+ * A shared database counts once, not four times.
265
+ *
266
+ * `writes` carries the sidecar expansion so that the grant and the sentence
267
+ * agree (see config.js), and this list read straight off it turned nine
268
+ * shared things into thirty-three — the same name with three suffixes,
269
+ * announced as if it were three more decisions to make. The expansion exists
270
+ * to stop a policy saying one thing four times; a warning that undoes that
271
+ * is the feature leaking into the place it was meant to clean up.
272
+ *
273
+ * A sidecar shared WITHOUT its database still counts on its own, because
274
+ * that is a real and odd thing to have.
275
+ */
276
+ const todas = new Set(seen);
277
+ return [...seen].filter((p) => !(/-(wal|shm|journal)$/.test(p) && todas.has(p.replace(/-(wal|shm|journal)$/, ""))));
106
278
  }
107
279
 
108
280
  /**
@@ -147,6 +319,58 @@ function homeReachWarning(config) {
147
319
 
148
320
 
149
321
  /** Every way this policy does not hold, each with what to do about it. */
322
+ /**
323
+ * Credential-shaped names at the top of the repository, and nothing deeper.
324
+ *
325
+ * One `readdir`, no recursion, conventional names only. It exists to answer
326
+ * "does this repo plausibly have secrets" fast enough to run on every `check`,
327
+ * not to find them — `seisin scan` is the one that looks properly.
328
+ */
329
+ const CREDENTIAL_NAMES = /^(\.env(\..+)?|\.secrets|secrets|\.netrc|.*\.(pem|key|p12|pfx))$/i;
330
+
331
+ function credentialish(root) {
332
+ let entries;
333
+ try {
334
+ entries = readdirSync(root, { withFileTypes: true });
335
+ } catch {
336
+ return [];
337
+ }
338
+ const found = new Set(entries.filter((e) => CREDENTIAL_NAMES.test(e.name)).map((e) => e.name));
339
+
340
+ /**
341
+ * And the ones whose name gives nothing away.
342
+ *
343
+ * The name test misses `credentials.txt`, `tokens.conf`, `config.local` —
344
+ * which a field review found by writing a test file this did not catch, and
345
+ * then blaming the test. The test was fine; the detector was name-only.
346
+ *
347
+ * So the root's small text files are also read, with the same shapes
348
+ * `seisin scan` uses. Root only, small only, and it stops at the first hit
349
+ * per file: this runs on every `check` and the full walk is `scan`'s job.
350
+ */
351
+ const CERTAIN = SHAPES.filter(([c]) => c === "certain").map(([, , re]) => re);
352
+ for (const e of entries) {
353
+ if (found.size >= 4) break;
354
+ if (!e.isFile() || found.has(e.name)) continue;
355
+ try {
356
+ const p = join(root, e.name);
357
+ if (statSync(p).size > 64 * 1024) continue;
358
+ const text = readFileSync(p, "utf8");
359
+ if (CERTAIN.some((re) => re.test(text))) found.add(e.name);
360
+ } catch {
361
+ /* unreadable, or not text: not our business here */
362
+ }
363
+ }
364
+ return [...found].sort().slice(0, 4);
365
+ }
366
+
367
+ /** Is this a command the shell would find? Walks PATH; asks no shell. */
368
+ function onPath(cmd) {
369
+ if (cmd.includes("/")) return existsSync(cmd);
370
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
371
+ return dirs.some((d) => existsSync(join(d, cmd)));
372
+ }
373
+
150
374
  function warningsFor(config, roles) {
151
375
  const warnings = [];
152
376
 
@@ -168,6 +392,34 @@ function warningsFor(config, roles) {
168
392
  });
169
393
  }
170
394
  }
395
+ warnings.push(...roleKeyWarnings(roles, config));
396
+
397
+ // A provider the parent could not find where no role writes. `run` refuses
398
+ // it with the same sentence; here it is found before anybody runs anything.
399
+ for (const [scheme, p] of Object.entries(config.keyProviders ?? {})) {
400
+ const cmd = p.command?.[0];
401
+ if (!cmd || cmd.includes("/")) continue;
402
+ try { resolveExecutable(cmd, config); } catch (e) {
403
+ if (!/a role can write/.test(e.message)) continue;
404
+ warnings.push({
405
+ kind: "provider-in-territory",
406
+ headline: `the ${scheme}:// provider "${cmd}" is only found where a role can write`,
407
+ detail: e.message.split("\n").slice(1).map((l) => l.trim()).join(" "),
408
+ });
409
+ }
410
+ }
411
+
412
+ // PATH entries that are not absolute resolve against wherever the parent is
413
+ // standing, which is the repo. They are ignored for every lookup seisin makes
414
+ // outside the box; said here because a lookup that skips them can find a
415
+ // different program than your shell does.
416
+ const unanchored = (process.env.PATH ?? "").split(delimiter).filter((d) => !d || !d.startsWith("/"));
417
+ if (unanchored.length)
418
+ warnings.push({
419
+ kind: "relative-path",
420
+ headline: `PATH has ${unanchored.length} relative entr${unanchored.length === 1 ? "y" : "ies"} (${unanchored.map((d) => JSON.stringify(d)).join(", ")})`,
421
+ detail: "They resolve inside the repo, where roles write, so seisin skips them when it looks for a program to run outside the box.",
422
+ });
171
423
  const shared = sharedPaths(config, roles);
172
424
 
173
425
  if (shared.length)
@@ -400,13 +652,84 @@ function warningsFor(config, roles) {
400
652
  });
401
653
  }
402
654
 
403
- if (config.keyDirs.length === 0 && Object.values(config.roles).some((r) => r.keys.length))
655
+ /**
656
+ * A policy with no credential floor at all, in a repo that has credentials.
657
+ *
658
+ * `[keys] dir` is what produces `denyRead`. Without it the emitted settings
659
+ * carry an empty deny list, so every role reads every secret in the
660
+ * repository — and until this warning, nothing said so. The existing
661
+ * `keys-unscoped` only fires when a role *lists* keys, which is the case
662
+ * where somebody already thought about it.
663
+ *
664
+ * The dangerous shape is the other one, and it is not hypothetical: a policy
665
+ * generated by a script. The deployment this was built against generates its
666
+ * `seisin.toml` from a Python program, and declares `[keys] dir` mostly so
667
+ * `scan` skips those directories. A generator that dropped that one line
668
+ * would hand every role the credential tree and produce a clean `check`.
669
+ *
670
+ * Credit where it is due: this is `L-01` of a code review of another
671
+ * project's contract type, which makes its credential floor impossible to
672
+ * construct empty. That is the right shape and it is a bigger change than a
673
+ * warning. This is the cheap half — and the review's claim that the file
674
+ * path was safe turned out to be generous: it was not.
675
+ *
676
+ * Deliberately NOT a full `seisin scan`: that walks the tree and `check` has
677
+ * to stay instant. A shallow look at the repository root catches the
678
+ * conventional places and costs one `readdir`.
679
+ */
680
+ if ((config.keyDirs ?? []).length === 0) {
681
+ const found = credentialish(config.root);
682
+ if (found.length)
683
+ warnings.push({
684
+ kind: "no-key-floor",
685
+ headline: `no [keys] dir is declared, and this repo has ${found.join(", ")} — every role can read them`,
686
+ detail:
687
+ "Without a key directory the emitted policy denies no reads at all. Declare the " +
688
+ "directory your secrets live in, and the roles that may read each one.",
689
+ });
690
+ }
691
+
692
+ // Only a key that is a PATH needs a key directory. A config whose secrets all
693
+ // live in a vault has no `[keys] dir` and nothing wrong with it, and warning
694
+ // there would be the tool insisting on the arrangement it was built for.
695
+ if (config.keyDirs.length === 0 &&
696
+ Object.values(config.roles).some((r) => entriesOf(r).some((k) => k.kind === "file")))
404
697
  warnings.push({
405
698
  kind: "keys-unscoped",
406
699
  headline: "keys are listed but [keys] dir is unset — nothing will be scoped",
407
700
  detail: null,
408
701
  });
409
702
 
703
+ /**
704
+ * A provider whose command is not on PATH.
705
+ *
706
+ * This is the half of validating references that can be done without
707
+ * resolving one — which is the whole requirement: a broken policy has to be
708
+ * visible in `check`, not on the first run, and certainly not by asking
709
+ * somebody's keychain for a password to find out. The scheme and the
710
+ * provider are checked when the config loads, so by the time we are here
711
+ * they exist; what is left is whether the command does.
712
+ *
713
+ * A warning and not an error: the binary can legitimately be missing on the
714
+ * machine reading the policy and present on the one that runs it, which is
715
+ * every CI checkout.
716
+ */
717
+ const used = new Set();
718
+ for (const r of Object.values(config.roles))
719
+ for (const k of entriesOf(r)) if (k.kind === "ref") used.add(k.scheme);
720
+ for (const scheme of [...used].sort()) {
721
+ const provider = config.keyProviders?.[scheme];
722
+ if (!provider || onPath(provider.command[0])) continue;
723
+ warnings.push({
724
+ kind: "provider-missing",
725
+ headline: `the ${scheme} provider runs "${provider.command[0]}", which is not on PATH`,
726
+ detail:
727
+ "every key of that scheme will stop the run rather than resolve. Nothing is " +
728
+ "substituted for a key that did not resolve, so this fails closed — but it fails " +
729
+ "at the start of a turn, not here.",
730
+ });
731
+ }
732
+
410
733
  return warnings;
411
734
  }
412
735
 
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
+ }