seisin 0.4.2 → 0.5.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 (50) hide show
  1. package/README.md +142 -54
  2. package/package.json +3 -3
  3. package/src/cli.js +145 -46
  4. package/src/commands/check.js +3 -2
  5. package/src/commands/explain.js +34 -13
  6. package/src/commands/hook.js +14 -4
  7. package/src/commands/init.js +126 -38
  8. package/src/commands/log.js +48 -9
  9. package/src/commands/requests.js +109 -32
  10. package/src/commands/review.js +41 -16
  11. package/src/commands/run.js +159 -37
  12. package/src/commands/scan.js +18 -3
  13. package/src/commands/ui.js +18 -5
  14. package/src/commands/walls.js +25 -6
  15. package/src/commands/whose.js +7 -6
  16. package/src/commands/wire.js +156 -17
  17. package/src/config.js +187 -25
  18. package/src/controls.js +244 -0
  19. package/src/diagnose.js +35 -33
  20. package/src/env.js +11 -8
  21. package/src/grants.js +15 -7
  22. package/src/help.js +159 -0
  23. package/src/hook.js +49 -19
  24. package/src/index.js +4 -4
  25. package/src/inspect.js +137 -71
  26. package/src/intake.js +20 -15
  27. package/src/keys.js +13 -11
  28. package/src/kinds.js +207 -0
  29. package/src/log.js +236 -51
  30. package/src/mcp.js +42 -28
  31. package/src/notify.js +13 -8
  32. package/src/owners.js +198 -41
  33. package/src/paths.js +37 -1
  34. package/src/redact.js +19 -5
  35. package/src/render.js +68 -10
  36. package/src/requests.js +173 -72
  37. package/src/review.js +30 -17
  38. package/src/rundir.js +4 -6
  39. package/src/scan.js +177 -19
  40. package/src/serve.js +377 -229
  41. package/src/spool.js +9 -29
  42. package/src/srt.js +38 -21
  43. package/src/suggest.js +58 -0
  44. package/src/surface.js +191 -44
  45. package/src/uilink.js +10 -4
  46. package/src/views.js +289 -0
  47. package/src/violations.js +104 -16
  48. package/src/walls.js +19 -42
  49. package/src/worktree.js +3 -11
  50. package/ui/index.html +1356 -537
