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
package/src/help.js ADDED
@@ -0,0 +1,179 @@
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
+ import { LIMITS } from "./inspect/limits.js";
12
+
13
+ export const COMMAND_HELP = {
14
+ run: {
15
+ usage: ["seisin run <role> [--observe] [--debug-env] [--agent <name>] [--handoff <id>] [--expect-denied <path>]... -- <command...>"],
16
+ values: ["--agent", "--handoff", "--expect-denied"],
17
+ flags: [
18
+ ["--observe", "open the repo for this run and record what the agent writes; keys and network stay shut"],
19
+ ["--debug-env", "print the environment variables the role does not get"],
20
+ ["--agent <name>", "claude, codex or none: which agent the command runs, when seisin cannot tell from it (a wrapper script). Its home is the run's scratch; the other agents' sign-in is closed"],
21
+ ["--handoff <id>", "this run takes a pending request handed to this role, and starts only if the policy routes that request here. Its own requests join the handoff chain"],
22
+ ["--expect-denied <path>", "a path the caller will probe and expects to be denied (its own canary). That exact path is logged as expected and files no request; repeatable. Any other path is asked as usual"],
23
+ ],
24
+ notes: ["Everything after -- is the command's own, flags included: `-- claude --settings x.json` goes to claude."],
25
+ examples: ['seisin run frontend -- claude', 'seisin run backend --observe -- claude -p "…"'],
26
+ exits: [["n", "the command's own exit status"], ["127", "the command is not on the role's PATH"],
27
+ ["86", "the canary proved the generated boundary does not hold; the command was not started"],
28
+ ["128+n", "killed by signal n (130 for ctrl-c)"], ["2", "seisin could not start it (usage, policy, runtime)"]],
29
+ },
30
+ check: {
31
+ usage: ["seisin check [role] [--verbose] [--verify] [--parallel] [--system-log]"],
32
+ flags: [["--verbose", "also print the protected paths and each warning's explanation (the limits of every repo: seisin help limits)"],
33
+ ["--verify", "run each role's verify command inside its sandbox (the one thing check executes)"],
34
+ ["--parallel", "experimental: with --verify, run role checks concurrently; shared resources may race"],
35
+ ["--system-log", "macOS: measure for 3 s whether the system log is dropping messages, and who writes the most; exits 1 when it is"]],
36
+ examples: ["seisin check", "seisin check frontend --verbose", "seisin check backend --verify", "seisin check --verify --parallel"],
37
+ exits: [["0", "the policy can be enforced as written (warnings included)"], ["1", "a role cannot be enforced as written, or its verify failed"], ["2", "usage or policy error"]],
38
+ },
39
+ explain: {
40
+ usage: ["seisin explain <role> read|write <path>", "seisin explain <role> read <key>", "seisin explain <role> mcp <server>"],
41
+ flags: [],
42
+ notes: ["A relative path is relative to the directory you are in."],
43
+ examples: ["seisin explain frontend write src/app.js", "seisin explain backend read stripe.txt", "seisin explain frontend mcp github"],
44
+ exits: [["0", "allowed"], ["1", "denied"], ["2", "usage error"]],
45
+ },
46
+ whose: {
47
+ usage: ["seisin whose <path>"],
48
+ flags: [],
49
+ notes: ["Safe to call from inside the sandbox. A relative path is relative to the directory you are in."],
50
+ examples: ["seisin whose backend/src/server.py"],
51
+ exits: [["0", "answered"], ["2", "usage error"]],
52
+ },
53
+ scan: {
54
+ usage: ["seisin scan [--all]"],
55
+ flags: [["--all", "walk the whole folder, not only where the territories are"]],
56
+ examples: ["seisin scan", "seisin scan --all"],
57
+ exits: [["0", "no certain credential outside the key dirs"], ["1", "at least one certain credential"], ["2", "usage error"]],
58
+ },
59
+ review: {
60
+ usage: ["seisin review [--all] [--min <n>] [--since <date|duration>]"],
61
+ values: ["--min", "--since"],
62
+ flags: [["--all", "every row, not the first ten per section"], ["--min <n>", "denials before a place counts as repeated (default 3)"],
63
+ ["--since <when>", "only what was recorded since a date (2026-10-01) or a duration back from now (7d, 12h, 2w)"]],
64
+ examples: ["seisin review", "seisin review --since 7d", "seisin review --min 5 --all"],
65
+ exits: [["0", "no role denied repeatedly"], ["1", "a role was denied repeatedly"], ["2", "usage error"]],
66
+ },
67
+ walls: {
68
+ usage: ["seisin walls <role> [--all] [--since <when>] [--min <n>]"],
69
+ values: ["--since", "--min"],
70
+ flags: [["--all", "include walls not hit in the last few runs"], ["--since <when>", "only denials since a date (2026-09-01) or a duration back from now (7d)"],
71
+ ["--min <n>", "times denied before it is a wall (default 2)"]],
72
+ examples: ["seisin walls frontend", "seisin walls frontend --since 2026-09-01 --min 3"],
73
+ exits: [["0", "no walls"], ["1", "walls listed"], ["2", "usage error"]],
74
+ },
75
+ wire: {
76
+ usage: ["seisin wire"],
77
+ flags: [],
78
+ notes: ["Writes .claude/settings.json in this repo, merged with what is there."],
79
+ examples: ["seisin wire"],
80
+ exits: [["0", "wired, or already wired"], ["2", "usage error, or settings.json is not valid JSON"]],
81
+ },
82
+ requests: {
83
+ usage: ["seisin requests [--json]"],
84
+ flags: [["--json", "the queue as data, each request with its handoff decision — the shape seisin_requests returns"]],
85
+ notes: ["#n is a position and shifts as requests are settled; the id under each one does not.",
86
+ "The line under each request says where it goes, decided from the policy as it stands: handed to its owner, or a person decides."],
87
+ examples: ["seisin requests", "seisin requests --json"],
88
+ exits: [["0", "nothing pending"], ["1", "requests pending"], ["2", "usage error"]],
89
+ },
90
+ grant: {
91
+ usage: ['seisin grant <n|id> [--file] [--reason "…"]'],
92
+ values: ["--reason"],
93
+ flags: [['--reason "…"', "recorded beside the grant in the policy"],
94
+ ["--file", "grant the file that was asked for, not its folder (a write). Claude Code cannot edit a lone file: its temporary lands beside it"]],
95
+ examples: ["seisin grant 1", "seisin grant 2 --file", "seisin grant 'frontend:write:docs' --reason \"owns the docs now\""],
96
+ exits: [["0", "granted, or already granted"], ["2", "no such request, or it cannot be granted"]],
97
+ },
98
+ decline: {
99
+ usage: ['seisin decline <n|id> [--reason "…"]'],
100
+ values: ["--reason"],
101
+ flags: [['--reason "…"', "recorded with the decision"]],
102
+ examples: ["seisin decline 2 --reason \"not frontend's key\""],
103
+ exits: [["0", "declined"], ["2", "no such request"]],
104
+ },
105
+ log: {
106
+ usage: ["seisin log [--role <r>] [--verdict allowed|denied|observed] [--limit <n>]", "seisin log verify"],
107
+ values: ["--role", "--verdict", "--limit"],
108
+ flags: [["--role <r>", "only this role"], ["--verdict <v>", "allowed, denied or observed"], ["--limit <n>", "how many entries (default 40)"]],
109
+ examples: ["seisin log --role frontend --verdict denied", "seisin log verify"],
110
+ exits: [["0", "printed; for verify, the hash chain holds"], ["1", "verify: the hash chain is broken"], ["2", "usage error"]],
111
+ },
112
+ watch: {
113
+ usage: ["seisin watch"],
114
+ flags: [],
115
+ examples: ["seisin watch"],
116
+ exits: [["0", "stopped with ctrl-c"], ["2", "usage error"]],
117
+ },
118
+ init: {
119
+ usage: ["seisin init [--force]", "seisin init --from-observations"],
120
+ flags: [["--force", "overwrite an existing seisin.toml, keeping the old one as seisin.toml.bak"],
121
+ ["--from-observations", "write seisin.toml.observed from what observed runs recorded"]],
122
+ examples: ["seisin init", "seisin init --from-observations"],
123
+ exits: [["0", "written"], ["2", "a policy is already there, or nothing was observed"]],
124
+ },
125
+ ui: {
126
+ usage: ["seisin ui [--port <n>] [--link]"],
127
+ values: ["--port"],
128
+ flags: [["--port <n>", "port on 127.0.0.1 (default 4178)"], ["--link", "print the link of a console already running, start nothing"]],
129
+ examples: ["seisin ui", "seisin ui --link"],
130
+ exits: [["0", "stopped"], ["2", "could not start"]],
131
+ },
132
+ mcp: {
133
+ usage: ["seisin mcp"],
134
+ flags: [],
135
+ notes: ["An MCP server on stdio, read-only: it can explain and draft, never grant."],
136
+ examples: ["claude mcp add seisin -- seisin mcp"],
137
+ exits: [["0", "stdin closed"]],
138
+ },
139
+ hook: {
140
+ usage: ["seisin hook"],
141
+ flags: [],
142
+ notes: ["Claude Code's hook: one event on stdin, a decision on stdout. `seisin wire` installs it."],
143
+ examples: [],
144
+ exits: [["0", "always — the hook explains, the kernel enforces"]],
145
+ },
146
+ };
147
+
148
+ /** `seisin help limits`, as text: LIMITS lives in inspect/limits.js, which `inspect()` also returns. */
149
+ export function renderLimits(C) {
150
+ const lines = ["", `${C.b}what seisin cannot do, in any repo${C.off}`, ""];
151
+ for (const l of LIMITS) lines.push(` ${C.b}${l.headline}${C.off}`, ` ${l.detail}`, "");
152
+ lines.push(` ${C.dim}seisin check lists what is wrong with this repo's policy${C.off}`, "", "");
153
+ return lines.join("\n");
154
+ }
155
+
156
+ /** The flag words a command accepts, for the unknown-flag check. */
157
+ export function flagsOf(name) {
158
+ const h = COMMAND_HELP[name];
159
+ if (!h) return null;
160
+ const values = h.values ?? [];
161
+ const known = h.flags.map(([f]) => f.split(" ")[0]).filter((f) => !values.includes(f));
162
+ return { known, values };
163
+ }
164
+
165
+ /** The help for one command, as text. */
166
+ export function renderHelp(name, C) {
167
+ const h = COMMAND_HELP[name];
168
+ const lines = ["", "usage:", ...h.usage.map((u) => ` ${u}`)];
169
+ if (h.flags.length) {
170
+ const w = Math.max(...h.flags.map(([f]) => f.length));
171
+ lines.push("", "flags:", ...h.flags.map(([f, d]) => ` ${f.padEnd(w)} ${d}`));
172
+ }
173
+ if (h.notes?.length) lines.push("", ...h.notes.map((n) => ` ${n}`));
174
+ if (h.examples.length) lines.push("", "examples:", ...h.examples.map((e) => ` ${e}`));
175
+ const w = Math.max(...h.exits.map(([c]) => c.length));
176
+ lines.push("", "exit codes:", ...h.exits.map(([c, d]) => ` ${c.padEnd(w)} ${d}`));
177
+ lines.push("", ` ${C.dim}seisin --help for every command${C.off}`, "", "");
178
+ return lines.join("\n");
179
+ }
package/src/hook.js CHANGED
@@ -17,10 +17,11 @@
17
17
  * What escapes this file goes unexplained, not unblocked.
