seisin 0.2.0 → 0.4.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 +72 -10
- package/package.json +7 -6
- package/src/cli.js +4 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/log.js +28 -1
- package/src/commands/requests.js +7 -2
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +112 -151
- package/src/commands/ui.js +72 -6
- package/src/commands/walls.js +8 -2
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +150 -2
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +62 -3
- package/src/inspect.js +178 -5
- package/src/intake.js +243 -0
- package/src/keys.js +108 -6
- package/src/log.js +203 -3
- package/src/mcp.js +28 -10
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +29 -0
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +134 -22
- package/src/spool.js +98 -20
- package/src/srt.js +123 -225
- package/src/surface.js +518 -0
- package/src/uilink.js +55 -0
- package/src/violations.js +37 -5
- package/src/walls.js +19 -5
- package/ui/favicon.svg +1 -1
- package/ui/index.html +221 -28
package/src/surface.js
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the parent trusts, and why no role may write any of it.
|
|
3
|
+
*
|
|
4
|
+
* The sandbox confines the agent. It does not confine the process that decides
|
|
5
|
+
* what the agent gets: `seisin run` reads the policy, runs the key providers,
|
|
6
|
+
* reads `file://` secrets and starts the runtime, all of it outside the box and
|
|
7
|
+
* as you. Every file that process reads or executes is therefore part of the
|
|
8
|
+
* boundary, and a role that can write one of them does not need to escape — it
|
|
9
|
+
* waits for the next run, or for you to type the command yourself.
|
|
10
|
+
*
|
|
11
|
+
* That was fixed one file at a time: the policy, then `.seisin/`, then provider
|
|
12
|
+
* scripts with a path. Each fix was right and each left the next member of the
|
|
13
|
+
* family open. Measured on 2026-09-23 against the real kernel, with the fixes
|
|
14
|
+
* above in place:
|
|
15
|
+
*
|
|
16
|
+
* - a provider named `fakeprov` resolved through PATH; a role with a PATH
|
|
17
|
+
* directory in its territory planted its own, and the next run executed it
|
|
18
|
+
* unsandboxed (a marker file appeared outside every territory);
|
|
19
|
+
* - a role swapped a `file://` target for a symlink to another role's key,
|
|
20
|
+
* and the next run read that key and handed it over;
|
|
21
|
+
* - every role could create files in `~/.local/share/claude/versions/`, where
|
|
22
|
+
* the Claude Code binary you run outside the box lives, because the shared
|
|
23
|
+
* runtime scratch includes `~/.local/share`.
|
|
24
|
+
*
|
|
25
|
+
* So this module answers the question once, for the family: **which paths does
|
|
26
|
+
* the parent trust?** Anything on that list that falls inside some role's
|
|
27
|
+
* writable area is denied to every role, and `seisin check` prints the list
|
|
28
|
+
* with the reason for each entry.
|
|
29
|
+
*/
|
|
30
|
+
import { readdirSync, statSync, accessSync, existsSync, realpathSync, constants } from "node:fs";
|
|
31
|
+
import { join, isAbsolute, delimiter, dirname, resolve, basename } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { writePathsOf, realOrSelf } from "./grants.js";
|
|
35
|
+
import { STATE_DIR, CONFIG_NAME } from "./layout.js";
|
|
36
|
+
|
|
37
|
+
/** seisin's own package: the code the next `seisin run` executes. */
|
|
38
|
+
const SEISIN_HOME = realOrSelf(join(dirname(fileURLToPath(import.meta.url)), ".."));
|
|
39
|
+
|
|
40
|
+
const under = (p, root) => p === root || p.startsWith(root.endsWith("/") ? root : root + "/");
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every directory some role can write in some run, as real paths.
|
|
44
|
+
*
|
|
45
|
+
* The union over roles, not one role's view: a role that cannot write a
|
|
46
|
+
* provider script can still wait for the role that can. The repo root is in
|
|
47
|
+
* it because `--observe` grants the whole repo to whichever role it runs.
|
|
48
|
+
*/
|
|
49
|
+
export function writableRoots(config) {
|
|
50
|
+
const root = realOrSelf(config.root);
|
|
51
|
+
const out = new Set([root]);
|
|
52
|
+
// A grant inside the repo is covered by the repo root already, so only the
|
|
53
|
+
// ones outside it are resolved. Resolving all of them was most of the cost:
|
|
54
|
+
// 301 paths for the portfolio's 32 roles, most of them sidecars that do not
|
|
55
|
+
// exist, 200 ms of failed realpath calls to learn nothing.
|
|
56
|
+
for (const role of Object.values(config.roles ?? {}))
|
|
57
|
+
for (const p of writePathsOf(config, role)) {
|
|
58
|
+
if (under(p, config.root)) continue;
|
|
59
|
+
out.add(realOrSelf(p));
|
|
60
|
+
}
|
|
61
|
+
// Nested roots add nothing either: ~/.local/share/x under ~/.local/share.
|
|
62
|
+
const all = [...out];
|
|
63
|
+
return all.filter((r) => !all.some((o) => o !== r && under(r, o)));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The PATH the parent uses for its own lookups: yours, minus every directory
|
|
68
|
+
* some role can write, minus relative entries.
|
|
69
|
+
*
|
|
70
|
+
* A relative entry (`.`, `bin`, an empty one between two colons) resolves
|
|
71
|
+
* against whatever directory the parent is standing in, which is the repo —
|
|
72
|
+
* the one place every role writes.
|
|
73
|
+
*/
|
|
74
|
+
export function trustedPath(config, env = process.env, roots = writableRoots(config)) {
|
|
75
|
+
return (env.PATH ?? "").split(delimiter)
|
|
76
|
+
.filter((d) => d && isAbsolute(d) && !roots.some((r) => under(realOrSelf(d), r)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isExecutable(p) {
|
|
80
|
+
try {
|
|
81
|
+
if (!statSync(p).isFile()) return false;
|
|
82
|
+
accessSync(p, constants.X_OK);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Where a command the parent runs lives, found only where no role can write.
|
|
91
|
+
*
|
|
92
|
+
* A command with a slash is a path, relative to the policy. A bare name is
|
|
93
|
+
* looked up on {@link trustedPath}. When the only match sits in a writable
|
|
94
|
+
* directory the lookup fails and says where it was found: running it would
|
|
95
|
+
* mean running whatever a role last put there, and dropping to the next match
|
|
96
|
+
* silently would run a different program than the one the policy's author
|
|
97
|
+
* tested with.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveExecutable(cmd, config, env = process.env) {
|
|
100
|
+
if (cmd.includes("/")) return isAbsolute(cmd) ? cmd : resolve(config.root ?? ".", cmd);
|
|
101
|
+
const roots = writableRoots(config);
|
|
102
|
+
for (const dir of trustedPath(config, env, roots)) {
|
|
103
|
+
const candidate = join(dir, cmd);
|
|
104
|
+
if (isExecutable(candidate)) return candidate;
|
|
105
|
+
}
|
|
106
|
+
const shadow = (env.PATH ?? "").split(delimiter).filter(Boolean)
|
|
107
|
+
.map((d) => join(isAbsolute(d) ? d : resolve(config.root ?? ".", d), cmd))
|
|
108
|
+
.find(isExecutable);
|
|
109
|
+
throw new Error(
|
|
110
|
+
shadow
|
|
111
|
+
? `"${cmd}" is only found at ${shadow}, in a directory a role can write.\n` +
|
|
112
|
+
` seisin runs it outside the sandbox, so it would run whatever a role last put ` +
|
|
113
|
+
`there. Install it outside every territory, or give its absolute path.`
|
|
114
|
+
: `"${cmd}" is not on PATH.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The target of a `file://` key, as written and resolved against the policy. */
|
|
118
|
+
function fileRefTargets(config) {
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const role of Object.values(config.roles ?? {})) {
|
|
121
|
+
for (const raw of role.keys ?? []) {
|
|
122
|
+
const m = /^(?:[A-Za-z_][A-Za-z0-9_]*=)?file:\/\/(.+)$/.exec(raw);
|
|
123
|
+
if (!m) continue;
|
|
124
|
+
const hash = m[1].lastIndexOf("#");
|
|
125
|
+
const path = hash === -1 ? m[1] : m[1].slice(0, hash);
|
|
126
|
+
out.push({ path: isAbsolute(path) ? path : resolve(config.root, path), raw });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Where a program lives, as the directory to protect.
|
|
134
|
+
*
|
|
135
|
+
* The package, not the binary alone: a Python tool installed by uv runs
|
|
136
|
+
* `~/.local/share/uv/tools/ruff/bin/ruff`, and the code it executes is the rest
|
|
137
|
+
* of that tree. So the walk goes up from the binary to the first directory
|
|
138
|
+
* that looks like the root of an installation — one holding a `package.json`
|
|
139
|
+
* or a `pyvenv.cfg`, or the parent of a `bin/` — and stops at the writable
|
|
140
|
+
* root. With nothing recognisable on the way, the binary's own directory.
|
|
141
|
+
*
|
|
142
|
+
* The first version protected the top-level entry under the writable root
|
|
143
|
+
* instead, and on the portfolio that was `01-activos/`: every project, denied
|
|
144
|
+
* to every role, because seisin itself lives in one of them.
|
|
145
|
+
*/
|
|
146
|
+
function packageRoot(p, root) {
|
|
147
|
+
let dir = dirname(p);
|
|
148
|
+
while (under(dir, root) && dir !== root) {
|
|
149
|
+
if (existsSync(join(dir, "package.json")) || existsSync(join(dir, "pyvenv.cfg"))) return dir;
|
|
150
|
+
if (basename(dir) === "bin") return dirname(dir);
|
|
151
|
+
dir = dirname(dir);
|
|
152
|
+
}
|
|
153
|
+
return dirname(p) === root ? p : dirname(p);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Programs that run outside the box and live where a role can write.
|
|
158
|
+
*
|
|
159
|
+
* Two ways in. A PATH directory inside a writable area: anything a role drops
|
|
160
|
+
* there shadows a real command the next time anybody types it. And a PATH
|
|
161
|
+
* entry that is a symlink into a writable area — `~/.local/bin/claude` points
|
|
162
|
+
* at `~/.local/share/claude/versions/…`, and that directory was writable by
|
|
163
|
+
* every role through the shared runtime scratch.
|
|
164
|
+
*
|
|
165
|
+
* Only symlinks are followed. A regular file in a directory no role writes is
|
|
166
|
+
* already out of reach; the cost is then one `readdir` per PATH entry and one
|
|
167
|
+
* `realpath` per link.
|
|
168
|
+
*/
|
|
169
|
+
const SURFACES = new WeakMap(); // config -> PATH -> entries
|
|
170
|
+
|
|
171
|
+
export function executionSurface(config, env = process.env, roots = null) {
|
|
172
|
+
// Once per config and PATH: `check` asks for every role's denies, and the
|
|
173
|
+
// PATH scan is the same answer 32 times on the portfolio.
|
|
174
|
+
const byPath = SURFACES.get(config) ?? SURFACES.set(config, new Map()).get(config);
|
|
175
|
+
const key = env.PATH ?? "";
|
|
176
|
+
if (!roots && byPath.has(key)) return byPath.get(key);
|
|
177
|
+
const result = scanSurface(config, env, roots ?? writableRoots(config));
|
|
178
|
+
if (!roots) byPath.set(key, result);
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function scanSurface(config, env, roots) {
|
|
183
|
+
const out = [];
|
|
184
|
+
const inWritable = (p) => roots.find((r) => under(p, r));
|
|
185
|
+
// Deduplicated: the PATH a shell hands down repeats itself (33 entries, 21
|
|
186
|
+
// distinct, on the machine this was measured on), and each repeat is a
|
|
187
|
+
// directory listing and its links resolved again.
|
|
188
|
+
for (const dir of new Set((env.PATH ?? "").split(delimiter))) {
|
|
189
|
+
if (!dir || !isAbsolute(dir)) continue;
|
|
190
|
+
const real = realOrSelf(dir);
|
|
191
|
+
if (inWritable(real)) {
|
|
192
|
+
out.push({ path: real, why: `on PATH (${dir}): a role could shadow any command there` });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
let entries;
|
|
196
|
+
try { entries = readdirSync(real, { withFileTypes: true }); } catch { continue; }
|
|
197
|
+
for (const e of entries) {
|
|
198
|
+
if (!e.isSymbolicLink()) continue;
|
|
199
|
+
// `.native`: one realpath(3) call. The JS implementation lstats every
|
|
200
|
+
// component, and /usr/local/bin alone holds hundreds of links.
|
|
201
|
+
let target;
|
|
202
|
+
try { target = realpathSync.native(join(real, e.name)); } catch { continue; }
|
|
203
|
+
const root = inWritable(target);
|
|
204
|
+
if (root) out.push({ path: packageRoot(target, root), why: `${join(dir, e.name)} runs from here` });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// What the next `seisin run` itself executes: node, seisin, the runtime.
|
|
208
|
+
// Found in a writable place when node comes from a version manager that
|
|
209
|
+
// installs under ~/.local/share (fnm), or seisin is a devDependency of the
|
|
210
|
+
// repo it governs.
|
|
211
|
+
for (const [p, why] of [
|
|
212
|
+
[process.execPath, "the node that runs seisin"],
|
|
213
|
+
[SEISIN_HOME, "seisin itself"],
|
|
214
|
+
]) {
|
|
215
|
+
const real = realOrSelf(p);
|
|
216
|
+
const root = inWritable(real);
|
|
217
|
+
if (root) out.push({ path: real === SEISIN_HOME ? real : packageRoot(real, root), why });
|
|
218
|
+
}
|
|
219
|
+
return dedupe(out);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** One entry per path; the first reason given wins. */
|
|
223
|
+
function dedupe(entries) {
|
|
224
|
+
const seen = new Map();
|
|
225
|
+
for (const e of entries) if (!seen.has(e.path)) seen.set(e.path, e);
|
|
226
|
+
return [...seen.values()];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Everything the parent reads or executes, named by the policy, with the reason.
|
|
231
|
+
*
|
|
232
|
+
* Denied to every role whatever its territory, the way `seisin.toml` always
|
|
233
|
+
* was: these are cheap to list and a role that can write one of them has no
|
|
234
|
+
* policy.
|
|
235
|
+
*/
|
|
236
|
+
export function parentInputs(config, { env = process.env } = {}) {
|
|
237
|
+
const abs = (p) => (isAbsolute(p) ? p : join(config.root, p));
|
|
238
|
+
const out = [
|
|
239
|
+
{ path: abs(config.path ?? CONFIG_NAME), why: "the policy" },
|
|
240
|
+
{ path: abs(STATE_DIR), why: "seisin's log and request queue" },
|
|
241
|
+
...(config.keyDirs ?? []).map((d) => ({ path: abs(d), why: "a key directory" })),
|
|
242
|
+
];
|
|
243
|
+
for (const [scheme, p] of Object.entries(config.keyProviders ?? {})) {
|
|
244
|
+
const cmd = p.command?.[0];
|
|
245
|
+
if (!cmd) continue;
|
|
246
|
+
let path;
|
|
247
|
+
// A provider that does not resolve here is not denied here: the run that
|
|
248
|
+
// needs it fails at resolution, with the message resolveExecutable gives.
|
|
249
|
+
try { path = resolveExecutable(cmd, config, env); } catch { continue; }
|
|
250
|
+
out.push({ path, why: `the ${scheme}:// key provider, run outside the box` });
|
|
251
|
+
}
|
|
252
|
+
for (const { path, raw } of fileRefTargets(config))
|
|
253
|
+
out.push({ path, why: `read by the parent for key "${raw}"` });
|
|
254
|
+
|
|
255
|
+
// Both spellings of every entry: the one written, and the one the kernel
|
|
256
|
+
// meets when a symlink sits on the way. A deny on the written path alone let
|
|
257
|
+
// writes through once already (never_writes, 2026-09-22).
|
|
258
|
+
return out.flatMap((e) => {
|
|
259
|
+
const real = realOrSelf(e.path);
|
|
260
|
+
return real === e.path ? [e] : [e, { ...e, path: real }];
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Files that tell a program running outside the box what to execute.
|
|
266
|
+
*
|
|
267
|
+
* Git runs `.git/hooks/*` and whatever `.git/config` names (core.hooksPath,
|
|
268
|
+
* core.fsmonitor). Claude Code runs the hooks, plugins and skills its settings
|
|
269
|
+
* declare, and the MCP servers in `.mcp.json`. direnv runs `.envrc` on `cd`.
|
|
270
|
+
* Codex starts what `config.toml` lists. A role that writes one of these does
|
|
271
|
+
* not escape the box; it leaves a command for you to run the next time you
|
|
272
|
+
* open that project — measured on 2026-09-23: every role could open
|
|
273
|
+
* `~/.claude/settings.json` for writing through the shared runtime scratch.
|
|
274
|
+
*
|
|
275
|
+
* The runtime already covers part of this with patterns (`**\/.git/hooks/**`,
|
|
276
|
+
* `**\/.mcp.json`, `**\/.claude/commands`). Literal paths hold more than
|
|
277
|
+
* patterns do — the runtime also protects each literal path's ancestors — so
|
|
278
|
+
* every control file of every project inside a role's territory is named
|
|
279
|
+
* literally, and the patterns stay as a second layer.
|
|
280
|
+
*/
|
|
281
|
+
export const CONTROL_FILES = [
|
|
282
|
+
".git/hooks", ".git/config",
|
|
283
|
+
".claude/settings.json", ".claude/settings.local.json",
|
|
284
|
+
".claude/hooks", ".claude/plugins", ".claude/skills", ".claude/commands", ".claude/agents",
|
|
285
|
+
".mcp.json", ".envrc", ".codex/config.toml",
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
const RUNNER = (f) =>
|
|
289
|
+
f.startsWith(".git/") ? "git" : f === ".envrc" ? "direnv" : f.startsWith(".codex/") ? "Codex" : "Claude Code";
|
|
290
|
+
|
|
291
|
+
// Case-insensitive, because the disk is: on APFS `.CLAUDE/Settings.json` is
|
|
292
|
+
// the same file, and the kernel was measured refusing it.
|
|
293
|
+
const CONTROL_RE = new RegExp(
|
|
294
|
+
`(^|/)(${CONTROL_FILES.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(/|$)`, "i");
|
|
295
|
+
|
|
296
|
+
/** Does this path name a control file, at any depth? What `explain` asks. */
|
|
297
|
+
export const isControlPath = (p) => CONTROL_RE.test(p);
|
|
298
|
+
|
|
299
|
+
/** Where no control file is looked for: dependencies and build output. */
|
|
300
|
+
const PRUNE = new Set(["node_modules", ".git", "vendor", "dist", "build", ".venv", "venv",
|
|
301
|
+
"__pycache__", ".next", "target", ".turbo", ".cache", "Pods", ".gradle", ".dart_tool"]);
|
|
302
|
+
|
|
303
|
+
/** A directory holding one of these is where somebody opens a project. */
|
|
304
|
+
const MARKERS = new Set([".git", ".claude", ".mcp.json", ".envrc", ".codex"]);
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* How deep under a territory to look for projects.
|
|
308
|
+
*
|
|
309
|
+
* Measured on the portfolio over every territory at once: depth 3 finds 128
|
|
310
|
+
* project roots reading 2.6k directories (~0.4 s under load); depth 4 finds
|
|
311
|
+
* 130 reading 5.5k (0.9 s); depth 6, 132 and 2.1 s. The four below depth 3
|
|
312
|
+
* are `.claude` folders agents left inside source trees (`features/…`,
|
|
313
|
+
* `tests/Feature/Api`), not projects anybody opens — and on macOS the pattern
|
|
314
|
+
* layer still refuses writing into them. The whole portfolio at depth 8 is
|
|
315
|
+
* 23k directories, which is why the walk is per role and never over the repo.
|
|
316
|
+
*/
|
|
317
|
+
const SCAN_DEPTH = 3;
|
|
318
|
+
|
|
319
|
+
function projectRoots(dir, depth = SCAN_DEPTH) {
|
|
320
|
+
const out = [];
|
|
321
|
+
const stack = [[dir, 0]];
|
|
322
|
+
while (stack.length) {
|
|
323
|
+
const [d, level] = stack.pop();
|
|
324
|
+
let entries;
|
|
325
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { continue; }
|
|
326
|
+
if (level === 0 || entries.some((e) => MARKERS.has(e.name))) out.push(d);
|
|
327
|
+
if (level >= depth) continue;
|
|
328
|
+
for (const e of entries)
|
|
329
|
+
if (e.isDirectory() && !e.isSymbolicLink() && !PRUNE.has(e.name)) stack.push([join(d, e.name), level + 1]);
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The control files of one project directory, as literal paths.
|
|
336
|
+
*
|
|
337
|
+
* On Linux only the ones that exist: bubblewrap denies a path by mounting over
|
|
338
|
+
* it, and to do that for a path that is not there it creates it on the host
|
|
339
|
+
* (never_writes has the measurement). A `.git` that is a file is a worktree
|
|
340
|
+
* pointing at its repository, and the pointer is what gets protected.
|
|
341
|
+
*/
|
|
342
|
+
function controlsOf(dir, platform) {
|
|
343
|
+
const out = [];
|
|
344
|
+
let git = null;
|
|
345
|
+
try { git = statSync(join(dir, ".git")); } catch {}
|
|
346
|
+
if (git?.isFile()) out.push({ path: join(dir, ".git"), why: "a worktree's pointer to its repository, read by git" });
|
|
347
|
+
if (git?.isDirectory())
|
|
348
|
+
for (const f of [".git/hooks", ".git/config"])
|
|
349
|
+
out.push({ path: join(dir, f), why: `${f}, run by git outside the box` });
|
|
350
|
+
/**
|
|
351
|
+
* `.claude` whole, as one entry, rather than its seven control files.
|
|
352
|
+
*
|
|
353
|
+
* Every literal path costs sandbox start-up — the runtime adds a rule for
|
|
354
|
+
* each of its ancestors. Measured with one real role's profile: 50 literals
|
|
355
|
+
* started in ~1.9 s like none at all, 150 in ~2.3 s, 400 in 4.8–7.7 s. One
|
|
356
|
+
* entry for the directory covers every file in it, its ancestors, and
|
|
357
|
+
* creating it where it did not exist. The cost is
|
|
358
|
+
* that a role cannot write a project's `.claude/` at all, which is the
|
|
359
|
+
* directory Claude Code reads its instructions to itself from.
|
|
360
|
+
*/
|
|
361
|
+
if (platform !== "linux" || existsSync(join(dir, ".claude")))
|
|
362
|
+
out.push({ path: join(dir, ".claude"), why: ".claude, whose settings, hooks and skills Claude Code runs outside the box" });
|
|
363
|
+
// The single files only where they exist: where they do not, the patterns
|
|
364
|
+
// refuse creating them, and a literal each would be start-up for nothing.
|
|
365
|
+
for (const f of [".mcp.json", ".envrc", ".codex/config.toml"]) {
|
|
366
|
+
const p = join(dir, f);
|
|
367
|
+
if (existsSync(p)) out.push({ path: p, why: `${f}, run by ${RUNNER(f)} outside the box` });
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The home's control files, one by one — never `~/.claude` whole.
|
|
374
|
+
*
|
|
375
|
+
* That directory is also where Claude Code keeps its sessions, todos and
|
|
376
|
+
* history, and it writes them from inside the box on every turn. Denying the
|
|
377
|
+
* directory would stop the agent; denying what it executes does not (checked
|
|
378
|
+
* with `claude -p` under the new profile: it answered, and what it was refused
|
|
379
|
+
* were lock and cache files under plugins/, which it tolerates).
|
|
380
|
+
*/
|
|
381
|
+
function homeControls(platform) {
|
|
382
|
+
const home = homedir();
|
|
383
|
+
return [
|
|
384
|
+
".claude/settings.json", ".claude/settings.local.json", ".claude/hooks", ".claude/plugins",
|
|
385
|
+
".claude/skills", ".claude/commands", ".claude/agents", ".codex/config.toml",
|
|
386
|
+
]
|
|
387
|
+
.map((f) => ({ path: join(home, f), why: `${f}, run by ${RUNNER(f)} outside the box` }))
|
|
388
|
+
.filter((e) => platform !== "linux" || existsSync(e.path));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Everything this role's profile must deny beyond its own `never_writes`.
|
|
393
|
+
*
|
|
394
|
+
* Per role, and that is not only a saving. The kernel profile is one role's,
|
|
395
|
+
* so the only protected paths that matter in it are the ones inside what that
|
|
396
|
+
* role can write; a provider script in `backend`'s territory is already out of
|
|
397
|
+
* `frontend`'s reach. The union over roles (writableRoots) is for the other
|
|
398
|
+
* question — which directories the parent may trust at all.
|
|
399
|
+
*/
|
|
400
|
+
const DENIES = new WeakMap(); // config -> key -> entries
|
|
401
|
+
const ROOTS = new WeakMap(); // config -> dir -> project roots under it
|
|
402
|
+
|
|
403
|
+
export function denyFor(config, role, { env = process.env, platform = process.platform, observe = false } = {}) {
|
|
404
|
+
// Memoised per config: `check` builds every role's settings and then asks
|
|
405
|
+
// for the same list again to show it; on the portfolio that was 2.6 s twice.
|
|
406
|
+
const memo = DENIES.get(config) ?? DENIES.set(config, new Map()).get(config);
|
|
407
|
+
const key = `${role.name}\0${observe}\0${platform}\0${env.PATH ?? ""}`;
|
|
408
|
+
if (memo.has(key)) return memo.get(key);
|
|
409
|
+
const result = computeDenies(config, role, { env, platform, observe });
|
|
410
|
+
memo.set(key, result);
|
|
411
|
+
return result;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Project roots under `dir`, once per config: roles share directories. */
|
|
415
|
+
function rootsUnder(config, dir) {
|
|
416
|
+
const memo = ROOTS.get(config) ?? ROOTS.set(config, new Map()).get(config);
|
|
417
|
+
if (!memo.has(dir)) memo.set(dir, projectRoots(dir));
|
|
418
|
+
return memo.get(dir);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function computeDenies(config, role, { env, platform, observe }) {
|
|
422
|
+
const mine = writePathsOf(config, role, { observe });
|
|
423
|
+
const minePaths = [...new Set(mine.flatMap((p) => [p, realOrSelf(p)]))];
|
|
424
|
+
const inMine = (p) => minePaths.some((w) => under(p, w));
|
|
425
|
+
|
|
426
|
+
const surface = executionSurface(config, env).filter((e) => inMine(e.path));
|
|
427
|
+
|
|
428
|
+
const dirs = mine.filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } });
|
|
429
|
+
const repoDirs = dirs.filter((p) => under(p, config.root));
|
|
430
|
+
const controls = repoDirs.flatMap((d) => rootsUnder(config, d)).flatMap((d) => controlsOf(d, platform));
|
|
431
|
+
// The home is a project too, for Claude Code and Codex: ~/.claude/settings.json
|
|
432
|
+
// is read by every session. Not walked — only its own control files.
|
|
433
|
+
if (["~/.claude", "~/.codex"].some((h) => inMine(join(homedir(), h.slice(2)))))
|
|
434
|
+
controls.push(...homeControls(platform));
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Patterns, macOS only (bubblewrap does not take them), and anchored to this
|
|
438
|
+
* role's own directories so that `.claude` means a project's and never the
|
|
439
|
+
* home's, where Claude Code keeps its sessions.
|
|
440
|
+
*
|
|
441
|
+
* Anchored patterns cost nothing at start-up (measured with 24: the same as
|
|
442
|
+
* none) and cover what the literals cannot: a `.claude` deeper than the walk,
|
|
443
|
+
* or one a role creates where there was none. The home's
|
|
444
|
+
* files stay literal (homeControls).
|
|
445
|
+
*/
|
|
446
|
+
const globs = platform === "darwin"
|
|
447
|
+
? repoDirs.flatMap((d) => [
|
|
448
|
+
`${d}/**/.claude`, `${d}/**/.claude/**`,
|
|
449
|
+
`${d}/**/.git/hooks/**`, `${d}/**/.git/config`,
|
|
450
|
+
`${d}/**/.mcp.json`, `${d}/**/.envrc`, `${d}/**/.codex/config.toml`,
|
|
451
|
+
]).map((path) => ({ path, why: "any project's control files in this territory, by pattern" }))
|
|
452
|
+
: [];
|
|
453
|
+
|
|
454
|
+
// The policy, `.seisin/` and the key directories are denied whatever the
|
|
455
|
+
// territory, as they always were. What the providers and `file://` keys
|
|
456
|
+
// point at is denied where this role can write it: `/usr/bin/security` in
|
|
457
|
+
// every profile would be a rule about the machine, and no role reaches it.
|
|
458
|
+
const always = new Set(["the policy", "seisin's log and request queue", "a key directory"]);
|
|
459
|
+
const inputs = parentInputs(config, { env }).filter((e) => always.has(e.why) || inMine(e.path));
|
|
460
|
+
|
|
461
|
+
return dedupe([...inputs, ...surface, ...controls, ...globs]);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The protection covering a repo-relative path, or null — what `explain` asks.
|
|
466
|
+
*
|
|
467
|
+
* So the sentence and the kernel agree: a request to write `seisin.toml` used
|
|
468
|
+
* to be filed as "belongs to dev" for a role with `writes = ["**"]`, and
|
|
469
|
+
* granting it would have granted nothing.
|
|
470
|
+
*/
|
|
471
|
+
export function protectedBy(config, target, { platform = process.platform } = {}) {
|
|
472
|
+
/**
|
|
473
|
+
* A project's `.claude/`, any file in it, and the other control files — the
|
|
474
|
+
* same rule the kernel is given. On Linux the kernel only holds the ones
|
|
475
|
+
* that exist, so only those are called protected there: a sentence stricter
|
|
476
|
+
* than the boundary is the direction this project refuses.
|
|
477
|
+
*/
|
|
478
|
+
const f = /(^|\/)\.claude(\/|$)/i.test(target)
|
|
479
|
+
? ".claude"
|
|
480
|
+
: CONTROL_FILES.find((c) => new RegExp(`(^|/)${c.replace(/\./g, "\\.")}(/|$)`, "i").test(target));
|
|
481
|
+
if (f && (platform !== "linux" || existsSync(isAbsolute(target) ? target : join(config.root, target)))) {
|
|
482
|
+
const why = f === ".claude"
|
|
483
|
+
? ".claude, whose settings, hooks and skills Claude Code runs outside the box"
|
|
484
|
+
: `${f}, run by ${RUNNER(f)} outside the box`;
|
|
485
|
+
return { path: target, why };
|
|
486
|
+
}
|
|
487
|
+
const full = realOrSelf(isAbsolute(target) ? target : join(config.root, target));
|
|
488
|
+
return parentInputs(config)
|
|
489
|
+
.find((e) => under(full, realOrSelf(e.path)) || under(join(config.root, target), e.path)) ?? null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* What `seisin check` shows: every protection that takes something away from a
|
|
494
|
+
* role — a path inside its territory that it will not be able to write — with
|
|
495
|
+
* the reason and the roles it applies to.
|
|
496
|
+
*
|
|
497
|
+
* The policy, `.seisin/` and the key directories are left out: they have been
|
|
498
|
+
* closed to every role since the first release and saying so on every check
|
|
499
|
+
* is noise. Patterns are left out too; they are a second layer, listed in
|
|
500
|
+
* surface.js.
|
|
501
|
+
*/
|
|
502
|
+
export function protections(config, roles = Object.values(config.roles), opts = {}) {
|
|
503
|
+
const always = new Set(["the policy", "seisin's log and request queue", "a key directory"]);
|
|
504
|
+
const byPath = new Map();
|
|
505
|
+
for (const role of roles) {
|
|
506
|
+
let entries;
|
|
507
|
+
try { entries = denyFor(config, role, opts); } catch { continue; }
|
|
508
|
+
for (const e of entries) {
|
|
509
|
+
if (always.has(e.why) || e.path.includes("*")) continue;
|
|
510
|
+
// Only what the role could otherwise write: a literal control-file path
|
|
511
|
+
// is listed for every project in the territory whether it exists or not,
|
|
512
|
+
// and the ones that do not exist are shown only if their project does.
|
|
513
|
+
const hit = byPath.get(e.path) ?? byPath.set(e.path, { path: e.path, why: e.why, roles: [] }).get(e.path);
|
|
514
|
+
hit.roles.push(role.name);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return [...byPath.values()].filter((e) => existsSync(e.path) || !e.why.includes(" run by "));
|
|
518
|
+
}
|
package/src/uilink.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a running `seisin ui` leaves its live link, so it can be found again.
|
|
3
|
+
*
|
|
4
|
+
* The console's token never travels in an HTTP response — that was the whole
|
|
5
|
+
* point of the redesign in serve.js: a page that could GET `/` used to walk
|
|
6
|
+
* away with it. The cost was that a tab which lost the fragment had nowhere to
|
|
7
|
+
* recover the token from but the terminal that printed it once. When that
|
|
8
|
+
* terminal is gone, so is the link.
|
|
9
|
+
*
|
|
10
|
+
* This is the recovery channel that does not reopen the leak: the link (token
|
|
11
|
+
* and all) is written to a file only this user can read — under `$TMPDIR/snr/`,
|
|
12
|
+
* the same 0700 directory runs already keep their sockets and keys in — and
|
|
13
|
+
* never sent over the wire. `seisin ui` on a busy port reads it and reopens the
|
|
14
|
+
* live link instead of failing; `seisin ui --link` prints it. The token now
|
|
15
|
+
* rests on disk (0600) for the life of the run rather than only in memory, a
|
|
16
|
+
* bounded, local-user exposure, and it is removed when the run exits.
|
|
17
|
+
*
|
|
18
|
+
* The pid is stored so a file left by a crashed run is recognisable: a link
|
|
19
|
+
* whose writer is gone is stale, not a running console.
|
|
20
|
+
*/
|
|
21
|
+
import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { runsRoot } from "./rundir.js";
|
|
24
|
+
|
|
25
|
+
function linkFile(port, base) {
|
|
26
|
+
return join(runsRoot(base), `ui-${port}.url`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Records this run's live URL for the port it bound. */
|
|
30
|
+
export function writeUiLink(port, url, base) {
|
|
31
|
+
writeFileSync(linkFile(port, base), JSON.stringify({ url, pid: process.pid }) + "\n", { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The live URL a `seisin ui` left for this port, with whether its writer is
|
|
36
|
+
* still alive — or null if there is no readable record.
|
|
37
|
+
*/
|
|
38
|
+
export function readUiLink(port, base) {
|
|
39
|
+
let raw;
|
|
40
|
+
try { raw = readFileSync(linkFile(port, base), "utf8"); } catch { return null; }
|
|
41
|
+
let e;
|
|
42
|
+
try { e = JSON.parse(raw); } catch { return null; }
|
|
43
|
+
if (!e || typeof e.url !== "string") return null;
|
|
44
|
+
let alive = null;
|
|
45
|
+
if (typeof e.pid === "number") {
|
|
46
|
+
// Signal 0 tests for the process without touching it: it throws if gone.
|
|
47
|
+
try { process.kill(e.pid, 0); alive = true; } catch { alive = false; }
|
|
48
|
+
}
|
|
49
|
+
return { url: e.url, pid: e.pid ?? null, alive };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Removes the record. Called on exit, and when a stale one is found. */
|
|
53
|
+
export function clearUiLink(port, base) {
|
|
54
|
+
try { unlinkSync(linkFile(port, base)); } catch {}
|
|
55
|
+
}
|
package/src/violations.js
CHANGED
|
@@ -55,12 +55,14 @@ const DENY = /Sandbox:\s+(\S+)\((\d+)\)\s+deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/;
|
|
|
55
55
|
const TAG = /CMD64_(.*?)_END_(\S*_SBX)\s*$/;
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* A Seatbelt operation, as one of the
|
|
58
|
+
* A Seatbelt operation, as one of the three verbs the log speaks.
|
|
59
59
|
*
|
|
60
|
-
*
|
|
61
|
-
* `
|
|
62
|
-
*
|
|
63
|
-
*
|
|
60
|
+
* `network-outbound` is `connect` — a refused dial to a local port or socket,
|
|
61
|
+
* kept narrow by `connectTarget` below. Everything else that is not a file
|
|
62
|
+
* operation — `mach-lookup`, `sysctl-read` — returns null and is dropped: the
|
|
63
|
+
* sandbox doing its job against the machine, not a role reaching for anything
|
|
64
|
+
* a person could decide about. Refusals by the proxy (a domain not on the
|
|
65
|
+
* list) never reach this log at all; see docs/decisions.md.
|
|
64
66
|
*
|
|
65
67
|
* **`file-read-metadata` is dropped too, and that one was learned the hard
|
|
66
68
|
* way.** It is a refused `stat()`, which is what any directory walk produces
|
|
@@ -79,6 +81,31 @@ const TAG = /CMD64_(.*?)_END_(\S*_SBX)\s*$/;
|
|
|
79
81
|
export function actionOf(operation) {
|
|
80
82
|
if (operation.startsWith("file-write")) return "write";
|
|
81
83
|
if (operation === "file-read-data") return "read";
|
|
84
|
+
if (operation === "network-outbound") return "connect";
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The target of a refused connection, or null when it is not one worth a line.
|
|
90
|
+
*
|
|
91
|
+
* `network-outbound` was dropped with the rest of the non-file operations, and
|
|
92
|
+
* that hid the one refusal an agent misreads the most: a role that cannot reach
|
|
93
|
+
* the test database gets `connect EPERM`, no path, and concludes the service is
|
|
94
|
+
* down (measured: a whole cell spent two rounds reporting Docker as broken
|
|
95
|
+
* while Docker was up). Kept narrow, because the lesson of
|
|
96
|
+
* `file-read-metadata` applies here as well:
|
|
97
|
+
*
|
|
98
|
+
* - `remote:*:<port>` — a TCP dial the profile refused. The kernel does not
|
|
99
|
+
* name the host, so the target is the port and nothing more.
|
|
100
|
+
* - a unix socket path — except under /var/run, where the system daemons
|
|
101
|
+
* live: every DNS lookup inside the box is a refused connect to
|
|
102
|
+
* mDNSResponder, and recording those would bury everything else.
|
|
103
|
+
* - anything else (an empty detail, which the kernel also writes) — dropped.
|
|
104
|
+
*/
|
|
105
|
+
export function connectTarget(detail) {
|
|
106
|
+
const port = /^remote:\S*:(\d+)$/.exec(detail)?.[1];
|
|
107
|
+
if (port) return `tcp:${port}`;
|
|
108
|
+
if (detail.startsWith("/") && !/^\/(private\/)?var\/run\//.test(detail)) return detail;
|
|
82
109
|
return null;
|
|
83
110
|
}
|
|
84
111
|
|
|
@@ -107,6 +134,11 @@ export function parseChunk(chunk) {
|
|
|
107
134
|
if (!deny) return null;
|
|
108
135
|
const action = actionOf(deny.operation);
|
|
109
136
|
if (!action) return null;
|
|
137
|
+
if (action === "connect") {
|
|
138
|
+
const target = connectTarget(deny.detail);
|
|
139
|
+
if (!target) return null;
|
|
140
|
+
return { ...deny, action, path: target, suffix: tag?.suffix ?? null, command: tag?.command ?? null };
|
|
141
|
+
}
|
|
110
142
|
// Only an absolute path is a territory question. A relative or malformed
|
|
111
143
|
// detail means the line was not what it looked like; dropping beats guessing.
|
|
112
144
|
if (!deny.detail.startsWith("/")) return null;
|