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.
- package/README.md +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- 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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
219
|
-
// denial to answer. See docs/
|
|
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
|
-
|
|
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
|
|
246
|
-
* same role had already been
|
|
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
|
|
294
|
+
* measured. So the denial carries its own history: the second time, it says
|
|
249
295
|
* it is the second time.
|
|
250
296
|
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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
|
|
256
|
-
const
|
|
257
|
-
?
|
|
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
|
|
278
|
-
: (denied.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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>;
|