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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seisin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|
|
7
7
|
"claude-code",
|
|
@@ -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
|
@@ -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, renderLimits } 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,27 @@ ${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
|
|
50
|
-
seisin review [--all]
|
|
44
|
+
seisin check [role] [--verbose] [--verify] [--parallel] [--system-log] --parallel is experimental; --system-log measures log loss (macOS)
|
|
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
|
|
48
|
+
seisin review [--all] [--since 7d] 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
|
+
seisin help limits says what seisin cannot do, in any repo.
|
|
64
|
+
|
|
63
65
|
Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
|
|
64
66
|
seisin decides what to ask for, and says whose file it was when the answer is no.
|
|
65
67
|
`;
|
|
@@ -71,6 +73,12 @@ function config() {
|
|
|
71
73
|
return loadConfig(path);
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
/** The policy here, or null — for a command that can answer without one. */
|
|
77
|
+
function maybeConfig() {
|
|
78
|
+
const path = findConfig();
|
|
79
|
+
return path ? loadConfig(path) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
74
82
|
/**
|
|
75
83
|
* Commands that need a config get it; commands that make one do not.
|
|
76
84
|
*
|
|
@@ -78,42 +86,58 @@ function config() {
|
|
|
78
86
|
* the exceptions — they hand the process over and never come back here.
|
|
79
87
|
*/
|
|
80
88
|
const COMMANDS = {
|
|
81
|
-
run: async () => await run(config(), argv),
|
|
89
|
+
run: async () => await (await import("./commands/run.js")).run(config(), argv),
|
|
82
90
|
// Non-zero when the policy cannot be enforced as written, so `seisin check`
|
|
83
91
|
// composes in a pre-commit hook or CI the way `explain` and `scan` already do.
|
|
84
92
|
// Warnings about a config that WILL work still exit 0 — failing on those
|
|
85
93
|
// would make the command unusable within a week.
|
|
86
|
-
check:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
},
|
|
98
|
+
explain: async () => ((await import("./commands/explain.js")).explainCommand(config(), argv).allowed ? 0 : 1),
|
|
99
|
+
scan: async () => ((await import("./commands/scan.js")).scanCommand(config(), argv).certain.length ? 1 : 0),
|
|
100
|
+
log: async () => void (await import("./commands/log.js")).log(config(), argv),
|
|
101
|
+
watch: async () => void (await import("./commands/log.js")).watch(config()),
|
|
102
|
+
init: async () => {
|
|
103
|
+
const { init, initFromObservations } = await import("./commands/init.js");
|
|
104
|
+
void (argv.includes("--from-observations") ? initFromObservations(config()) : init(process.cwd(), { force: argv.includes("--force") }));
|
|
105
|
+
},
|
|
106
|
+
// --link only reads a record; it answers without a policy here too, and
|
|
107
|
+
// says so when the console it finds serves another one.
|
|
108
|
+
ui: async () => (await import("./commands/ui.js")).ui(argv.includes("--link") ? maybeConfig() : config(), argv),
|
|
109
|
+
whose: async () => void (await import("./commands/whose.js")).whose(config(), argv),
|
|
110
|
+
wire: async () => void (await import("./commands/wire.js")).wire(config()),
|
|
111
|
+
review: async () => ((await import("./commands/review.js")).reviewCommand(config(), argv).friction.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),
|
|
114
|
+
grant: async () => void (await import("./commands/requests.js")).grant(config(), argv),
|
|
99
115
|
/**
|
|
100
116
|
* A person DECLINES a request; the boundary DENIES a write. One word each,
|
|
101
|
-
* because they are opposite events and
|
|
117
|
+
* because they are opposite events and one word used to name both — a queue
|
|
102
118
|
* entry said `first refused on …` (the kernel) and the answer said
|
|
103
|
-
* `refused frontend ✕ …` (you).
|
|
104
|
-
*
|
|
105
|
-
* `deny` stays and is not documented. Renaming a published command over a
|
|
106
|
-
* word is not worth breaking somebody's script; teaching the new one is.
|
|
119
|
+
* `refused frontend ✕ …` (you). The old command is gone (see RETIRED).
|
|
107
120
|
*/
|
|
108
|
-
decline: () => void
|
|
109
|
-
deny: () => void deny(config(), argv),
|
|
121
|
+
decline: async () => void (await import("./commands/requests.js")).decline(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
|
+
// 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");
|
|
138
|
+
} catch (e) {
|
|
139
|
+
err(`seisin hook: ${e?.message ?? e} — no decision, the tool call goes ahead\n`);
|
|
140
|
+
}
|
|
117
141
|
return 0;
|
|
118
142
|
},
|
|
119
143
|
"--version": () => void out(version() + "\n"),
|
|
@@ -124,16 +148,115 @@ function version() {
|
|
|
124
148
|
return JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version;
|
|
125
149
|
}
|
|
126
150
|
|
|
127
|
-
|
|
151
|
+
/**
|
|
152
|
+
* `--help` is a question, never an instruction to do the thing.
|
|
153
|
+
*
|
|
154
|
+
* No command looked for it, so each one took it as an argument: `ui --help`
|
|
155
|
+
* started a server on 4178, `wire --help` wrote .claude/settings.json, `watch
|
|
156
|
+
* --help` followed the log until ctrl-c, `mcp --help` sat waiting on stdin, and
|
|
157
|
+
* `seisin --help` printed the usage and exited 2. Answered here, before any
|
|
158
|
+
* command is loaded or any policy read, so it works in a directory with none.
|
|
159
|
+
*
|
|
160
|
+
* Only seisin's own part of the line is searched: after `--` the words are the
|
|
161
|
+
* agent's, and so is everything after `run <role> <command>` without one —
|
|
162
|
+
* `seisin run dev -- ls --help` asks `ls`. The value of a flag that takes one
|
|
163
|
+
* (`--reason -h`) is a value.
|
|
164
|
+
*/
|
|
165
|
+
const HELP = new Set(["--help", "-h"]);
|
|
166
|
+
function asksHelp(name, args) {
|
|
167
|
+
const takesValue = new Set(flagsOf(name)?.values ?? []);
|
|
168
|
+
const split = args.indexOf("--");
|
|
169
|
+
let ours = split === -1 ? args : args.slice(0, split);
|
|
170
|
+
if (name === "run" && split === -1) {
|
|
171
|
+
let i = 1;
|
|
172
|
+
while (i < args.length && args[i].startsWith("-")) i += takesValue.has(args[i]) ? 2 : 1;
|
|
173
|
+
ours = args.slice(0, i);
|
|
174
|
+
}
|
|
175
|
+
for (let i = 0; i < ours.length; i++) {
|
|
176
|
+
if (takesValue.has(ours[i])) i++;
|
|
177
|
+
else if (HELP.has(ours[i])) return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One command's help: its flags, examples and exit codes, or the whole usage. */
|
|
183
|
+
function helpFor(name) {
|
|
184
|
+
if (COMMAND_HELP[name]) return renderHelp(name, C);
|
|
185
|
+
const lines = USAGE.split("\n").filter((l) => l.startsWith(` seisin ${name} `) || l === ` seisin ${name}`);
|
|
186
|
+
if (!lines.length) return USAGE;
|
|
187
|
+
return `\nusage:\n${lines.join("\n")}\n\n ${C.dim}seisin --help for every command${C.off}\n\n`;
|
|
188
|
+
}
|
|
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
|
+
}
|
|
195
|
+
if (command === undefined || command === "help" || HELP.has(command)) {
|
|
196
|
+
const about = command === "help" && COMMANDS[argv[0]] && !argv[0].startsWith("-") ? argv[0] : null;
|
|
197
|
+
out(about ? helpFor(about) : USAGE);
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}
|
|
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
|
+
|
|
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
|
+
}
|
|
128
216
|
if (!chosen) {
|
|
129
|
-
|
|
130
|
-
|
|
217
|
+
// One line, and the nearest command. The whole usage, printed for a typo,
|
|
218
|
+
// pushed the one line that mattered off the screen.
|
|
219
|
+
const near = command.startsWith("-") ? nearest(command, ["--help", "--version"])
|
|
220
|
+
: nearest(command, Object.keys(COMMANDS).filter((c) => !c.startsWith("-")));
|
|
221
|
+
err(`${C.red}seisin:${C.off} unknown ${command.startsWith("-") ? "option" : "command"} "${command}"` +
|
|
222
|
+
`${near ? ` — did you mean "${near}"?` : ""}\n seisin --help lists every command\n`);
|
|
223
|
+
process.exit(2);
|
|
224
|
+
}
|
|
225
|
+
if (!command.startsWith("-") && asksHelp(command, argv)) {
|
|
226
|
+
out(helpFor(command));
|
|
227
|
+
process.exit(0);
|
|
131
228
|
}
|
|
132
229
|
|
|
133
230
|
try {
|
|
231
|
+
refuseUnknownFlags(command, argv);
|
|
134
232
|
const code = await chosen();
|
|
135
233
|
if (typeof code === "number") process.exit(code);
|
|
136
234
|
} catch (e) {
|
|
137
235
|
err(`${C.red}seisin:${C.off} ${e.message}\n`);
|
|
138
|
-
|
|
236
|
+
// A command that was not found is 127 wherever a shell runs it; everything
|
|
237
|
+
// else seisin refuses is a usage or policy error.
|
|
238
|
+
process.exit(e.exitCode ?? 2);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A flag a command does not take is an error, never ignored: `check --verbsoe`
|
|
243
|
+
* printed the short map as if nothing had been asked. For `run`, only seisin's
|
|
244
|
+
* own part of the line — before `--`, or the leading options — is checked.
|
|
245
|
+
*/
|
|
246
|
+
function refuseUnknownFlags(name, args) {
|
|
247
|
+
const f = flagsOf(name);
|
|
248
|
+
if (!f || name === "hook" || name === "mcp") return;
|
|
249
|
+
let ours = args;
|
|
250
|
+
if (name === "run") {
|
|
251
|
+
const split = args.indexOf("--");
|
|
252
|
+
if (split !== -1) ours = args.slice(1, split);
|
|
253
|
+
else {
|
|
254
|
+
let i = 1;
|
|
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;
|
|
258
|
+
ours = args.slice(1, i);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
checkFlags(name, ours, f.known, f.values);
|
|
139
262
|
}
|