seisin 0.5.0 → 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 +385 -129
- package/package.json +13 -5
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +47 -23
- package/src/commands/check.js +154 -3
- package/src/commands/explain.js +2 -5
- package/src/commands/hook.js +7 -3
- package/src/commands/init.js +57 -50
- package/src/commands/requests.js +31 -56
- package/src/commands/review.js +33 -6
- package/src/commands/run.js +332 -110
- package/src/commands/walls.js +39 -7
- package/src/commands/whose.js +2 -1
- package/src/config.js +321 -15
- package/src/controls.js +5 -5
- package/src/diagnose.js +67 -16
- package/src/env.js +3 -0
- package/src/grants.js +107 -2
- package/src/handoff.js +285 -10
- package/src/help.js +39 -19
- package/src/hook.js +59 -30
- package/src/index.d.ts +217 -0
- package/src/index.js +20 -12
- 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 +13 -815
- package/src/intake.js +131 -21
- package/src/keys.js +223 -67
- package/src/kinds.js +42 -0
- package/src/lines.js +26 -0
- package/src/log.js +7 -2
- package/src/mcp.js +28 -43
- package/src/nested.js +12 -3
- package/src/notify.js +53 -12
- package/src/owners.js +165 -3
- package/src/paths.js +17 -1
- package/src/provider-run.js +81 -0
- package/src/redact.js +34 -15
- package/src/render.js +35 -9
- package/src/requests.js +363 -45
- package/src/review.js +21 -9
- package/src/rundir.js +29 -8
- 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 +7 -3
- package/src/serve.js +35 -17
- package/src/spool.js +8 -17
- package/src/srt.js +263 -33
- package/src/surface.js +129 -16
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +15 -6
- package/src/views.js +40 -14
- package/src/violations.js +143 -7
- package/src/walls.js +62 -9
- package/ui/index.html +85 -13
- package/ui/favicon.svg +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seisin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it denies something it tells you whose file it was.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
"codeowners",
|
|
14
14
|
"rbac",
|
|
15
15
|
"agent-permissions",
|
|
16
|
-
"code-ownership"
|
|
16
|
+
"code-ownership",
|
|
17
|
+
"mcp",
|
|
18
|
+
"security",
|
|
19
|
+
"developer-tools"
|
|
17
20
|
],
|
|
18
21
|
"license": "MIT",
|
|
19
22
|
"author": "Carlos Tapia",
|
|
@@ -31,7 +34,7 @@
|
|
|
31
34
|
},
|
|
32
35
|
"files": [
|
|
33
36
|
"src",
|
|
34
|
-
"ui",
|
|
37
|
+
"ui/index.html",
|
|
35
38
|
"README.md",
|
|
36
39
|
"LICENSE"
|
|
37
40
|
],
|
|
@@ -39,15 +42,20 @@
|
|
|
39
42
|
"node": ">=18"
|
|
40
43
|
},
|
|
41
44
|
"dependencies": {
|
|
42
|
-
"@anthropic-ai/sandbox-runtime": "0.0.
|
|
45
|
+
"@anthropic-ai/sandbox-runtime": "0.0.81"
|
|
43
46
|
},
|
|
44
47
|
"scripts": {
|
|
45
48
|
"test": "node --test test/*.test.js",
|
|
49
|
+
"build:ui": "node ui/build.js",
|
|
46
50
|
"prepublishOnly": "npm test"
|
|
47
51
|
},
|
|
48
52
|
"main": "./src/index.js",
|
|
53
|
+
"types": "./src/index.d.ts",
|
|
49
54
|
"exports": {
|
|
50
|
-
".":
|
|
55
|
+
".": {
|
|
56
|
+
"types": "./src/index.d.ts",
|
|
57
|
+
"import": "./src/index.js"
|
|
58
|
+
},
|
|
51
59
|
"./package.json": "./package.json"
|
|
52
60
|
}
|
|
53
61
|
}
|
package/src/browsers.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `browsers = ["chromium"]`: what a headless browser needs from the sandbox
|
|
3
|
+
* beyond files and the network, per role and only when the role says so.
|
|
4
|
+
*
|
|
5
|
+
* Measured on macOS with Playwright's Chromium headless shell (build 1208), as
|
|
6
|
+
* the kernel saw it under the default profile:
|
|
7
|
+
*
|
|
8
|
+
* - the browser process starts by registering a Mach service named
|
|
9
|
+
* `org.chromium.Chromium.MachPortRendezvousServer.<its pid>`, and dies at
|
|
10
|
+
* once when that is refused (`bootstrap_check_in ... Permission denied
|
|
11
|
+
* (1100)`, FATAL in base/apple/mach_port_rendezvous_mac.cc);
|
|
12
|
+
* - every child it starts (GPU, network, renderer) then looks that same
|
|
13
|
+
* service up to receive its ports, and loops forever when the lookup is
|
|
14
|
+
* refused: over a thousand refusals in forty seconds, no page.
|
|
15
|
+
*
|
|
16
|
+
* Those two, with that one prefix, are all it took: with them the browser
|
|
17
|
+
* started, rendered and navigated through seisin's proxy, and the other Mach
|
|
18
|
+
* services it reached for (configd, the window server, analytics) stayed
|
|
19
|
+
* refused without stopping it. So that is all a preset grants. Never a bare
|
|
20
|
+
* `mach-register`: a name is registered in the bootstrap namespace other
|
|
21
|
+
* processes look names up in, and an open grant would let the role stand up a
|
|
22
|
+
* service under any name.
|
|
23
|
+
*
|
|
24
|
+
* The prefix is the browser's bundle id, which is why the preset is named for
|
|
25
|
+
* the build. Chrome and Chrome for Testing use their own (`com.google.Chrome`,
|
|
26
|
+
* `com.google.chrome.for.testing`) and also need a unix socket for their
|
|
27
|
+
* process singleton, measured and not granted here: they are not presets yet.
|
|
28
|
+
*/
|
|
29
|
+
export const BROWSERS = {
|
|
30
|
+
chromium: {
|
|
31
|
+
// The browser registers it; its children look it up.
|
|
32
|
+
register: ["org.chromium.Chromium.MachPortRendezvousServer."],
|
|
33
|
+
lookup: ["org.chromium.Chromium.MachPortRendezvousServer."],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** A Mach name prefix as the profile may carry it: no quotes, no spaces, ends in a dot. */
|
|
38
|
+
export const MACH_PREFIX = /^[A-Za-z0-9][A-Za-z0-9.-]*\.$/;
|
|
39
|
+
|
|
40
|
+
/** The Mach names a role's browsers need registered and looked up, de-duplicated. */
|
|
41
|
+
export function machOf(role) {
|
|
42
|
+
const names = (role?.browsers ?? []).map((b) => BROWSERS[b]).filter(Boolean);
|
|
43
|
+
return {
|
|
44
|
+
register: [...new Set(names.flatMap((b) => b.register))],
|
|
45
|
+
lookup: [...new Set(names.flatMap((b) => b.lookup))],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The preset whose register prefix this Mach name falls under, or null. */
|
|
50
|
+
export function browserFor(name) {
|
|
51
|
+
return Object.keys(BROWSERS).find((b) => BROWSERS[b].register.some((p) => name.startsWith(p))) ?? null;
|
|
52
|
+
}
|
package/src/canary.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canary: before the agent starts, the policy is tried from inside.
|
|
3
|
+
*
|
|
4
|
+
* The settings are generated by software, and software breaks. A runtime
|
|
5
|
+
* upgrade, a path that turned out to be a symlink, an `isolate` level combined
|
|
6
|
+
* with something it was not measured with — any of them can leave a door open,
|
|
7
|
+
* and without this the first anyone hears of it is after something went
|
|
8
|
+
* through it. So every run starts with a short process under the SAME runtime,
|
|
9
|
+
* the SAME settings file and the SAME environment as the agent, and the agent
|
|
10
|
+
* starts only if every answer is the expected one.
|
|
11
|
+
*
|
|
12
|
+
* Judged by effect, never by exit code: a write "denied" is a file that is not
|
|
13
|
+
* there afterwards; a read "denied" is a nonce that never came out; a
|
|
14
|
+
* connection "refused" is a nonce the decoy never delivered. And each denial
|
|
15
|
+
* has a positive control beside it — the sandbox ran at all, and a place the
|
|
16
|
+
* role may write took the write — because a sandbox that runs nothing denies
|
|
17
|
+
* everything too.
|
|
18
|
+
*
|
|
19
|
+
* **Fails closed, including on a missing tool.** In an orchestrator we compared against, the network
|
|
20
|
+
* probe passed whenever `nc` was absent: no tool, no connection, "refused".
|
|
21
|
+
* Here a probe that cannot be made is a failed canary.
|
|
22
|
+
*
|
|
23
|
+
* **No flag skips it.** A flag becomes an alias, and the alias becomes the way
|
|
24
|
+
* every run starts. It costs one extra start of the runtime, a few tenths of a
|
|
25
|
+
* second, against a run that lasts minutes.
|
|
26
|
+
*/
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import { createServer } from "node:net";
|
|
29
|
+
import { randomBytes } from "node:crypto";
|
|
30
|
+
import { writeFileSync, readFileSync, rmSync, existsSync, statSync } from "node:fs";
|
|
31
|
+
import { join, dirname } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** Where sandbox-runtime's shared temp directory is on this host. */
|
|
34
|
+
const SHARED_TMP_DIR = "/tmp/claude";
|
|
35
|
+
|
|
36
|
+
/** The exit code of a run the canary stopped: not the agent's, and not one a shell uses. */
|
|
37
|
+
export const CANARY_EXIT = 86;
|
|
38
|
+
|
|
39
|
+
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Runs the canary and resolves to `{ ok, failed: [reasons], ms }`.
|
|
43
|
+
*
|
|
44
|
+
* `runDir` is this run's directory and `runsRoot` the one above it; the
|
|
45
|
+
* secret is planted in the second, which the settings deny to every run but
|
|
46
|
+
* the run's own directory. `role.localBinding` skips the network probe and
|
|
47
|
+
* says so: that role was granted every localhost port on purpose.
|
|
48
|
+
*/
|
|
49
|
+
export async function runCanary({ srt, wrap = null, settingsFile, settings, env, cwd, runDir, runsRoot, role, granted, timeoutMs = 20_000 }) {
|
|
50
|
+
// How the probe is started: under the run's sandbox, exactly as the agent
|
|
51
|
+
// will be (sandboxes/). `srt` alone is the runtime's own command line, kept
|
|
52
|
+
// for callers from before there was more than one sandbox.
|
|
53
|
+
const boxed = wrap ?? ((inner) => [srt, "--settings", settingsFile, "--", ...inner]);
|
|
54
|
+
const t0 = Date.now();
|
|
55
|
+
const nonce = randomBytes(8).toString("hex");
|
|
56
|
+
const failed = [];
|
|
57
|
+
|
|
58
|
+
const secret = join(runsRoot, `canary-${nonce}.secret`);
|
|
59
|
+
const outside = join(runDir, `canary-${nonce}.out`);
|
|
60
|
+
// The first directory the POLICY grants that exists — taken from the
|
|
61
|
+
// policy and not from the settings, so settings that lost the grant fail
|
|
62
|
+
// here instead of skipping the check. Absent (a role with nothing to
|
|
63
|
+
// write), the write control is skipped and "the sandbox ran" stands alone.
|
|
64
|
+
// Outside the repo first (the runtime's scratch, the role's home): a file
|
|
65
|
+
// appearing and vanishing in a watched tree restarts a dev server.
|
|
66
|
+
const candidates = (granted ?? settings.filesystem.allowWrite ?? []).filter((p) => isDir(p));
|
|
67
|
+
const writable = candidates.find((p) => !(cwd && (p === cwd || p.startsWith(cwd + "/") || cwd.startsWith(p + "/")))) ?? candidates[0];
|
|
68
|
+
const inside = writable ? join(writable, `.seisin-canary-${nonce}`) : null;
|
|
69
|
+
// The runtime's shared temp directory (srt.js, SHARED_TMP): granted by the
|
|
70
|
+
// runtime to every sandbox, taken back by seisin. Probed where it exists;
|
|
71
|
+
// where it does not, there is nothing beside which to write.
|
|
72
|
+
const shared = isDir(SHARED_TMP_DIR) ? join(SHARED_TMP_DIR, `.seisin-canary-${nonce}`) : null;
|
|
73
|
+
const policy = settingsFile;
|
|
74
|
+
|
|
75
|
+
writeFileSync(secret, `SECRET-${nonce}\n`, { mode: 0o600 });
|
|
76
|
+
|
|
77
|
+
let server = null;
|
|
78
|
+
let port = null;
|
|
79
|
+
if (!role.localBinding) {
|
|
80
|
+
server = createServer((s) => s.end(`HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\nNET-${nonce}\n`));
|
|
81
|
+
await new Promise((res, rej) => server.listen(0, "127.0.0.1", res).on("error", rej));
|
|
82
|
+
port = server.address().port;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
86
|
+
const script = [
|
|
87
|
+
`echo ALIVE-${nonce}`,
|
|
88
|
+
`(echo x > ${q(outside)}) 2>/dev/null`,
|
|
89
|
+
`cat ${q(secret)} 2>/dev/null`,
|
|
90
|
+
`cat ${q(policy)} >/dev/null 2>&1 && echo POLICY-READ-${nonce}`,
|
|
91
|
+
inside ? `(echo IN-${nonce} > ${q(inside)}) 2>/dev/null` : ":",
|
|
92
|
+
shared ? `(echo x > ${q(shared)}) 2>/dev/null` : ":",
|
|
93
|
+
// curl when there is one; otherwise the node seisin itself runs on, which
|
|
94
|
+
// every machine with seisin has — a slim image without curl would
|
|
95
|
+
// otherwise never start anything. Neither: a failed canary, not a pass.
|
|
96
|
+
port ? `if command -v curl >/dev/null 2>&1; then curl -s -m 3 -w PROBED-${nonce} http://127.0.0.1:${port}/ 2>/dev/null; ` +
|
|
97
|
+
`elif [ -x ${q(process.execPath)} ]; then ${q(process.execPath)} -e ${q(`process.stdout.write("PROBED-${nonce}");const s=require("net").connect(${port},"127.0.0.1");s.on("data",d=>process.stdout.write(d));s.on("error",()=>{});setTimeout(()=>process.exit(0),3000)`)} 2>/dev/null; ` +
|
|
98
|
+
`else echo NO-PROBE-${nonce}; fi` : ":",
|
|
99
|
+
].join("; ");
|
|
100
|
+
|
|
101
|
+
let output = "";
|
|
102
|
+
let stderr = "";
|
|
103
|
+
let timedOut = false;
|
|
104
|
+
try {
|
|
105
|
+
const status = await new Promise((resolve) => {
|
|
106
|
+
const [file, ...args] = boxed(["/bin/sh", "-c", script]);
|
|
107
|
+
const child = spawn(file, args, {
|
|
108
|
+
cwd, env, stdio: ["ignore", "pipe", "pipe"],
|
|
109
|
+
});
|
|
110
|
+
const timer = setTimeout(() => { timedOut = true; try { child.kill("SIGKILL"); } catch {} }, timeoutMs);
|
|
111
|
+
child.stdout.on("data", (d) => { output += d; });
|
|
112
|
+
child.stderr.on("data", (d) => { stderr = (stderr + d).slice(-2000); });
|
|
113
|
+
child.on("error", () => { clearTimeout(timer); resolve(null); });
|
|
114
|
+
child.on("close", (code) => { clearTimeout(timer); resolve(code); });
|
|
115
|
+
});
|
|
116
|
+
void status;
|
|
117
|
+
} finally {
|
|
118
|
+
server?.close();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (timedOut) failed.push(`the canary did not finish in ${timeoutMs / 1000}s`);
|
|
122
|
+
if (!output.includes(`ALIVE-${nonce}`)) {
|
|
123
|
+
// The runtime's own words, when it said any: a sandbox that refuses to
|
|
124
|
+
// start usually says why (measured: a path the runtime read as a broken
|
|
125
|
+
// glob), and without this the reason was thrown away with the stream.
|
|
126
|
+
const said = stderr.trim().split("\n").filter(Boolean).slice(-3).join(" / ");
|
|
127
|
+
failed.push(`the sandbox did not run the canary at all${said ? ` — the runtime said: ${said}` : ""}`);
|
|
128
|
+
}
|
|
129
|
+
if (existsSync(outside)) failed.push(`a write outside the territory went through (${outside})`);
|
|
130
|
+
if (shared && existsSync(shared)) failed.push(`a write into the runtime's shared temp directory went through (${shared})`);
|
|
131
|
+
if (output.includes(`SECRET-${nonce}`)) failed.push(`a planted secret was readable (${secret})`);
|
|
132
|
+
if (output.includes(`POLICY-READ-${nonce}`)) failed.push(`the run's own settings were readable (${policy})`);
|
|
133
|
+
if (inside) {
|
|
134
|
+
let wrote = false;
|
|
135
|
+
try { wrote = readFileSync(inside, "utf8").trim() === `IN-${nonce}`; } catch {}
|
|
136
|
+
if (!wrote) failed.push(`a write the role is granted did not go through (${dirname(inside)}) — the positive control`);
|
|
137
|
+
}
|
|
138
|
+
if (port) {
|
|
139
|
+
// The probe has to say it ran. Silence is not "unreachable": a node the
|
|
140
|
+
// sandbox will not execute prints nothing, and nothing would read as a pass.
|
|
141
|
+
if (!output.includes(`PROBED-${nonce}`) && !output.includes(`NO-PROBE-${nonce}`))
|
|
142
|
+
failed.push("the network probe did not run — a probe that cannot be made is not a pass");
|
|
143
|
+
if (output.includes(`NO-PROBE-${nonce}`)) failed.push("neither curl nor node can run inside the sandbox to probe the network with — a probe that cannot be made is not a pass");
|
|
144
|
+
if (output.includes(`NET-${nonce}`)) failed.push(`a loopback port outside the policy was reachable (127.0.0.1:${port})`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const p of [secret, outside, inside, shared]) if (p) try { rmSync(p, { force: true }); } catch {}
|
|
148
|
+
return { ok: failed.length === 0, failed, ms: Date.now() - t0 };
|
|
149
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -19,7 +19,7 @@ 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";
|
|
22
|
+
import { COMMAND_HELP, flagsOf, renderHelp, renderLimits } from "./help.js";
|
|
23
23
|
import { nearest, checkFlags } from "./suggest.js";
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -41,11 +41,11 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
|
|
|
41
41
|
|
|
42
42
|
seisin run <role> -- <command...> run a command as that role
|
|
43
43
|
seisin run <role> --observe -- <cmd> open the repo and watch — the network stays shut
|
|
44
|
-
seisin check [role] [--verbose]
|
|
44
|
+
seisin check [role] [--verbose] [--verify] [--parallel] [--system-log] --parallel is experimental; --system-log measures log loss (macOS)
|
|
45
45
|
seisin explain <role> read|write|mcp <path, key or server>
|
|
46
46
|
seisin whose <path> who owns it — safe to call from inside the sandbox
|
|
47
47
|
seisin scan [--all] find secrets outside the declared key dirs
|
|
48
|
-
seisin review [--all]
|
|
48
|
+
seisin review [--all] [--since 7d] what the log says about the policy
|
|
49
49
|
seisin walls <role> [--all] what that role keeps being denied, and what it cost
|
|
50
50
|
seisin wire let the agent record what it does
|
|
51
51
|
seisin requests what the agents asked for and cannot have
|
|
@@ -60,6 +60,7 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
|
|
|
60
60
|
seisin mcp an MCP server on stdio — read-only
|
|
61
61
|
|
|
62
62
|
seisin <command> --help lists a command's flags, examples and exit codes.
|
|
63
|
+
seisin help limits says what seisin cannot do, in any repo.
|
|
63
64
|
|
|
64
65
|
Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
|
|
65
66
|
seisin decides what to ask for, and says whose file it was when the answer is no.
|
|
@@ -90,7 +91,10 @@ const COMMANDS = {
|
|
|
90
91
|
// composes in a pre-commit hook or CI the way `explain` and `scan` already do.
|
|
91
92
|
// Warnings about a config that WILL work still exit 0 — failing on those
|
|
92
93
|
// would make the command unusable within a week.
|
|
93
|
-
check: async () =>
|
|
94
|
+
check: async () => {
|
|
95
|
+
const report = await (await import("./commands/check.js")).check(config(), argv);
|
|
96
|
+
return (await import("./commands/check.js")).checkStatus(report);
|
|
97
|
+
},
|
|
94
98
|
explain: async () => ((await import("./commands/explain.js")).explainCommand(config(), argv).allowed ? 0 : 1),
|
|
95
99
|
scan: async () => ((await import("./commands/scan.js")).scanCommand(config(), argv).certain.length ? 1 : 0),
|
|
96
100
|
log: async () => void (await import("./commands/log.js")).log(config(), argv),
|
|
@@ -105,20 +109,16 @@ const COMMANDS = {
|
|
|
105
109
|
whose: async () => void (await import("./commands/whose.js")).whose(config(), argv),
|
|
106
110
|
wire: async () => void (await import("./commands/wire.js")).wire(config()),
|
|
107
111
|
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).
|
|
109
|
-
requests: async () => ((await import("./commands/requests.js")).requests(config()).length ? 1 : 0),
|
|
112
|
+
walls: async () => ((await import("./commands/walls.js")).wallsCommand(config(), argv).some((w) => !w.expected) ? 1 : 0),
|
|
113
|
+
requests: async () => ((await import("./commands/requests.js")).requests(config(), argv).length ? 1 : 0),
|
|
110
114
|
grant: async () => void (await import("./commands/requests.js")).grant(config(), argv),
|
|
111
115
|
/**
|
|
112
116
|
* A person DECLINES a request; the boundary DENIES a write. One word each,
|
|
113
|
-
* because they are opposite events and
|
|
117
|
+
* because they are opposite events and one word used to name both — a queue
|
|
114
118
|
* entry said `first refused on …` (the kernel) and the answer said
|
|
115
|
-
* `refused frontend ✕ …` (you).
|
|
116
|
-
*
|
|
117
|
-
* `deny` stays and is not documented. Renaming a published command over a
|
|
118
|
-
* word is not worth breaking somebody's script; teaching the new one is.
|
|
119
|
+
* `refused frontend ✕ …` (you). The old command is gone (see RETIRED).
|
|
119
120
|
*/
|
|
120
|
-
decline: async () => void (await import("./commands/requests.js")).
|
|
121
|
-
deny: async () => void (await import("./commands/requests.js")).deny(config(), argv),
|
|
121
|
+
decline: async () => void (await import("./commands/requests.js")).decline(config(), argv),
|
|
122
122
|
mcp: async () => {
|
|
123
123
|
await (await import("./mcp.js")).serveMcp(version());
|
|
124
124
|
return 0;
|
|
@@ -129,7 +129,12 @@ const COMMANDS = {
|
|
|
129
129
|
hook: async () => {
|
|
130
130
|
try {
|
|
131
131
|
const decision = await (await import("./commands/hook.js")).hook();
|
|
132
|
-
|
|
132
|
+
// Only what the hook protocol defines. `decision` and `logged` are for the
|
|
133
|
+
// callers inside seisin; Codex validates a hook's output against a schema
|
|
134
|
+
// that admits no other field (and `decision` only as approve|block), and
|
|
135
|
+
// drops the whole answer when it does not fit — measured 2026-09-30 with
|
|
136
|
+
// codex 0.150.1: the deny was ignored and the command ran into the kernel.
|
|
137
|
+
if (decision?.hookSpecificOutput) out(JSON.stringify({ hookSpecificOutput: decision.hookSpecificOutput }) + "\n");
|
|
133
138
|
} catch (e) {
|
|
134
139
|
err(`seisin hook: ${e?.message ?? e} — no decision, the tool call goes ahead\n`);
|
|
135
140
|
}
|
|
@@ -158,18 +163,17 @@ function version() {
|
|
|
158
163
|
* (`--reason -h`) is a value.
|
|
159
164
|
*/
|
|
160
165
|
const HELP = new Set(["--help", "-h"]);
|
|
161
|
-
const TAKES_VALUE = new Set(["--port", "--role", "--verdict", "--limit", "--min", "--since", "--reason"]);
|
|
162
|
-
|
|
163
166
|
function asksHelp(name, args) {
|
|
167
|
+
const takesValue = new Set(flagsOf(name)?.values ?? []);
|
|
164
168
|
const split = args.indexOf("--");
|
|
165
169
|
let ours = split === -1 ? args : args.slice(0, split);
|
|
166
170
|
if (name === "run" && split === -1) {
|
|
167
171
|
let i = 1;
|
|
168
|
-
while (i < args.length && args[i].startsWith("-")) i
|
|
172
|
+
while (i < args.length && args[i].startsWith("-")) i += takesValue.has(args[i]) ? 2 : 1;
|
|
169
173
|
ours = args.slice(0, i);
|
|
170
174
|
}
|
|
171
175
|
for (let i = 0; i < ours.length; i++) {
|
|
172
|
-
if (
|
|
176
|
+
if (takesValue.has(ours[i])) i++;
|
|
173
177
|
else if (HELP.has(ours[i])) return true;
|
|
174
178
|
}
|
|
175
179
|
return false;
|
|
@@ -178,24 +182,42 @@ function asksHelp(name, args) {
|
|
|
178
182
|
/** One command's help: its flags, examples and exit codes, or the whole usage. */
|
|
179
183
|
function helpFor(name) {
|
|
180
184
|
if (COMMAND_HELP[name]) return renderHelp(name, C);
|
|
181
|
-
const
|
|
182
|
-
const lines = USAGE.split("\n").filter((l) => l.startsWith(` seisin ${alias} `) || l === ` seisin ${alias}`);
|
|
185
|
+
const lines = USAGE.split("\n").filter((l) => l.startsWith(` seisin ${name} `) || l === ` seisin ${name}`);
|
|
183
186
|
if (!lines.length) return USAGE;
|
|
184
187
|
return `\nusage:\n${lines.join("\n")}\n\n ${C.dim}seisin --help for every command${C.off}\n\n`;
|
|
185
188
|
}
|
|
186
189
|
|
|
190
|
+
// The standing limits: the same for every repo, so they are help, not `check`.
|
|
191
|
+
if (command === "help" && argv[0] === "limits") {
|
|
192
|
+
out(renderLimits(C));
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}
|
|
187
195
|
if (command === undefined || command === "help" || HELP.has(command)) {
|
|
188
196
|
const about = command === "help" && COMMANDS[argv[0]] && !argv[0].startsWith("-") ? argv[0] : null;
|
|
189
197
|
out(about ? helpFor(about) : USAGE);
|
|
190
198
|
process.exit(0);
|
|
191
199
|
}
|
|
192
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Commands that were renamed, and what to type now. Not aliases: the old name
|
|
203
|
+
* runs nothing, so a script that calls it stops and says what changed rather
|
|
204
|
+
* than doing the right thing under the wrong word for ever.
|
|
205
|
+
*/
|
|
206
|
+
const RETIRED = {
|
|
207
|
+
deny: `"seisin deny" was removed in 0.6.0: a person declines, the boundary denies.\n` +
|
|
208
|
+
` Use seisin decline <n|id> [--reason "…"] — same arguments, same effect.`,
|
|
209
|
+
};
|
|
210
|
+
|
|
193
211
|
const chosen = Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : null;
|
|
212
|
+
if (!chosen && Object.hasOwn(RETIRED, command)) {
|
|
213
|
+
err(`${C.red}seisin:${C.off} ${RETIRED[command]}\n`);
|
|
214
|
+
process.exit(2);
|
|
215
|
+
}
|
|
194
216
|
if (!chosen) {
|
|
195
217
|
// One line, and the nearest command. The whole usage, printed for a typo,
|
|
196
218
|
// pushed the one line that mattered off the screen.
|
|
197
219
|
const near = command.startsWith("-") ? nearest(command, ["--help", "--version"])
|
|
198
|
-
: nearest(command, Object.keys(COMMANDS).filter((c) => !c.startsWith("-")
|
|
220
|
+
: nearest(command, Object.keys(COMMANDS).filter((c) => !c.startsWith("-")));
|
|
199
221
|
err(`${C.red}seisin:${C.off} unknown ${command.startsWith("-") ? "option" : "command"} "${command}"` +
|
|
200
222
|
`${near ? ` — did you mean "${near}"?` : ""}\n seisin --help lists every command\n`);
|
|
201
223
|
process.exit(2);
|
|
@@ -230,9 +252,11 @@ function refuseUnknownFlags(name, args) {
|
|
|
230
252
|
if (split !== -1) ours = args.slice(1, split);
|
|
231
253
|
else {
|
|
232
254
|
let i = 1;
|
|
233
|
-
|
|
255
|
+
// Every flag that takes a value, from the one table: listing them here by
|
|
256
|
+
// hand left `--handoff` out, so its id was checked as a flag of its own.
|
|
257
|
+
while (i < args.length && args[i].startsWith("-")) i += f.values.includes(args[i]) ? 2 : 1;
|
|
234
258
|
ours = args.slice(1, i);
|
|
235
259
|
}
|
|
236
260
|
}
|
|
237
|
-
checkFlags(name
|
|
261
|
+
checkFlags(name, ours, f.known, f.values);
|
|
238
262
|
}
|
package/src/commands/check.js
CHANGED
|
@@ -1,10 +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 = []) {
|
|
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");
|
|
6
16
|
const role = argv.find((a) => !a.startsWith("-")) ?? null;
|
|
7
17
|
const report = inspect(config, role, config.path);
|
|
8
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();
|
|
9
22
|
return report;
|
|
10
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
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { toRepoRelative, fromCwd } from "../paths.js";
|
|
9
9
|
import { isAbsolute } from "node:path";
|
|
10
|
-
import { explain,
|
|
11
|
-
import { CREDENTIAL_HOMES, expand } from "../grants.js";
|
|
10
|
+
import { explain, readTarget } from "../owners.js";
|
|
12
11
|
import { unknownRole } from "../suggest.js";
|
|
13
12
|
import { twinsOf, whereIs } from "../worktree.js";
|
|
14
13
|
import { renderVerdict, C, out } from "../render.js";
|
|
@@ -33,9 +32,7 @@ export function explainCommand(config, argv = []) {
|
|
|
33
32
|
|
|
34
33
|
if (verb === "read") {
|
|
35
34
|
const asked = readTarget(config, target);
|
|
36
|
-
const verdict =
|
|
37
|
-
? explain(config, role, "read", asked.target)
|
|
38
|
-
: explainFileRead(config, role, asked.target, CREDENTIAL_HOMES.map(expand));
|
|
35
|
+
const verdict = explain(config, role, "read", target, process.cwd());
|
|
39
36
|
out(renderVerdict(role, verb, asked.target, verdict));
|
|
40
37
|
return { ...verdict, worktree: [] };
|
|
41
38
|
}
|
package/src/commands/hook.js
CHANGED
|
@@ -12,6 +12,7 @@ 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;
|
|
@@ -34,15 +35,18 @@ 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; }
|
|
46
50
|
}
|
|
47
51
|
// Caught like the two halves above, and for a sharper reason: PreToolUse is
|
|
48
52
|
// the one event where a crash is not silent. cli.js turns a throw into exit
|
|
@@ -51,7 +55,7 @@ export async function hook(stdin = process.stdin, env = process.env) {
|
|
|
51
55
|
// never to do. Said on stderr so the bug is still findable.
|
|
52
56
|
let decision = null;
|
|
53
57
|
try {
|
|
54
|
-
decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1" });
|
|
58
|
+
decision = decide(config, role, event, { observe: env.SEISIN_OBSERVE === "1", chain });
|
|
55
59
|
} catch (e) {
|
|
56
60
|
process.stderr.write(`seisin hook: ${e?.message ?? e} — no decision, the tool call goes ahead\n`);
|
|
57
61
|
}
|