seisin 0.4.2 → 0.6.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.
Files changed (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Handing a role a family of control files, and protecting instruction files,
3
+ * as edits a person makes from the console.
4
+ *
5
+ * Both settings were only reachable by opening seisin.toml in an editor. That
6
+ * is fine for whoever wrote the policy and a wall for whoever inherited it:
7
+ * the console showed the refusal ("protected: .vscode, whose settings … VS
8
+ * Code applies outside the sandbox") and offered no way to act on it except
9
+ * approving a request that a grant can never satisfy (refuseIfBarred says so).
10
+ *
11
+ * What this module does not do is widen the menu. The families are exactly
12
+ * config.js's CONTROL_FAMILIES; `.claude/`, git hooks, `.mcp.json` and `.envrc`
13
+ * are never offered, because they are what a program outside the sandbox executes
14
+ * and no role writes them — not from the file, not from here.
15
+ *
16
+ * Every edit is text, bounded to one table, like applyGrant: comments and the
17
+ * order a person gave the file survive, and a change for one role can never
18
+ * land in another role's table.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { loadConfig, CONTROL_FAMILIES, stripComment, closesArray } from "./config.js";
22
+ import { settingsFor } from "./srt.js";
23
+ import { denyFor } from "./surface.js";
24
+ import { cleanReason } from "./requests.js";
25
+
26
+ const RE_HEADER = /^\[([A-Za-z0-9_.\-]+)\]$/;
27
+ const RE_KEY = /^([A-Za-z0-9_\-]+)\s*=/;
28
+
29
+ /** An error that is the caller's fault: the server answers it with 400. */
30
+ function bad(message) {
31
+ return Object.assign(new Error(message), { status: 400 });
32
+ }
33
+
34
+ export const hashOf = (text) => createHash("sha256").update(text).digest("hex").slice(0, 24);
35
+
36
+ /**
37
+ * The families as the caller sent them, validated and in the canonical order.
38
+ *
39
+ * Refused, not filtered: a request for ".claude" is somebody asking for the
40
+ * one thing this tool will not hand out, and silently dropping it would save a
41
+ * policy that is not what they asked for.
42
+ */
43
+ export function normalizeFamilies(families) {
44
+ if (!Array.isArray(families) || families.some((f) => typeof f !== "string"))
45
+ throw bad(`families must be a list of names — some of ${CONTROL_FAMILIES.map((f) => JSON.stringify(f)).join(", ")}`);
46
+ for (const f of families)
47
+ if (!CONTROL_FAMILIES.includes(f))
48
+ throw bad(`${JSON.stringify(f)} is not a family of control files. Known: ` +
49
+ `${CONTROL_FAMILIES.map((x) => JSON.stringify(x)).join(", ")}. The others ` +
50
+ `(.claude, git hooks, .mcp.json, .envrc) are never handed to a role.`);
51
+ return CONTROL_FAMILIES.filter((f) => families.includes(f));
52
+ }
53
+
54
+ /**
55
+ * The lines of one table: where its header is, where it ends, and each
56
+ * key's first and last line. A multi-line array is one key, so a line inside
57
+ * one is never taken for a header or for another key.
58
+ */
59
+ function tableOf(lines, name) {
60
+ let header = -1;
61
+ for (let i = 0; i < lines.length; i++) {
62
+ const h = RE_HEADER.exec(stripComment(lines[i]).trim());
63
+ if (h && h[1] === name) { header = i; break; }
64
+ }
65
+ if (header === -1) return null;
66
+ const keys = [];
67
+ let end = lines.length;
68
+ for (let i = header + 1; i < lines.length; i++) {
69
+ const line = stripComment(lines[i]).trim();
70
+ if (!line) continue;
71
+ if (RE_HEADER.test(line)) { end = i; break; }
72
+ const k = RE_KEY.exec(line);
73
+ if (!k) continue;
74
+ const from = i;
75
+ const value = line.slice(line.indexOf("=") + 1).trim();
76
+ if (value.startsWith("[") && !closesArray(value))
77
+ while (i + 1 < lines.length && !closesArray(stripComment(lines[++i]).trim()));
78
+ keys.push({ key: k[1], from, to: i });
79
+ }
80
+ return { header, end, keys };
81
+ }
82
+
83
+ function stamp(note, source = "in the console") {
84
+ return `# set ${source} ${new Date().toISOString().slice(0, 10)}` +
85
+ (note ? ` · «${cleanReason(note)}»` : "");
86
+ }
87
+
88
+ /**
89
+ * Sets `key = value` inside one table: replaces the line (or the lines, for a
90
+ * multi-line array) where the key was, or adds it after the table's last key.
91
+ * The replaced line's own trailing comment goes with it — it described the
92
+ * value being replaced, and the stamp now describes the new one. Every other
93
+ * line, comments included, stays byte for byte.
94
+ */
95
+ export function setKey(text, table, key, value, note, source) {
96
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
97
+ const lines = text.split("\n");
98
+ const cr = eol === "\r\n" ? "\r" : "";
99
+ const t = tableOf(lines, table);
100
+ if (!t) return null;
101
+ const at = t.keys.find((k) => k.key === key);
102
+ if (at) {
103
+ // The same value is no edit: re-stamping it would record a decision
104
+ // nobody made, and move the date of the one somebody did.
105
+ const was = lines.slice(at.from, at.to + 1).map((l) => stripComment(l).trim()).join(" ");
106
+ if (was.slice(was.indexOf("=") + 1).replace(/\s+/g, "").replace(/,\]$/, "]") === value.replace(/\s+/g, "")) return text;
107
+ const indent = /^\s*/.exec(lines[at.from])[0];
108
+ const line = `${indent}${key} = ${value} ${stamp(note, source)}${cr}`;
109
+ lines.splice(at.from, at.to - at.from + 1, line);
110
+ } else {
111
+ // After the last key, not the last line: a comment above the next table
112
+ // belongs to that table, and a line inserted under it would read as the
113
+ // next role's.
114
+ const after = t.keys.length ? t.keys[t.keys.length - 1].to : t.header;
115
+ lines.splice(after + 1, 0, `${key} = ${value} ${stamp(note, source)}${cr}`);
116
+ }
117
+ return lines.join("\n");
118
+ }
119
+
120
+ const listOf = (families) => `[${families.map((f) => `"${f}"`).join(", ")}]`;
121
+
122
+ /**
123
+ * The policy text with `control_files` of one role set to `families`.
124
+ *
125
+ * An empty list is written as `control_files = []` with its stamp when the key
126
+ * was there, rather than deleted: the line is the record that somebody took
127
+ * the family away, and when. A role that never had the key and still has none
128
+ * is left alone.
129
+ */
130
+ export function applyControlFiles(toml, role, families, note = "") {
131
+ const want = normalizeFamilies(families);
132
+ const lines = toml.split("\n");
133
+ const t = tableOf(lines, `roles.${role}`);
134
+ if (!t) throw bad(`no [roles.${role}] section in the policy`);
135
+ if (!want.length && !t.keys.some((k) => k.key === "control_files")) return { toml, changed: false };
136
+ const out = setKey(toml, `roles.${role}`, "control_files", listOf(want), note);
137
+ return { toml: out, changed: out !== toml };
138
+ }
139
+
140
+ /**
141
+ * The policy text with `[protect] instructions` set.
142
+ *
143
+ * A missing `[protect]` table is created only to turn the protection on
144
+ * (off is the default and needs no line), and it goes above the first role:
145
+ * it is a setting about every role, and a reader meets it before the roles
146
+ * it changes. A comment block that sits directly above that role stays with
147
+ * the role.
148
+ */
149
+ export function applyProtect(toml, on, note = "") {
150
+ if (typeof on !== "boolean") throw bad("protect must be true or false");
151
+ const lines = toml.split("\n");
152
+ if (tableOf(lines, "protect")) {
153
+ const out = setKey(toml, "protect", "instructions", String(on), note);
154
+ return { toml: out, changed: out !== toml };
155
+ }
156
+ if (!on) return { toml, changed: false };
157
+ const cr = toml.includes("\r\n") ? "\r" : "";
158
+ let at = lines.findIndex((l) => /^roles\./.test(RE_HEADER.exec(stripComment(l).trim())?.[1] ?? ""));
159
+ const block = [`[protect]${cr}`, `instructions = true ${stamp(note)}${cr}`, cr];
160
+ if (at === -1) {
161
+ // Unreachable for a policy that loads (it has roles), kept total anyway.
162
+ const sep = toml.endsWith("\n") || !toml ? "" : "\n";
163
+ return { toml: `${toml}${sep}\n${block[0]}\n${block[1]}\n`, changed: true };
164
+ }
165
+ while (at > 0 && /^\s*#/.test(lines[at - 1])) at--;
166
+ lines.splice(at, 0, ...block);
167
+ return { toml: lines.join("\n"), changed: true };
168
+ }
169
+
170
+ /** Which roles may write each family after the edit. `null`: nothing protects it. */
171
+ export function accessOf(config) {
172
+ const roles = Object.values(config.roles);
173
+ return {
174
+ ide: roles.filter((r) => r.controlFiles.includes("ide")).map((r) => r.name),
175
+ instructions: config.protect?.instructions
176
+ ? roles.filter((r) => r.controlFiles.includes("instructions")).map((r) => r.name)
177
+ : null,
178
+ };
179
+ }
180
+
181
+ /** A role's denyWrite, each entry with the reason denyFor gives for it. */
182
+ function deniesOf(config, name) {
183
+ const why = new Map();
184
+ try { for (const e of denyFor(config, config.roles[name])) why.set(e.path, e.why); } catch {}
185
+ const list = settingsFor(config, name).filesystem?.denyWrite ?? [];
186
+ return new Map(list.map((p) => [p, why.get(p) ?? "never_writes, or a runtime default"]));
187
+ }
188
+
189
+ /**
190
+ * What the edit changes in each role's sandbox profile, by denyWrite entry.
191
+ *
192
+ * Computed from the two policies as `seisin run` would build them — settingsFor
193
+ * over the text before and the text after — never from a description of the
194
+ * families. A role whose territory holds no project where these files could
195
+ * be gets an empty diff, and that is the honest answer: the toggle changes
196
+ * nothing for it today.
197
+ */
198
+ export function profileDiff(before, after, names) {
199
+ return names.map((name) => {
200
+ try {
201
+ const a = deniesOf(before, name);
202
+ const b = deniesOf(after, name);
203
+ return {
204
+ role: name,
205
+ added: [...b].filter(([p]) => !a.has(p)).map(([path, why]) => ({ path, why })),
206
+ removed: [...a].filter(([p]) => !b.has(p)).map(([path, why]) => ({ path, why })),
207
+ };
208
+ } catch (e) {
209
+ return { role: name, error: e.message, added: [], removed: [] };
210
+ }
211
+ });
212
+ }
213
+
214
+ /**
215
+ * One edit, described without writing it: the new text, and what it changes.
216
+ *
217
+ * The new text is loaded with the real loader against the real root, so an
218
+ * edit that would leave a policy that no longer loads is refused here, before
219
+ * anything is written.
220
+ */
221
+ export function planEdit(config, text, edit) {
222
+ let result;
223
+ if (edit.kind === "role") {
224
+ if (typeof edit.role !== "string" || !Object.hasOwn(config.roles, edit.role))
225
+ throw bad(`unknown role ${JSON.stringify(edit.role)}`);
226
+ result = applyControlFiles(text, edit.role, edit.families, edit.reason);
227
+ } else {
228
+ result = applyProtect(text, edit.on, edit.reason);
229
+ }
230
+ let after;
231
+ try { after = loadConfig(config.path, result.toml); }
232
+ catch (e) { throw bad(`the edited policy would not load, so nothing was written: ${e.message}`); }
233
+ const names = edit.kind === "role" ? [edit.role] : Object.keys(config.roles);
234
+ const diff = result.changed ? profileDiff(config, after, names).filter((d) => d.error || d.added.length || d.removed.length) : [];
235
+ return {
236
+ changed: result.changed,
237
+ toml: result.toml,
238
+ base: hashOf(text),
239
+ diff,
240
+ access: accessOf(after),
241
+ protect: { instructions: after.protect.instructions },
242
+ ...(edit.kind === "role" && { role: edit.role, controlFiles: after.roles[edit.role].controlFiles }),
243
+ };
244
+ }
package/src/diagnose.js CHANGED
@@ -2,10 +2,10 @@
2
2
  * The refusal the agent never heard, said after the fact.
3
3
  *
4
4
  * PreToolUse sees the path a tool call names, and that is all it sees. What the
5
- * kernel refuses on its own — a child process, `/bin/rm` inside a script, a
5
+ * kernel denies on its own — a child process, `/bin/rm` inside a script, a
6
6
  * path the regex did not catch — reaches the agent as `Operation not permitted`
7
- * with no path and no reason, and it retries. Measured on a real portfolio from
8
- * 2026-09-14 to 2026-09-22: 5,617 refusals, all of them from the kernel, 72%
7
+ * with no path and no reason, and it retries. Measured on a multi-repo workspace from
8
+ * 2026-09-14 to 2026-09-22: 5,617 denials, all of them from the kernel, 72%
9
9
  * the same role hitting the same path again.
10
10
  *
11
11
  * Two hooks close that, the shape nono uses for its sandbox diagnostics:
@@ -15,7 +15,7 @@
15
15
  * refusals from the last moments and hand the agent the same sentence the
16
16
  * PreToolUse hook would have — whose it is, how many times, and that it
17
17
  * should hand it over rather than retry. The kernel's line reaches the log
18
- * about 30 ms after the refusal (measured inside the box), so this reads it.
18
+ * about 30 ms after the refusal (measured inside the sandbox), so this reads it.
19
19
  * - when a session starts, resumes or is compacted (SessionStart): the role's
20
20
  * territory and what it keeps being refused, before the first refusal —
21
21
  * and right after compaction, which is when an agent forgets what it was
@@ -28,10 +28,14 @@
28
28
  * On Linux the runtime's refusals cannot be read from outside (violations.js),
29
29
  * so the after-the-fact half finds nothing there and stays quiet.
30
30
  */
31
- import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
31
+ import { isAbsolute } from "node:path";
32
+ import { readEntries } from "./log.js";
32
33
  import { explain } from "./owners.js";
33
- import { timesHit, walls } from "./walls.js";
34
+ import { hitsOf, againWords, walls } from "./walls.js";
34
35
  import { collapseSidecars } from "./render.js";
36
+ import { decideHandoff, toAgent, isStopped, ELSEWHERE } from "./handoff.js";
37
+ import { keyOf } from "./requests.js";
38
+ import { destinationOf } from "./intake.js";
35
39
 
36
40
  /** What a refused write or read looks like in a tool's output. A trigger to look, not a verdict. */
37
41
  export const DENIAL_SIGNS = /Operation not permitted|EPERM|EACCES|Permission denied|Read-only file system/i;
@@ -64,38 +68,19 @@ export function knownRefusals(evidence) {
64
68
 
65
69
  /** How far back a refusal still belongs to the call that just failed. */
66
70
  const WINDOW_MS = 120_000;
67
- /** Read only the end of the log: this runs on every failed command. */
71
+ /** Read only the end of the log: this runs on every failed command. The line the cut lands in is dropped (readLines). */
68
72
  const TAIL_BYTES = 512 * 1024;
69
73
  /** How many paths to name. More than this is a wall of text nobody acts on. */
70
74
  const MAX_NAMED = 3;
71
75
 
72
- function tail(file) {
73
- if (!existsSync(file)) return [];
74
- const { size } = statSync(file);
75
- const from = Math.max(0, size - TAIL_BYTES);
76
- const fd = openSync(file, "r");
77
- try {
78
- const buf = Buffer.alloc(size - from);
79
- readSync(fd, buf, 0, buf.length, from);
80
- const lines = buf.toString("utf8").split("\n");
81
- if (from > 0) lines.shift(); // the first line is cut in half
82
- const out = [];
83
- for (const l of lines) {
84
- if (!l.trim()) continue;
85
- try { out.push(JSON.parse(l)); } catch { /* a line being written */ }
86
- }
87
- return out;
88
- } finally {
89
- closeSync(fd);
90
- }
91
- }
92
-
93
76
  /** This role's refusals by the kernel since `sinceMs`, one per path, newest first. */
94
77
  export function recentKernelDenials(file, role, sinceMs) {
95
78
  const since = new Date(sinceMs).toISOString();
96
79
  const seen = new Map();
97
- for (const e of tail(file).reverse()) {
80
+ for (const e of readEntries(file, { tail: TAIL_BYTES }).reverse()) {
98
81
  if (e.role !== role || e.verdict !== "denied" || e.source !== "kernel") continue;
82
+ // A system service is not a path the agent can act on, and the sentence below is about paths.
83
+ if (e.kind === "system") continue;
99
84
  if (!e.at || e.at < since || !e.target) continue;
100
85
  const k = `${e.action}\u0000${e.target}`;
101
86
  if (!seen.has(k)) seen.set(k, e);
@@ -104,41 +89,80 @@ export function recentKernelDenials(file, role, sinceMs) {
104
89
  }
105
90
 
106
91
  /** The sentence for one refusal, in the words the PreToolUse hook uses. */
107
- function sentence(config, role, file, e) {
92
+ function sentence(config, role, file, e, chain = null, run = null) {
108
93
  const action = e.kind === "key" ? "read" : e.action === "read" || e.action === "connect" ? e.action : "write";
94
+ // An atomic save refused at its temporary file is about the file it was
95
+ // saving. Said about that file, or the agent goes looking for a missing
96
+ // directory (measured).
97
+ const destination = action === "write" ? destinationOf(e.target) : null;
98
+ if (destination) {
99
+ if (explain(config, role, "write", destination).allowed)
100
+ return `${destination} was not saved: it is ${role}'s to write, but the sandbox denied the temporary file ` +
101
+ `${e.target} that the save writes beside it, and no grant can name that file. Write ${destination} with the shell ` +
102
+ "instead (a redirect writes the file itself).";
103
+ e = { ...e, target: destination };
104
+ }
109
105
  const v = explain(config, role, action, e.target);
110
106
  if (v.allowed) return null; // the policy moved since: nothing to say
111
- const times = timesHit(file, role, action, e.target);
112
- const again = times > 1 ? ` Refused ${times} times now; the next try ends the same way.` : "";
113
- if (v.neverWrites) return `${v.reason}. Do not ask for it.${again}`;
114
- if (v.owners?.length)
115
- return `${e.target} belongs to ${v.owners.join(", ")}. It is not ${role}'s to change — ` +
116
- `hand it over rather than working around it.${again}`;
107
+ // Read after the refusal was logged, so the counts include it.
108
+ const { total: times, inRun } = hitsOf(file, role, action, e.target, run);
109
+ const again = againWords({ times, inRun });
110
+ if (v.neverWrites) return `${v.reason}. Do not ask for it. ${ELSEWHERE}${again}`;
111
+ // A write, or a key: the denials that become requests, worded from the same
112
+ // handoff decision the hook and the queue use. Everything else — a protected
113
+ // file, a connection, a read the mode shuts — keeps the policy's own reason.
114
+ const handed = (action === "write" || e.kind === "key") && !v.gitMetadata && !v.protected && !v.privateTo;
115
+ if (handed)
116
+ return toAgent(decideHandoff({ request: { role, action, target: e.target }, config, chain }),
117
+ { target: e.target, role, key: keyOf({ role, action, target: e.target }), again });
117
118
  return `${e.target}: ${v.reason}.${again}`;
118
119
  }
119
120
 
120
121
  const PREFACE =
121
- "seisin — the sandbox this agent runs in — refused part of that. This is not a Unix permission " +
122
+ "The sandbox this agent runs in, set up by seisin, denied part of that. This is not a Unix permission " +
122
123
  "or a macOS privacy prompt: chmod, sudo or a different path to the same file will not change it.";
123
124
 
125
+ /**
126
+ * What the tool said, and whether it said it failed.
127
+ *
128
+ * PostToolUseFailure is a failure by definition, and its evidence is all of it.
129
+ * PostToolUse is a Bash result, and most of those are successes: a command that
130
+ * prints "Permission denied" to stdout — a grep through logs, a test that
131
+ * checks for it — used to pay the full retry schedule (413 ms measured) to
132
+ * find nothing. So there, only stderr counts, and only an explicit failure
133
+ * (a non-zero exit code, or `is_error`) is a reason to wait for the kernel.
134
+ * A success with the words on stderr — `cmd; true` swallowed the status —
135
+ * still gets one look at the log, just not the waits.
136
+ */
137
+ function evidenceOf(event) {
138
+ if (event.hook_event_name !== "PostToolUse")
139
+ return { text: JSON.stringify({ r: event.tool_response, e: event.error, o: event.tool_output }), failed: true };
140
+ const r = event.tool_response;
141
+ if (typeof r === "string") return { text: JSON.stringify(r), failed: false };
142
+ const code = r?.exit_code ?? r?.exitCode ?? r?.code;
143
+ const failed = r?.is_error === true || (typeof code === "number" && code !== 0);
144
+ return { text: JSON.stringify(typeof r?.stderr === "string" ? r.stderr : ""), failed };
145
+ }
146
+
124
147
  /**
125
148
  * After a tool call failed. Returns the hook output, or null to say nothing.
126
149
  *
127
150
  * `wait` retries the read a few times: the kernel's line lands ~30 ms after the
128
- * refusal, and a loaded machine can be slower than the hook.
151
+ * refusal, and a loaded machine can be slower than the hook. Only when the tool
152
+ * reported a failure — see {@link evidenceOf}.
129
153
  */
130
- export async function afterTool(config, role, event, { file, now = Date.now, wait = [0, 100, 300] } = {}) {
131
- const evidence = JSON.stringify({ r: event.tool_response, e: event.error, o: event.tool_output });
154
+ export async function afterTool(config, role, event, { file, now = Date.now, wait = [0, 100, 300], chain = null, run = process.env.SEISIN_RUN_ID ?? null } = {}) {
155
+ const { text: evidence, failed } = evidenceOf(event);
132
156
  if (!DENIAL_SIGNS.test(evidence)) return null;
133
157
  // JSON escapes what the program printed; the signatures are written against the text.
134
158
  const known = knownRefusals(evidence.replace(/\\n/g, "\n").replace(/\\"/g, '"'));
135
159
  let found = [];
136
- for (const ms of wait) {
160
+ for (const ms of failed ? wait : [0]) {
137
161
  if (ms) await new Promise((ok) => setTimeout(ok, ms));
138
162
  found = recentKernelDenials(file, role, now() - WINDOW_MS);
139
163
  if (found.length) break;
140
164
  }
141
- const lines = [...known, ...found.map((e) => sentence(config, role, file, e)).filter(Boolean)].slice(0, MAX_NAMED);
165
+ const lines = [...known, ...found.map((e) => sentence(config, role, file, e, chain, run)).filter(Boolean)].slice(0, MAX_NAMED);
142
166
  if (!lines.length) return null;
143
167
  const total = known.length + found.length;
144
168
  const more = total > MAX_NAMED ? ` (and ${total - MAX_NAMED} more — \`seisin walls ${role}\`)` : "";
@@ -151,7 +175,7 @@ export async function afterTool(config, role, event, { file, now = Date.now, wai
151
175
  }
152
176
 
153
177
  /** When a session starts, resumes or is compacted: the map, before the first wall. */
154
- export function atSessionStart(config, role, event, { file } = {}) {
178
+ export function atSessionStart(config, role, event, { file, chain = null } = {}) {
155
179
  const r = config.roles[role];
156
180
  const writes = collapseSidecars(r.writesDeclared ?? r.writes);
157
181
  const lines = [
@@ -160,13 +184,42 @@ export function atSessionStart(config, role, event, { file } = {}) {
160
184
  ];
161
185
  if ((r.neverWritesDeclared ?? r.neverWrites ?? []).length)
162
186
  lines.push(`Never, even inside that: ${(r.neverWritesDeclared ?? r.neverWrites).join(", ")}.`);
187
+ if (r.private?.length) lines.push(`Private to you (yours to read and write; no other role reads it): ${r.private.join(", ")}.`);
188
+ const theirs = Object.values(config.roles).filter((o) => o.name !== role && o.private?.length)
189
+ .map((o) => `${o.private.join(", ")} (${o.name})`);
190
+ if (theirs.length) lines.push(`Private to another role — you can neither read nor write it: ${theirs.join("; ")}.`);
163
191
  if (r.keys.length) lines.push(`Keys you hold: ${r.keys.join(", ")}.`);
164
- const w = file ? walls(config, role, { file, limit: 5, fresh: true }) : [];
165
- if (w.length) {
166
- lines.push("Already refused more than once, and still refused — do not try again, hand it over:");
167
- for (const x of w)
168
- lines.push(`- ${x.action} ${x.target} (${x.times}×) — ${x.owners?.length ? `belongs to ${x.owners.join(", ")}` : x.reason}`);
192
+ // An expected refusal (Claude Code's plugin updater) is not the agent's to
193
+ // stop trying, so it does not take one of the five lines.
194
+ const w = file ? walls(config, role, { file, limit: 0, fresh: true }).filter((x) => !x.expected).slice(0, 5) : [];
195
+ // Outside the repository nobody owns anything, so there is no one to hand
196
+ // it to: under "hand it over", ~/.npm/_logs read as work for somebody else.
197
+ const outside = (x) => isAbsolute(x.target) || x.target.startsWith("~");
198
+ // Where this run's handoff chain stops, the wall names no owner: measured,
199
+ // an agent told the owner here repeated it after the sentence its denial
200
+ // told it to pass on alone.
201
+ const stops = (x) => isStopped(decideHandoff({ request: { role, action: x.action, target: x.target }, config, chain }));
202
+ const inRepo = w.filter((x) => !outside(x)), out = w.filter(outside);
203
+ const stopped = inRepo.filter(stops), mine = inRepo.filter((x) => !stops(x));
204
+ const wall = (x) => `- ${x.action} ${x.target} (${x.times}×) — ${x.owners?.length ? `belongs to ${x.owners.join(", ")}` : x.reason}`;
205
+ if (mine.length) {
206
+ lines.push("Already denied more than once, and still denied — do not try again, hand it over:");
207
+ for (const x of mine) lines.push(wall(x));
208
+ }
209
+ if (stopped.length) {
210
+ lines.push("Already denied more than once, where this run's handoff chain stops — do not try again; a person decides, " +
211
+ "and the denial gives you the one sentence to pass on:");
212
+ for (const x of stopped) lines.push(`- ${x.action} ${x.target} (${x.times}×)`);
213
+ }
214
+ if (out.length) {
215
+ lines.push("Denied more than once outside the repository, where no role owns anything — nothing to hand over; leave it:");
216
+ for (const x of out) lines.push(`- ${x.action} ${x.target} (${x.times}×)`);
169
217
  }
170
- lines.push("A file outside that is somebody else's: say whose it is and hand it over, rather than working around it.");
218
+ // In a handed run a denial can be where the chain stops, and there the agent
219
+ // is to say one sentence and not whose the file is: the map cannot ask the
220
+ // opposite.
221
+ lines.push(chain?.visited?.length
222
+ ? "A file outside that is not yours to change: do not work around it. Where a denial gives you a sentence to pass on, pass on that sentence and nothing else about the file."
223
+ : "A file outside that is somebody else's: say whose it is and hand it over, rather than working around it.");
171
224
  return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } };
172
225
  }
package/src/env.js CHANGED
@@ -29,6 +29,9 @@ export const BASE = [
29
29
  "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME",
30
30
  "TERM", "TERM_PROGRAM", "COLORTERM", "LANG", "LC_ALL", "LC_CTYPE", "TZ",
31
31
  "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
32
+ // The launcher's W3C trace context: ids that join the agent's own telemetry to
33
+ // the launcher's trace (trace.js). By the spec they are not secrets.
34
+ "TRACEPARENT", "TRACESTATE",
32
35
  ];
33
36
 
34
37
  /**
@@ -39,8 +42,8 @@ export const BASE = [
39
42
  * needs **set**, not forwarded.
40
43
  *
41
44
  * `GIT_OPTIONAL_LOCKS=0` is the whole list, and it is here because of what the
42
- * denial log looks like without it. Measured on a real portfolio: of the last
43
- * 60 refusals, **58 were `.git/index.lock`** — 97% — and most carried no owner
45
+ * denial log looks like without it. Measured on a multi-repo workspace: of the last
46
+ * 60 denials, **58 were `.git/index.lock`** — 97% — and most carried no owner
44
47
  * at all, so not one of them was a territory question. `git status` and
45
48
  * `git diff` refresh the index as a courtesy, refreshing it takes the lock, and
46
49
  * a role reading a repo it does not own trips the boundary while doing nothing
@@ -64,7 +67,7 @@ export const DEFAULTS = {
64
67
  GIT_OPTIONAL_LOCKS: "0",
65
68
  /**
66
69
  * Node's built-in `fetch()` ignores HTTP_PROXY/HTTPS_PROXY unless told
67
- * otherwise, so inside the box it tries DNS directly and dies with ENOTFOUND
70
+ * otherwise, so inside the sandbox it tries DNS directly and dies with ENOTFOUND
68
71
  * while `npm`, which reads the proxy itself, works. Measured on Node 24.2:
69
72
  * ENOTFOUND without it, 200 with it. It only makes Node use the proxy the
70
73
  * sandbox already routes everything through — nothing is widened. Older
@@ -90,12 +93,15 @@ export const DEFAULTS = {
90
93
  };
91
94
 
92
95
  /**
93
- * Names that must never ride along, even if a role asks for them by pattern.
96
+ * Names that ride along only when the role names them itself.
94
97
  *
95
98
  * A role can name a variable explicitly and get it — that is the point of the
96
- * `env` list. What this blocks is a wildcard quietly sweeping up a credential,
97
- * which is how "pass through what the build needs" becomes "pass through
98
- * everything" three commits later.
99
+ * `env` list. Everything else that crosses comes from BASE or from a caller's
100
+ * `extra`, and this keeps a credential-shaped name out of both. Today it
101
+ * filters nothing: no BASE name matches it and `run` passes no `extra`. It is
102
+ * kept, not deleted, as the guard for the day either list grows — a
103
+ * `SSH_AUTH_SOCK` added "for git" would otherwise cross for every role
104
+ * silently — and for embedders who pass `extra`.
99
105
  */
100
106
  const NEVER = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|SESSION|COOKIE|PRIVATE)(_|$)/i;
101
107
 
@@ -118,7 +124,7 @@ export function buildEnv(parent, role, extra = []) {
118
124
  if (!wanted.has(name)) { dropped.push(name); continue; }
119
125
  if (NEVER.test(name) && !(role.env ?? []).includes(name)) { dropped.push(name); continue; }
120
126
  // Not even when named: whoever can read the notify URL can send the person
121
- // a fake "approve #3". Only the parent uses it (notify.js).
127
+ // a fake "grant #3". Only the parent uses it (notify.js).
122
128
  if (name === "SEISIN_NOTIFY_URL") { dropped.push(name); continue; }
123
129
  env[name] = value;
124
130
  }