18
18
  */
19
19
  import { toRepoRelative } from "./paths.js";
20
- import { explain, normalize } from "./owners.js";
20
+ import { explain, normalize, privateOwner } from "./owners.js";
21
21
  import { append, logPath } from "./log.js";
22
- import { timesHit } from "./walls.js";
23
- import { record, requestsPath } from "./requests.js";
22
+ import { hitsOf, againWords } from "./walls.js";
23
+ import { record, requestsPath, keyOf } from "./requests.js";
24
+ import { decideHandoff, toAgent, queuedLine, isStopped, ELSEWHERE } from "./handoff.js";
24
25
 
25
26
  /** Tools whose input names a file directly. */
26
27
  const FILE_TOOLS = {
@@ -28,6 +29,30 @@ const FILE_TOOLS = {
28
29
  Read: "read", NotebookRead: "read",
29
30
  };
30
31
 
32
+ /**
33
+ * The tools {@link targetsOf} has anything to say about, as a hook matcher.
34
+ *
35
+ * `seisin wire` used to install PreToolUse with `"*"`, so Claude Code started
36
+ * this hook for Grep, Glob and TodoWrite too — a whole Node start-up, measured
37
+ * at 218 ms, to return nothing. Built from the same table targetsOf reads, so
38
+ * a tool added there is matched here. Anchored, because Claude Code reads a
39
+ * matcher as a regular expression and an unanchored `Write` matches
40
+ * `TodoWrite`.
41
+ */
42
+ export const TOOL_MATCHER = `^(${[...Object.keys(FILE_TOOLS), "Bash", "apply_patch"].join("|")}|mcp__.*)$`;
43
+
44
+ /**
45
+ * The files a Codex `apply_patch` call touches, from the patch's own headers.
46
+ *
47
+ * Codex sends its hooks the same event Claude Code does — `tool_name: "Bash"`
48
+ * with `tool_input.command` for the shell — but its file edits arrive as
49
+ * `apply_patch`, with the whole patch in `tool_input.command` (measured
50
+ * 2026-09-30, codex 0.150.1). The headers are a fixed grammar, so unlike a
51
+ * shell line they can be read exactly: Add, Update and Delete name the file,
52
+ * and `Move to` names where an update lands.
53
+ */
54
+ const PATCH_HEADER = /^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+?)\s*$/gm;
55
+
31
56
  /**
32
57
  * Paths a shell command is going to write.
33
58
  *
@@ -44,7 +69,7 @@ const WRITERS = /(?:^|\s|;|&&|\|\|)(?:cp|mv|install|tee|touch|mkdir|rm|rmdir|tru
44
69
  * The split is on the double underscore: a server name carries single ones
45
70
  * (`claude_ai_Gmail`) and so does a tool name (`execute_sql`), so the server is
46
71
  * everything between `mcp__` and the next `__`. Shared with the parent's intake,
47
- * which recomputes the same verdict rather than trusting the line from the box.
72
+ * which recomputes the same verdict rather than trusting the line from the sandbox.
48
73
  */