@@ -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) {
84
+ return `# set in the console ${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
+ function setKey(text, table, key, value, note) {
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)}${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)}${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,7 +28,7 @@
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 { readEntries } from "./log.js";
32
32
  import { explain } from "./owners.js";
33
33
  import { timesHit, walls } from "./walls.js";
34
34
  import { collapseSidecars } from "./render.js";
@@ -64,37 +64,16 @@ export function knownRefusals(evidence) {
64
64
 
65
65
  /** How far back a refusal still belongs to the call that just failed. */
66
66
  const WINDOW_MS = 120_000;
67
- /** Read only the end of the log: this runs on every failed command. */
67
+ /** Read only the end of the log: this runs on every failed command. The line the cut lands in is dropped (readLines). */
68
68
  const TAIL_BYTES = 512 * 1024;
69
69
  /** How many paths to name. More than this is a wall of text nobody acts on. */
70
70
  const MAX_NAMED = 3;
71
71
 
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
72
  /** This role's refusals by the kernel since `sinceMs`, one per path, newest first. */
94
73
  export function recentKernelDenials(file, role, sinceMs) {
95
74
  const since = new Date(sinceMs).toISOString();
96
75
  const seen = new Map();
97
- for (const e of tail(file).reverse()) {
76
+ for (const e of readEntries(file, { tail: TAIL_BYTES }).reverse()) {
98
77
  if (e.role !== role || e.verdict !== "denied" || e.source !== "kernel") continue;
99
78
  if (!e.at || e.at < since || !e.target) continue;
100
79
  const k = `${e.action}\u0000${e.target}`;
@@ -118,22 +97,45 @@ function sentence(config, role, file, e) {
118
97
  }
119
98
 
120
99
  const PREFACE =
121
- "seisin — the sandbox this agent runs in — refused part of that. This is not a Unix permission " +
100
+ "The sandbox this agent runs in, set up by seisin, denied part of that. This is not a Unix permission " +
122
101
  "or a macOS privacy prompt: chmod, sudo or a different path to the same file will not change it.";
123
102
 
103
+ /**
104
+ * What the tool said, and whether it said it failed.
105
+ *
106
+ * PostToolUseFailure is a failure by definition, and its evidence is all of it.
107
+ * PostToolUse is a Bash result, and most of those are successes: a command that
108
+ * prints "Permission denied" to stdout — a grep through logs, a test that
109
+ * checks for it — used to pay the full retry schedule (413 ms measured) to
110
+ * find nothing. So there, only stderr counts, and only an explicit failure
111
+ * (a non-zero exit code, or `is_error`) is a reason to wait for the kernel.
112
+ * A success with the words on stderr — `cmd; true` swallowed the status —
113
+ * still gets one look at the log, just not the waits.
114
+ */
115
+ function evidenceOf(event) {
116
+ if (event.hook_event_name !== "PostToolUse")
117
+ return { text: JSON.stringify({ r: event.tool_response, e: event.error, o: event.tool_output }), failed: true };
118
+ const r = event.tool_response;
119
+ if (typeof r === "string") return { text: JSON.stringify(r), failed: false };
120
+ const code = r?.exit_code ?? r?.exitCode ?? r?.code;
121
+ const failed = r?.is_error === true || (typeof code === "number" && code !== 0);
122
+ return { text: JSON.stringify(typeof r?.stderr === "string" ? r.stderr : ""), failed };
123
+ }
124
+
124
125
  /**
125
126
  * After a tool call failed. Returns the hook output, or null to say nothing.
126
127
  *
127
128
  * `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.
129
+ * refusal, and a loaded machine can be slower than the hook. Only when the tool
130
+ * reported a failure — see {@link evidenceOf}.
129
131
  */
130
132
  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 });
133
+ const { text: evidence, failed } = evidenceOf(event);
132
134
  if (!DENIAL_SIGNS.test(evidence)) return null;
133
135
  // JSON escapes what the program printed; the signatures are written against the text.
