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/commands/check.js
CHANGED
|
@@ -1,9 +1,161 @@
|
|
|
1
|
-
/** `seisin check` — print the map and every way it does not hold. Runs nothing
|
|
1
|
+
/** `seisin check` — print the map and every way it does not hold. Runs nothing, unless `--verify`. */
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
2
4
|
import { inspect } from "../inspect.js";
|
|
3
|
-
import { renderReport, out } from "../render.js";
|
|
5
|
+
import { renderReport, out, C } from "../render.js";
|
|
6
|
+
import { measureSystemLog } from "../violations.js";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { settingsFor } from "../srt.js";
|
|
9
|
+
import { weigh } from "../sandboxes/index.js";
|
|
10
|
+
import { runsRoot } from "../rundir.js";
|
|
11
|
+
import { channelName } from "../spool.js";
|
|
4
12
|
|
|
5
|
-
export function check(config, argv = []) {
|
|
6
|
-
|
|
7
|
-
|
|
13
|
+
export async function check(config, argv = []) {
|
|
14
|
+
if (argv.includes("--parallel") && !argv.includes("--verify"))
|
|
15
|
+
throw new Error("--parallel is experimental and requires --verify");
|
|
16
|
+
const role = argv.find((a) => !a.startsWith("-")) ?? null;
|
|
17
|
+
const report = inspect(config, role, config.path);
|
|
18
|
+
out(renderReport(report, { verbose: argv.includes("--verbose") }));
|
|
19
|
+
if (config.sandbox?.provider) report.sandboxes = sandboxes(config, role);
|
|
20
|
+
if (argv.includes("--verify")) report.verify = await verify(config, role, argv.includes("--parallel"));
|
|
21
|
+
if (argv.includes("--system-log")) report.systemLog = await systemLog();
|
|
8
22
|
return report;
|
|
9
23
|
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `--system-log`: is the log a run reads denials from dropping messages now?
|
|
27
|
+
*
|
|
28
|
+
* A flooded unified log loses lines before they are filtered, and a kernel
|
|
29
|
+
* denial lost there files nothing: the run says so afterwards, and this says
|
|
30
|
+
* it before, with who is writing the most — the only lead to a fix. A flag,
|
|
31
|
+
* because it takes seconds and `check` otherwise answers at once.
|
|
32
|
+
*/
|
|
33
|
+
async function systemLog() {
|
|
34
|
+
const m = await measureSystemLog({ seconds: SYSTEM_LOG_SECONDS });
|
|
35
|
+
if (!m.available) {
|
|
36
|
+
out(`\n${C.dim}system log: not measured — ${m.reason}${C.off}\n`);
|
|
37
|
+
return m;
|
|
38
|
+
}
|
|
39
|
+
const top = m.top.map((t) => `${t.who} ${t.perSecond}/s`).join(", ");
|
|
40
|
+
out(m.dropped
|
|
41
|
+
? `\n${C.yellow}⚠ system log: ${m.dropped} batch(es) of messages dropped in ${m.seconds} s — kernel denials can go unrecorded now.${C.off}\n` +
|
|
42
|
+
` ${m.perSecond} messages/s; most from ${top}. Stop or quiet the busiest writer, then measure again.\n`
|
|
43
|
+
: `\n${C.green}✓${C.off} system log: nothing dropped in ${m.seconds} s (${m.perSecond} messages/s; most from ${top})\n`);
|
|
44
|
+
return m;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SYSTEM_LOG_SECONDS = 3;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `[sandbox] provider`: which sandbox each role would get, and why the others not.
|
|
51
|
+
*
|
|
52
|
+
* Said before anything starts, because the alternative is finding out at
|
|
53
|
+
* `seisin run` that a role cannot be held by the sandbox the config asked for.
|
|
54
|
+
* The settings are the ones a run would build, down to a channel in the runs
|
|
55
|
+
* root, so a sandbox that cannot re-open this run's directory is caught here
|
|
56
|
+
* too. Printed only when `[sandbox] provider` is set: without it every role
|
|
57
|
+
* runs under srt, as it always has, and there is nothing to choose.
|
|
58
|
+
*/
|
|
59
|
+
export function sandboxes(config, only = null, { platform = process.platform } = {}) {
|
|
60
|
+
const sock = join(runsRoot(), "check", channelName(platform));
|
|
61
|
+
const rows = [];
|
|
62
|
+
out(`\n${C.b}sandboxes${C.off} (${config.sandbox.provider.join(" → ")})\n`);
|
|
63
|
+
for (const r of Object.values(config.roles)) {
|
|
64
|
+
if (only && r.name !== only) continue;
|
|
65
|
+
let settings;
|
|
66
|
+
try { settings = settingsFor(config, r.name, sock); } catch (e) { rows.push({ role: r.name, chosen: null, error: e.message }); continue; }
|
|
67
|
+
const { chosen, options } = weigh(config, settings, { platform, role: r.name });
|
|
68
|
+
rows.push({ role: r.name, chosen: chosen?.sandbox.name ?? null,
|
|
69
|
+
options: options.map((o) => ({ name: o.sandbox.name, missing: o.missing, unmet: o.unmet, warnings: o.warnings })) });
|
|
70
|
+
out(chosen ? ` ${C.green}✓${C.off} ${r.name}: ${chosen.sandbox.name}\n` : ` ${C.red}✗${C.off} ${r.name}: no sandbox can hold it\n`);
|
|
71
|
+
// Held, but not the way the role may expect: the sandbox's own words.
|
|
72
|
+
for (const w of chosen?.warnings ?? []) out(` ${C.yellow}⚠ ${chosen.sandbox.name}: ${w}${C.off}\n`);
|
|
73
|
+
// Recorded nowhere otherwise: a run under it would still be held, and say nothing of what it refused.
|
|
74
|
+
if (chosen && !chosen.sandbox.tagsDenials && platform === "darwin")
|
|
75
|
+
out(` ${C.yellow}⚠ ${chosen.sandbox.name} does not tag its denials, so none of the kernel's denials under it are recorded${C.off}\n`);
|
|
76
|
+
for (const o of options) {
|
|
77
|
+
if (o === chosen) break;
|
|
78
|
+
if (o.missing) out(` ${o.sandbox.name}: ${o.missing}\n`);
|
|
79
|
+
else for (const u of o.unmet) out(` ${o.sandbox.name}: ${u}\n`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return rows;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The exit status: 1 when a role cannot be enforced as written, or when what a
|
|
87
|
+
* flag asked for failed. `--system-log` is asked for like `--verify` and fails
|
|
88
|
+
* the same way — a log dropping messages now is a run that would lose denials
|
|
89
|
+
* now, and a CI step has only the status to go on.
|
|
90
|
+
*/
|
|
91
|
+
export function checkStatus(report) {
|
|
92
|
+
return report.warnings.some((w) => w.kind === "cannot-be-enforced") || report.verify?.some((v) => !v.ok) ||
|
|
93
|
+
report.sandboxes?.some((s) => !s.chosen) ||
|
|
94
|
+
report.systemLog?.dropped > 0 ? 1 : 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `--verify`: each role's `verify` command, run inside that role's sandbox.
|
|
99
|
+
*
|
|
100
|
+
* Through `seisin run` itself and not a copy of its setup, so what passes here
|
|
101
|
+
* is what the agent will get: the same settings, environment and PATH. With
|
|
102
|
+
* `--agent none`, because the question is the toolchain, not the agent's home.
|
|
103
|
+
* In the repo root, where a test command expects to start.
|
|
104
|
+
*
|
|
105
|
+
* The one thing `check` executes, which is why it is a flag: without it
|
|
106
|
+
* `check` still runs nothing.
|
|
107
|
+
*/
|
|
108
|
+
async function verify(config, only, parallel = false) {
|
|
109
|
+
const cli = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
110
|
+
const roles = Object.values(config.roles).filter((r) => (only ? r.name === only : true) && r.verify);
|
|
111
|
+
const results = [];
|
|
112
|
+
if (!roles.length) out(`\n${C.yellow}verify: no role ${only ? `"${only}" ` : ""}declares a verify command${C.off}\n`);
|
|
113
|
+
const run = async (r) => {
|
|
114
|
+
const res = parallel
|
|
115
|
+
? await runParallel(process.execPath, [cli, "run", r.name, "--agent", "none", "--", ...r.verify], config.root)
|
|
116
|
+
: spawnSync(process.execPath, [cli, "run", r.name, "--agent", "none", "--", ...r.verify], {
|
|
117
|
+
cwd: config.root, encoding: "utf8", timeout: 300_000, env: process.env,
|
|
118
|
+
});
|
|
119
|
+
const ok = res.status === 0;
|
|
120
|
+
const result = { role: r.name, ok, status: res.status };
|
|
121
|
+
const output = `${res.stderr ?? ""}${res.stdout ?? ""}`.trim().split("\n");
|
|
122
|
+
const tail = output.slice(-20).join("\n ");
|
|
123
|
+
const denied = output.filter((line) => /denied|Operation not permitted|Permission denied|not readable/i.test(line));
|
|
124
|
+
const suggestion = denied.some((line) => (r.reads ?? []).some((p) => line.includes(p)))
|
|
125
|
+
? "The command reached a declared reads path; check that the entry names the needed subtree and exists."
|
|
126
|
+
: `If the missing path is data, add the narrow path to reads; if it is an interpreter, runtime or library, add its stack to toolchain${r.toolchain?.length ? ` (currently ${r.toolchain.join(", ")})` : ""}.`;
|
|
127
|
+
out(`\n${ok ? C.green + "✓" : C.red + "✗"} verify ${r.name}${C.off}: ${r.verify.join(" ")}` +
|
|
128
|
+
(ok ? "\n" : ` — exit ${res.status ?? res.signal}\n ${tail}\n` +
|
|
129
|
+
` This role cannot run its own checks inside its sandbox. ${suggestion}\n`));
|
|
130
|
+
return result;
|
|
131
|
+
};
|
|
132
|
+
if (parallel) {
|
|
133
|
+
out(`\n${C.yellow}experimental: verify commands are running concurrently; shared builds, caches, ports, or generated files may race. Use plain --verify for serial checks.${C.off}\n`);
|
|
134
|
+
results.push(...await Promise.all(roles.map(run)));
|
|
135
|
+
} else {
|
|
136
|
+
for (const r of roles) results.push(await run(r));
|
|
137
|
+
}
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function runParallel(command, args, cwd) {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
const child = spawn(command, args, { cwd, env: process.env });
|
|
144
|
+
let stdout = "", stderr = "", timedOut = false, forceTimer;
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
timedOut = true;
|
|
147
|
+
child.kill("SIGTERM");
|
|
148
|
+
forceTimer = setTimeout(() => child.kill("SIGKILL"), 2_000);
|
|
149
|
+
}, 300_000);
|
|
150
|
+
child.stdout.setEncoding("utf8").on("data", (s) => { stdout += s; });
|
|
151
|
+
child.stderr.setEncoding("utf8").on("data", (s) => { stderr += s; });
|
|
152
|
+
child.on("error", (error) => {
|
|
153
|
+
clearTimeout(timer); clearTimeout(forceTimer);
|
|
154
|
+
resolve({ status: null, signal: null, stdout, stderr: `${stderr}${error.message}\n` });
|
|
155
|
+
});
|
|
156
|
+
child.on("close", (status, signal) => {
|
|
157
|
+
clearTimeout(timer); clearTimeout(forceTimer);
|
|
158
|
+
resolve({ status: timedOut ? null : status, signal: timedOut ? "SIGTERM (timeout)" : signal, stdout, stderr });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
package/src/commands/explain.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
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, readTarget } from "../owners.js";
|
|
11
|
+
import { unknownRole } from "../suggest.js";
|
|
11
12
|
import { twinsOf, whereIs } from "../worktree.js";
|
|
12
13
|
import { renderVerdict, C, out } from "../render.js";
|
|
13
14
|
|
|
@@ -15,7 +16,11 @@ export function explainCommand(config, argv = []) {
|
|
|
15
16
|
const [role, action, target] = argv;
|
|
16
17
|
if (!role || !action || !target)
|
|
17
18
|
throw new Error("usage: seisin explain <role> <read|write|mcp> <path, key or MCP server>");
|
|
18
|
-
if (!config.roles[role]) throw
|
|
19
|
+
if (!config.roles[role]) throw unknownRole(config, role);
|
|
20
|
+
// Anything else used to be answered as a write, confidently, to a question
|
|
21
|
+
// nobody asked. An unknown action is a usage error, like an unknown flag.
|
|
22
|
+
if (!["read", "write", "mcp"].includes(action))
|
|
23
|
+
throw new Error(`unknown action "${action}" — use read, write or mcp`);
|
|
19
24
|
|
|
20
25
|
if (action === "mcp") {
|
|
21
26
|
const verdict = explain(config, role, "mcp", target);
|
|
@@ -23,7 +28,14 @@ export function explainCommand(config, argv = []) {
|
|
|
23
28
|
return { ...verdict, worktree: [] };
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
const verb = action
|
|
31
|
+
const verb = action;
|
|
32
|
+
|
|
33
|
+
if (verb === "read") {
|
|
34
|
+
const asked = readTarget(config, target);
|
|
35
|
+
const verdict = explain(config, role, "read", target, process.cwd());
|
|
36
|
+
out(renderVerdict(role, verb, asked.target, verdict));
|
|
37
|
+
return { ...verdict, worktree: [] };
|
|
38
|
+
}
|
|
27
39
|
|
|
28
40
|
/**
|
|
29
41
|
* An absolute path inside the repo is the same question as the relative one.
|
|
@@ -36,17 +48,23 @@ export function explainCommand(config, argv = []) {
|
|
|
36
48
|
* check a boundary was the one that was wrong.
|
|
37
49
|
*
|
|
38
50
|
* Wrong in the direction that matters, too: it reported a path as unowned and
|
|
39
|
-
*
|
|
51
|
+
* denied while the kernel allowed it — see "Refusing beats widening" in
|
|
40
52
|
* docs/decisions.md. A path outside the root is left exactly as written,
|
|
41
53
|
* because that is a question about somewhere else and it still deserves its
|
|
42
54
|
* honest "no owner".
|
|
43
55
|
*/
|
|
44
|
-
|
|
56
|
+
//
|
|
57
|
+
// A relative path is relative to where you are standing, as in every other
|
|
58
|
+
// command line tool: from frontend/src, `app.js` is frontend/src/app.js. It
|
|
59
|
+
// was read against the policy's root, so the same question asked from a
|
|
60
|
+
// subdirectory came back "no owner". The answer prints the path the policy
|
|
61
|
+
// was asked about, so the reader sees which file was meant.
|
|
62
|
+
const asked = toRepoRelative(config, fromCwd(config, target));
|
|
45
63
|
|
|
46
64
|
const verdict = explain(config, role, verb, asked);
|
|
47
|
-
out(renderVerdict(role, verb,
|
|
65
|
+
out(renderVerdict(role, verb, asked, verdict));
|
|
48
66
|
|
|
49
|
-
const worktree =
|
|
67
|
+
const worktree = elsewhere(config, role, isAbsolute(target) ? target : asked, verdict);
|
|
50
68
|
for (const w of worktree) out(renderElsewhere(w));
|
|
51
69
|
return { ...verdict, worktree };
|
|
52
70
|
}
|
|
@@ -55,7 +73,7 @@ export function explainCommand(config, argv = []) {
|
|
|
55
73
|
* The same question, asked about the same file in the other checkouts.
|
|
56
74
|
*
|
|
57
75
|
* Only a twin that gets a DIFFERENT answer is worth a line. A worktree where
|
|
58
|
-
* the role is equally allowed, or equally
|
|
76
|
+
* the role is equally allowed, or equally denied, changes nothing about what
|
|
59
77
|
* was just printed, and a diagnosis that mentions it anyway is the noise this
|
|
60
78
|
* tool keeps having to remove.
|
|
61
79
|
*
|
|
@@ -73,7 +91,7 @@ function elsewhere(config, role, target, verdict) {
|
|
|
73
91
|
|
|
74
92
|
/**
|
|
75
93
|
* Three lines: where the other checkout is, what the answer is there, and
|
|
76
|
-
* which of the two the policy names — the sentence the
|
|
94
|
+
* which of the two the policy names — the sentence the denial never carried.
|
|
77
95
|
*/
|
|
78
96
|
function renderElsewhere(t) {
|
|
79
97
|
const there = t.verdict.allowed
|
|
@@ -82,12 +100,12 @@ function renderElsewhere(t) {
|
|
|
82
100
|
const named = t.asked.allowed ? t.here : t;
|
|
83
101
|
const other = t.asked.allowed ? t : t.here;
|
|
84
102
|
// "allowed" is the answer that misleads, so it is the one that gets told
|
|
85
|
-
// where the
|
|
86
|
-
const
|
|
103
|
+
// where the denial is. After "denied" that clause would only repeat it.
|
|
104
|
+
const denied = t.asked.allowed ? ` — a write in ${kind(other)} is denied` : "";
|
|
87
105
|
return (
|
|
88
106
|
` ${C.yellow}worktree${C.off} ${t.where}\n` +
|
|
89
107
|
` ${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)}${
|
|
108
|
+
` The policy names ${kind(named)}, not ${kind(other)}${denied}.${C.off}\n\n`
|
|
91
109
|
);
|
|
92
110
|
}
|
|
93
111
|
|
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";
|
|
@@ -12,10 +12,11 @@ import { decide } from "../hook.js";
|
|
|
12
12
|
import { afterTool, atSessionStart } from "../diagnose.js";
|
|
13
13
|
import { logPath } from "../log.js";
|
|
14
14
|
import { flush } from "../spool.js";
|
|
15
|
+
import { chainFromEnv } from "../handoff.js";
|
|
15
16
|
|
|
16
17
|
export async function hook(stdin = process.stdin, env = process.env) {
|
|
17
18
|
const role = env.SEISIN_ROLE;
|
|
18
|
-
if (!role) return null; // not launched by seisin: say nothing,
|
|
19
|
+
if (!role) return null; // not launched by seisin: say nothing, stop nothing
|
|
19
20
|
|
|
20
21
|
let raw = "";
|
|
21
22
|
for await (const chunk of stdin) raw += chunk;
|
|
@@ -24,7 +25,7 @@ export async function hook(stdin = process.stdin, env = process.env) {
|
|
|
24
25
|
try {
|
|
25
26
|
event = JSON.parse(raw || "{}");
|
|
26
27
|
} catch {
|
|
27
|
-
return null; // an unreadable event is not grounds to
|
|
28
|
+
return null; // an unreadable event is not grounds to stop work
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
let config;
|
|
@@ -34,17 +35,30 @@ export async function hook(stdin = process.stdin, env = process.env) {
|
|
|
34
35
|
return null;
|
|
35
36
|
}
|
|
36
37
|
if (!config.roles[role]) return null;
|
|
38
|
+
// Set by `seisin run --handoff`; it words the denial and decides nothing
|
|
39
|
+
// (handoff.js, CHAIN_ENV).
|
|
40
|
+
const chain = chainFromEnv(env);
|
|
37
41
|
|
|
38
42
|
// One command for every event it is wired to, so `seisin wire` adds one name.
|
|
39
43
|
// The after-the-fact and session-start halves only explain; neither decides.
|
|
40
44
|
const event_ = event.hook_event_name;
|
|
41
45
|
if (event_ === "PostToolUse" || event_ === "PostToolUseFailure") {
|
|
42
|
-
try { return await afterTool(config, role, event, { file: logPath(config.root) }); } catch { return null; }
|
|
46
|
+
try { return await afterTool(config, role, event, { file: logPath(config.root), chain }); } catch { return null; }
|
|
43
47
|
}
|
|
44
48
|
if (event_ === "SessionStart") {
|
|
45
|
-
try { return atSessionStart(config, role, event, { file: logPath(config.root) }); } catch { return null; }
|
|
49
|
+
try { return atSessionStart(config, role, event, { file: logPath(config.root), chain }); } catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
// Caught like the two halves above, and for a sharper reason: PreToolUse is
|
|
52
|
+
// the one event where a crash is not silent. cli.js turns a throw into exit
|
|
53
|
+
// 2, and Claude Code reads exit 2 from this hook as "block the tool". A bug
|
|
54
|
+
// here would stop the agent's work — the one thing this command promises
|
|
55
|
+
// never to do. Said on stderr so the bug is still findable.
|
|
56
|
+
let decision = null;
|
|
57
|
+
try {
|
|
58
|
+
decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1", chain });
|
|
59
|
+
} catch (e) {
|
|
60
|
+
process.stderr.write(`seisin hook: ${e?.message ?? e} — no decision, the tool call goes ahead\n`);
|
|
46
61
|
}
|
|
47
|
-
const decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1" });
|
|
48
62
|
// The entries went to a socket, and cli.js exits as soon as we return. An
|
|
49
63
|
// exit does not drain a socket, so the record would be lost precisely on the
|
|
50
64
|
// turns that produced one.
|