49
74
  export function mcpServer(tool) {
50
75
  const m = typeof tool === "string" && /^mcp__(.+?)__(.+)$/.exec(tool);
@@ -52,15 +77,19 @@ export function mcpServer(tool) {
52
77
  }
53
78
 
54
79
  export function targetsOf(tool, input = {}) {
80
+ // The event is whatever the agent's harness sent. `tool_input: null` or a
81
+ // path that is a number used to throw below, and a throwing hook exits 2 —
82
+ // which Claude Code reads as "block". Malformed means "nothing to say".
83
+ if (!input || typeof input !== "object") input = {};
55
84
  const kind = FILE_TOOLS[tool];
56
85
  if (kind) {
57
- const p = input.file_path ?? input.path ?? input.notebook_path;
58
- return p ? [{ action: kind, path: p }] : [];
86
+ const p = [input.file_path, input.path, input.notebook_path].find((x) => x != null);
87
+ return typeof p === "string" && p ? [{ action: kind, path: p }] : [];
59
88
  }
60
89
  /**
61
90
  * An MCP tool call names a resource, not a path: `mcp__<server>__<tool>`.
62
91
  *
63
- * The kernel never sees it — the MCP server runs outside the box — so a call
92
+ * The kernel never sees it — the MCP server runs outside the sandbox — so a call
64
93
  * that goes unrecorded here goes unrecorded everywhere. Measured 2026-09-24:
65
94
  * the event reaches PreToolUse with the tool's own arguments as `tool_input`,
66
95
  * which is exactly why the file-tool code above returns nothing for it. We
@@ -73,6 +102,11 @@ export function targetsOf(tool, input = {}) {
73
102
  if (typeof tool === "string" && tool.startsWith("mcp__")) {
74
103
  return [{ action: "use", tool, server: mcpServer(tool) }];
75
104
  }
105
+ if (tool === "apply_patch") {
106
+ const body = typeof input.command === "string" ? input.command : typeof input.patch === "string" ? input.patch : "";
107
+ const paths = [...new Set([...body.matchAll(PATCH_HEADER)].map((m) => m[1]))];
108
+ return paths.map((path) => ({ action: "write", path }));
109
+ }
76
110
  if (tool !== "Bash" || typeof input.command !== "string") return [];
77
111
 
78
112
  const out = [];
@@ -132,28 +166,24 @@ function kindOf(config, path) {
132
166
  }
133
167
 
134
168
  /**
135
- * The half of the refusal that nothing inside the box could otherwise know.
169
+ * The half of the refusal that nothing inside the sandbox could otherwise know.
136
170
  *
137
171
  * `ask()` has already filed a request by the time this sentence is built — and
138
172
  * until now it did so silently, so an agent could not tell the person who sent
139
173
  * it that anything was pending. It closes two loops the bare refusal leaves
140
174
  * open: retrying, because the queue deduplicates and a second attempt only
141
175
  * raises a counter; and waiting, because nothing inside the sandbox can
142
- * approve.
176
+ * grant.
143
177
  *
144
178
  * It deliberately says nothing about the MCP server. The hook cannot know
145
179
  * whether one is configured — `wire` writes `.claude/settings.json` and MCP
146
180
  * servers live elsewhere — and naming a tool the agent may not hold costs it a
147
181
  * turn to find out. An agent that does hold the tools discovers them the normal
148
182
  * way; that is what their descriptions are for.
183
+ *
184
+ * The words are handoff.js's queuedLine: a route ends on the role it goes to,
185
+ * everything else on a person.
149
186
  */
150
- const QUEUED = "Already queued for a person to answer — retrying or waiting will not move it.";
151
-
152
- /** 4 -> "fourth". Past a handful the numeral reads better than the word. */
153
- function ordinal(n) {
154
- const words = ["", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth"];
155
- return words[n] ?? `${n}th`;
156
- }
157
187
 
158
188
  /**
159
189
  * Decides and records one tool call.
@@ -162,11 +192,20 @@ function ordinal(n) {
162
192
  * the same decisions and returns none of them, so a policy can be written from
163
193
  * what a real run did instead of from what someone imagined it would do.
164
194
  */
165
- export function decide(config, role, event, { observe = false, now = append, ask = record } = {}) {
195
+ export function decide(config, role, event, { observe = false, now = append, ask = record, chain = null, run = process.env.SEISIN_RUN_ID ?? null } = {}) {
166
196
  const targets = targetsOf(event.tool_name, event.tool_input);
167
197
  const file = logPath(config.root);
168
198
  const queue = requestsPath(config.root);
169
199
  const verdicts = [];
200
+ // How many times the first refusal here was given BEFORE this attempt. Read
201
+ // before the entry is written, because where the entry goes decides whether
202
+ // a read afterwards sees it: to the file outside `seisin run`, but to the
203
+ // parent's socket inside it — and that line is not on disk until the parent
204
+ // gets to it, so counting afterwards came out one behind there.
205
+ let before = null;
206
+ const priorOnce = (action, target) => {
207
+ if (before === null) before = hitsOf(file, role, action, target, run);
208
+ };
170
209
 
171
210
  for (const t of targets) {
172
211
  // A tool call, resolved to its MCP server. "Whose is it" is the question
@@ -179,6 +218,7 @@ export function decide(config, role, event, { observe = false, now = append, ask
179
218
  const v = t.server
180
219
  ? explain(config, role, "mcp", t.server)
181
220
  : { allowed: false, owners: [], mcp: true, reason: `${t.tool} is not a recognisable mcp__<server>__<tool> name` };
221
+ if (!observe && !v.allowed) priorOnce("use", t.tool);
182
222
  now(file, {
183
223
  role, tool: event.tool_name, action: "use", kind: "tool", target: t.tool,
184
224
  verdict: observe ? "observed" : v.allowed ? "allowed" : "denied",
@@ -199,9 +239,13 @@ export function decide(config, role, event, { observe = false, now = append, ask
199
239
  // of a markdown file, the agent burned its turns unable to look at what it
200
240
  // had been asked to edit, and the log showed a tidy `denied` that looked
201
241
  // like the system working.
202
- if (t.action === "read" && kind !== "key") continue;
242
+ // The one exception: another role's private folder, which the kernel shuts
243
+ // to reading as well, so the hook says so before the read is tried.
244
+ const shutRead = t.action === "read" && kind !== "key" && privateOwner(config, rel);
245
+ if (t.action === "read" && kind !== "key" && (!shutRead || shutRead.role === role)) continue;
203
246
 
204
- const v = explain(config, role, kind === "key" ? "read" : "write", rel);
247
+ const v = explain(config, role, kind === "key" || shutRead ? "read" : "write", rel);
248
+ if (!observe && !v.allowed) priorOnce(kind === "key" ? "read" : t.action, rel);
205
249
 
206
250
  now(file, {
207
251
  role,
@@ -215,13 +259,16 @@ export function decide(config, role, event, { observe = false, now = append, ask
215
259
  });
216
260
  // A denial already carries everything a request needs, so leave one behind:
217
261
  // the refusal stops being a dead end and becomes something a person can act
218
- // on in one command. Observing records nothing to approve — there was no
219
- // denial to answer. See docs/permission-requests.md.
262
+ // on in one command. Observing records nothing to grant — there was no
263
+ // denial to answer. See docs/requests.md.
220
264
  //
221
265
  // Except a `never_writes` refusal. That one is the policy saying no on
222
266
  // purpose; a request for it asks a person to undo a subtraction somebody
223
267
  // wrote, and approving it is how the subtraction disappears unnoticed.
224
- if (!observe && !v.allowed && !v.neverWrites && !v.gitMetadata)
268
+ // Nor a protected file: no grant opens one (the kernel keeps refusing it),
269
+ // which is why `seisin run` never queued those either (intake.js).
270
+ // Nor another role's private folder: no grant opens it either.
271
+ if (!observe && !v.allowed && !v.neverWrites && !v.gitMetadata && !v.protected && !v.privateTo)
225
272
  ask(queue, { role, action: kind === "key" ? "read" : t.action, target: rel, owners: v.owners ?? [] });
226
273
 
227
274
  // The action as it was RECORDED, not as the tool named it: a read of a key
@@ -236,26 +283,39 @@ export function decide(config, role, event, { observe = false, now = append, ask
236
283
 
237
284
  const denied = verdicts.find((v) => !v.allowed);
238
285
  if (!denied) return { decision: null, logged: verdicts.length };
239
- const wasRead = denied.kind === "key";
240
286
 
241
287
  /**
242
288
  * How many times this exact denial has already been handed to this role.
243
289
  *
244
290
  * The sentence below is correct every time and that has not been enough:
245
- * measured on a real team, 25% of all denials were a repeat of something the
246
- * same role had already been refused, one of them nineteen times. Correct
291
+ * measured on a multi-repo workspace, 25% of all denials were a repeat of something the
292
+ * same role had already been denied, one of them nineteen times. Correct
247
293
  * and *heard* are different properties, and only the first was being
248
- * measured. So the refusal carries its own history: the second time, it says
294
+ * measured. So the denial carries its own history: the second time, it says
249
295
  * it is the second time.
250
296
  *
251
- * Counted from the log that was just written, so the count includes this
252
- * attempt. Silent on the first — a counter that says "1×" on every first
253
- * refusal is noise on the turn where the sentence is already doing its job.
297
+ * The earlier refusals, read before this one was recorded, plus this one —
298
+ * the same number whether the entry went to the file or to the socket. Silent
299
+ * on the first — a counter that says "1×" on every first refusal is noise on
300
+ * the turn where the sentence is already doing its job.
301
+ */
302
+ const times = (before?.total ?? 0) + 1;
303
+ const inRun = before?.inRun == null ? null : before.inRun + 1;
304
+
305
+ /**
306
+ * Whose it is and where the work goes, as the queue will say it: decided by
307
+ * handoff.js for the one path this sentence is about, in the run's handoff
308
+ * chain when it has one. Only for a denial that files a request — the
309
+ * branches above say why nothing was queued, which is not a handoff.
254
310
  */
255
- const before = timesHit(logPath(config.root), role, denied.action, denied.target);
256
- const again = before > 1
257
- ? ` You have been denied this ${before} times now; it is not going to work on the ${ordinal(before + 1)} try.`
258
- : "";
311
+ const queued = !denied.neverWrites && !denied.gitMetadata && !denied.protected && !denied.privateTo && !denied.mcp;
312
+ const handoff = queued
313
+ ? decideHandoff({ request: { role, action: denied.action, target: denied.target }, config, chain })
314
+ : null;
315
+ // A denied write that nothing will ever grant is still a change not to
316
+ // make under another path; a denied read is not a change at all.
317
+ const elsewhere = denied.action === "write" ? ` ${ELSEWHERE}` : "";
318
+ const again = againWords({ times, inRun });
259
319
 
260
320
  // The hook advises; it does not enforce. Returning `deny` here stops the call
261
321
  // early and — the part that matters — hands the agent a sentence it can act
@@ -270,16 +330,15 @@ export function decide(config, role, event, { observe = false, now = append, ask
270
330
  // The sentence has to name a next step, not just a refusal. An agent
271
331
  // told "no" retries; an agent told whose it is asks, or moves on.
272
332
  denied.neverWrites
273
- ? `${denied.reason}. Do not ask for it: nothing was queued, because the answer is already written down.` + again
274
- : denied.gitMetadata
275
- ? `${denied.reason}. Nothing was queued.` + again
333
+ ? `${denied.reason}. Do not ask for it: nothing was queued, because the answer is already written down.` + elsewhere + again
334
+ : denied.gitMetadata || denied.protected || denied.privateTo
335
+ ? `${denied.reason}. Nothing was queued.` + elsewhere + again
276
336
  : denied.mcp
277
- ? `${denied.reason}. Nothing was queued: which MCP servers a role may load is set in its mcp list, a policy change a person makes, not a grant they approve.` + again
278
- : (denied.owners?.length
279
- ? wasRead
280
- ? `${denied.target} is declared for ${denied.owners.join(", ")}, not ${role}. Ask for what you need from it rather than reading the key.`
281
- : `${denied.target} belongs to ${denied.owners.join(", ")}. It is not ${role}'s to change — hand it over rather than working around it.`
282
- : `${denied.reason} (seisin)`) + again + " " + QUEUED,
337
+ ? `${denied.reason}. Nothing was queued: which MCP servers a role may load is set in its mcp list, a policy change a person makes, not something they grant from the queue.` + again
338
+ : toAgent(handoff, { target: denied.target, role, key: keyOf({ role, action: denied.action, target: denied.target }), again }) +
339
+ // Where a chain stops, the sentence to pass on already says the
340
+ // request is waiting, and is the last thing the agent reads.
341
+ (isStopped(handoff) ? "" : " " + queuedLine(handoff)),
283
342
  },
284
343
  };
285
344
  }
package/src/index.d.ts ADDED
@@ -0,0 +1,217 @@
1
+ export type KeyMode = "env" | "run-file" | "inject";
2
+ export type ReadMode = "all" | "territory";
3
+ export type Isolation = false | "credentials" | "home";
4
+ export type Action = "read" | "write" | "mcp";
5
+
6
+ export interface KeyEntry {
7
+ kind: "file" | "ref";
8
+ raw: string;
9
+ scheme?: string;
10
+ ref?: string;
11
+ name?: string;
12
+ }
13
+
14
+ export interface RolePolicy {
15
+ name: string;
16
+ writes: string[];
17
+ writesDeclared: string[];
18
+ keys: string[];
19
+ keyEntries: KeyEntry[];
20
+ keyMode: KeyMode | null;
21
+ env: string[];
22
+ network: string[] | null;
23
+ neverWrites: string[];
24
+ neverWritesDeclared: string[];
25
+ /** Folders only this role reads and writes; also part of `writes`. */
26
+ private: string[];
27
+ reads: string[];
28
+ toolchain: string[];
29
+ verify: string[] | null;
30
+ localBinding: boolean;
31
+ localPorts: number[];
32
+ mcp: string[] | null;
33
+ trustd: boolean;
34
+ controlFiles: string[];
35
+ /** False for `handoff = false`: the role owns its territory but is never handed work. */
36
+ handoff: boolean;
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ export interface KeyProvider {
41
+ name: string;
42
+ command: string[];
43
+ mode: KeyMode | null;
44
+ }
45
+
46
+ export interface SeisinConfig {
47
+ root: string;
48
+ path: string;
49
+ keyDirs: string[];
50
+ keyProviders: Record<string, KeyProvider>;
51
+ allowedDomains: string[];
52
+ runtimeWrites?: string[];
53
+ isolate: Isolation;
54
+ read: ReadMode;
55
+ redact: boolean | undefined;
56
+ scanIgnore: string[];
57
+ protect: Record<string, unknown>;
58
+ roles: Record<string, RolePolicy>;
59
+ [key: string]: unknown;
60
+ }
61
+
62
+ export interface Decision {
63
+ allowed: boolean;
64
+ owners: string[];
65
+ reason: string;
66
+ [key: string]: unknown;
67
+ }
68
+
69
+ export interface SandboxSettings {
70
+ network: {
71
+ allowedDomains?: string[];
72
+ deniedDomains: string[];
73
+ allowUnixSockets: string[];
74
+ allowLocalBinding: boolean;
75
+ };
76
+ filesystem: {
77
+ denyRead: string[];
78
+ allowRead: string[];
79
+ allowWrite: string[];
80
+ denyWrite: string[];
81
+ [key: string]: unknown;
82
+ };
83
+ [key: string]: unknown;
84
+ }
85
+
86
+ export interface Wall {
87
+ action: string;
88
+ target: string;
89
+ times: number;
90
+ reason: string;
91
+ owners: string[];
92
+ firstAt: string;
93
+ lastAt: string;
94
+ /** Refused as expected — the agent program's own housekeeping, not friction. */
95
+ expected?: { kind: "plugins"; hint: string };
96
+ }
97
+
98
+ export interface ScanHit {
99
+ file: string;
100
+ line?: number;
101
+ shape: string;
102
+ level: "certain" | "review" | "named";
103
+ }
104
+
105
+ export interface ScanResult {
106
+ hits: ScanHit[];
107
+ skipped: Record<string, number>;
108
+ nestedPaths: string[];
109
+ truncated: boolean;
110
+ omitted: number;
111
+ }
112
+
113
+ export const CONFIG_NAME: "seisin.toml";
114
+ export const STATE_DIR: ".seisin";
115
+ export const LOG_NAME: "log.jsonl";
116
+
117
+ export function findConfig(from?: string): string | null;
118
+ export function loadConfig(path: string, text?: string): SeisinConfig;
119
+ export function parseToml(text: string): Record<string, unknown>;
120
+
121
+ export function covers(pattern: string, path: string): boolean;
122
+ export function ownersOf(config: SeisinConfig, path: string): string[];
123
+ export function keyHolders(config: SeisinConfig, key: string): string[];
124
+ export function explain(config: SeisinConfig, role: string, action: Action, target: string, cwd?: string): Decision;
125
+ export function explainFileRead(config: SeisinConfig, role: string, path: string, credentialHomes?: string[]): Decision;
126
+ export function readTarget(config: SeisinConfig, target: string, cwd?: string): { key: boolean; target: string };
127
+
128
+ export function settingsFor(config: SeisinConfig, role: string, spool?: string | null, observe?: boolean,
129
+ options?: { agent?: string; program?: string }): SandboxSettings;
130
+
131
+ export function parseKey(entry: string): KeyEntry;
132
+ export function defaultName(ref: string): string;
133
+ export function entriesOf(role: Pick<RolePolicy, "keys" | "keyEntries">): KeyEntry[];
134
+ export const MODES: readonly KeyMode[];
135
+
136
+ export function walls(config: SeisinConfig, role: string, options: {
137
+ file: string; entries?: Record<string, unknown>[] | null; min?: number; since?: string | null;
138
+ limit?: number; fresh?: boolean; ask?: typeof explain;
139
+ }): Wall[];
140
+ export function wasted(walls: Wall[]): number;
141
+
142
+ export function inspect(config: SeisinConfig, role?: string | null, where?: string): Record<string, unknown>;
143
+ export function sharedPaths(config: SeisinConfig, roles?: RolePolicy[]): string[];
144
+ export function scan(root: string, protectedDirs?: string[], ignore?: string[], limit?: number,
145
+ options?: { roots?: string[] | null }): ScanResult;
146
+
147
+ export interface LogEntry {
148
+ role?: string;
149
+ action?: string;
150
+ target?: string;
151
+ verdict?: string;
152
+ at?: string;
153
+ [key: string]: unknown;
154
+ }
155
+ export function readLog(file: string, options?: {
156
+ role?: string; verdict?: string; since?: string; limit?: number;
157
+ }): LogEntry[];
158
+ export function logPath(root: string): string;
159
+ export function observed(entries: LogEntry[]): Map<string, { writes: Set<string>; keys: Set<string> }>;
160
+ export function generalise(paths: string[]): string[];
161
+
162
+ export function pendingRequests(file: string, options?: { includeSettled?: boolean; keyDirs?: string[] | null; readOnly?: boolean }): unknown[];
163
+ export function requestsPath(root: string): string;
164
+ export function grantFor(request: { action: string; target: string }, keyDir?: string | null): string;
165
+
166
+ /** Where a denied write goes, decided from the policy as it stands. Nothing executes it. */
167
+ export type HandoffType = "route" | "human" | "resolved" | "cycle" | "depth" | "throttled";
168
+ export interface HandoffDecision {
169
+ type: HandoffType;
170
+ /** The owners as the policy says now — for a key, the roles that declare it. */
171
+ owners: string[];
172
+ /** The role a route goes to; for cycle/depth/throttled, the one it would have gone to. */
173
+ role?: string;
174
+ /** For `human`: why a person decides. */
175
+ reason?: "unowned" | "ambiguous" | "no-receiver" | "key" | "read";
176
+ /** The owners that take handed work, when a `handoff = false` role left some out. */
177
+ receivers?: string[];
178
+ /** For `resolved`. */
179
+ resolution?: "policy_changed";
180
+ /** For a route: the receiver's depth in the handoff chain, and every role the chain has visited. */
181
+ depth?: number;
182
+ visited?: string[];
183
+ max?: number;
184
+ limit?: "senderChains" | "receiverConcurrent";
185
+ /** The handoff chain this decision extends: the id of the request that started it. */
186
+ chainId: string | null;
187
+ /** Hash of the policy file the decision was made against. */
188
+ revision: string | null;
189
+ }
190
+ export interface HandoffChain { id: string | null; depth: number; visited: string[] }
191
+ export interface QueuedRequest {
192
+ key: string; role: string; action: "read" | "write"; target: string; grant: string;
193
+ owners: string[]; ownersThen?: string[]; declared?: boolean;
194
+ times: number; first: string; last: string; state: string;
195
+ handoff: HandoffDecision;
196
+ /** Present when the request was asked by a run started with `--handoff`. */
197
+ chain?: HandoffChain;
198
+ /** What whatever starts roles last recorded about it (recordHandoff). */
199
+ attempt?: { outcome: string; role?: string; limit?: string; max?: number; reason?: string; depth?: number; chainId?: string; at: string };
200
+ stale?: { runs: number; since: string };
201
+ }
202
+ export const MAX_DEPTH: number;
203
+ export function decideHandoff(input: {
204
+ request: { role: string; target: string; action?: string; key?: string };
205
+ config: SeisinConfig | { roles: Record<string, unknown> };
206
+ chain?: Partial<HandoffChain> | null;
207
+ limits?: { depth?: number; senderChains?: number; receiverConcurrent?: number };
208
+ load?: { senderOpenChains?: number; receiverRunning?: number };
209
+ revision?: string | null;
210
+ }): HandoffDecision;
211
+ export function requestQueue(config: SeisinConfig, history?: LogEntry[] | null): QueuedRequest[];
212
+ export function recordHandoff(file: string, key: string, decision: Partial<HandoffDecision> & { type: string }): boolean;
213
+
214
+ export function targetsOf(tool: string, input?: Record<string, unknown>): unknown[];
215
+ export function decide(config: SeisinConfig, role: string, event: Record<string, unknown>, options?: {
216
+ observe?: boolean; now?: (...args: unknown[]) => unknown; ask?: (...args: unknown[]) => unknown;
217
+ }): Record<string, unknown>;