seisin 0.4.2 → 0.5.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 +142 -54
- package/package.json +3 -3
- package/src/cli.js +145 -46
- package/src/commands/check.js +3 -2
- package/src/commands/explain.js +34 -13
- package/src/commands/hook.js +14 -4
- package/src/commands/init.js +126 -38
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +109 -32
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +159 -37
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +25 -6
- package/src/commands/whose.js +7 -6
- package/src/commands/wire.js +156 -17
- package/src/config.js +187 -25
- package/src/controls.js +244 -0
- package/src/diagnose.js +35 -33
- package/src/env.js +11 -8
- package/src/grants.js +15 -7
- package/src/help.js +159 -0
- package/src/hook.js +49 -19
- package/src/index.js +4 -4
- package/src/inspect.js +137 -71
- package/src/intake.js +20 -15
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +236 -51
- package/src/mcp.js +42 -28
- package/src/notify.js +13 -8
- package/src/owners.js +198 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +68 -10
- package/src/requests.js +173 -72
- package/src/review.js +30 -17
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +377 -229
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +191 -44
- package/src/uilink.js +10 -4
- package/src/views.js +289 -0
- package/src/violations.js +104 -16
- package/src/walls.js +19 -42
- package/src/worktree.js +3 -11
- package/ui/index.html +1356 -537
package/src/surface.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The sandbox confines the agent. It does not confine the process that decides
|
|
5
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
|
|
6
|
+
* reads `file://` secrets and starts the runtime, all of it outside the sandbox and
|
|
7
7
|
* as you. Every file that process reads or executes is therefore part of the
|
|
8
8
|
* boundary, and a role that can write one of them does not need to escape — it
|
|
9
9
|
* waits for the next run, or for you to type the command yourself.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* - a role swapped a `file://` target for a symlink to another role's key,
|
|
20
20
|
* and the next run read that key and handed it over;
|
|
21
21
|
* - every role could create files in `~/.local/share/claude/versions/`, where
|
|
22
|
-
* the Claude Code binary you run outside the
|
|
22
|
+
* the Claude Code binary you run outside the sandbox lives, because the shared
|
|
23
23
|
* runtime scratch includes `~/.local/share`.
|
|
24
24
|
*
|
|
25
25
|
* So this module answers the question once, for the family: **which paths does
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* with the reason for each entry.
|
|
29
29
|
*/
|
|
30
30
|
import { readdirSync, statSync, accessSync, existsSync, realpathSync, constants } from "node:fs";
|
|
31
|
-
import { join, isAbsolute, delimiter, dirname, resolve, basename } from "node:path";
|
|
31
|
+
import { join, isAbsolute, delimiter, dirname, resolve, basename, sep } from "node:path";
|
|
32
32
|
import { fileURLToPath } from "node:url";
|
|
33
33
|
import { homedir } from "node:os";
|
|
34
34
|
import { writePathsOf, realOrSelf } from "./grants.js";
|
|
@@ -46,12 +46,25 @@ const under = (p, root) => p === root || p.startsWith(root.endsWith("/") ? root
|
|
|
46
46
|
* provider script can still wait for the role that can. The repo root is in
|
|
47
47
|
* it because `--observe` grants the whole repo to whichever role it runs.
|
|
48
48
|
*/
|
|
49
|
+
const ROOTS_OF = new WeakMap(); // config -> writable roots
|
|
50
|
+
|
|
49
51
|
export function writableRoots(config) {
|
|
52
|
+
// Once per config: protectedBy asks for it through parentInputs on every
|
|
53
|
+
// call, and on a multi-repo workspace that was 1.7 ms each — 50 ms for a hook call
|
|
54
|
+
// with two providers. A config is reloaded whenever the policy can have
|
|
55
|
+
// changed (hook, console, MCP), so a memory per object is never stale for
|
|
56
|
+
// longer than the object lives.
|
|
57
|
+
let roots = ROOTS_OF.get(config);
|
|
58
|
+
if (!roots) ROOTS_OF.set(config, (roots = computeWritableRoots(config)));
|
|
59
|
+
return roots;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function computeWritableRoots(config) {
|
|
50
63
|
const root = realOrSelf(config.root);
|
|
51
64
|
const out = new Set([root]);
|
|
52
65
|
// A grant inside the repo is covered by the repo root already, so only the
|
|
53
66
|
// ones outside it are resolved. Resolving all of them was most of the cost:
|
|
54
|
-
// 301 paths for
|
|
67
|
+
// 301 paths for a policy with 32 roles, most of them sidecars that do not
|
|
55
68
|
// exist, 200 ms of failed realpath calls to learn nothing.
|
|
56
69
|
for (const role of Object.values(config.roles ?? {}))
|
|
57
70
|
for (const p of writePathsOf(config, role)) {
|
|
@@ -140,8 +153,8 @@ function fileRefTargets(config) {
|
|
|
140
153
|
* root. With nothing recognisable on the way, the binary's own directory.
|
|
141
154
|
*
|
|
142
155
|
* The first version protected the top-level entry under the writable root
|
|
143
|
-
* instead, and
|
|
144
|
-
* to every role, because seisin itself lives in one
|
|
156
|
+
* instead, and in a multi-repo workspace that was the folder holding every
|
|
157
|
+
* project: all of them, denied to every role, because seisin itself lives in one.
|
|
145
158
|
*/
|
|
146
159
|
function packageRoot(p, root) {
|
|
147
160
|
let dir = dirname(p);
|
|
@@ -154,7 +167,7 @@ function packageRoot(p, root) {
|
|
|
154
167
|
}
|
|
155
168
|
|
|
156
169
|
/**
|
|
157
|
-
* Programs that run outside the
|
|
170
|
+
* Programs that run outside the sandbox and live where a role can write.
|
|
158
171
|
*
|
|
159
172
|
* Two ways in. A PATH directory inside a writable area: anything a role drops
|
|
160
173
|
* there shadows a real command the next time anybody types it. And a PATH
|
|
@@ -170,7 +183,7 @@ const SURFACES = new WeakMap(); // config -> PATH -> entries
|
|
|
170
183
|
|
|
171
184
|
export function executionSurface(config, env = process.env, roots = null) {
|
|
172
185
|
// Once per config and PATH: `check` asks for every role's denies, and the
|
|
173
|
-
// PATH scan is the same answer 32 times
|
|
186
|
+
// PATH scan is the same answer 32 times for a policy with 32 roles.
|
|
174
187
|
const byPath = SURFACES.get(config) ?? SURFACES.set(config, new Map()).get(config);
|
|
175
188
|
const key = env.PATH ?? "";
|
|
176
189
|
if (!roots && byPath.has(key)) return byPath.get(key);
|
|
@@ -233,17 +246,32 @@ function dedupe(entries) {
|
|
|
233
246
|
* was: these are cheap to list and a role that can write one of them has no
|
|
234
247
|
* policy.
|
|
235
248
|
*/
|
|
249
|
+
const INPUTS = new WeakMap(); // config -> PATH -> entries
|
|
250
|
+
|
|
236
251
|
export function parentInputs(config, { env = process.env } = {}) {
|
|
252
|
+
const byPath = INPUTS.get(config) ?? INPUTS.set(config, new Map()).get(config);
|
|
253
|
+
const key = env.PATH ?? "";
|
|
254
|
+
if (!byPath.has(key)) byPath.set(key, computeParentInputs(config, env));
|
|
255
|
+
return byPath.get(key);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* `always` marks the three that are denied whatever the territory — the
|
|
260
|
+
* policy, `.seisin/` and the key directories. It is a field, not a comparison
|
|
261
|
+
* against the wording of `why`: that sentence is for people, and rewording it
|
|
262
|
+
* would have quietly moved a key directory out of every profile.
|
|
263
|
+
*/
|
|
264
|
+
function computeParentInputs(config, env) {
|
|
237
265
|
const abs = (p) => (isAbsolute(p) ? p : join(config.root, p));
|
|
238
266
|
const policy = abs(config.path ?? CONFIG_NAME);
|
|
239
267
|
const out = [
|
|
240
|
-
{ path: policy, why: "the policy" },
|
|
268
|
+
{ path: policy, why: "the policy", always: true },
|
|
241
269
|
// `seisin.toml.observed` is the proposal `init --from-observations` writes
|
|
242
270
|
// for a person to diff and move onto the policy; a role that could write it
|
|
243
271
|
// could plant a policy the operator then promotes in good faith.
|
|
244
|
-
{ path: policy + ".observed", why: "the policy" },
|
|
245
|
-
{ path: abs(STATE_DIR), why: "seisin's log and request queue" },
|
|
246
|
-
...(config.keyDirs ?? []).map((d) => ({ path: abs(d), why: "a key directory" })),
|
|
272
|
+
{ path: policy + ".observed", why: "the policy", always: true },
|
|
273
|
+
{ path: abs(STATE_DIR), why: "seisin's log and request queue", always: true },
|
|
274
|
+
...(config.keyDirs ?? []).map((d) => ({ path: abs(d), why: "a key directory", always: true })),
|
|
247
275
|
];
|
|
248
276
|
for (const [scheme, p] of Object.entries(config.keyProviders ?? {})) {
|
|
249
277
|
const cmd = p.command?.[0];
|
|
@@ -252,7 +280,7 @@ export function parentInputs(config, { env = process.env } = {}) {
|
|
|
252
280
|
// A provider that does not resolve here is not denied here: the run that
|
|
253
281
|
// needs it fails at resolution, with the message resolveExecutable gives.
|
|
254
282
|
try { path = resolveExecutable(cmd, config, env); } catch { continue; }
|
|
255
|
-
out.push({ path, why: `the ${scheme}:// key provider, run outside the
|
|
283
|
+
out.push({ path, why: `the ${scheme}:// key provider, run outside the sandbox` });
|
|
256
284
|
}
|
|
257
285
|
for (const { path, raw } of fileRefTargets(config))
|
|
258
286
|
out.push({ path, why: `read by the parent for key "${raw}"` });
|
|
@@ -267,13 +295,13 @@ export function parentInputs(config, { env = process.env } = {}) {
|
|
|
267
295
|
}
|
|
268
296
|
|
|
269
297
|
/**
|
|
270
|
-
* Files that tell a program running outside the
|
|
298
|
+
* Files that tell a program running outside the sandbox what to execute.
|
|
271
299
|
*
|
|
272
300
|
* Git runs `.git/hooks/*` and whatever `.git/config` names (core.hooksPath,
|
|
273
301
|
* core.fsmonitor). Claude Code runs the hooks, plugins and skills its settings
|
|
274
302
|
* declare, and the MCP servers in `.mcp.json`. direnv runs `.envrc` on `cd`.
|
|
275
303
|
* Codex starts what `config.toml` lists. A role that writes one of these does
|
|
276
|
-
* not escape the
|
|
304
|
+
* not escape the sandbox; it leaves a command for you to run the next time you
|
|
277
305
|
* open that project — measured on 2026-09-23: every role could open
|
|
278
306
|
* `~/.claude/settings.json` for writing through the shared runtime scratch.
|
|
279
307
|
*
|
|
@@ -293,30 +321,97 @@ export const CONTROL_FILES = [
|
|
|
293
321
|
const RUNNER = (f) =>
|
|
294
322
|
f.startsWith(".git/") ? "git" : f === ".envrc" ? "direnv" : f.startsWith(".codex/") ? "Codex" : "Claude Code";
|
|
295
323
|
|
|
324
|
+
/** `s` as a literal inside a RegExp — every metacharacter, not only `.`. */
|
|
325
|
+
const literal = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
326
|
+
|
|
327
|
+
/** `name` as a whole path segment run, anywhere in a path. */
|
|
328
|
+
const segment = (name, flags = "") => new RegExp(`(^|/)${literal(name)}(/|$)`, flags);
|
|
329
|
+
|
|
296
330
|
// Case-insensitive, because the disk is: on APFS `.CLAUDE/Settings.json` is
|
|
297
|
-
// the same file, and the kernel was measured refusing it.
|
|
298
|
-
|
|
299
|
-
|
|
331
|
+
// the same file, and the kernel was measured refusing it. Compiled once: this
|
|
332
|
+
// used to build one RegExp per control file on every call to protectedBy.
|
|
333
|
+
const CLAUDE_DIR = segment(".claude", "i");
|
|
334
|
+
const CONTROL_RES = CONTROL_FILES.map((c) => [c, segment(c, "i")]);
|
|
300
335
|
|
|
301
|
-
/**
|
|
302
|
-
|
|
336
|
+
/**
|
|
337
|
+
* Two more families of control files, which a role may be let to edit.
|
|
338
|
+
*
|
|
339
|
+
* The ones above make a program outside the sandbox *execute* something, and no
|
|
340
|
+
* role writes them. These two are softer, and that is why a policy can hand
|
|
341
|
+
* them to a role (`control_files = ["ide"]`, `["instructions"]`) where the
|
|
342
|
+
* others cannot be handed to anybody:
|
|
343
|
+
*
|
|
344
|
+
* - **ide** — `.vscode/`, `.cursor/`, `.windsurf/`. The editor applies them as
|
|
345
|
+
* soon as they change: tasks, terminal profiles, settings that turn on tool
|
|
346
|
+
* auto-approval or add an MCP server. GitHub Security Lab showed an injected
|
|
347
|
+
* agent doing exactly that to `.vscode/settings.json`; the editor reloads
|
|
348
|
+
* before a person can choose Undo. Protected by default — but a front-end
|
|
349
|
+
* role that maintains its project's launch configs has a real reason to edit
|
|
350
|
+
* them, and a boundary with no way to say so gets switched off whole.
|
|
351
|
+
* - **instructions** — `CLAUDE.md`, `AGENTS.md` and their kin. Nothing runs
|
|
352
|
+
* them; the next agent session *reads* them, as instructions. One role
|
|
353
|
+
* writing them is how it reaches another role's context. Off by default
|
|
354
|
+
* (`[protect] instructions = true` turns it on), because keeping them current
|
|
355
|
+
* is ordinary work for most teams, and git then fights the protection: a
|
|
356
|
+
* role's `git checkout` cannot update a file it may not write, git exits 0,
|
|
357
|
+
* and the old version stays behind as a modification.
|
|
358
|
+
*/
|
|
359
|
+
export const FAMILIES = {
|
|
360
|
+
ide: [".vscode", ".cursor", ".windsurf"],
|
|
361
|
+
instructions: ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules",
|
|
362
|
+
".github/copilot-instructions.md", ".github/instructions"],
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const EDITOR = { ".vscode": "VS Code", ".cursor": "Cursor", ".windsurf": "Windsurf" };
|
|
366
|
+
// The families, compiled once per case rule (see protectedBy).
|
|
367
|
+
const FAMILY_RES = {
|
|
368
|
+
fold: Object.fromEntries(Object.entries(FAMILIES).map(([f, names]) => [f, names.map((n) => [n, segment(n, "i")])])),
|
|
369
|
+
exact: Object.fromEntries(Object.entries(FAMILIES).map(([f, names]) => [f, names.map((n) => [n, segment(n)])])),
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const familyWhy = (family, name) => family === "ide"
|
|
373
|
+
? `${name}, whose settings and tasks ${EDITOR[name]} applies outside the sandbox`
|
|
374
|
+
: `${name}, which the next agent session reads as its instructions`;
|
|
375
|
+
|
|
376
|
+
/** The families this role's profile protects: the defaults, minus what the policy hands it. */
|
|
377
|
+
export function familiesFor(config, role) {
|
|
378
|
+
const mine = new Set(role?.controlFiles ?? []);
|
|
379
|
+
const out = [];
|
|
380
|
+
if (!mine.has("ide")) out.push("ide");
|
|
381
|
+
if (config.protect?.instructions && !mine.has("instructions")) out.push("instructions");
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* A pattern that matches exactly `name` under `root`, and only there.
|
|
387
|
+
*
|
|
388
|
+
* `**\/.vscode` would also match inside `node_modules`, and npm would then
|
|
389
|
+
* drop those files from the packages it unpacks, with `EPERM` in a warning and
|
|
390
|
+
* exit 0. Anchored at the project root it cannot. The last character goes in a
|
|
391
|
+
* one-letter class so the runtime treats the entry as a pattern — which costs
|
|
392
|
+
* nothing at start-up — and not as a literal, which costs a rule for each of
|
|
393
|
+
* its ancestors and would be paid for every project whether the file is there
|
|
394
|
+
* or not.
|
|
395
|
+
*/
|
|
396
|
+
const exactly = (root, name) => `${root}/${name.slice(0, -1)}[${name.slice(-1)}]`;
|
|
303
397
|
|
|
304
398
|
/** Where no control file is looked for: dependencies and build output. */
|
|
305
399
|
const PRUNE = new Set(["node_modules", ".git", "vendor", "dist", "build", ".venv", "venv",
|
|
306
400
|
"__pycache__", ".next", "target", ".turbo", ".cache", "Pods", ".gradle", ".dart_tool"]);
|
|
307
401
|
|
|
308
402
|
/** A directory holding one of these is where somebody opens a project. */
|
|
309
|
-
const MARKERS = new Set([".git", ".claude", ".mcp.json", ".envrc", ".codex"
|
|
403
|
+
const MARKERS = new Set([".git", ".claude", ".mcp.json", ".envrc", ".codex",
|
|
404
|
+
...FAMILIES.ide, ...FAMILIES.instructions.filter((f) => !f.includes("/"))]);
|
|
310
405
|
|
|
311
406
|
/**
|
|
312
407
|
* How deep under a territory to look for projects.
|
|
313
408
|
*
|
|
314
|
-
* Measured on
|
|
409
|
+
* Measured on a multi-repo workspace over every territory at once: depth 3 finds 128
|
|
315
410
|
* project roots reading 2.6k directories (~0.4 s under load); depth 4 finds
|
|
316
411
|
* 130 reading 5.5k (0.9 s); depth 6, 132 and 2.1 s. The four below depth 3
|
|
317
412
|
* are `.claude` folders agents left inside source trees (`features/…`,
|
|
318
413
|
* `tests/Feature/Api`), not projects anybody opens — and on macOS the pattern
|
|
319
|
-
* layer still
|
|
414
|
+
* layer still denies writing into them. The whole workspace at depth 8 is
|
|
320
415
|
* 23k directories, which is why the walk is per role and never over the repo.
|
|
321
416
|
*/
|
|
322
417
|
const SCAN_DEPTH = 3;
|
|
@@ -344,14 +439,14 @@ function projectRoots(dir, depth = SCAN_DEPTH) {
|
|
|
344
439
|
* (never_writes has the measurement). A `.git` that is a file is a worktree
|
|
345
440
|
* pointing at its repository, and the pointer is what gets protected.
|
|
346
441
|
*/
|
|
347
|
-
function controlsOf(dir, platform) {
|
|
442
|
+
function controlsOf(dir, platform, families = []) {
|
|
348
443
|
const out = [];
|
|
349
444
|
let git = null;
|
|
350
445
|
try { git = statSync(join(dir, ".git")); } catch {}
|
|
351
446
|
if (git?.isFile()) out.push({ path: join(dir, ".git"), why: "a worktree's pointer to its repository, read by git" });
|
|
352
447
|
if (git?.isDirectory())
|
|
353
448
|
for (const f of [".git/hooks", ".git/config"])
|
|
354
|
-
out.push({ path: join(dir, f), why: `${f}, run by git outside the
|
|
449
|
+
out.push({ path: join(dir, f), why: `${f}, run by git outside the sandbox`, ifPresent: true });
|
|
355
450
|
/**
|
|
356
451
|
* `.claude` whole, as one entry, rather than its seven control files.
|
|
357
452
|
*
|
|
@@ -364,13 +459,21 @@ function controlsOf(dir, platform) {
|
|
|
364
459
|
* directory Claude Code reads its instructions to itself from.
|
|
365
460
|
*/
|
|
366
461
|
if (platform !== "linux" || existsSync(join(dir, ".claude")))
|
|
367
|
-
out.push({ path: join(dir, ".claude"), why: ".claude, whose settings, hooks and skills Claude Code runs outside the
|
|
462
|
+
out.push({ path: join(dir, ".claude"), why: ".claude, whose settings, hooks and skills Claude Code runs outside the sandbox" });
|
|
368
463
|
// The single files only where they exist: where they do not, the patterns
|
|
369
464
|
// refuse creating them, and a literal each would be start-up for nothing.
|
|
370
465
|
for (const f of [".mcp.json", ".envrc", ".codex/config.toml"]) {
|
|
371
466
|
const p = join(dir, f);
|
|
372
|
-
if (existsSync(p)) out.push({ path: p, why: `${f}, run by ${RUNNER(f)} outside the
|
|
467
|
+
if (existsSync(p)) out.push({ path: p, why: `${f}, run by ${RUNNER(f)} outside the sandbox`, ifPresent: true });
|
|
373
468
|
}
|
|
469
|
+
// The families literally only where they exist, on every platform. Where
|
|
470
|
+
// they do not, macOS gets a pattern for creating them (computeDenies) and
|
|
471
|
+
// Linux gets nothing: bubblewrap would create the path to mount over it.
|
|
472
|
+
for (const family of families)
|
|
473
|
+
for (const f of FAMILIES[family]) {
|
|
474
|
+
const p = join(dir, f);
|
|
475
|
+
if (existsSync(p)) out.push({ path: p, why: familyWhy(family, f), family });
|
|
476
|
+
}
|
|
374
477
|
return out;
|
|
375
478
|
}
|
|
376
479
|
|
|
@@ -378,7 +481,7 @@ function controlsOf(dir, platform) {
|
|
|
378
481
|
* The home's control files, one by one — never `~/.claude` whole.
|
|
379
482
|
*
|
|
380
483
|
* That directory is also where Claude Code keeps its sessions, todos and
|
|
381
|
-
* history, and it writes them from inside the
|
|
484
|
+
* history, and it writes them from inside the sandbox on every turn. Denying the
|
|
382
485
|
* directory would stop the agent; denying what it executes does not (checked
|
|
383
486
|
* with `claude -p` under the new profile: it answered, and what it was refused
|
|
384
487
|
* were lock and cache files under plugins/, which it tolerates).
|
|
@@ -389,7 +492,7 @@ function homeControls(platform) {
|
|
|
389
492
|
".claude/settings.json", ".claude/settings.local.json", ".claude/hooks", ".claude/plugins",
|
|
390
493
|
".claude/skills", ".claude/commands", ".claude/agents", ".codex/config.toml",
|
|
391
494
|
]
|
|
392
|
-
.map((f) => ({ path: join(home, f), why: `${f}, run by ${RUNNER(f)} outside the
|
|
495
|
+
.map((f) => ({ path: join(home, f), why: `${f}, run by ${RUNNER(f)} outside the sandbox`, ifPresent: true }))
|
|
393
496
|
.filter((e) => platform !== "linux" || existsSync(e.path));
|
|
394
497
|
}
|
|
395
498
|
|
|
@@ -407,7 +510,7 @@ const ROOTS = new WeakMap(); // config -> dir -> project roots under
|
|
|
407
510
|
|
|
408
511
|
export function denyFor(config, role, { env = process.env, platform = process.platform, observe = false } = {}) {
|
|
409
512
|
// Memoised per config: `check` builds every role's settings and then asks
|
|
410
|
-
// for the same list again to show it; on
|
|
513
|
+
// for the same list again to show it; on a multi-repo workspace that was 2.6 s twice.
|
|
411
514
|
const memo = DENIES.get(config) ?? DENIES.set(config, new Map()).get(config);
|
|
412
515
|
const key = `${role.name}\0${observe}\0${platform}\0${env.PATH ?? ""}`;
|
|
413
516
|
if (memo.has(key)) return memo.get(key);
|
|
@@ -432,7 +535,9 @@ function computeDenies(config, role, { env, platform, observe }) {
|
|
|
432
535
|
|
|
433
536
|
const dirs = mine.filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } });
|
|
434
537
|
const repoDirs = dirs.filter((p) => under(p, config.root));
|
|
435
|
-
const
|
|
538
|
+
const families = familiesFor(config, role);
|
|
539
|
+
const roots = [...new Set(repoDirs.flatMap((d) => rootsUnder(config, d)))];
|
|
540
|
+
const controls = roots.flatMap((d) => controlsOf(d, platform, families));
|
|
436
541
|
// The home is a project too, for Claude Code and Codex: ~/.claude/settings.json
|
|
437
542
|
// is read by every session. Not walked — only its own control files.
|
|
438
543
|
if (["~/.claude", "~/.codex"].some((h) => inMine(join(homedir(), h.slice(2)))))
|
|
@@ -456,7 +561,7 @@ function computeDenies(config, role, { env, platform, observe }) {
|
|
|
456
561
|
// own config and hooks, and the files that redirect a worktree to
|
|
457
562
|
// another gitdir or config. Ordinary commits never write these, so
|
|
458
563
|
// denying them costs a working role nothing; a role that could write
|
|
459
|
-
// one would leave a command for the next `git` outside the
|
|
564
|
+
// one would leave a command for the next `git` outside the sandbox. The
|
|
460
565
|
// subtree of `.git` stays writable on purpose, so the agent can commit.
|
|
461
566
|
`${d}/**/.git/config.worktree`,
|
|
462
567
|
`${d}/**/.git/modules/**/config`, `${d}/**/.git/modules/**/hooks/**`,
|
|
@@ -464,13 +569,20 @@ function computeDenies(config, role, { env, platform, observe }) {
|
|
|
464
569
|
`${d}/**/.mcp.json`, `${d}/**/.envrc`, `${d}/**/.codex/config.toml`,
|
|
465
570
|
]).map((path) => ({ path, why: "any project's control files in this territory, by pattern" }))
|
|
466
571
|
: [];
|
|
572
|
+
// Creating one of the families where it is not there yet: one exact pattern
|
|
573
|
+
// per project root, never `**` (see `exactly`).
|
|
574
|
+
if (platform === "darwin")
|
|
575
|
+
for (const root of roots)
|
|
576
|
+
for (const family of families)
|
|
577
|
+
for (const f of FAMILIES[family])
|
|
578
|
+
if (!existsSync(join(root, f)))
|
|
579
|
+
globs.push({ path: exactly(root, f), why: `${familyWhy(family, f)}, if it is created`, family });
|
|
467
580
|
|
|
468
581
|
// The policy, `.seisin/` and the key directories are denied whatever the
|
|
469
582
|
// territory, as they always were. What the providers and `file://` keys
|
|
470
583
|
// point at is denied where this role can write it: `/usr/bin/security` in
|
|
471
584
|
// every profile would be a rule about the machine, and no role reaches it.
|
|
472
|
-
const
|
|
473
|
-
const inputs = parentInputs(config, { env }).filter((e) => always.has(e.why) || inMine(e.path));
|
|
585
|
+
const inputs = parentInputs(config, { env }).filter((e) => e.always || inMine(e.path));
|
|
474
586
|
|
|
475
587
|
return dedupe([...inputs, ...surface, ...controls, ...globs]);
|
|
476
588
|
}
|
|
@@ -482,27 +594,60 @@ function computeDenies(config, role, { env, platform, observe }) {
|
|
|
482
594
|
* to be filed as "belongs to dev" for a role with `writes = ["**"]`, and
|
|
483
595
|
* granting it would have granted nothing.
|
|
484
596
|
*/
|
|
485
|
-
export function protectedBy(config, target, { platform = process.platform } = {}) {
|
|
597
|
+
export function protectedBy(config, target, { platform = process.platform, role = null } = {}) {
|
|
486
598
|
/**
|
|
487
599
|
* A project's `.claude/`, any file in it, and the other control files — the
|
|
488
600
|
* same rule the kernel is given. On Linux the kernel only holds the ones
|
|
489
601
|
* that exist, so only those are called protected there: a sentence stricter
|
|
490
602
|
* than the boundary is the direction this project refuses.
|
|
491
603
|
*/
|
|
492
|
-
const f =
|
|
604
|
+
const f = CLAUDE_DIR.test(target)
|
|
493
605
|
? ".claude"
|
|
494
|
-
:
|
|
495
|
-
|
|
606
|
+
: CONTROL_RES.find(([, re]) => re.test(target))?.[0];
|
|
607
|
+
const re = f === ".claude" ? CLAUDE_DIR : CONTROL_RES.find(([name]) => name === f)?.[1];
|
|
608
|
+
if (f && (platform !== "linux" || heldOnLinux(config, target, re))) {
|
|
496
609
|
const why = f === ".claude"
|
|
497
|
-
? ".claude, whose settings, hooks and skills Claude Code runs outside the
|
|
498
|
-
: `${f}, run by ${RUNNER(f)} outside the
|
|
610
|
+
? ".claude, whose settings, hooks and skills Claude Code runs outside the sandbox"
|
|
611
|
+
: `${f}, run by ${RUNNER(f)} outside the sandbox`;
|
|
499
612
|
return { path: target, why };
|
|
500
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* The two families, as the kernel is given them. Case-insensitive on macOS
|
|
616
|
+
* only: APFS folds case and the kernel refuses `.VSCODE/`, ext4 does not, and
|
|
617
|
+
* saying "protected" about a spelling the kernel lets through is the one
|
|
618
|
+
* direction this project refuses. With a role, what the policy hands that
|
|
619
|
+
* role is not protected for it; without one, the answer carries the family
|
|
620
|
+
* so the caller can ask whose it is.
|
|
621
|
+
*/
|
|
622
|
+
const active = role ? familiesFor(config, config.roles?.[role]) : familiesFor(config, null);
|
|
623
|
+
const compiled = platform === "darwin" ? FAMILY_RES.fold : FAMILY_RES.exact;
|
|
624
|
+
for (const family of active)
|
|
625
|
+
for (const [name, re] of compiled[family]) {
|
|
626
|
+
if (!re.test(target)) continue;
|
|
627
|
+
if (platform === "linux" && !heldOnLinux(config, target, re)) continue;
|
|
628
|
+
return { path: target, why: familyWhy(family, name), family };
|
|
629
|
+
}
|
|
501
630
|
const full = realOrSelf(isAbsolute(target) ? target : join(config.root, target));
|
|
502
631
|
return parentInputs(config)
|
|
503
632
|
.find((e) => under(full, realOrSelf(e.path)) || under(join(config.root, target), e.path)) ?? null;
|
|
504
633
|
}
|
|
505
634
|
|
|
635
|
+
/**
|
|
636
|
+
* Whether the Linux kernel holds `target`: the path itself, or a directory
|
|
637
|
+
* above it that the same rule names, exists — bubblewrap denies what exists,
|
|
638
|
+
* and a denied directory takes everything created inside it. Checking only the
|
|
639
|
+
* target called `web/.vscode/settings.json` unprotected while `web/.vscode` was
|
|
640
|
+
* denied whole, so a grant for it went through and changed nothing.
|
|
641
|
+
*/
|
|
642
|
+
function heldOnLinux(config, target, re) {
|
|
643
|
+
const full = isAbsolute(target) ? target : join(config.root, target);
|
|
644
|
+
for (let at = full; ; at = dirname(at)) {
|
|
645
|
+
const rel = at.startsWith(config.root + sep) ? at.slice(config.root.length + 1) : at;
|
|
646
|
+
if (re.test(rel) && existsSync(at)) return true;
|
|
647
|
+
if (at === config.root || dirname(at) === at || !re.test(rel)) return false;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
506
651
|
/**
|
|
507
652
|
* What `seisin check` shows: every protection that takes something away from a
|
|
508
653
|
* role — a path inside its territory that it will not be able to write — with
|
|
@@ -514,19 +659,21 @@ export function protectedBy(config, target, { platform = process.platform } = {}
|
|
|
514
659
|
* surface.js.
|
|
515
660
|
*/
|
|
516
661
|
export function protections(config, roles = Object.values(config.roles), opts = {}) {
|
|
517
|
-
const always = new Set(["the policy", "seisin's log and request queue", "a key directory"]);
|
|
518
662
|
const byPath = new Map();
|
|
519
663
|
for (const role of roles) {
|
|
520
664
|
let entries;
|
|
521
665
|
try { entries = denyFor(config, role, opts); } catch { continue; }
|
|
522
666
|
for (const e of entries) {
|
|
523
|
-
if (
|
|
667
|
+
if (e.always || /[*?[\]]/.test(e.path)) continue;
|
|
524
668
|
// Only what the role could otherwise write: a literal control-file path
|
|
525
669
|
// is listed for every project in the territory whether it exists or not,
|
|
526
670
|
// and the ones that do not exist are shown only if their project does.
|
|
527
|
-
const hit = byPath.get(e.path) ??
|
|
671
|
+
const hit = byPath.get(e.path) ??
|
|
672
|
+
byPath.set(e.path, { path: e.path, why: e.why, roles: [], ifPresent: e.ifPresent === true }).get(e.path);
|
|
528
673
|
hit.roles.push(role.name);
|
|
529
674
|
}
|
|
530
675
|
}
|
|
531
|
-
return [...byPath.values()]
|
|
676
|
+
return [...byPath.values()]
|
|
677
|
+
.filter((e) => !e.ifPresent || existsSync(e.path))
|
|
678
|
+
.map(({ ifPresent, ...e }) => e);
|
|
532
679
|
}
|
package/src/uilink.js
CHANGED
|
@@ -26,9 +26,15 @@ function linkFile(port, base) {
|
|
|
26
26
|
return join(runsRoot(base), `ui-${port}.url`);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Records this run's live URL for the port it bound, and the policy it serves.
|
|
31
|
+
*
|
|
32
|
+
* The record is per port, not per repo, so `ui --link` in one repo found the
|
|
33
|
+
* console another repo had started — and opened a page about the wrong policy
|
|
34
|
+
* with nothing on it to say so. The policy's path travels with the link now.
|
|
35
|
+
*/
|
|
36
|
+
export function writeUiLink(port, url, base, policy = null) {
|
|
37
|
+
writeFileSync(linkFile(port, base), JSON.stringify({ url, pid: process.pid, policy }) + "\n", { mode: 0o600 });
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/**
|
|
@@ -46,7 +52,7 @@ export function readUiLink(port, base) {
|
|
|
46
52
|
// Signal 0 tests for the process without touching it: it throws if gone.
|
|
47
53
|
try { process.kill(e.pid, 0); alive = true; } catch { alive = false; }
|
|
48
54
|
}
|
|
49
|
-
return { url: e.url, pid: e.pid ?? null, alive };
|
|
55
|
+
return { url: e.url, pid: e.pid ?? null, alive, policy: typeof e.policy === "string" ? e.policy : null };
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
/** Removes the record. Called on exit, and when a stale one is found. */
|