seisin 0.4.0 → 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.
- package/README.md +151 -67
- package/package.json +3 -3
- package/src/cli.js +145 -46
- package/src/commands/check.js +3 -2
- package/src/commands/explain.js +34 -13
- package/src/commands/hook.js +14 -4
- package/src/commands/init.js +126 -30
- package/src/commands/log.js +55 -10
- package/src/commands/requests.js +124 -39
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +174 -39
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +25 -6
- package/src/commands/whose.js +7 -6
- package/src/commands/wire.js +156 -17
- package/src/config.js +261 -27
- package/src/controls.js +244 -0
- package/src/diagnose.js +35 -33
- package/src/env.js +11 -8
- package/src/grants.js +15 -7
- package/src/help.js +159 -0
- package/src/hook.js +49 -19
- package/src/index.js +4 -4
- package/src/inspect.js +137 -71
- package/src/intake.js +24 -16
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +312 -55
- package/src/mcp.js +96 -43
- package/src/notify.js +15 -10
- package/src/owners.js +323 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +81 -12
- package/src/requests.js +250 -72
- package/src/review.js +42 -21
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +395 -205
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +203 -42
- package/src/uilink.js +10 -4
- package/src/views.js +289 -0
- package/src/violations.js +104 -16
- package/src/walls.js +19 -42
- package/src/worktree.js +3 -11
- package/ui/index.html +1366 -529
package/src/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* seisin — give each agent its own folders and its own keys.
|
|
4
4
|
*
|
|
5
5
|
* This file is dispatch and nothing else: parse argv, load the config, call one
|
|
6
|
-
* command,
|
|
6
|
+
* command, make a thrown error an exit code. Every command lives in
|
|
7
7
|
* `commands/` and every decision lives in a module beside it, so that a change
|
|
8
8
|
* to how something is printed cannot change what is allowed.
|
|
9
9
|
*
|
|
@@ -19,21 +19,19 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
import { findConfig, loadConfig } from "./config.js";
|
|
20
20
|
import { CONFIG_NAME } from "./layout.js";
|
|
21
21
|
import { C, out, err } from "./render.js";
|
|
22
|
+
import { COMMAND_HELP, flagsOf, renderHelp } from "./help.js";
|
|
23
|
+
import { nearest, checkFlags } from "./suggest.js";
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
import { wallsCommand } from "./commands/walls.js";
|
|
34
|
-
import { reviewCommand } from "./commands/review.js";
|
|
35
|
-
import { wire } from "./commands/wire.js";
|
|
36
|
-
import { serveMcp } from "./mcp.js";
|
|
25
|
+
/**
|
|
26
|
+
* Each command is imported when it is the one being run, not before.
|
|
27
|
+
*
|
|
28
|
+
* All fifteen used to load up front — 42 of 47 modules, the MCP server and the
|
|
29
|
+
* HTTP console among them — and `seisin hook` paid for every one on every tool
|
|
30
|
+
* call of every agent: 218 ms measured, where the hook itself needs a fraction
|
|
31
|
+
* of that. A dispatcher that loads everything to run one thing charges the
|
|
32
|
+
* most frequent command for the rarest ones. So each entry in COMMANDS below
|
|
33
|
+
* does its own `import()`.
|
|
34
|
+
*/
|
|
37
35
|
|
|
38
36
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
39
37
|
const [, , command, ...argv] = process.argv;
|
|
@@ -43,23 +41,26 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
|
|
|
43
41
|
|
|
44
42
|
seisin run <role> -- <command...> run a command as that role
|
|
45
43
|
seisin run <role> --observe -- <cmd> open the repo and watch — the network stays shut
|
|
46
|
-
seisin check [role]
|
|
47
|
-
seisin explain <role> read|write <path>
|
|
48
|
-
seisin whose <path> who owns it — safe to call from inside the
|
|
49
|
-
seisin scan
|
|
44
|
+
seisin check [role] [--verbose] print the map, run nothing
|
|
45
|
+
seisin explain <role> read|write|mcp <path, key or server>
|
|
46
|
+
seisin whose <path> who owns it — safe to call from inside the sandbox
|
|
47
|
+
seisin scan [--all] find secrets outside the declared key dirs
|
|
50
48
|
seisin review [--all] what the log says about the policy
|
|
51
49
|
seisin walls <role> [--all] what that role keeps being denied, and what it cost
|
|
52
50
|
seisin wire let the agent record what it does
|
|
53
51
|
seisin requests what the agents asked for and cannot have
|
|
54
|
-
seisin grant <n> [--reason "…"]
|
|
55
|
-
seisin decline <n> [--reason "…"]
|
|
56
|
-
seisin log [--role r] [--verdict denied]
|
|
52
|
+
seisin grant <n> [--reason "…"] grant one: add it to ${CONFIG_NAME}, with its provenance
|
|
53
|
+
seisin decline <n> [--reason "…"] decline one, and record why
|
|
54
|
+
seisin log [--role r] [--verdict denied] [--limit n]
|
|
57
55
|
seisin log verify does the log's hash chain hold
|
|
58
56
|
seisin watch follow the log live
|
|
59
|
-
seisin init [--
|
|
57
|
+
seisin init [--force] propose a ${CONFIG_NAME} for this repo
|
|
58
|
+
seisin init --from-observations write ${CONFIG_NAME}.observed from observed runs
|
|
60
59
|
seisin ui [--port n] [--link] open the console, live (--link reopens a running one)
|
|
61
60
|
seisin mcp an MCP server on stdio — read-only
|
|
62
61
|
|
|
62
|
+
seisin <command> --help lists a command's flags, examples and exit codes.
|
|
63
|
+
|
|
63
64
|
Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
|
|
64
65
|
seisin decides what to ask for, and says whose file it was when the answer is no.
|
|
65
66
|
`;
|
|
@@ -71,6 +72,12 @@ function config() {
|
|
|
71
72
|
return loadConfig(path);
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** The policy here, or null — for a command that can answer without one. */
|
|
76
|
+
function maybeConfig() {
|
|
77
|
+
const path = findConfig();
|
|
78
|
+
return path ? loadConfig(path) : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
74
81
|
/**
|
|
75
82
|
* Commands that need a config get it; commands that make one do not.
|
|
76
83
|
*
|
|
@@ -78,24 +85,29 @@ function config() {
|
|
|
78
85
|
* the exceptions — they hand the process over and never come back here.
|
|
79
86
|
*/
|
|
80
87
|
const COMMANDS = {
|
|
81
|
-
run: async () => await run(config(), argv),
|
|
88
|
+
run: async () => await (await import("./commands/run.js")).run(config(), argv),
|
|
82
89
|
// Non-zero when the policy cannot be enforced as written, so `seisin check`
|
|
83
90
|
// composes in a pre-commit hook or CI the way `explain` and `scan` already do.
|
|
84
91
|
// Warnings about a config that WILL work still exit 0 — failing on those
|
|
85
92
|
// would make the command unusable within a week.
|
|
86
|
-
check: () => (check(config(), argv).warnings.some((w) => w.kind === "cannot-be-enforced") ? 1 : 0),
|
|
87
|
-
explain: () => (explainCommand(config(), argv).allowed ? 0 : 1),
|
|
88
|
-
scan: () => (scanCommand(config()).certain.length ? 1 : 0),
|
|
89
|
-
log: () => void log(config(), argv),
|
|
90
|
-
watch: () => void watch(config()),
|
|
91
|
-
init: () =>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
93
|
+
check: async () => ((await import("./commands/check.js")).check(config(), argv).warnings.some((w) => w.kind === "cannot-be-enforced") ? 1 : 0),
|
|
94
|
+
explain: async () => ((await import("./commands/explain.js")).explainCommand(config(), argv).allowed ? 0 : 1),
|
|
95
|
+
scan: async () => ((await import("./commands/scan.js")).scanCommand(config(), argv).certain.length ? 1 : 0),
|
|
96
|
+
log: async () => void (await import("./commands/log.js")).log(config(), argv),
|
|
97
|
+
watch: async () => void (await import("./commands/log.js")).watch(config()),
|
|
98
|
+
init: async () => {
|
|
99
|
+
const { init, initFromObservations } = await import("./commands/init.js");
|
|
100
|
+
void (argv.includes("--from-observations") ? initFromObservations(config()) : init(process.cwd(), { force: argv.includes("--force") }));
|
|
101
|
+
},
|
|
102
|
+
// --link only reads a record; it answers without a policy here too, and
|
|
103
|
+
// says so when the console it finds serves another one.
|
|
104
|
+
ui: async () => (await import("./commands/ui.js")).ui(argv.includes("--link") ? maybeConfig() : config(), argv),
|
|
105
|
+
whose: async () => void (await import("./commands/whose.js")).whose(config(), argv),
|
|
106
|
+
wire: async () => void (await import("./commands/wire.js")).wire(config()),
|
|
107
|
+
review: async () => ((await import("./commands/review.js")).reviewCommand(config(), argv).friction.length ? 1 : 0),
|
|
108
|
+
walls: async () => ((await import("./commands/walls.js")).wallsCommand(config(), argv).length ? 1 : 0),
|
|
109
|
+
requests: async () => ((await import("./commands/requests.js")).requests(config()).length ? 1 : 0),
|
|
110
|
+
grant: async () => void (await import("./commands/requests.js")).grant(config(), argv),
|
|
99
111
|
/**
|
|
100
112
|
* A person DECLINES a request; the boundary DENIES a write. One word each,
|
|
101
113
|
* because they are opposite events and `deny` used to name both — a queue
|
|
@@ -105,15 +117,22 @@ const COMMANDS = {
|
|
|
105
117
|
* `deny` stays and is not documented. Renaming a published command over a
|
|
106
118
|
* word is not worth breaking somebody's script; teaching the new one is.
|
|
107
119
|
*/
|
|
108
|
-
decline: () => void deny(config(), argv),
|
|
109
|
-
deny: () => void deny(config(), argv),
|
|
120
|
+
decline: async () => void (await import("./commands/requests.js")).deny(config(), argv),
|
|
121
|
+
deny: async () => void (await import("./commands/requests.js")).deny(config(), argv),
|
|
110
122
|
mcp: async () => {
|
|
111
|
-
await serveMcp(version());
|
|
123
|
+
await (await import("./mcp.js")).serveMcp(version());
|
|
112
124
|
return 0;
|
|
113
125
|
},
|
|
126
|
+
// Always 0. Claude Code reads a non-zero exit from a PreToolUse hook as
|
|
127
|
+
// "block", so an internal error here would stop the agent's tool call — and
|
|
128
|
+
// the hook explains, it never enforces. The error goes to stderr instead.
|
|
114
129
|
hook: async () => {
|
|
115
|
-
|
|
116
|
-
|
|
130
|
+
try {
|
|
131
|
+
const decision = await (await import("./commands/hook.js")).hook();
|
|
132
|
+
if (decision?.hookSpecificOutput) out(JSON.stringify(decision) + "\n");
|
|
133
|
+
} catch (e) {
|
|
134
|
+
err(`seisin hook: ${e?.message ?? e} — no decision, the tool call goes ahead\n`);
|
|
135
|
+
}
|
|
117
136
|
return 0;
|
|
118
137
|
},
|
|
119
138
|
"--version": () => void out(version() + "\n"),
|
|
@@ -124,16 +143,96 @@ function version() {
|
|
|
124
143
|
return JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version;
|
|
125
144
|
}
|
|
126
145
|
|
|
127
|
-
|
|
146
|
+
/**
|
|
147
|
+
* `--help` is a question, never an instruction to do the thing.
|
|
148
|
+
*
|
|
149
|
+
* No command looked for it, so each one took it as an argument: `ui --help`
|
|
150
|
+
* started a server on 4178, `wire --help` wrote .claude/settings.json, `watch
|
|
151
|
+
* --help` followed the log until ctrl-c, `mcp --help` sat waiting on stdin, and
|
|
152
|
+
* `seisin --help` printed the usage and exited 2. Answered here, before any
|
|
153
|
+
* command is loaded or any policy read, so it works in a directory with none.
|
|
154
|
+
*
|
|
155
|
+
* Only seisin's own part of the line is searched: after `--` the words are the
|
|
156
|
+
* agent's, and so is everything after `run <role> <command>` without one —
|
|
157
|
+
* `seisin run dev -- ls --help` asks `ls`. The value of a flag that takes one
|
|
158
|
+
* (`--reason -h`) is a value.
|
|
159
|
+
*/
|
|
160
|
+
const HELP = new Set(["--help", "-h"]);
|
|
161
|
+
const TAKES_VALUE = new Set(["--port", "--role", "--verdict", "--limit", "--min", "--since", "--reason"]);
|
|
162
|
+
|
|
163
|
+
function asksHelp(name, args) {
|
|
164
|
+
const split = args.indexOf("--");
|
|
165
|
+
let ours = split === -1 ? args : args.slice(0, split);
|
|
166
|
+
if (name === "run" && split === -1) {
|
|
167
|
+
let i = 1;
|
|
168
|
+
while (i < args.length && args[i].startsWith("-")) i++;
|
|
169
|
+
ours = args.slice(0, i);
|
|
170
|
+
}
|
|
171
|
+
for (let i = 0; i < ours.length; i++) {
|
|
172
|
+
if (TAKES_VALUE.has(ours[i])) i++;
|
|
173
|
+
else if (HELP.has(ours[i])) return true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One command's help: its flags, examples and exit codes, or the whole usage. */
|
|
179
|
+
function helpFor(name) {
|
|
180
|
+
if (COMMAND_HELP[name]) return renderHelp(name, C);
|
|
181
|
+
const alias = name === "deny" ? "decline" : name;
|
|
182
|
+
const lines = USAGE.split("\n").filter((l) => l.startsWith(` seisin ${alias} `) || l === ` seisin ${alias}`);
|
|
183
|
+
if (!lines.length) return USAGE;
|
|
184
|
+
return `\nusage:\n${lines.join("\n")}\n\n ${C.dim}seisin --help for every command${C.off}\n\n`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (command === undefined || command === "help" || HELP.has(command)) {
|
|
188
|
+
const about = command === "help" && COMMANDS[argv[0]] && !argv[0].startsWith("-") ? argv[0] : null;
|
|
189
|
+
out(about ? helpFor(about) : USAGE);
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const chosen = Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : null;
|
|
128
194
|
if (!chosen) {
|
|
129
|
-
|
|
130
|
-
|
|
195
|
+
// One line, and the nearest command. The whole usage, printed for a typo,
|
|
196
|
+
// pushed the one line that mattered off the screen.
|
|
197
|
+
const near = command.startsWith("-") ? nearest(command, ["--help", "--version"])
|
|
198
|
+
: nearest(command, Object.keys(COMMANDS).filter((c) => !c.startsWith("-") && c !== "deny"));
|
|
199
|
+
err(`${C.red}seisin:${C.off} unknown ${command.startsWith("-") ? "option" : "command"} "${command}"` +
|
|
200
|
+
`${near ? ` — did you mean "${near}"?` : ""}\n seisin --help lists every command\n`);
|
|
201
|
+
process.exit(2);
|
|
202
|
+
}
|
|
203
|
+
if (!command.startsWith("-") && asksHelp(command, argv)) {
|
|
204
|
+
out(helpFor(command));
|
|
205
|
+
process.exit(0);
|
|
131
206
|
}
|
|
132
207
|
|
|
133
208
|
try {
|
|
209
|
+
refuseUnknownFlags(command, argv);
|
|
134
210
|
const code = await chosen();
|
|
135
211
|
if (typeof code === "number") process.exit(code);
|
|
136
212
|
} catch (e) {
|
|
137
213
|
err(`${C.red}seisin:${C.off} ${e.message}\n`);
|
|
138
|
-
|
|
214
|
+
// A command that was not found is 127 wherever a shell runs it; everything
|
|
215
|
+
// else seisin refuses is a usage or policy error.
|
|
216
|
+
process.exit(e.exitCode ?? 2);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A flag a command does not take is an error, never ignored: `check --verbsoe`
|
|
221
|
+
* printed the short map as if nothing had been asked. For `run`, only seisin's
|
|
222
|
+
* own part of the line — before `--`, or the leading options — is checked.
|
|
223
|
+
*/
|
|
224
|
+
function refuseUnknownFlags(name, args) {
|
|
225
|
+
const f = flagsOf(name);
|
|
226
|
+
if (!f || name === "hook" || name === "mcp") return;
|
|
227
|
+
let ours = args;
|
|
228
|
+
if (name === "run") {
|
|
229
|
+
const split = args.indexOf("--");
|
|
230
|
+
if (split !== -1) ours = args.slice(1, split);
|
|
231
|
+
else {
|
|
232
|
+
let i = 1;
|
|
233
|
+
while (i < args.length && args[i].startsWith("-")) i++;
|
|
234
|
+
ours = args.slice(1, i);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
checkFlags(name === "deny" ? "decline" : name, ours, f.known, f.values);
|
|
139
238
|
}
|
package/src/commands/check.js
CHANGED
|
@@ -3,7 +3,8 @@ import { inspect } from "../inspect.js";
|
|
|
3
3
|
import { renderReport, out } from "../render.js";
|
|
4
4
|
|
|
5
5
|
export function check(config, argv = []) {
|
|
6
|
-
const
|
|
7
|
-
|
|
6
|
+
const role = argv.find((a) => !a.startsWith("-")) ?? null;
|
|
7
|
+
const report = inspect(config, role, config.path);
|
|
8
|
+
out(renderReport(report, { verbose: argv.includes("--verbose") }));
|
|
8
9
|
return report;
|
|
9
10
|
}
|
package/src/commands/explain.js
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* That is the only reason the exit code is not always 0 — a denial is an answer,
|
|
6
6
|
* not a failure, but a shell has one channel for both.
|
|
7
7
|
*/
|
|
8
|
-
import { toRepoRelative } from "../paths.js";
|
|
8
|
+
import { toRepoRelative, fromCwd } from "../paths.js";
|
|
9
9
|
import { isAbsolute } from "node:path";
|
|
10
|
-
import { explain } from "../owners.js";
|
|
10
|
+
import { explain, explainFileRead, readTarget } from "../owners.js";
|
|
11
|
+
import { CREDENTIAL_HOMES, expand } from "../grants.js";
|
|
12
|
+
import { unknownRole } from "../suggest.js";
|
|
11
13
|
import { twinsOf, whereIs } from "../worktree.js";
|
|
12
14
|
import { renderVerdict, C, out } from "../render.js";
|
|
13
15
|
|
|
@@ -15,7 +17,11 @@ export function explainCommand(config, argv = []) {
|
|
|
15
17
|
const [role, action, target] = argv;
|
|
16
18
|
if (!role || !action || !target)
|
|
17
19
|
throw new Error("usage: seisin explain <role> <read|write|mcp> <path, key or MCP server>");
|
|
18
|
-
if (!config.roles[role]) throw
|
|
20
|
+
if (!config.roles[role]) throw unknownRole(config, role);
|
|
21
|
+
// Anything else used to be answered as a write, confidently, to a question
|
|
22
|
+
// nobody asked. An unknown action is a usage error, like an unknown flag.
|
|
23
|
+
if (!["read", "write", "mcp"].includes(action))
|
|
24
|
+
throw new Error(`unknown action "${action}" — use read, write or mcp`);
|
|
19
25
|
|
|
20
26
|
if (action === "mcp") {
|
|
21
27
|
const verdict = explain(config, role, "mcp", target);
|
|
@@ -23,7 +29,16 @@ export function explainCommand(config, argv = []) {
|
|
|
23
29
|
return { ...verdict, worktree: [] };
|
|
24
30
|
}
|
|
25
31
|
|
|
26
|
-
const verb = action
|
|
32
|
+
const verb = action;
|
|
33
|
+
|
|
34
|
+
if (verb === "read") {
|
|
35
|
+
const asked = readTarget(config, target);
|
|
36
|
+
const verdict = asked.key
|
|
37
|
+
? explain(config, role, "read", asked.target)
|
|
38
|
+
: explainFileRead(config, role, asked.target, CREDENTIAL_HOMES.map(expand));
|
|
39
|
+
out(renderVerdict(role, verb, asked.target, verdict));
|
|
40
|
+
return { ...verdict, worktree: [] };
|
|
41
|
+
}
|
|
27
42
|
|
|
28
43
|
/**
|
|
29
44
|
* An absolute path inside the repo is the same question as the relative one.
|
|
@@ -36,17 +51,23 @@ export function explainCommand(config, argv = []) {
|
|
|
36
51
|
* check a boundary was the one that was wrong.
|
|
37
52
|
*
|
|
38
53
|
* Wrong in the direction that matters, too: it reported a path as unowned and
|
|
39
|
-
*
|
|
54
|
+
* denied while the kernel allowed it — see "Refusing beats widening" in
|
|
40
55
|
* docs/decisions.md. A path outside the root is left exactly as written,
|
|
41
56
|
* because that is a question about somewhere else and it still deserves its
|
|
42
57
|
* honest "no owner".
|
|
43
58
|
*/
|
|
44
|
-
|
|
59
|
+
//
|
|
60
|
+
// A relative path is relative to where you are standing, as in every other
|
|
61
|
+
// command line tool: from frontend/src, `app.js` is frontend/src/app.js. It
|
|
62
|
+
// was read against the policy's root, so the same question asked from a
|
|
63
|
+
// subdirectory came back "no owner". The answer prints the path the policy
|
|
64
|
+
// was asked about, so the reader sees which file was meant.
|
|
65
|
+
const asked = toRepoRelative(config, fromCwd(config, target));
|
|
45
66
|
|
|
46
67
|
const verdict = explain(config, role, verb, asked);
|
|
47
|
-
out(renderVerdict(role, verb,
|
|
68
|
+
out(renderVerdict(role, verb, asked, verdict));
|
|
48
69
|
|
|
49
|
-
const worktree =
|
|
70
|
+
const worktree = elsewhere(config, role, isAbsolute(target) ? target : asked, verdict);
|
|
50
71
|
for (const w of worktree) out(renderElsewhere(w));
|
|
51
72
|
return { ...verdict, worktree };
|
|
52
73
|
}
|
|
@@ -55,7 +76,7 @@ export function explainCommand(config, argv = []) {
|
|
|
55
76
|
* The same question, asked about the same file in the other checkouts.
|
|
56
77
|
*
|
|
57
78
|
* Only a twin that gets a DIFFERENT answer is worth a line. A worktree where
|
|
58
|
-
* the role is equally allowed, or equally
|
|
79
|
+
* the role is equally allowed, or equally denied, changes nothing about what
|
|
59
80
|
* was just printed, and a diagnosis that mentions it anyway is the noise this
|
|
60
81
|
* tool keeps having to remove.
|
|
61
82
|
*
|
|
@@ -73,7 +94,7 @@ function elsewhere(config, role, target, verdict) {
|
|
|
73
94
|
|
|
74
95
|
/**
|
|
75
96
|
* Three lines: where the other checkout is, what the answer is there, and
|
|
76
|
-
* which of the two the policy names — the sentence the
|
|
97
|
+
* which of the two the policy names — the sentence the denial never carried.
|
|
77
98
|
*/
|
|
78
99
|
function renderElsewhere(t) {
|
|
79
100
|
const there = t.verdict.allowed
|
|
@@ -82,12 +103,12 @@ function renderElsewhere(t) {
|
|
|
82
103
|
const named = t.asked.allowed ? t.here : t;
|
|
83
104
|
const other = t.asked.allowed ? t : t.here;
|
|
84
105
|
// "allowed" is the answer that misleads, so it is the one that gets told
|
|
85
|
-
// where the
|
|
86
|
-
const
|
|
106
|
+
// where the denial is. After "denied" that clause would only repeat it.
|
|
107
|
+
const denied = t.asked.allowed ? ` — a write in ${kind(other)} is denied` : "";
|
|
87
108
|
return (
|
|
88
109
|
` ${C.yellow}worktree${C.off} ${t.where}\n` +
|
|
89
110
|
` ${C.dim} the same file there is ${C.off}${C.b}${t.rel}${C.off}${C.dim}, and it ${there}.\n` +
|
|
90
|
-
` The policy names ${kind(named)}, not ${kind(other)}${
|
|
111
|
+
` The policy names ${kind(named)}, not ${kind(other)}${denied}.${C.off}\n\n`
|
|
91
112
|
);
|
|
92
113
|
}
|
|
93
114
|
|
package/src/commands/hook.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Claude Code runs this once per tool call, so every path through it is short
|
|
5
5
|
* and every failure is silent-and-permissive. That asymmetry is deliberate: the
|
|
6
6
|
* hook explains, the kernel enforces. A hook that crashes should cost you an
|
|
7
|
-
* explanation, never a
|
|
7
|
+
* explanation, never a stopped tool call — and it cannot cost you a boundary,
|
|
8
8
|
* because it was never the boundary.
|
|
9
9
|
*/
|
|
10
10
|
import { findConfig, loadConfig } from "../config.js";
|
|
@@ -15,7 +15,7 @@ import { flush } from "../spool.js";
|
|
|
15
15
|
|
|
16
16
|
export async function hook(stdin = process.stdin, env = process.env) {
|
|
17
17
|
const role = env.SEISIN_ROLE;
|
|
18
|
-
if (!role) return null; // not launched by seisin: say nothing,
|
|
18
|
+
if (!role) return null; // not launched by seisin: say nothing, stop nothing
|
|
19
19
|
|
|
20
20
|
let raw = "";
|
|
21
21
|
for await (const chunk of stdin) raw += chunk;
|
|
@@ -24,7 +24,7 @@ export async function hook(stdin = process.stdin, env = process.env) {
|
|
|
24
24
|
try {
|
|
25
25
|
event = JSON.parse(raw || "{}");
|
|
26
26
|
} catch {
|
|
27
|
-
return null; // an unreadable event is not grounds to
|
|
27
|
+
return null; // an unreadable event is not grounds to stop work
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
let config;
|
|
@@ -44,7 +44,17 @@ export async function hook(stdin = process.stdin, env = process.env) {
|
|
|
44
44
|
if (event_ === "SessionStart") {
|
|
45
45
|
try { return atSessionStart(config, role, event, { file: logPath(config.root) }); } catch { return null; }
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
// Caught like the two halves above, and for a sharper reason: PreToolUse is
|
|
48
|
+
// the one event where a crash is not silent. cli.js turns a throw into exit
|
|
49
|
+
// 2, and Claude Code reads exit 2 from this hook as "block the tool". A bug
|
|
50
|
+
// here would stop the agent's work — the one thing this command promises
|
|
51
|
+
// never to do. Said on stderr so the bug is still findable.
|
|
52
|
+
let decision = null;
|
|
53
|
+
try {
|
|
54
|
+
decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1" });
|
|
55
|
+
} catch (e) {
|
|
56
|
+
process.stderr.write(`seisin hook: ${e?.message ?? e} — no decision, the tool call goes ahead\n`);
|
|
57
|
+
}
|
|
48
58
|
// The entries went to a socket, and cli.js exits as soon as we return. An
|
|
49
59
|
// exit does not drain a socket, so the record would be lost precisely on the
|
|
50
60
|
// turns that produced one.
|
package/src/commands/init.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* The second is better and needs a run first. Neither writes a policy you did
|
|
12
12
|
* not read: `--from-observations` lands as `.observed`, not as the config.
|
|
13
13
|
*/
|
|
14
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, statSync, copyFileSync, appendFileSync } from "node:fs";
|
|
15
15
|
import { join, relative } from "node:path";
|
|
16
16
|
import { CONFIG_NAME, tomlString, tomlName } from "../layout.js";
|
|
17
17
|
import { read, logPath, observed, generalise } from "../log.js";
|
|
@@ -22,43 +22,104 @@ import { C, out } from "../render.js";
|
|
|
22
22
|
* actually means. None is a policy, so all of them are proposals.
|
|
23
23
|
*/
|
|
24
24
|
export function discover(root) {
|
|
25
|
+
const keyDir = KEY_DIRS.find((d) => isDir(join(root, d))) ?? null;
|
|
25
26
|
const agents = join(root, ".claude", "agents");
|
|
26
27
|
if (existsSync(agents)) {
|
|
27
28
|
const roles = readdirSync(agents)
|
|
28
29
|
.filter((f) => f.endsWith(".md"))
|
|
29
30
|
.map((f) => ({ name: f.replace(/\.md$/, ""), writes: [], keys: [] }));
|
|
30
|
-
if (roles.length) return { source: ".claude/agents/", roles };
|
|
31
|
+
if (roles.length) return { source: ".claude/agents/", roles, agents: roles.length, keyDir };
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
for (const p of ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"]) {
|
|
34
35
|
const file = join(root, p);
|
|
35
36
|
if (!existsSync(file)) continue;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
.map((l) => l.replace(/#.*$/, "").trim())
|
|
39
|
-
.filter(Boolean)
|
|
40
|
-
.map((l) => l.split(/\s+/)[0])
|
|
41
|
-
.filter((g) => g && g !== "*");
|
|
42
|
-
if (paths.length)
|
|
43
|
-
return {
|
|
44
|
-
source: p,
|
|
45
|
-
roles: [...new Set(paths)].slice(0, 8).map((g, i) => ({
|
|
46
|
-
name: g.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "") || `role-${i + 1}`,
|
|
47
|
-
writes: [g.endsWith("/") ? g + "**" : g],
|
|
48
|
-
keys: [],
|
|
49
|
-
})),
|
|
50
|
-
};
|
|
37
|
+
const roles = fromCodeowners(readFileSync(file, "utf8"));
|
|
38
|
+
if (roles.length) return { source: p, roles, agents: 0, keyDir };
|
|
51
39
|
}
|
|
52
40
|
|
|
41
|
+
const dirs = codeDirs(root);
|
|
53
42
|
return {
|
|
54
|
-
source:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
43
|
+
source: dirs.length
|
|
44
|
+
? "the top-level folders that hold code — one role each"
|
|
45
|
+
: "nothing to read — this is a blank start",
|
|
46
|
+
roles: dirs.length
|
|
47
|
+
? dirs.map((d) => ({ name: roleName(d), writes: [`${d}/**`], keys: [] }))
|
|
48
|
+
: [{ name: "agent", writes: [], keys: [] }],
|
|
49
|
+
agents: 0,
|
|
50
|
+
keyDir,
|
|
59
51
|
};
|
|
60
52
|
}
|
|
61
53
|
|
|
54
|
+
/** Where a repo keeps its keys by convention, in the order they are looked for. */
|
|
55
|
+
const KEY_DIRS = [".secrets", "secrets", ".keys"];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One role per owner, with every path that owner has.
|
|
59
|
+
*
|
|
60
|
+
* It was one role per LINE, named after the path, and cut at eight — so a
|
|
61
|
+
* team's ownership came back as eight roles called `apps-web`, `apps-api`… with
|
|
62
|
+
* the rest dropped and nothing said. An owner is the thing a role stands for.
|
|
63
|
+
* A line's first owner takes it: CODEOWNERS lets several share a path, and
|
|
64
|
+
* seisin would rather print one proposal a person corrects than invent an
|
|
65
|
+
* overlap. `*` is skipped — it is the fallback owner, not a territory.
|
|
66
|
+
*/
|
|
67
|
+
export function fromCodeowners(text) {
|
|
68
|
+
const byOwner = new Map();
|
|
69
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
70
|
+
const [glob, owner] = raw.replace(/#.*$/, "").trim().split(/\s+/);
|
|
71
|
+
if (!glob || glob === "*" || !owner) continue;
|
|
72
|
+
// CODEOWNERS anchors a leading "/" at the repo root; seisin's writes
|
|
73
|
+
// are already repo-relative, so a literal "/apps/**" would ask the
|
|
74
|
+
// kernel for the machine's /apps and make `explain` (which reads it as
|
|
75
|
+
// outside the repo) disagree with what the profile grants. Strip the
|
|
76
|
+
// anchor. A trailing "/" means a directory, so it becomes "/**".
|
|
77
|
+
const rel = glob.replace(/^\/+/, "");
|
|
78
|
+
const path = rel.endsWith("/") ? rel + "**" : rel;
|
|
79
|
+
const name = roleName(owner.replace(/^@/, "").replace(/^.*\//, "")) || `role-${byOwner.size + 1}`;
|
|
80
|
+
const role = byOwner.get(name) ?? byOwner.set(name, { name, writes: [], keys: [] }).get(name);
|
|
81
|
+
if (!role.writes.includes(path)) role.writes.push(path);
|
|
82
|
+
}
|
|
83
|
+
return [...byOwner.values()];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const roleName = (s) => String(s).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The top-level folders that hold code, for a repo that says nothing else.
|
|
90
|
+
*
|
|
91
|
+
* It proposed `src/web/**` and `src/api/**` whatever the repo looked like, so
|
|
92
|
+
* the first `check` was about folders that did not exist. Kept simple on
|
|
93
|
+
* purpose: a folder counts when some file under it (a few levels down) has a
|
|
94
|
+
* source extension. Dot-folders, dependencies and build output are skipped,
|
|
95
|
+
* and a folder of only documents or images proposes nothing — it is somebody's
|
|
96
|
+
* territory, but not the first thing a person wants to decide.
|
|
97
|
+
*/
|
|
98
|
+
const CODE = /\.(js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|kt|kts|swift|c|h|cc|cpp|hpp|cs|php|sh|sql|vue|svelte|astro|dart|scala|ex|exs|lua|pl|r|m|html|css|scss)$/i;
|
|
99
|
+
const SKIP = new Set(["node_modules", "vendor", "dist", "build", "out", "target", "coverage", "__pycache__", "venv"]);
|
|
100
|
+
|
|
101
|
+
function codeDirs(root) {
|
|
102
|
+
let top;
|
|
103
|
+
try { top = readdirSync(root, { withFileTypes: true }); } catch { return []; }
|
|
104
|
+
return top
|
|
105
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name) && roleName(e.name))
|
|
106
|
+
.map((e) => e.name)
|
|
107
|
+
.filter((d) => holdsCode(join(root, d), 4))
|
|
108
|
+
.sort();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function holdsCode(dir, depth) {
|
|
112
|
+
let entries;
|
|
113
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return false; }
|
|
114
|
+
if (entries.some((e) => e.isFile() && CODE.test(e.name))) return true;
|
|
115
|
+
if (depth <= 1) return false;
|
|
116
|
+
return entries.some((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name) && holdsCode(join(dir, e.name), depth - 1));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isDir(p) {
|
|
120
|
+
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
121
|
+
}
|
|
122
|
+
|
|
62
123
|
/** The proposed config, as TOML. Pure: takes what `discover` found. */
|
|
63
124
|
export function renderConfig(found) {
|
|
64
125
|
const lines = [
|
|
@@ -67,9 +128,14 @@ export function renderConfig(found) {
|
|
|
67
128
|
`# Proposed from: ${found.source}`,
|
|
68
129
|
"# Nothing here is enforced until you run the agent through `seisin run`.",
|
|
69
130
|
"# Anything not listed is denied. There is no permissive default.",
|
|
131
|
+
"#",
|
|
132
|
+
"# Each role runs as its own process: seisin run <role> -- <agent>",
|
|
133
|
+
"# A Claude Code subagent runs inside its parent's process, so it has its",
|
|
134
|
+
"# parent's role — give it its own by running it as its own `seisin run`.",
|
|
70
135
|
"",
|
|
71
|
-
|
|
72
|
-
|
|
136
|
+
...(found.keyDir
|
|
137
|
+
? ["[keys]", `dir = ${tomlString(found.keyDir)} # every key lives here; roles name the files they may read`]
|
|
138
|
+
: ["# [keys]", '# dir = ".secrets" # every key lives here; roles name the files they may read']),
|
|
73
139
|
"",
|
|
74
140
|
"[network]",
|
|
75
141
|
"# The agent's own API comes first. Leave it out and the agent cannot even",
|
|
@@ -87,6 +153,7 @@ export function renderConfig(found) {
|
|
|
87
153
|
"",
|
|
88
154
|
];
|
|
89
155
|
for (const r of found.roles) {
|
|
156
|
+
if (!r.writes.length) lines.push("# writes nothing yet: add the folders this role owns, e.g. \"app/**\"");
|
|
90
157
|
lines.push(`[roles.${tomlName(r.name)}]`);
|
|
91
158
|
lines.push(`writes = [${r.writes.map(tomlString).join(", ")}]`);
|
|
92
159
|
lines.push(`keys = [${r.keys.map(tomlString).join(", ")}]`);
|
|
@@ -150,25 +217,54 @@ export function renderObserved(config, entries) {
|
|
|
150
217
|
return { toml: lines.join("\n"), roles: roles.size };
|
|
151
218
|
}
|
|
152
219
|
|
|
153
|
-
export function init(cwd = process.cwd()) {
|
|
220
|
+
export function init(cwd = process.cwd(), { force = false } = {}) {
|
|
154
221
|
const target = join(cwd, CONFIG_NAME);
|
|
155
|
-
|
|
156
|
-
|
|
222
|
+
const had = existsSync(target);
|
|
223
|
+
if (had && !force)
|
|
224
|
+
throw new Error(`${CONFIG_NAME} already exists here. seisin init --force replaces it, keeping the old one as ${CONFIG_NAME}.bak`);
|
|
157
225
|
|
|
158
226
|
const found = discover(cwd);
|
|
227
|
+
if (had) copyFileSync(target, target + ".bak");
|
|
159
228
|
writeFileSync(target, renderConfig(found));
|
|
229
|
+
const ignored = ignoreState(cwd);
|
|
160
230
|
out(
|
|
161
231
|
`\n wrote ${C.b}${CONFIG_NAME}${C.off} with ${found.roles.length} role(s) from ${found.source}\n` +
|
|
162
|
-
` ${C.dim}
|
|
163
|
-
`
|
|
232
|
+
(had ? ` ${C.dim}the previous one is ${CONFIG_NAME}.bak${C.off}\n` : "") +
|
|
233
|
+
(found.keyDir ? ` ${C.dim}[keys] dir = "${found.keyDir}" — it is there, so it is declared${C.off}\n` : "") +
|
|
234
|
+
(ignored ? ` ${C.dim}added .seisin/ to .gitignore — the log and the request queue belong to this machine${C.off}\n` : "") +
|
|
235
|
+
` ${C.dim}These are a proposal, not a policy. Read them before you run anything.${C.off}\n` +
|
|
236
|
+
// Said once, here, where someone with one agent decides whether to go on.
|
|
237
|
+
(found.agents <= 1
|
|
238
|
+
? ` ${C.dim}one agent? its own sandbox covers most of this. seisin adds a built environment, a key dir no role reads\n` +
|
|
239
|
+
` unless it is declared, and an owner named on every denial — it earns its setup at two roles.${C.off}\n`
|
|
240
|
+
: "") +
|
|
241
|
+
`\n next: seisin check\n\n`
|
|
164
242
|
);
|
|
165
243
|
return found;
|
|
166
244
|
}
|
|
167
245
|
|
|
246
|
+
/**
|
|
247
|
+
* `.seisin/` into the repo's .gitignore, when it has one and it is not there.
|
|
248
|
+
* The state directory is this machine's record; committed, every clone would
|
|
249
|
+
* start with somebody else's log and queue. No .gitignore, nothing written.
|
|
250
|
+
*/
|
|
251
|
+
function ignoreState(cwd) {
|
|
252
|
+
const file = join(cwd, ".gitignore");
|
|
253
|
+
if (!existsSync(file)) return false;
|
|
254
|
+
const text = readFileSync(file, "utf8");
|
|
255
|
+
if (/^\/?\.seisin\/?\s*$/m.test(text)) return false;
|
|
256
|
+
appendFileSync(file, `${text.endsWith("\n") || !text ? "" : "\n"}.seisin/\n`);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
168
260
|
export function initFromObservations(config) {
|
|
169
261
|
const entries = read(logPath(config.root), { verdict: "observed" });
|
|
170
262
|
if (entries.length === 0)
|
|
171
|
-
throw new Error(
|
|
263
|
+
throw new Error(
|
|
264
|
+
"nothing observed yet.\n" +
|
|
265
|
+
" observations come from the hook: run `seisin wire` once, then observe an agent —\n" +
|
|
266
|
+
" seisin run <role> --observe -- claude (or codex, …)\n" +
|
|
267
|
+
" a plain shell command goes through no hook and records nothing. keys stay denied while observing.");
|
|
172
268
|
|
|
173
269
|
const { toml, roles } = renderObserved(config, entries);
|
|
174
270
|
const file = join(config.root, CONFIG_NAME + ".observed");
|