134
136
  const known = knownRefusals(evidence.replace(/\\n/g, "\n").replace(/\\"/g, '"'));
135
137
  let found = [];
136
- for (const ms of wait) {
138
+ for (const ms of failed ? wait : [0]) {
137
139
  if (ms) await new Promise((ok) => setTimeout(ok, ms));
138
140
  found = recentKernelDenials(file, role, now() - WINDOW_MS);
139
141
  if (found.length) break;
@@ -163,7 +165,7 @@ export function atSessionStart(config, role, event, { file } = {}) {
163
165
  if (r.keys.length) lines.push(`Keys you hold: ${r.keys.join(", ")}.`);
164
166
  const w = file ? walls(config, role, { file, limit: 5, fresh: true }) : [];
165
167
  if (w.length) {
166
- lines.push("Already refused more than once, and still refused — do not try again, hand it over:");
168
+ lines.push("Already denied more than once, and still denied — do not try again, hand it over:");
167
169
  for (const x of w)
168
170
  lines.push(`- ${x.action} ${x.target} (${x.times}×) — ${x.owners?.length ? `belongs to ${x.owners.join(", ")}` : x.reason}`);
169
171
  }
package/src/env.js CHANGED
@@ -39,8 +39,8 @@ export const BASE = [
39
39
  * needs **set**, not forwarded.
40
40
  *
41
41
  * `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
42
+ * denial log looks like without it. Measured on a multi-repo workspace: of the last
43
+ * 60 denials, **58 were `.git/index.lock`** — 97% — and most carried no owner
44
44
  * at all, so not one of them was a territory question. `git status` and
45
45
  * `git diff` refresh the index as a courtesy, refreshing it takes the lock, and
46
46
  * a role reading a repo it does not own trips the boundary while doing nothing
@@ -64,7 +64,7 @@ export const DEFAULTS = {
64
64
  GIT_OPTIONAL_LOCKS: "0",
65
65
  /**
66
66
  * 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
67
+ * otherwise, so inside the sandbox it tries DNS directly and dies with ENOTFOUND
68
68
  * while `npm`, which reads the proxy itself, works. Measured on Node 24.2:
69
69
  * ENOTFOUND without it, 200 with it. It only makes Node use the proxy the
70
70
  * sandbox already routes everything through — nothing is widened. Older
@@ -90,12 +90,15 @@ export const DEFAULTS = {
90
90
  };
91
91
 
92
92
  /**
93
- * Names that must never ride along, even if a role asks for them by pattern.
93
+ * Names that ride along only when the role names them itself.
94
94
  *
95
95
  * 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.
96
+ * `env` list. Everything else that crosses comes from BASE or from a caller's
97
+ * `extra`, and this keeps a credential-shaped name out of both. Today it
98
+ * filters nothing: no BASE name matches it and `run` passes no `extra`. It is
99
+ * kept, not deleted, as the guard for the day either list grows — a
100
+ * `SSH_AUTH_SOCK` added "for git" would otherwise cross for every role
101
+ * silently — and for embedders who pass `extra`.
99
102
  */
100
103
  const NEVER = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|SESSION|COOKIE|PRIVATE)(_|$)/i;
101
104
 
@@ -118,7 +121,7 @@ export function buildEnv(parent, role, extra = []) {
118
121
  if (!wanted.has(name)) { dropped.push(name); continue; }
119
122
  if (NEVER.test(name) && !(role.env ?? []).includes(name)) { dropped.push(name); continue; }
120
123
  // 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).
124
+ // a fake "grant #3". Only the parent uses it (notify.js).
122
125
  if (name === "SEISIN_NOTIFY_URL") { dropped.push(name); continue; }
123
126
  env[name] = value;
124
127
  }
package/src/grants.js CHANGED
@@ -14,11 +14,12 @@ import { join } from "node:path";
14
14
  import { homedir, tmpdir } from "node:os";
15
15
  import { realpathSync, lstatSync } from "node:fs";
16
16
  import { createHash } from "node:crypto";
17
+ import { WILD } from "./paths.js";
17
18
 
18
19
  /**
19
20
  * What every agent needs to write no matter which role it is.
20
21
  *
21
- * Found by running a real cell config through the sandbox: territory alone
22
+ * Found by running a policy with 32 roles through the sandbox: territory alone
22
23
  * looks correct and is unusable. An agent writes its session state under its
23
24
  * own config directory and its tools write scratch files to the temp dir, so a
24
25
  * policy of "your folders and nothing else" stops the agent before it starts.
@@ -67,7 +68,7 @@ export function roleHomeRoot(config) {
67
68
  *
68
69
  * This was `base64url(root).slice(-16)` — the TAIL of the encoded path, which
69
70
  * is the tail of the path itself. Two checkouts that end the same way get the
70
- * same id: `/Users/ana/dev/proyecto` and `/Users/bob/dev/proyecto` collide, and
71
+ * same id: `/Users/ana/dev/project` and `/Users/bob/dev/project` collide, and
71
72
  * so do `/home/a/work/api` and `/home/b/work/api`. A collision here is not a
72
73
  * cosmetic clash — both repos' role `dev` would share one HOME, which is the
73
74
  * session token of one handed to the other. Measured on three of four ordinary
@@ -145,10 +146,18 @@ export function isLink(p) {
145
146
  }
146
147
  }
147
148
 
148
- /** The path with symlinks followed, or the path itself if it is not on disk. */
149
+ /**
150
+ * The path with symlinks followed, or the path itself if it is not on disk.
151
+ *
152
+ * `.native` is realpath(3): one call instead of an lstat per component, and on
153
+ * a case-insensitive disk (APFS) it answers with the case the disk has. The JS
154
+ * version keeps whatever case it was given, so `/users/x` and `/Users/x` —
155
+ * one directory — compared as two, and a path spelled the other way came out
156
+ * "outside the repo" while the kernel treated it as inside.
157
+ */
149
158
  export function realOrSelf(p) {
150
159
  try {
151
- return realpathSync(p);
160
+ return realpathSync.native(p);
152
161
  } catch {
153
162
  return p;
154
163
  }
@@ -159,7 +168,7 @@ export function expand(p) {
159
168
  if (p === "$TMPDIR") out = tmpdir();
160
169
  else if (p === "~" || p.startsWith("~/")) out = join(homedir(), p.slice(2));
161
170
  try {
162
- return realpathSync(out);
171
+ return realpathSync.native(out);
163
172
  } catch {
164
173
  return out; // not on disk yet; hand the literal through rather than drop it
165
174
  }
@@ -195,7 +204,6 @@ export function toWritePath(glob, key = "writes") {
195
204
  * prefixes, and "one level down" is not a prefix. Refusing is not a placeholder
196
205
  * here, it is the answer.
197
206
  */
198
- const WILD = /[*?[\]]/; // owners.js treats all four as wildcards
199
207
  const EXPRESSIBLE = (g) =>
200
208
  g === "**" || // the whole repo
201
209
  !WILD.test(g) || // a literal path
@@ -217,7 +225,7 @@ function unexpressible(glob, key = "writes") {
217
225
  * grants the whole repo, so `observe: true` answers for that case.
218
226
  *
219
227
  * `.seisin/` is deliberately NOT here. It used to be, because the hook runs
220
- * inside the box and has to record what it decided — which made the log and
228
+ * inside the sandbox and has to record what it decided — which made the log and
221
229
  * the queue writable by the process they are a record of. The hook now sends
222
230
  * its lines to the parent over a socket and the parent holds the file. See
223
231
  * spool.js.
package/src/help.js ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `seisin <command> --help` — one command's flags, examples and exit codes.
3
+ *
4
+ * The flags listed here are also the flags each command accepts: cli.js
5
+ * refuses any other, so a typo is an error instead of a question answered as
6
+ * if it had not been asked. One table, so the help and the check cannot
7
+ * disagree about what a command takes.
8
+ *
9
+ * `values` are flags that take the next word as their value.
10
+ */
11
+ export const COMMAND_HELP = {
12
+ run: {
13
+ usage: ["seisin run <role> [--observe] [--debug-env] -- <command...>"],
14
+ flags: [
15
+ ["--observe", "open the repo for this run and record what the agent writes; keys and network stay shut"],
16
+ ["--debug-env", "print the environment variables the role does not get"],
17
+ ],
18
+ notes: ["Everything after -- is the command's own, flags included: `-- claude --settings x.json` goes to claude."],
19
+ examples: ['seisin run frontend -- claude', 'seisin run backend --observe -- claude -p "…"'],
20
+ exits: [["n", "the command's own exit status"], ["127", "the command is not on the role's PATH"],
21
+ ["128+n", "killed by signal n (130 for ctrl-c)"], ["2", "seisin could not start it (usage, policy, runtime)"]],
22
+ },
23
+ check: {
24
+ usage: ["seisin check [role] [--verbose]"],
25
+ flags: [["--verbose", "also print the protected paths, each warning's explanation and the standing limits"]],
26
+ examples: ["seisin check", "seisin check frontend --verbose"],
27
+ exits: [["0", "the policy can be enforced as written (warnings included)"], ["1", "a role cannot be enforced as written"], ["2", "usage or policy error"]],
28
+ },
29
+ explain: {
30
+ usage: ["seisin explain <role> read|write <path>", "seisin explain <role> read <key>", "seisin explain <role> mcp <server>"],
31
+ flags: [],
32
+ notes: ["A relative path is relative to the directory you are in."],
33
+ examples: ["seisin explain frontend write src/app.js", "seisin explain backend read stripe.txt", "seisin explain frontend mcp github"],
34
+ exits: [["0", "allowed"], ["1", "denied"], ["2", "usage error"]],
35
+ },
36
+ whose: {
37
+ usage: ["seisin whose <path>"],
38
+ flags: [],
39
+ notes: ["Safe to call from inside the sandbox. A relative path is relative to the directory you are in."],
40
+ examples: ["seisin whose backend/src/server.py"],
41
+ exits: [["0", "answered"], ["2", "usage error"]],
42
+ },
43
+ scan: {
44
+ usage: ["seisin scan [--all]"],
45
+ flags: [["--all", "walk the whole folder, not only where the territories are"]],
46
+ examples: ["seisin scan", "seisin scan --all"],
47
+ exits: [["0", "no certain credential outside the key dirs"], ["1", "at least one certain credential"], ["2", "usage error"]],
48
+ },
49
+ review: {
50
+ usage: ["seisin review [--all] [--min <n>]"],
51
+ values: ["--min"],
52
+ flags: [["--all", "every row, not the first ten per section"], ["--min <n>", "denials before a place counts as repeated (default 3)"]],
53
+ examples: ["seisin review", "seisin review --min 5 --all"],
54
+ exits: [["0", "no role denied repeatedly"], ["1", "a role was denied repeatedly"], ["2", "usage error"]],
55
+ },
56
+ walls: {
57
+ usage: ["seisin walls <role> [--all] [--since <iso>] [--min <n>]"],
58
+ values: ["--since", "--min"],
59
+ flags: [["--all", "include walls not hit in the last few runs"], ["--since <iso>", "only denials after this time"],
60
+ ["--min <n>", "times denied before it is a wall (default 2)"]],
61
+ examples: ["seisin walls frontend", "seisin walls frontend --since 2026-09-01 --min 3"],
62
+ exits: [["0", "no walls"], ["1", "walls listed"], ["2", "usage error"]],
63
+ },
64
+ wire: {
65
+ usage: ["seisin wire"],
66
+ flags: [],
67
+ notes: ["Writes .claude/settings.json in this repo, merged with what is there."],
68
+ examples: ["seisin wire"],
69
+ exits: [["0", "wired, or already wired"], ["2", "usage error, or settings.json is not valid JSON"]],
70
+ },
71
+ requests: {
72
+ usage: ["seisin requests"],
73
+ flags: [],
74
+ notes: ["#n is a position and shifts as requests are settled; the id under each one does not."],
75
+ examples: ["seisin requests"],
76
+ exits: [["0", "nothing pending"], ["1", "requests pending"], ["2", "usage error"]],
77
+ },
78
+ grant: {
79
+ usage: ['seisin grant <n|id> [--reason "…"]'],
80
+ values: ["--reason"],
81
+ flags: [['--reason "…"', "recorded beside the grant in the policy"]],
82
+ examples: ["seisin grant 1", "seisin grant 'frontend:write:docs' --reason \"owns the docs now\""],
83
+ exits: [["0", "granted, or already granted"], ["2", "no such request, or it cannot be granted"]],
84
+ },
85
+ decline: {
86
+ usage: ['seisin decline <n|id> [--reason "…"]'],
87
+ values: ["--reason"],
88
+ flags: [['--reason "…"', "recorded with the decision"]],
89
+ examples: ["seisin decline 2 --reason \"not frontend's key\""],
90
+ exits: [["0", "declined"], ["2", "no such request"]],
91
+ },
92
+ log: {
93
+ usage: ["seisin log [--role <r>] [--verdict allowed|denied|observed] [--limit <n>]", "seisin log verify"],
94
+ values: ["--role", "--verdict", "--limit"],
95
+ flags: [["--role <r>", "only this role"], ["--verdict <v>", "allowed, denied or observed"], ["--limit <n>", "how many entries (default 40)"]],
96
+ examples: ["seisin log --role frontend --verdict denied", "seisin log verify"],
97
+ exits: [["0", "printed; for verify, the hash chain holds"], ["1", "verify: the hash chain is broken"], ["2", "usage error"]],
98
+ },
99
+ watch: {
100
+ usage: ["seisin watch"],
101
+ flags: [],
102
+ examples: ["seisin watch"],
103
+ exits: [["0", "stopped with ctrl-c"], ["2", "usage error"]],
104
+ },
105
+ init: {
106
+ usage: ["seisin init [--force]", "seisin init --from-observations"],
107
+ flags: [["--force", "overwrite an existing seisin.toml, keeping the old one as seisin.toml.bak"],
108
+ ["--from-observations", "write seisin.toml.observed from what observed runs recorded"]],
109
+ examples: ["seisin init", "seisin init --from-observations"],
110
+ exits: [["0", "written"], ["2", "a policy is already there, or nothing was observed"]],
111
+ },
112
+ ui: {
113
+ usage: ["seisin ui [--port <n>] [--link]"],
114
+ values: ["--port"],
115
+ flags: [["--port <n>", "port on 127.0.0.1 (default 4178)"], ["--link", "print the link of a console already running, start nothing"]],
116
+ examples: ["seisin ui", "seisin ui --link"],
117
+ exits: [["0", "stopped"], ["2", "could not start"]],
118
+ },
119
+ mcp: {
120
+ usage: ["seisin mcp"],
121
+ flags: [],
122
+ notes: ["An MCP server on stdio, read-only: it can explain and draft, never grant."],
123
+ examples: ["claude mcp add seisin -- seisin mcp"],
124
+ exits: [["0", "stdin closed"]],
125
+ },
126
+ hook: {
127
+ usage: ["seisin hook"],
128
+ flags: [],
129
+ notes: ["Claude Code's hook: one event on stdin, a decision on stdout. `seisin wire` installs it."],
130
+ examples: [],
131
+ exits: [["0", "always — the hook explains, the kernel enforces"]],
132
+ },
133
+ };
134
+ COMMAND_HELP.deny = COMMAND_HELP.decline;
135
+
136
+ /** The flag words a command accepts, for the unknown-flag check. */
137
+ export function flagsOf(name) {
138
+ const h = COMMAND_HELP[name];
139
+ if (!h) return null;
140
+ const values = h.values ?? [];
141
+ const known = h.flags.map(([f]) => f.split(" ")[0]).filter((f) => !values.includes(f));
142
+ return { known, values };
143
+ }
144
+
145
+ /** The help for one command, as text. */
146
+ export function renderHelp(name, C) {
147
+ const h = COMMAND_HELP[name];
148
+ const lines = ["", "usage:", ...h.usage.map((u) => ` ${u}`)];
149
+ if (h.flags.length) {
150
+ const w = Math.max(...h.flags.map(([f]) => f.length));
151
+ lines.push("", "flags:", ...h.flags.map(([f, d]) => ` ${f.padEnd(w)} ${d}`));
152
+ }
153
+ if (h.notes?.length) lines.push("", ...h.notes.map((n) => ` ${n}`));
154
+ if (h.examples.length) lines.push("", "examples:", ...h.examples.map((e) => ` ${e}`));
155
+ const w = Math.max(...h.exits.map(([c]) => c.length));
156
+ lines.push("", "exit codes:", ...h.exits.map(([c, d]) => ` ${c.padEnd(w)} ${d}`));
157
+ lines.push("", ` ${C.dim}seisin --help for every command${C.off}`, "", "");
158
+ return lines.join("\n");
159
+ }