seisin 0.1.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/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +52 -0
- package/src/cli.js +125 -0
- package/src/commands/check.js +9 -0
- package/src/commands/explain.js +87 -0
- package/src/commands/hook.js +42 -0
- package/src/commands/init.js +181 -0
- package/src/commands/log.js +68 -0
- package/src/commands/requests.js +145 -0
- package/src/commands/review.js +94 -0
- package/src/commands/run.js +395 -0
- package/src/commands/scan.js +21 -0
- package/src/commands/ui.js +31 -0
- package/src/commands/whose.js +94 -0
- package/src/commands/wire.js +74 -0
- package/src/config.js +317 -0
- package/src/env.js +61 -0
- package/src/handoff.js +111 -0
- package/src/hook.js +195 -0
- package/src/index.js +52 -0
- package/src/inspect.js +477 -0
- package/src/layout.js +47 -0
- package/src/log.js +124 -0
- package/src/mcp.js +290 -0
- package/src/owners.js +166 -0
- package/src/redact.js +82 -0
- package/src/render.js +150 -0
- package/src/requests.js +259 -0
- package/src/review.js +167 -0
- package/src/scan.js +175 -0
- package/src/serve.js +168 -0
- package/src/spool.js +164 -0
- package/src/srt.js +424 -0
- package/src/violations.js +540 -0
- package/src/worktree.js +216 -0
- package/ui/favicon.svg +1 -0
- package/ui/index.html +937 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `seisin requests`, `grant` and `deny` — the human end of the loop.
|
|
3
|
+
*
|
|
4
|
+
* These live in the CLI and in the console, and deliberately NOT in the MCP
|
|
5
|
+
* server. An agent may ask and may draft; turning a request into policy takes a
|
|
6
|
+
* person acting in a channel the agent does not have. That is the invariant the
|
|
7
|
+
* whole feature rests on — see docs/permission-requests.md.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { pending, settle, applyGrant, requestsPath } from "../requests.js";
|
|
11
|
+
import { C, out } from "../render.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Which request you meant — by position, or by the identity it carries.
|
|
15
|
+
*
|
|
16
|
+
* "Stable while you read it" was the old claim and it is false exactly where it
|
|
17
|
+
* matters. The queue is filled by agents that are still running: on a live
|
|
18
|
+
* repository it grew and shrank between a listing and the next command, and
|
|
19
|
+
* `deny 1` settled a different request than the one printed as #1 seconds
|
|
20
|
+
* earlier. That is time-of-check-to-time-of-use in the one command whose whole
|
|
21
|
+
* job is deciding a permission, and it happened twice in one session.
|
|
22
|
+
*
|
|
23
|
+
* A position is still accepted, because reading a list and typing a number is
|
|
24
|
+
* how anyone will use this. An argument that is not a number is matched against
|
|
25
|
+
* the request's own id — `<role>:<action>:<path>` — which does not move when
|
|
26
|
+
* the queue does. `seisin requests` prints it under each entry.
|
|
27
|
+
*
|
|
28
|
+
* An ambiguous match is refused rather than resolved. Choosing for you is the
|
|
29
|
+
* failure being fixed.
|
|
30
|
+
*/
|
|
31
|
+
function pick(config, n) {
|
|
32
|
+
const queue = pending(requestsPath(config.root));
|
|
33
|
+
if (!queue.length) throw new Error("no pending requests");
|
|
34
|
+
|
|
35
|
+
const arg = String(n).trim();
|
|
36
|
+
if (/^\d+$/.test(arg)) {
|
|
37
|
+
const req = queue[Number(arg) - 1];
|
|
38
|
+
if (!req) throw new Error(`no request #${arg} — there are ${queue.length}`);
|
|
39
|
+
return req;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hits = queue.filter((r) => r.key === arg || r.key.startsWith(arg));
|
|
43
|
+
if (hits.length === 1) return hits[0];
|
|
44
|
+
if (!hits.length)
|
|
45
|
+
throw new Error(
|
|
46
|
+
`no pending request matches "${arg}".\n` +
|
|
47
|
+
" Use its number, or the id printed under it by `seisin requests`.");
|
|
48
|
+
throw new Error(
|
|
49
|
+
`"${arg}" matches ${hits.length} pending requests:\n` +
|
|
50
|
+
hits.slice(0, 5).map((r) => ` ${r.key}`).join("\n") +
|
|
51
|
+
"\n Name one exactly. Choosing for you is the mistake this avoids.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function requests(config) {
|
|
55
|
+
const queue = pending(requestsPath(config.root));
|
|
56
|
+
out(renderQueue(queue));
|
|
57
|
+
return queue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The pending queue as text. Pure, so the same list renders the same everywhere. */
|
|
61
|
+
export function renderQueue(queue) {
|
|
62
|
+
if (queue.length === 0) return `\n ${C.dim}no pending requests${C.off}\n\n`;
|
|
63
|
+
|
|
64
|
+
const lines = [`\n ${C.yellow}${queue.length} pending request(s)${C.off}\n\n`];
|
|
65
|
+
queue.forEach((r, i) => {
|
|
66
|
+
const owners = r.owners.length ? ` ${C.dim}(owned by ${r.owners.join(", ")})${C.off}` : ` ${C.dim}(unowned)${C.off}`;
|
|
67
|
+
const times = r.times > 1 ? ` ${C.dim}· asked ${r.times}×${C.off}` : "";
|
|
68
|
+
lines.push(` ${C.b}#${i + 1}${C.off} ${r.role} wants ${r.action} on ${C.b}${r.grant}${C.off}${owners}${times}\n`);
|
|
69
|
+
lines.push(` ${C.dim}first asked over ${r.target}${C.off}\n`);
|
|
70
|
+
// The stable way to name it: a number is a position in a queue that agents
|
|
71
|
+
// are still writing to, and this does not move when the queue does.
|
|
72
|
+
lines.push(` ${C.dim}id ${r.key}${C.off}\n`);
|
|
73
|
+
const note = handoffNote(r.handoff);
|
|
74
|
+
if (note) lines.push(` ${C.yellow}${note}${C.off}\n`);
|
|
75
|
+
});
|
|
76
|
+
lines.push(`\n ${C.dim}seisin grant <n> [--reason "…"] · seisin deny <n> [--reason "…"]${C.off}\n\n`);
|
|
77
|
+
return lines.join("");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One line saying why no worker is running for a request that is still open.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately not a scheduler view. The failure this closes is narrow and
|
|
84
|
+
* expensive: work that was refused admission, left the queue looking idle, and
|
|
85
|
+
* was never done by anybody. A row that says which limit held it back is the
|
|
86
|
+
* whole fix; deciding who retries and when is a separate question that this
|
|
87
|
+
* does not have to answer first.
|
|
88
|
+
*/
|
|
89
|
+
export function handoffNote(h) {
|
|
90
|
+
if (!h) return "";
|
|
91
|
+
switch (h.outcome) {
|
|
92
|
+
case "throttled":
|
|
93
|
+
return `handoff held: ${limitWords(h.limit, h.role)} ${h.max}/${h.max} — still to do`;
|
|
94
|
+
case "cycle":
|
|
95
|
+
return `handoff stopped: ${h.role} is already in this chain — needs a person to split the work`;
|
|
96
|
+
case "depth":
|
|
97
|
+
return `handoff stopped: chain reached ${h.depth} of ${h.max} — needs a person`;
|
|
98
|
+
case "human":
|
|
99
|
+
return `handoff stopped: ${h.reason === "ambiguous" ? "more than one role owns this" : "no role owns this"}`;
|
|
100
|
+
case "route":
|
|
101
|
+
return `handed to ${h.role}`;
|
|
102
|
+
case "resolved":
|
|
103
|
+
return `no handoff needed — the policy moved and ${h.role} owns it now`;
|
|
104
|
+
default:
|
|
105
|
+
return `handoff outcome: ${h.outcome}`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The limit names are internal; what a person reads should not be a field name. */
|
|
110
|
+
function limitWords(limit, role) {
|
|
111
|
+
if (limit === "senderChains") return "open chains for this role";
|
|
112
|
+
if (limit === "receiverConcurrent") return `${role ?? "the owner"} already running`;
|
|
113
|
+
return limit ?? "a limit";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function grant(config, argv = []) {
|
|
117
|
+
const req = pick(config, argv[0]);
|
|
118
|
+
const i = argv.indexOf("--reason");
|
|
119
|
+
const reason = i === -1 ? "" : argv[i + 1] ?? "";
|
|
120
|
+
|
|
121
|
+
const before = readFileSync(config.path, "utf8");
|
|
122
|
+
const { toml, changed } = applyGrant(before, req, reason);
|
|
123
|
+
if (!changed) throw new Error(`${req.role} already has ${req.grant} — nothing to add`);
|
|
124
|
+
|
|
125
|
+
writeFileSync(config.path, toml);
|
|
126
|
+
settle(requestsPath(config.root), req.key, "granted", reason);
|
|
127
|
+
out(
|
|
128
|
+
`\n ${C.green}granted${C.off} ${req.role} → ${C.b}${req.grant}${C.off}\n` +
|
|
129
|
+
` ${C.dim}written into ${config.path} with its provenance. It applies on the next run.${C.off}\n\n`
|
|
130
|
+
);
|
|
131
|
+
return { request: req, grant: req.grant };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function deny(config, argv = []) {
|
|
135
|
+
const req = pick(config, argv[0]);
|
|
136
|
+
const i = argv.indexOf("--reason");
|
|
137
|
+
const reason = i === -1 ? "" : argv[i + 1] ?? "";
|
|
138
|
+
|
|
139
|
+
settle(requestsPath(config.root), req.key, "denied", reason);
|
|
140
|
+
out(
|
|
141
|
+
`\n ${C.yellow}declined${C.off} ${req.role} ✕ ${req.grant}\n` +
|
|
142
|
+
` ${C.dim}${reason || "no reason recorded"}${C.off}\n\n`
|
|
143
|
+
);
|
|
144
|
+
return { request: req, reason };
|
|
145
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `seisin review` — what the log says about the policy.
|
|
3
|
+
*
|
|
4
|
+
* The counterpart to `check`. `check` reads the config and tells you what it
|
|
5
|
+
* would do; this reads what actually happened and tells you where the config
|
|
6
|
+
* was wrong about it. Both run nothing.
|
|
7
|
+
*
|
|
8
|
+
* Exits 1 when there is friction, so it composes — a role blocked over and over
|
|
9
|
+
* is a thing to fix, and a CI job that says so beats a human noticing in a
|
|
10
|
+
* month. Unused grants and unowned paths exit 0: they are worth reading, not
|
|
11
|
+
* worth failing a build over.
|
|
12
|
+
*
|
|
13
|
+
* A role held at a `[keys] dir` exits 0 too, and deliberately. That is the
|
|
14
|
+
* boundary doing exactly what it was configured to do; failing a build over it
|
|
15
|
+
* would mean a correct policy cannot go green.
|
|
16
|
+
*/
|
|
17
|
+
import { review } from "../review.js";
|
|
18
|
+
import { C, out } from "../render.js";
|
|
19
|
+
|
|
20
|
+
export function reviewCommand(config, argv = []) {
|
|
21
|
+
const i = argv.indexOf("--min");
|
|
22
|
+
const r = review(config, { minDenials: i === -1 ? 3 : Number(argv[i + 1]) });
|
|
23
|
+
|
|
24
|
+
if (!r.entries) {
|
|
25
|
+
out(`\n ${C.dim}nothing recorded yet. Run an agent through seisin first.${C.off}\n\n`);
|
|
26
|
+
return r;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const day = (s) => (s ?? "").slice(0, 10);
|
|
30
|
+
out(`\n ${C.dim}${r.entries} decisions, ${day(r.window.from)} to ${day(r.window.to)}${C.off}\n`);
|
|
31
|
+
|
|
32
|
+
if (r.friction.length) {
|
|
33
|
+
out(`\n ${C.b}Stopped, repeatedly${C.off}\n`);
|
|
34
|
+
out(` ${C.dim}a role blocked on the same place over and over is a policy that is wrong,${C.off}\n`);
|
|
35
|
+
out(` ${C.dim}not an agent that is misbehaving${C.off}\n\n`);
|
|
36
|
+
for (const f of r.friction) {
|
|
37
|
+
const whose = f.owners.length ? ` ${C.dim}— belongs to ${f.owners.join(", ")}${C.off}` : "";
|
|
38
|
+
out(` ${C.yellow}${String(f.times).padStart(4)}×${C.off} ${C.b}${f.role}${C.off} ${f.action} ${f.where}${whose}\n`);
|
|
39
|
+
}
|
|
40
|
+
out(`\n ${C.dim}seisin grant, or move the territory. Either way it is a decision, not noise.${C.off}\n`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The same repetition, and the opposite conclusion.
|
|
45
|
+
*
|
|
46
|
+
* A key directory is closed to everyone rather than held by somebody, so
|
|
47
|
+
* there is no owner to name and no handoff to make — and printing it under
|
|
48
|
+
* the advice above turned the loudest thing this command says into *grant
|
|
49
|
+
* two roles the credential directory*. Separated, the same lines read as
|
|
50
|
+
* confirmation instead of as a recommendation, which is what they are.
|
|
51
|
+
*/
|
|
52
|
+
if (r.guarded.length) {
|
|
53
|
+
out(`\n ${C.b}Held at the keys${C.off}\n`);
|
|
54
|
+
out(` ${C.dim}a [keys] dir is closed to every role, so this is the policy working${C.off}\n`);
|
|
55
|
+
out(` ${C.dim}— not a territory drawn wrong, and never a thing to grant${C.off}\n\n`);
|
|
56
|
+
for (const g of r.guarded)
|
|
57
|
+
out(` ${C.dim}${String(g.times).padStart(4)}×${C.off} ${C.b}${g.role}${C.off} ${g.action} ${g.where}\n`);
|
|
58
|
+
out(`\n ${C.dim}Worth a look only if a role genuinely needs one of these: that is a${C.off}\n`);
|
|
59
|
+
out(` ${C.dim}\`keys\` line for the file, never a write grant on the directory.${C.off}\n`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Says why it cannot answer, instead of answering wrongly.
|
|
64
|
+
*
|
|
65
|
+
* Without the hook the log holds denials and nothing else, so every grant
|
|
66
|
+
* looks dead. The old text called itself the only evidence anyone will ever
|
|
67
|
+
* have for making a permission file smaller, printed over the whole policy.
|
|
68
|
+
*/
|
|
69
|
+
if (!r.unusedKnowable) {
|
|
70
|
+
out(`\n ${C.b}Granted, never used${C.off} ${C.dim}— cannot be answered from this log${C.off}\n`);
|
|
71
|
+
out(` ${C.dim}nothing here records what was ALLOWED, only what was refused. Every grant${C.off}\n`);
|
|
72
|
+
out(` ${C.dim}would look dead, so nothing is listed. Run \`seisin wire\` and come back.${C.off}\n`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (r.unused.length) {
|
|
76
|
+
out(`\n ${C.b}Granted, never used${C.off}\n`);
|
|
77
|
+
out(` ${C.dim}nothing was written here in the window above. This is the only evidence${C.off}\n`);
|
|
78
|
+
out(` ${C.dim}anyone will ever have for making a permission file smaller${C.off}\n\n`);
|
|
79
|
+
for (const u of r.unused) out(` ${C.b}${u.role}${C.off} ${u.glob}\n`);
|
|
80
|
+
out(`\n ${C.dim}A short window proves nothing. Check the dates before you delete a line.${C.off}\n`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (r.unowned.length) {
|
|
84
|
+
out(`\n ${C.b}Owned by nobody${C.off}\n`);
|
|
85
|
+
out(` ${C.dim}touched by an agent, claimed by no role — holes in the map${C.off}\n\n`);
|
|
86
|
+
for (const u of r.unowned.slice(0, 10)) out(` ${String(u.times).padStart(4)}× ${u.where}\n`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!r.friction.length && !r.guarded.length && !r.unused.length && !r.unowned.length && r.unusedKnowable)
|
|
90
|
+
out(`\n ${C.green}nothing to report${C.off} ${C.dim}— no repeated blocks, no dead grants, no unowned paths${C.off}\n`);
|
|
91
|
+
|
|
92
|
+
out("\n");
|
|
93
|
+
return r;
|
|
94
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `seisin run` — the whole point of the tool.
|
|
3
|
+
*
|
|
4
|
+
* Writes the role's sandbox settings, builds the child's environment from the
|
|
5
|
+
* policy instead of inheriting it, wraps the command in the sandbox runtime,
|
|
6
|
+
* and masks the role's own secrets on the way out.
|
|
7
|
+
*
|
|
8
|
+
* Everything in here is ordering that was wrong once. The comments say which.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
|
|
12
|
+
import { join, dirname, delimiter } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { settingsFor, roleHome } from "../srt.js";
|
|
15
|
+
import { buildEnv } from "../env.js";
|
|
16
|
+
import { spool, spoolPath, SOCK_ENV } from "../spool.js";
|
|
17
|
+
import { secretsOf, redactor } from "../redact.js";
|
|
18
|
+
import { STATE_DIR } from "../layout.js";
|
|
19
|
+
import { C, err } from "../render.js";
|
|
20
|
+
import { pending, record, requestsPath } from "../requests.js";
|
|
21
|
+
import { ownersOf, explain } from "../owners.js";
|
|
22
|
+
import { append, logPath } from "../log.js";
|
|
23
|
+
import { renderQueue } from "./requests.js";
|
|
24
|
+
import { watchDenials, inScope, scopeOf, reachedForContent } from "../violations.js";
|
|
25
|
+
|
|
26
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The ceiling on waiting for the kernel's last word when a run ends.
|
|
30
|
+
*
|
|
31
|
+
* A ceiling, not a delay: the drain resolves as soon as `log stream` stops
|
|
32
|
+
* talking, which on a run with nothing pending is immediate. The number only
|
|
33
|
+
* matters on a machine under enough load that the stream is behind, and there
|
|
34
|
+
* a quarter second is cheaper than a denial that never made it into the record.
|
|
35
|
+
*/
|
|
36
|
+
const DRAIN_MS = 250;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The bundled runtime first, a global one second, and nothing third.
|
|
40
|
+
*
|
|
41
|
+
* There is deliberately no fallback to running unsandboxed. A permission tool
|
|
42
|
+
* that quietly becomes a no-op when its enforcer is missing is worse than one
|
|
43
|
+
* that refuses, because you keep trusting it.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveSrt() {
|
|
46
|
+
const bundled = join(HERE, "..", "..", "node_modules", ".bin", "srt");
|
|
47
|
+
if (existsSync(bundled)) return bundled;
|
|
48
|
+
|
|
49
|
+
// Walk PATH rather than asking a shell. `spawnSync(..., { shell: true })`
|
|
50
|
+
// prints a deprecation warning on every single run — Node's DEP0190 — which
|
|
51
|
+
// is a line of noise in front of every user for the sake of finding one file.
|
|
52
|
+
// It is also the concatenation hazard the warning is about.
|
|
53
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
54
|
+
if (!dir) continue;
|
|
55
|
+
const candidate = join(dir, "srt");
|
|
56
|
+
if (existsSync(candidate)) return candidate;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Writes `.seisin/<role>.json` and returns its path. */
|
|
62
|
+
export function writeSettings(config, role, sock = null, observe = false) {
|
|
63
|
+
const dir = join(config.root, STATE_DIR);
|
|
64
|
+
mkdirSync(dir, { recursive: true });
|
|
65
|
+
const file = join(dir, `${role}.json`);
|
|
66
|
+
writeFileSync(file, JSON.stringify(settingsFor(config, role, sock, observe), null, 2) + "\n");
|
|
67
|
+
return file;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function run(config, argv) {
|
|
71
|
+
/**
|
|
72
|
+
* Ours before the `--`, theirs after it.
|
|
73
|
+
*
|
|
74
|
+
* `argv.includes("--observe")` scanned the whole line, so `seisin run x --
|
|
75
|
+
* claude --observe` put seisin into observe mode over a flag that belonged to
|
|
76
|
+
* the agent. It is the same mistake the `--` separator was added to stop srt
|
|
77
|
+
* making with the agent's flags, made one layer up.
|
|
78
|
+
*/
|
|
79
|
+
const split = argv.indexOf("--");
|
|
80
|
+
const role = argv[0];
|
|
81
|
+
const mine = split === -1 ? argv.slice(1) : argv.slice(1, split);
|
|
82
|
+
const cmd = split === -1 ? argv.slice(1) : argv.slice(split + 1);
|
|
83
|
+
if (!role || cmd.length === 0) throw new Error("usage: seisin run <role> -- <command...>");
|
|
84
|
+
if (!config.roles[role])
|
|
85
|
+
throw new Error(`unknown role "${role}". Known: ${Object.keys(config.roles).join(", ")}`);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* seisin does not run inside seisin, and says so here rather than later.
|
|
89
|
+
*
|
|
90
|
+
* Measured in both directions, with the inner role both wider and narrower
|
|
91
|
+
* than the outer one: the inner run dies in the runtime with rc=13 and a Node
|
|
92
|
+
* warning about an unsettled await — no role named, no boundary named, and it
|
|
93
|
+
* reads as seisin crashing rather than as a refusal. Before that it failed
|
|
94
|
+
* even earlier, on a $TMPDIR the outer box had already reshaped.
|
|
95
|
+
*
|
|
96
|
+
* This is the refusal, not a fix, because the shape it is usually reached for
|
|
97
|
+
* is wrong anyway: a dispatcher that starts roles from inside one role's box
|
|
98
|
+
* makes every worker a descendant of the box that should hold the least. Such
|
|
99
|
+
* a dispatcher belongs above the roles and gets asked, rather than running
|
|
100
|
+
* inside one of them.
|
|
101
|
+
*/
|
|
102
|
+
const outer = process.env.SEISIN_ROLE;
|
|
103
|
+
if (outer)
|
|
104
|
+
throw new Error(
|
|
105
|
+
`already inside the box as "${outer}" — seisin does not nest.\n` +
|
|
106
|
+
` Starting "${role}" from in here does not give it its own territory: the run dies in the\n` +
|
|
107
|
+
` sandbox runtime, and what territory a second box would even hold is undefined.\n` +
|
|
108
|
+
` Run roles as siblings from outside instead. Something that needs to start roles belongs\n` +
|
|
109
|
+
` above them — asked by the role that wants the work done, not run inside it.`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The audit spool: this process holds the log and the queue, and the hook
|
|
114
|
+
* inside the box gets a socket instead of a directory. That is the whole
|
|
115
|
+
* reason `.seisin/` is no longer in anybody's allowWrite — see spool.js.
|
|
116
|
+
*
|
|
117
|
+
* Entries arrive with their own `at` already set by the sender, so what lands
|
|
118
|
+
* on disk is when the decision happened, not when the parent got around to it.
|
|
119
|
+
*/
|
|
120
|
+
const observe = mine.includes("--observe");
|
|
121
|
+
const sockPath = spoolPath();
|
|
122
|
+
const audit = await spool((to, entry) => {
|
|
123
|
+
/**
|
|
124
|
+
* Nothing from inside the box is taken at its word.
|
|
125
|
+
*
|
|
126
|
+
* The sender is the process being recorded, so every field it supplies is
|
|
127
|
+
* a claim. Two of them matter. `role` decides whose request this is — left
|
|
128
|
+
* alone, a frontend agent could file one as backend and wait for a human to
|
|
129
|
+
* approve it. `owners` decides who the queue says it belongs to, and the
|
|
130
|
+
* parent can work that out itself from the policy.
|
|
131
|
+
*
|
|
132
|
+
* So the role is overwritten with the role of this run, the owners are
|
|
133
|
+
* recomputed, and anything shaped wrong is dropped. Forging a *log* line is
|
|
134
|
+
* noise. Forging a *request* is a sentence placed in front of a person for
|
|
135
|
+
* approval, and that is a different thing entirely.
|
|
136
|
+
*/
|
|
137
|
+
if (!entry || typeof entry !== "object") return;
|
|
138
|
+
|
|
139
|
+
if (to === "log") {
|
|
140
|
+
// The same treatment the queue already got. The role was overwritten but
|
|
141
|
+
// everything else was passed through, so a process inside the box could
|
|
142
|
+
// write "allowed" lines for paths it never touched — which would not move
|
|
143
|
+
// the boundary, but would move `review`, and `init --from-observations`
|
|
144
|
+
// builds a policy out of exactly this.
|
|
145
|
+
if (entry.action !== "read" && entry.action !== "write") return;
|
|
146
|
+
if (!["allowed", "denied", "observed"].includes(entry.verdict)) return;
|
|
147
|
+
if (typeof entry.target !== "string" || !entry.target.trim()) return;
|
|
148
|
+
return void append(logPath(config.root), {
|
|
149
|
+
at: entry.at,
|
|
150
|
+
role,
|
|
151
|
+
tool: String(entry.tool ?? "").slice(0, 40),
|
|
152
|
+
action: entry.action,
|
|
153
|
+
kind: entry.kind === "key" ? "key" : "file",
|
|
154
|
+
target: entry.target.slice(0, 1000),
|
|
155
|
+
verdict: entry.verdict,
|
|
156
|
+
owners: ownersOf(config, entry.target), // recomputed, never taken
|
|
157
|
+
reason: String(entry.reason ?? "").slice(0, 500),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (entry.action !== "read" && entry.action !== "write") return;
|
|
162
|
+
if (typeof entry.target !== "string" || !entry.target.trim()) return;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The policy has to actually refuse it, or it does not belong in a queue.
|
|
166
|
+
*
|
|
167
|
+
* Nothing checked. A process inside the box could file a request for a path
|
|
168
|
+
* its own role already owns, and the queue would print *first refused on
|
|
169
|
+
* …* about something that was never refused — a sentence placed in front of
|
|
170
|
+
* a person for approval, describing an event that did not happen.
|
|
171
|
+
*
|
|
172
|
+
* Same treatment as `owners` one branch up: the claim is recomputed here
|
|
173
|
+
* rather than believed. It is the policy answering a question about itself,
|
|
174
|
+
* which is arithmetic, and it is the parent asking — the one process the
|
|
175
|
+
* confined side cannot reach.
|
|
176
|
+
*
|
|
177
|
+
* This does not require the agent to have *tried*. Asking for a permission
|
|
178
|
+
* before reaching for it is a reasonable thing to do, and the wording says
|
|
179
|
+
* "asked" rather than "refused" for exactly that case. What it rules out is
|
|
180
|
+
* a request for something that is not refused at all.
|
|
181
|
+
*/
|
|
182
|
+
if (explain(config, role, entry.action, entry.target).allowed) return;
|
|
183
|
+
|
|
184
|
+
record(requestsPath(config.root), {
|
|
185
|
+
role,
|
|
186
|
+
action: entry.action,
|
|
187
|
+
target: entry.target,
|
|
188
|
+
owners: ownersOf(config, entry.target),
|
|
189
|
+
});
|
|
190
|
+
}, sockPath);
|
|
191
|
+
|
|
192
|
+
const settings = settingsFor(config, role, sockPath, observe);
|
|
193
|
+
const file = writeSettings(config, role, sockPath, observe);
|
|
194
|
+
|
|
195
|
+
const srt = resolveSrt();
|
|
196
|
+
if (!srt) throw new Error("sandbox runtime not found. Install it with: npm i -g @anthropic-ai/sandbox-runtime");
|
|
197
|
+
|
|
198
|
+
// The hook runs inside the child and has to know which role it is. These are
|
|
199
|
+
// the only variables seisin injects, and none carries a secret.
|
|
200
|
+
const { env, dropped } = buildEnv(process.env, config.roles[role]);
|
|
201
|
+
env.SEISIN_ROLE = role;
|
|
202
|
+
env.SEISIN_CONFIG = config.path;
|
|
203
|
+
env[SOCK_ENV] = sockPath;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Isolated mode: point the toolchain at a home of this role's own.
|
|
207
|
+
*
|
|
208
|
+
* Granting `~/.claude` to every role is what made "its own folders" only
|
|
209
|
+
* true of the repo. A CLI that keeps session state — or a token — under the
|
|
210
|
+
* real home put it somewhere every other role could read and write.
|
|
211
|
+
*
|
|
212
|
+
* The XDG variables are set as well as HOME, because a tool that reads
|
|
213
|
+
* `XDG_CACHE_HOME` directly would otherwise still land in the real one, and
|
|
214
|
+
* that failure is silent: the run works and the isolation does not.
|
|
215
|
+
*/
|
|
216
|
+
// Only the `home` level moves HOME. `credentials` closes the places
|
|
217
|
+
// credentials live and leaves the home where it is — which is what keeps the
|
|
218
|
+
// agent logged in, since on macOS its credential is in the login keychain and
|
|
219
|
+
// the keychain is found through HOME.
|
|
220
|
+
if (config.isolate === "home" || config.isolate === true) {
|
|
221
|
+
const home = roleHome(config, role);
|
|
222
|
+
for (const d of [home, join(home, ".config"), join(home, ".local", "share"),
|
|
223
|
+
join(home, ".local", "state"), join(home, ".cache"), join(home, "tmp")])
|
|
224
|
+
mkdirSync(d, { recursive: true });
|
|
225
|
+
env.HOME = home;
|
|
226
|
+
env.XDG_CONFIG_HOME = join(home, ".config");
|
|
227
|
+
env.XDG_DATA_HOME = join(home, ".local", "share");
|
|
228
|
+
env.XDG_STATE_HOME = join(home, ".local", "state");
|
|
229
|
+
env.XDG_CACHE_HOME = join(home, ".cache");
|
|
230
|
+
env.TMPDIR = join(home, "tmp");
|
|
231
|
+
}
|
|
232
|
+
if (observe) env.SEISIN_OBSERVE = "1";
|
|
233
|
+
if (mine.includes("--debug-env")) err(`${C.dim}seisin: dropped ${dropped.join(" ")}${C.off}\n`);
|
|
234
|
+
|
|
235
|
+
err(
|
|
236
|
+
`${C.dim}seisin: ${role} · writes ${settings.filesystem.allowWrite.length} path(s) · ` +
|
|
237
|
+
`reads ${settings.filesystem.allowRead.length} key(s) · ` +
|
|
238
|
+
`env ${Object.keys(env).length} kept, ${dropped.length} dropped` +
|
|
239
|
+
`${observe ? ` · ${C.yellow}OBSERVING — the repo is writable, the network is NOT${C.off}${C.dim}` : ""}${C.off}\n`
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Redaction needs the output to pass through this process, and piping breaks
|
|
243
|
+
// anything that draws its own screen. So it turns itself off on a terminal
|
|
244
|
+
// and on for the runs that end up in a log, which is where a leaked key
|
|
245
|
+
// actually survives. Say so rather than deciding it quietly.
|
|
246
|
+
const wantRedact = config.redact !== false && config.roles[role].keys.length > 0;
|
|
247
|
+
const canRedact = wantRedact && !process.stdout.isTTY;
|
|
248
|
+
if (wantRedact && !canRedact) err(`${C.dim}seisin: redaction off — interactive terminal${C.off}\n`);
|
|
249
|
+
|
|
250
|
+
const secrets = canRedact ? secretsOf(settings, readFileSync) : [];
|
|
251
|
+
const outStream = secrets.length ? redactor(secrets) : null;
|
|
252
|
+
const errStream = secrets.length ? redactor(secrets) : null;
|
|
253
|
+
if (outStream) { outStream.pipe(process.stdout); errStream.pipe(process.stderr); }
|
|
254
|
+
|
|
255
|
+
// `--` before the user's command, and it is not cosmetic. Without it the
|
|
256
|
+
// sandbox's own argument parser reaches into what the agent was invoked with
|
|
257
|
+
// and eats anything resembling one of its flags: `claude --settings`,
|
|
258
|
+
// `claude -c`, `claude --debug`. Found by passing --settings to claude and
|
|
259
|
+
// watching the sandbox reject it as its own malformed config.
|
|
260
|
+
/**
|
|
261
|
+
* The kernel's own refusals, alongside the hook's.
|
|
262
|
+
*
|
|
263
|
+
* Started BEFORE the spawn, and that ordering is the whole difference between
|
|
264
|
+
* this working and this recording nothing. `log stream` is a separate process
|
|
265
|
+
* with its own startup; a `sh -c` that gets refused is over in single-digit
|
|
266
|
+
* milliseconds. Started after the spawn, the first version of this caught
|
|
267
|
+
* zero denials on exactly the short commands an agent runs most.
|
|
268
|
+
*
|
|
269
|
+
* Attribution still needs the child's pid, so it is handed over the moment
|
|
270
|
+
* there is one — `attributeTo` below — and anything that arrived in between is
|
|
271
|
+
* held and re-examined rather than credited to this role on faith.
|
|
272
|
+
*
|
|
273
|
+
* These lines go through the same `append()` the spool does, with `owners`
|
|
274
|
+
* recomputed here exactly as they are for a hook line. The rule that nothing
|
|
275
|
+
* is taken at its word still applies; what changes is who is claiming. The
|
|
276
|
+
* hook is the process being recorded, and the kernel is the thing that
|
|
277
|
+
* actually refused — so when the two disagree, this is the one that is right.
|
|
278
|
+
*/
|
|
279
|
+
const scope = scopeOf(settings, config.root);
|
|
280
|
+
const keyDirs = (config.keyDirs ?? []).map((d) => (d.startsWith("/") ? d : join(config.root, d)));
|
|
281
|
+
let offPolicy = 0; // refused, but about nothing the policy names
|
|
282
|
+
let walks = 0; // a recursive search reaching a closed door
|
|
283
|
+
const denials = watchDenials((d) => {
|
|
284
|
+
if (!inScope(d.path, scope)) { offPolicy++; return; }
|
|
285
|
+
if (!reachedForContent(d)) { walks++; return; }
|
|
286
|
+
// Relative inside the repo, absolute outside it. A role can be refused at
|
|
287
|
+
// ~/.ssh under `isolate`, and "../../../.ssh/id_rsa" would be a worse
|
|
288
|
+
// answer to "what was refused" than the path itself.
|
|
289
|
+
const rel = d.path.startsWith(config.root + "/") ? d.path.slice(config.root.length + 1) : d.path;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A refusal the hook never saw still leaves a request behind.
|
|
293
|
+
*
|
|
294
|
+
* Without this the two halves of the record disagree in the worst
|
|
295
|
+
* direction: `review` would show a role stopped repeatedly on a directory
|
|
296
|
+
* while the queue held nothing to approve, so the one refusal a person most
|
|
297
|
+
* needed to see — the one that escaped the hook — would be the one with no
|
|
298
|
+
* way to act on it.
|
|
299
|
+
*
|
|
300
|
+
* There is no double counting to avoid. When the hook catches something it
|
|
301
|
+
* denies the tool call outright and the command never runs, so the kernel
|
|
302
|
+
* never sees it. These are the ones that got past it, which is the whole
|
|
303
|
+
* reason this exists. Inside the repo only: `~/.ssh` is refused on purpose
|
|
304
|
+
* under `isolate` and is not a territory anyone is meant to ask for.
|
|
305
|
+
*/
|
|
306
|
+
if (rel !== d.path)
|
|
307
|
+
record(requestsPath(config.root), { role, action: d.action, target: rel, owners: ownersOf(config, rel) });
|
|
308
|
+
|
|
309
|
+
append(logPath(config.root), {
|
|
310
|
+
at: new Date().toISOString(),
|
|
311
|
+
role,
|
|
312
|
+
tool: "kernel",
|
|
313
|
+
source: "kernel",
|
|
314
|
+
action: d.action,
|
|
315
|
+
kind: keyDirs.some((k) => d.path === k || d.path.startsWith(k + "/")) ? "key" : "file",
|
|
316
|
+
target: rel,
|
|
317
|
+
verdict: "denied",
|
|
318
|
+
owners: ownersOf(config, rel),
|
|
319
|
+
reason: d.operation,
|
|
320
|
+
});
|
|
321
|
+
}, { argv: cmd });
|
|
322
|
+
|
|
323
|
+
// Deliberately NOT announced here. On Linux this branch is taken every time,
|
|
324
|
+
// so saying it per run puts a line the reader cannot act on in front of every
|
|
325
|
+
// command they type — and a notice that appears five hundred times is one
|
|
326
|
+
// nobody reads the five hundred and first. It is a standing property of the
|
|
327
|
+
// platform, so it belongs with the other standing limits, in `seisin check`.
|
|
328
|
+
void denials.available;
|
|
329
|
+
|
|
330
|
+
const child = spawn(srt, ["--settings", file, "--", ...cmd], {
|
|
331
|
+
stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
332
|
+
env,
|
|
333
|
+
});
|
|
334
|
+
denials.attributeTo(child.pid);
|
|
335
|
+
if (outStream) { child.stdout.pipe(outStream); child.stderr.pipe(errStream); }
|
|
336
|
+
|
|
337
|
+
// The exit code is the child's, and the output has to be all the way out
|
|
338
|
+
// before we leave. Both halves were wrong once and neither was loud:
|
|
339
|
+
//
|
|
340
|
+
// - Calling exit() the moment the child exits discards whatever is still in
|
|
341
|
+
// the redaction stream, so the command looks like it produced nothing.
|
|
342
|
+
// - Waiting for `finish` but subscribing AFTER end() misses the event when
|
|
343
|
+
// it fires synchronously, so nothing ever calls exit and the process
|
|
344
|
+
// drifts out of the event loop with status 0 — every failure reported as
|
|
345
|
+
// a success. A battery of seven sandbox tests came back green on that.
|
|
346
|
+
//
|
|
347
|
+
// Subscribe first, end second, and keep a real timer (not unref'd, or it
|
|
348
|
+
// cannot save anything) as the floor.
|
|
349
|
+
child.on("exit", async (code, signal) => {
|
|
350
|
+
const status = signal ? 1 : code ?? 0;
|
|
351
|
+
// The notice rides on what you are already looking at. A queue nobody opens
|
|
352
|
+
// is not human-in-the-loop, and this is the terminal that just showed you
|
|
353
|
+
// the denial — so it goes to stderr, beside it, not into the agent's stdout
|
|
354
|
+
// where a pipeline would swallow it.
|
|
355
|
+
// Close the spool before reading the queue: a line the hook sent on the
|
|
356
|
+
// agent's last turn may still be in flight, and reporting a queue that is
|
|
357
|
+
// one entry behind is how a request goes unnoticed for a day.
|
|
358
|
+
audit.close();
|
|
359
|
+
// Same reason, one layer further out: the kernel writes its line through a
|
|
360
|
+
// separate process, so the last refusal of a run can still be in flight
|
|
361
|
+
// here. This is the only wait the mechanism adds and it is per run — it
|
|
362
|
+
// returns as soon as the stream goes quiet, so a run that was refused
|
|
363
|
+
// nothing pays a tick.
|
|
364
|
+
await denials.close({ drain: DRAIN_MS });
|
|
365
|
+
// The run made this directory, so the run removes it.
|
|
366
|
+
try { rmSync(dirname(sockPath), { recursive: true, force: true }); } catch {}
|
|
367
|
+
|
|
368
|
+
// Say what was dropped rather than only what was kept. A filter nobody can
|
|
369
|
+
// see is indistinguishable from a monitor that is not working, and this one
|
|
370
|
+
// drops the majority of what the kernel says on a busy run.
|
|
371
|
+
// Only when something was actually recorded. A run where every refusal was
|
|
372
|
+
// filtered out has nothing to contextualise, and `0 recorded, 1 outside the
|
|
373
|
+
// policy's paths` is a line that answers a question nobody asked — the
|
|
374
|
+
// counts exist to explain a filter, not to announce that one ran.
|
|
375
|
+
const { attributed, foreign } = denials.stats;
|
|
376
|
+
const recorded = attributed - offPolicy - walks;
|
|
377
|
+
if (recorded > 0)
|
|
378
|
+
err(`${C.dim}seisin: ${recorded} kernel denial(s) recorded` +
|
|
379
|
+
`${walks ? `, ${walks} directory scan(s)` : ""}` +
|
|
380
|
+
`${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}` +
|
|
381
|
+
`${foreign ? `, ${foreign} from other sandboxes` : ""}${C.off}\n`);
|
|
382
|
+
|
|
383
|
+
const queue = pending(requestsPath(config.root));
|
|
384
|
+
if (queue.length) err(renderQueue(queue));
|
|
385
|
+
if (!outStream) return process.exit(status);
|
|
386
|
+
let left = 2;
|
|
387
|
+
const guard = setTimeout(() => process.exit(status), 2000);
|
|
388
|
+
const tick = () => { if (--left > 0) return; clearTimeout(guard); process.exit(status); };
|
|
389
|
+
outStream.once("finish", tick);
|
|
390
|
+
errStream.once("finish", tick);
|
|
391
|
+
outStream.end();
|
|
392
|
+
errStream.end();
|
|
393
|
+
});
|
|
394
|
+
child.on("error", (e) => { throw new Error(`could not start the sandbox: ${e.message}`); });
|
|
395
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `seisin scan` — what is NOT covered by the key declaration.
|
|
3
|
+
*
|
|
4
|
+
* Exits 1 only on a `certain` finding. A `review` line is a prompt to look, and
|
|
5
|
+
* failing a build on those would make the command unusable in CI within a week.
|
|
6
|
+
*/
|
|
7
|
+
import { scan } from "../scan.js";
|
|
8
|
+
import { renderScan, out } from "../render.js";
|
|
9
|
+
|
|
10
|
+
export function scanCommand(config) {
|
|
11
|
+
const { hits, skipped, truncated } = scan(config.root, config.keyDirs, config.scanIgnore);
|
|
12
|
+
const result = {
|
|
13
|
+
certain: hits.filter((h) => h.level === "certain"),
|
|
14
|
+
review: hits.filter((h) => h.level === "review"),
|
|
15
|
+
links: hits.filter((h) => h.level === "link"),
|
|
16
|
+
skipped,
|
|
17
|
+
truncated,
|
|
18
|
+
};
|
|
19
|
+
out(renderScan(result, config.keyDirs));
|
|
20
|
+
return result;
|
|
21
|
+
}
|