seisin 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- package/ui/favicon.svg +0 -1
package/src/territory.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `[runtime] read = "territory"`: reading denied where people keep their data,
|
|
3
|
+
* re-opened only along the paths a role needs.
|
|
4
|
+
*
|
|
5
|
+
* The default read model is a list of what not to read — key directories, the
|
|
6
|
+
* places credentials live, other agents' sign-in. It holds for agents you run
|
|
7
|
+
* yourself. Against one you would not trust it loses by one every time a new
|
|
8
|
+
* product leaves a folder of its own: measured on 2026-10-06 by an orchestrator that evaluates agents,
|
|
9
|
+
* a role confined that way could still list `~/.claude`, list the whole
|
|
10
|
+
* project tree of the person running it, and `cat` an evaluation check kept
|
|
11
|
+
* outside its worktree. None of the three was a credential, so none was on
|
|
12
|
+
* the list.
|
|
13
|
+
*
|
|
14
|
+
* So this mode turns the list around, but not all the way. Denying `/` and
|
|
15
|
+
* allowing back is the read allowlist decisions.md rejected, for a reason that
|
|
16
|
+
* still holds: every interpreter, library and cache a toolchain touches would
|
|
17
|
+
* have to be named. The data roots are what gets denied — homes, temp
|
|
18
|
+
* directories, mounted volumes — and the system stays readable, because there
|
|
19
|
+
* is nobody's data in `/usr`. What a role needs from inside a data root is
|
|
20
|
+
* finite and known before it starts: its repo, its territory, its run, its
|
|
21
|
+
* toolchain. That list is the short one, and it is the one written.
|
|
22
|
+
*
|
|
23
|
+
* **Denied whole, allowed back.** Each data root is one deny, and what the
|
|
24
|
+
* role keeps is an `allowRead` inside it. A deny covers what is created after
|
|
25
|
+
* it, so another run's directory made beside the repo an hour into this one
|
|
26
|
+
* is shut the moment it exists. That is the point of it.
|
|
27
|
+
*
|
|
28
|
+
* It was carved before — every sibling on the way to a kept path denied by
|
|
29
|
+
* name, which is a photograph: an entry created during the run beside a kept
|
|
30
|
+
* path was not covered, and read (measured, 2026-10-12). Two measured runtime
|
|
31
|
+
* behaviours kept the allow out then, and each is now handled where it bites:
|
|
32
|
+
*
|
|
33
|
+
* - macOS: an allow is emitted after the denies, and a deny that sits inside
|
|
34
|
+
* an allow is re-emitted after it whole (last match wins). For the key
|
|
35
|
+
* directory inside the repo that re-emitted deny also shuts the key files
|
|
36
|
+
* `[keys]` re-allows inside it. A deny spelt as a pattern is re-emitted
|
|
37
|
+
* MINUS the allows inside it, so those denies are spelt as one
|
|
38
|
+
* (outlastAllows, below).
|
|
39
|
+
* - Linux: the runtime restores the write mounts a deny wiped and then the
|
|
40
|
+
* read allows, read-only, on top of them — so a repo allowed back covers
|
|
41
|
+
* the territory inside it and every write fails with "Read-only file
|
|
42
|
+
* system" (srt 0.0.75/0.0.78). A kept path that holds a write path is
|
|
43
|
+
* allowed back piece by piece around it (territoryReads, below).
|
|
44
|
+
*
|
|
45
|
+
* **What that costs, said where it is decided.** On Linux, inside a kept
|
|
46
|
+
* directory that is on the way to a write path, an entry created during the
|
|
47
|
+
* run is not there for the role until the next run: it fails closed, it does
|
|
48
|
+
* not leak. On both, the names of what the role keeps are visible where the
|
|
49
|
+
* runtime has to show them (a Linux `ls ~` lists the mount points), and
|
|
50
|
+
* nothing else in a data root is.
|
|
51
|
+
*/
|
|
52
|
+
import { readdirSync, lstatSync } from "node:fs";
|
|
53
|
+
import { homedir, tmpdir } from "node:os";
|
|
54
|
+
import { realOrSelf, writePathsOf, expand } from "./grants.js";
|
|
55
|
+
import { realAncestor } from "./paths.js";
|
|
56
|
+
|
|
57
|
+
export const READ_MODES = ["all", "territory"];
|
|
58
|
+
|
|
59
|
+
/** Files an agent reads at start that live outside its home directory. */
|
|
60
|
+
export const AGENT_FILES = { claude: ["~/.claude.json"], codex: [] };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Where data lives, per platform. Whole roots: what is under them belongs to
|
|
64
|
+
* somebody. `/opt/homebrew/var` and `/usr/local/var` hold the databases a
|
|
65
|
+
* package manager runs (Postgres, Redis); the rest of `/opt/homebrew` is the
|
|
66
|
+
* toolchain and stays readable.
|
|
67
|
+
*/
|
|
68
|
+
export const DATA_ROOTS = {
|
|
69
|
+
darwin: [
|
|
70
|
+
"/Users", "/private/tmp", "/private/var/folders", "/private/var/root", "/Volumes",
|
|
71
|
+
"/opt/homebrew/var", "/usr/local/var", "/Library/Keychains",
|
|
72
|
+
],
|
|
73
|
+
linux: [
|
|
74
|
+
"/home", "/root", "/tmp", "/var/tmp", "/mnt", "/media", "/srv", "/run/user",
|
|
75
|
+
"/run/secrets", "/var/lib/docker",
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The data roots of this machine: the platform's, plus HOME and TMPDIR wherever they are. */
|
|
80
|
+
export function dataRoots(platform = process.platform, home = homedir(), tmp = tmpdir(), real = realOrSelf) {
|
|
81
|
+
const base = DATA_ROOTS[platform] ?? DATA_ROOTS.linux;
|
|
82
|
+
return [...new Set([...base, home, tmp].map(real))];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The most entries one directory may have where a kept path is allowed back
|
|
87
|
+
* piece by piece (Linux, around a write path).
|
|
88
|
+
*
|
|
89
|
+
* Every piece is one allow, and one mount. The carving this replaced hit the
|
|
90
|
+
* same wall from the other side: a repo kept inside a Mac's TMPDIR, 9,105
|
|
91
|
+
* entries, made 9,163 denies, 815 KB of profile, and `spawn E2BIG` — no
|
|
92
|
+
* agent, and no reason anyone could read. Past this the answer is a refusal
|
|
93
|
+
* that names the directory, not a sandbox that cannot start.
|
|
94
|
+
*/
|
|
95
|
+
export const MAX_CARVE = 1000;
|
|
96
|
+
|
|
97
|
+
/** What the runtime treats as glob syntax in a path (sandbox-utils containsGlobChars). */
|
|
98
|
+
const GLOB = /[[\]*?]/;
|
|
99
|
+
|
|
100
|
+
const under = (p, dir) => p === dir || p.startsWith(dir === "/" ? "/" : dir + "/");
|
|
101
|
+
const inside = (p, dir) => p !== dir && under(p, dir);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The read rules that leave exactly `keep` readable inside `roots`:
|
|
105
|
+
* `{ deny, allow }`, for denyRead and allowRead.
|
|
106
|
+
*
|
|
107
|
+
* Each root is denied whole, unless something kept covers it. Each kept path
|
|
108
|
+
* under a root is allowed back, in both spellings. On Linux a kept path that
|
|
109
|
+
* holds one of `writes` is not allowed back itself — its read mount would
|
|
110
|
+
* land on the write mount inside it — but walked toward the writes, and every
|
|
111
|
+
* child not on the way is allowed instead; the writes come back writable
|
|
112
|
+
* through the runtime's own restore. Nothing is denied by name, so nothing
|
|
113
|
+
* created later escapes the deny.
|
|
114
|
+
*
|
|
115
|
+
* Nothing at or under `denied` — the key directories, other roles' private
|
|
116
|
+
* folders, the credential homes — is allowed back: an allow there would win
|
|
117
|
+
* over the deny on macOS, and on Linux the deny's own mount restores it.
|
|
118
|
+
*
|
|
119
|
+
* `fs` is injectable for tests; it needs `readdir(dir) -> names`,
|
|
120
|
+
* `kind(path) -> "dir" | "link" | "other" | null` and `real(path) -> path`.
|
|
121
|
+
*/
|
|
122
|
+
export function territoryReads(roots, keep, { writes = [], denied = [], platform = process.platform, fs = realFs } = {}) {
|
|
123
|
+
// realAncestor, not realOrSelf: a territory not created yet still has to
|
|
124
|
+
// be spelt the way the kernel will meet it (`/tmp/x` is `/private/tmp/x`).
|
|
125
|
+
// Both spellings of each: where it resolves, and where its NAME lives (the
|
|
126
|
+
// directories above resolved, the last component as written). A kept
|
|
127
|
+
// symlink — `~/.local/bin/claude` — has to be reachable by name for a PATH
|
|
128
|
+
// lookup to find it, and by target for the read to land.
|
|
129
|
+
const nameOf = (k) => { const d = k.slice(0, k.lastIndexOf("/")) || "/"; const r = fs.real(d); return `${r === "/" ? "" : r}/${k.slice(k.lastIndexOf("/") + 1)}`; };
|
|
130
|
+
const no = [...new Set(denied.map(fs.real))];
|
|
131
|
+
const open = (p) => !no.some((d) => under(p, d));
|
|
132
|
+
const kept = [...new Set(keep.flatMap((k) => [fs.real(k), nameOf(k)]))].filter(open);
|
|
133
|
+
// Nested roots (`/private/var/folders/…/T` inside `/private/var/folders`)
|
|
134
|
+
// are covered by the outer deny; one kept whole needs no deny at all.
|
|
135
|
+
const real = [...new Set(roots.map(fs.real))].filter((r) => fs.kind(r) === "dir");
|
|
136
|
+
const deny = real.filter((r) => !real.some((o) => inside(r, o)) && !kept.some((k) => under(r, k)));
|
|
137
|
+
const shut = kept.filter((k) => deny.some((r) => inside(k, r)));
|
|
138
|
+
if (platform !== "linux") return { deny, allow: shut };
|
|
139
|
+
|
|
140
|
+
const w = [...new Set(writes.map(fs.real))];
|
|
141
|
+
const allow = [];
|
|
142
|
+
const walk = (dir) => {
|
|
143
|
+
let names;
|
|
144
|
+
try { names = fs.readdir(dir); } catch { return; } // unreadable: nothing comes back
|
|
145
|
+
if (names.length > MAX_CARVE)
|
|
146
|
+
throw new Error(
|
|
147
|
+
`read = "territory" cannot keep ${dir} readable around what the role writes inside it: it holds ` +
|
|
148
|
+
`${names.length} entries, and on Linux each is one mount (the most it takes is ${MAX_CARVE} per directory).\n` +
|
|
149
|
+
` Written inside it: ${w.filter((x) => inside(x, dir)).join(", ")}.\n` +
|
|
150
|
+
` Move the repo to a directory with fewer neighbours, or narrow the reads entry that keeps this path.`);
|
|
151
|
+
for (const name of names.sort()) {
|
|
152
|
+
const child = dir === "/" ? `/${name}` : `${dir}/${name}`;
|
|
153
|
+
if (w.some((x) => under(child, x)) || !open(child)) continue; // a write, or denied
|
|
154
|
+
if (fs.kind(child) === "dir" && w.some((x) => inside(x, child))) walk(child);
|
|
155
|
+
else allow.push(child);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
for (const k of shut) {
|
|
159
|
+
if (w.some((x) => under(k, x))) continue; // comes back as a write
|
|
160
|
+
if (w.some((x) => inside(x, k)) && fs.kind(k) === "dir") walk(k);
|
|
161
|
+
else allow.push(k);
|
|
162
|
+
}
|
|
163
|
+
return { deny, allow };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The denies, respelt so that each keeps its own allows open on macOS.
|
|
168
|
+
*
|
|
169
|
+
* A deny strictly inside an allow is re-emitted after the allows, because the
|
|
170
|
+
* last rule that matches wins and the allow would otherwise re-open it. A
|
|
171
|
+
* deny written as a path comes back whole, and that shuts every allow inside
|
|
172
|
+
* IT — the key files inside the key directory inside the repo. A deny written
|
|
173
|
+
* as a pattern comes back minus each allow its pattern covers (srt 0.0.78,
|
|
174
|
+
* macos-sandbox-utils lateReadDenyFilters). So those denies, and only those,
|
|
175
|
+
* are spelt as a pattern that matches exactly their own path: one character
|
|
176
|
+
* of the last name in brackets, `.secrets` as `.secret[s]`.
|
|
177
|
+
*
|
|
178
|
+
* A path that already holds glob syntax cannot be spelt that way without
|
|
179
|
+
* matching something else, and is refused.
|
|
180
|
+
*/
|
|
181
|
+
export function outlastAllows(denies, allows, real = (p) => p) {
|
|
182
|
+
const open = allows.map(real);
|
|
183
|
+
return denies.map((written) => {
|
|
184
|
+
const d = real(written);
|
|
185
|
+
if (!open.some((a) => inside(a, d)) || !open.some((a) => inside(d, a))) return written;
|
|
186
|
+
const slash = d.lastIndexOf("/");
|
|
187
|
+
let at = d.length - 1;
|
|
188
|
+
while (at > slash && !/[A-Za-z0-9_-]/.test(d[at])) at--;
|
|
189
|
+
if (GLOB.test(d) || at <= slash)
|
|
190
|
+
throw new Error(
|
|
191
|
+
`read = "territory" cannot keep ${d} shut around what is allowed inside it: its name holds ` +
|
|
192
|
+
`characters the sandbox reads as a pattern, or none it can bracket.\n Rename it.`);
|
|
193
|
+
return `${d.slice(0, at)}[${d[at]}]${d.slice(at + 1)}`;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const realFs = {
|
|
198
|
+
real: realAncestor,
|
|
199
|
+
readdir: (d) => readdirSync(d),
|
|
200
|
+
kind: (p) => {
|
|
201
|
+
try {
|
|
202
|
+
const s = lstatSync(p);
|
|
203
|
+
return s.isSymbolicLink() ? "link" : s.isDirectory() ? "dir" : "other";
|
|
204
|
+
} catch { return null; }
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What a role keeps readable: its repo, what it writes, its `reads` and its
|
|
210
|
+
* `toolchain`. One answer for the settings and for `check`, so the warning
|
|
211
|
+
* about an unreadable PATH entry and the kernel agree.
|
|
212
|
+
*/
|
|
213
|
+
export function keptFor(config, role, { observe = false, agent, program } = {}) {
|
|
214
|
+
return [
|
|
215
|
+
config.root,
|
|
216
|
+
// The program the run starts, and where its link points: without it there
|
|
217
|
+
// is no run. Measured: `claude` installs as ~/.local/bin/claude ->
|
|
218
|
+
// ~/.local/share/claude/versions/<v>, and the run died on `env: claude: No
|
|
219
|
+
// such file or directory`. The file, not its directory — the rest of
|
|
220
|
+
// ~/.local/bin stays shut.
|
|
221
|
+
...(program ? [program, realOrSelf(program)] : []),
|
|
222
|
+
// The agent's own files that are not under its home directory. Its home
|
|
223
|
+
// (~/.claude) is scratch and kept with the writes; ~/.claude.json, its
|
|
224
|
+
// settings and session index, sits beside it, and without it Claude Code
|
|
225
|
+
// starts and says "Not logged in" (measured, 2026-10-09). A run of no
|
|
226
|
+
// agent, or of another, does not get it.
|
|
227
|
+
...(agent ? (AGENT_FILES[agent] ?? []).map(expand) : []),
|
|
228
|
+
...writePathsOf(config, role, { observe, agent }),
|
|
229
|
+
...(role.reads ?? []).map((p) => readPath(config.root, p)),
|
|
230
|
+
...(role.toolchain ?? []).map((p) => readPath(config.root, p)),
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Whether `p` is shut by the mode: under a data root, and under nothing kept. */
|
|
235
|
+
export function shutByTerritory(p, keep, roots = dataRoots(), real = realAncestor) {
|
|
236
|
+
const r = real(p);
|
|
237
|
+
const kept = keep.map(real);
|
|
238
|
+
if (!roots.some((root) => under(r, root))) return false;
|
|
239
|
+
return !kept.some((k) => under(r, k));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The `bin` directory of each toolchain entry that has one, for the front of
|
|
244
|
+
* PATH. A venv, `~/.cargo`, a node install all keep their programs there;
|
|
245
|
+
* declaring the toolchain and then finding `python` resolves to another one
|
|
246
|
+
* would be a second way of not having it.
|
|
247
|
+
*/
|
|
248
|
+
export function toolchainBins(config, role, isDir) {
|
|
249
|
+
return (role.toolchain ?? []).map((p) => readPath(config.root, p))
|
|
250
|
+
.flatMap((p) => [p.endsWith("/bin") ? p : `${p}/bin`].filter(isDir));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** `~` and relative spellings of a read entry, absolute. Missing paths are kept as written. */
|
|
254
|
+
export function readPath(root, p) {
|
|
255
|
+
const trimmed = p.replace(/\/\*\*$/, "") || ".";
|
|
256
|
+
if (trimmed === "~") return homedir();
|
|
257
|
+
if (trimmed.startsWith("~/")) return `${homedir()}/${trimmed.slice(2)}`;
|
|
258
|
+
if (trimmed.startsWith("/")) return trimmed;
|
|
259
|
+
return `${root}/${trimmed}`.replace(/\/\.$/, "");
|
|
260
|
+
}
|
|
261
|
+
|
package/src/trace.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The launcher's trace, carried into the log.
|
|
3
|
+
*
|
|
4
|
+
* A launcher that runs many agents (a team, a CI job) gives each run a W3C
|
|
5
|
+
* `TRACEPARENT`. Without it in the log, nothing says which of these lines belong
|
|
6
|
+
* to which of the launcher's runs, and the log cannot be joined with anything
|
|
7
|
+
* else that observed the same run (a tracer, the launcher's own spans). With it,
|
|
8
|
+
* any OpenTelemetry tool joins them by trace id; seisin does not need to know
|
|
9
|
+
* who else is looking.
|
|
10
|
+
*
|
|
11
|
+
* Only a well-formed value is used (W3C Trace Context, version 00): a malformed
|
|
12
|
+
* one is ignored rather than copied into a log that other tools will trust.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const PART = { version: /^[0-9a-f]{2}$/, trace: /^[0-9a-f]{32}$/, span: /^[0-9a-f]{16}$/, flags: /^[0-9a-f]{2}$/ };
|
|
16
|
+
|
|
17
|
+
/** `{ trace_id, span_id }` from a `TRACEPARENT` value, or null. */
|
|
18
|
+
export function parseTraceparent(value) {
|
|
19
|
+
if (typeof value !== "string") return null;
|
|
20
|
+
const parts = value.trim().split("-");
|
|
21
|
+
if (parts.length < 4) return null;
|
|
22
|
+
const [version, trace, span, flags] = parts;
|
|
23
|
+
if (!PART.version.test(version) || version === "ff") return null;
|
|
24
|
+
if (version === "00" && parts.length !== 4) return null;
|
|
25
|
+
if (!PART.trace.test(trace) || !PART.span.test(span) || !PART.flags.test(flags)) return null;
|
|
26
|
+
if (/^0+$/.test(trace) || /^0+$/.test(span)) return null;
|
|
27
|
+
return { trace_id: trace, span_id: span };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The fields a log entry carries for the launcher's trace: `{}` when there is none. */
|
|
31
|
+
export function traceFields(env = process.env) {
|
|
32
|
+
return parseTraceparent(env.TRACEPARENT) ?? {};
|
|
33
|
+
}
|
package/src/uilink.js
CHANGED
|
@@ -18,17 +18,35 @@
|
|
|
18
18
|
* The pid is stored so a file left by a crashed run is recognisable: a link
|
|
19
19
|
* whose writer is gone is stale, not a running console.
|
|
20
20
|
*/
|
|
21
|
-
import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
21
|
+
import { chmodSync, renameSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
22
23
|
import { join } from "node:path";
|
|
23
24
|
import { runsRoot } from "./rundir.js";
|
|
25
|
+
import { alive as processAlive } from "./log.js";
|
|
24
26
|
|
|
25
27
|
function linkFile(port, base) {
|
|
26
28
|
return join(runsRoot(base), `ui-${port}.url`);
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Records this run's live URL for the port it bound, and the policy it serves.
|
|
33
|
+
*
|
|
34
|
+
* The record is per port, not per repo, so `ui --link` in one repo found the
|
|
35
|
+
* console another repo had started — and opened a page about the wrong policy
|
|
36
|
+
* with nothing on it to say so. The policy's path travels with the link now.
|
|
37
|
+
*/
|
|
38
|
+
export function writeUiLink(port, url, base, policy = null) {
|
|
39
|
+
const file = linkFile(port, base);
|
|
40
|
+
const temp = `${file}.tmp-${randomBytes(12).toString("hex")}`;
|
|
41
|
+
writeFileSync(temp, JSON.stringify({ url, pid: process.pid, policy }) + "\n", { flag: "wx", mode: 0o600 });
|
|
42
|
+
try {
|
|
43
|
+
// Replacing an existing record must neither inherit its mode nor follow
|
|
44
|
+
// its symlink. Readers see one complete record, including after a crash.
|
|
45
|
+
chmodSync(temp, 0o600);
|
|
46
|
+
renameSync(temp, file);
|
|
47
|
+
} finally {
|
|
48
|
+
try { unlinkSync(temp); } catch {}
|
|
49
|
+
}
|
|
32
50
|
}
|
|
33
51
|
|
|
34
52
|
/**
|
|
@@ -42,11 +60,8 @@ export function readUiLink(port, base) {
|
|
|
42
60
|
try { e = JSON.parse(raw); } catch { return null; }
|
|
43
61
|
if (!e || typeof e.url !== "string") return null;
|
|
44
62
|
let alive = null;
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
try { process.kill(e.pid, 0); alive = true; } catch { alive = false; }
|
|
48
|
-
}
|
|
49
|
-
return { url: e.url, pid: e.pid ?? null, alive };
|
|
63
|
+
if (Number.isInteger(e.pid) && e.pid > 0) alive = processAlive(e.pid);
|
|
64
|
+
return { url: e.url, pid: e.pid ?? null, alive, policy: typeof e.policy === "string" ? e.policy : null };
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
/** Removes the record. Called on exit, and when a stale one is found. */
|
package/src/views.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the console and the MCP server both say about the log, computed once.
|
|
3
|
+
*
|
|
4
|
+
* The two readers are different — a person opens the console, an agent calls
|
|
5
|
+
* the MCP — and they must not disagree about the same file. This held the
|
|
6
|
+
* shared arithmetic inside serve.js, so the MCP server imported node:http to
|
|
7
|
+
* group denials. Nothing here opens a socket or writes a file.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { explain, standingOf } from "./owners.js";
|
|
12
|
+
import { kindsOf, KINDS, expectedOf } from "./kinds.js";
|
|
13
|
+
import { walls } from "./walls.js";
|
|
14
|
+
import { queueOf } from "./requests.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An ISO date from what a person or an agent typed, or null for no filter.
|
|
18
|
+
*
|
|
19
|
+
* A date (`2026-10-01`, any ISO time) or a duration back from `now`: `7d`,
|
|
20
|
+
* `12h`, `2w`, `30m` (minutes — a month is not a fixed length). Anything that
|
|
21
|
+
* does not parse is null; the MCP ignores it rather than refusing (a bad filter
|
|
22
|
+
* should show everything, not an error), and the CLI says so instead.
|
|
23
|
+
*/
|
|
24
|
+
const UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000 };
|
|
25
|
+
export function parseSince(raw, now = Date.now()) {
|
|
26
|
+
const d = /^\s*(\d+)\s*([mhdw])\s*$/i.exec(String(raw ?? ""));
|
|
27
|
+
if (d) return new Date(now - Number(d[1]) * UNIT_MS[d[2].toLowerCase()]).toISOString();
|
|
28
|
+
const t = raw ? Date.parse(raw) : NaN;
|
|
29
|
+
return Number.isNaN(t) ? null : new Date(t).toISOString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `explain`, remembered while the policy file stays the same.
|
|
34
|
+
*
|
|
35
|
+
* Causes and walls ask the policy about every refused path, and a real log has
|
|
36
|
+
* two thousand of them: ~0.75 ms each, most of the console's two-second poll.
|
|
37
|
+
* The answer depends on the policy text, so that is the key — and a little on
|
|
38
|
+
* the disk (on Linux a control file is protected only once it exists), so the
|
|
39
|
+
* memory is dropped after a minute too, as standingOf does. A config that was
|
|
40
|
+
* not read from a file gets a memory for this call only.
|
|
41
|
+
*/
|
|
42
|
+
const VERDICT_TTL = 60_000;
|
|
43
|
+
let verdictMemo = { key: null, at: 0, map: new Map() };
|
|
44
|
+
|
|
45
|
+
export function verdicts(cfg, { now = Date.now() } = {}) {
|
|
46
|
+
let key = null;
|
|
47
|
+
try {
|
|
48
|
+
if (cfg.path) key = `${cfg.path}\u0000${createHash("sha256").update(readFileSync(cfg.path)).digest("hex")}`;
|
|
49
|
+
} catch {}
|
|
50
|
+
let map;
|
|
51
|
+
if (key === null) map = new Map();
|
|
52
|
+
else {
|
|
53
|
+
if (verdictMemo.key !== key || now - verdictMemo.at > VERDICT_TTL) verdictMemo = { key, at: now, map: new Map() };
|
|
54
|
+
map = verdictMemo.map;
|
|
55
|
+
}
|
|
56
|
+
return (c, role, action, target) => {
|
|
57
|
+
const k = `${role}\u0000${action}\u0000${target}`;
|
|
58
|
+
let v = map.get(k);
|
|
59
|
+
if (!v) map.set(k, (v = explain(c, role, action, target)));
|
|
60
|
+
return v;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Today's denials, grouped by what was denied rather than by who asked.
|
|
66
|
+
*
|
|
67
|
+
* Called `causes` and not `friction`: `seisin review` already has a `friction`
|
|
68
|
+
* that counts something narrower — repeated denials that are not about keys —
|
|
69
|
+
* and two screens of one product reporting different numbers under one label
|
|
70
|
+
* is the kind of thing nobody notices until they are compared.
|
|
71
|
+
*
|
|
72
|
+
* The console used to show them per role, which is the same list a hundred
|
|
73
|
+
* times over: measured on one deployment, 1,080 of 1,422 refusals were a
|
|
74
|
+
* single lock file across six repositories, and per-role that reads as six
|
|
75
|
+
* hundred identical rows instead of one sentence. Grouped, the shape of the
|
|
76
|
+
* day is visible at a glance and it is almost never a territory dispute.
|
|
77
|
+
*
|
|
78
|
+
* `stillDenied` is recomputed against the policy as it stands, not read out
|
|
79
|
+
* of the log — a cause that has been granted since is history, not friction,
|
|
80
|
+
* and leaving it on the page sends somebody to fix what is already fixed.
|
|
81
|
+
*/
|
|
82
|
+
export function causesOf(cfg, entries, { ask = explain, limit = 12 } = {}) {
|
|
83
|
+
const by = new Map();
|
|
84
|
+
for (const e of entries) {
|
|
85
|
+
if (e.verdict !== "denied" || !e.target || !e.action) continue;
|
|
86
|
+
// A probe the caller of the run asked to be denied (`--expect-denied`):
|
|
87
|
+
// in the log, and not a cause of anything.
|
|
88
|
+
if (e.expected === true) continue;
|
|
89
|
+
const k = `${e.action}\u0000${e.target}`;
|
|
90
|
+
const g = by.get(k) ?? { action: e.action, target: e.target, kind: e.kind, times: 0, roles: new Set(), ownersThen: e.owners ?? [] };
|
|
91
|
+
g.times++;
|
|
92
|
+
g.roles.add(e.role);
|
|
93
|
+
by.set(k, g);
|
|
94
|
+
}
|
|
95
|
+
const total = [...by.values()].reduce((n, g) => n + g.times, 0);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The same grouping again, one level coarser: by the NAME at the end of the
|
|
99
|
+
* path rather than the path.
|
|
100
|
+
*
|
|
101
|
+
* Grouping by path alone gets the most important reading backwards. Measured
|
|
102
|
+
* here: six of the top seven causes were `.git/index.lock` in six different
|
|
103
|
+
* repositories, no single one above 17% — so "no cause dominates" is what the
|
|
104
|
+
* arithmetic says and the opposite of what is true. Three quarters of the
|
|
105
|
+
* window was one *kind* of thing, and that is a tooling problem with a
|
|
106
|
+
* mechanical fix, not a territory question anybody needs to rule on.
|
|
107
|
+
*
|
|
108
|
+
* By last segment, and nothing cleverer. A regex that recognised lock files,
|
|
109
|
+
* caches and build outputs would be a list of guesses about other people's
|
|
110
|
+
* toolchains that quietly goes stale; a repeated filename is a fact about
|
|
111
|
+
* this log.
|
|
112
|
+
*/
|
|
113
|
+
const fams = new Map();
|
|
114
|
+
for (const g of by.values()) {
|
|
115
|
+
const name = g.target.split("/").filter(Boolean).pop() ?? g.target;
|
|
116
|
+
const f = fams.get(name) ?? { name, times: 0, paths: 0, where: [], roles: new Set() };
|
|
117
|
+
f.times += g.times;
|
|
118
|
+
f.paths++;
|
|
119
|
+
// The places, so the console can show what a name is made of instead of
|
|
120
|
+
// asserting a percentage the reader has to take on faith.
|
|
121
|
+
f.where.push({ target: g.target, times: g.times });
|
|
122
|
+
for (const r of g.roles) f.roles.add(r);
|
|
123
|
+
fams.set(name, f);
|
|
124
|
+
}
|
|
125
|
+
const families = [...fams.values()]
|
|
126
|
+
.sort((a, b) => b.times - a.times)
|
|
127
|
+
.map((f) => ({
|
|
128
|
+
name: f.name, times: f.times, paths: f.paths,
|
|
129
|
+
share: total ? f.times / total : 0,
|
|
130
|
+
roles: [...f.roles].sort(),
|
|
131
|
+
where: f.where.sort((a, b) => b.times - a.times).slice(0, 20),
|
|
132
|
+
}));
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* What a `grep` over the log cannot tell you: where each refused path stands
|
|
136
|
+
* against the policy as it is now.
|
|
137
|
+
*
|
|
138
|
+
* The headline used to be "74% of it is one name", and a field review
|
|
139
|
+
* applied this project's own test to it: *did the number tell you something
|
|
140
|
+
* you did not know?* For somebody who reads the raw log, no. So the console
|
|
141
|
+
* added how many causes are on paths no role owns — a decision rather than a
|
|
142
|
+
* number — and then computed it from the `owners` each log line carried,
|
|
143
|
+
* which is the one thing the log already had. It said the policy and read
|
|
144
|
+
* the past: every grant made since left its refusals counted as "nobody's",
|
|
145
|
+
* and protected surfaces, ports and paths outside the repository, which
|
|
146
|
+
* nobody can ever own, were counted as waiting for somebody to claim them.
|
|
147
|
+
*
|
|
148
|
+
* Now every cause is classified by `standing` (owners.js), and the four
|
|
149
|
+
* piles are reported in paths and in refusals. `unowned` keeps its name and
|
|
150
|
+
* now means what the page always said it meant. The owners the log recorded
|
|
151
|
+
* are kept on each cause as `ownersThen`: evidence of what was true, not
|
|
152
|
+
* authority over what is.
|
|
153
|
+
*/
|
|
154
|
+
const standOf = standingOf(cfg);
|
|
155
|
+
const piles = Object.fromEntries(["unowned", "owned", "protected", "outside"].map((k) => [k, { paths: 0, denials: 0 }]));
|
|
156
|
+
for (const g of by.values()) {
|
|
157
|
+
const s = standOf({ kind: g.kind, target: g.target });
|
|
158
|
+
g.standing = s;
|
|
159
|
+
piles[s.kind].paths++;
|
|
160
|
+
piles[s.kind].denials += g.times;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* And for the unowned pile, what kind of thing each path is (kinds.js).
|
|
165
|
+
*
|
|
166
|
+
* Suggesting an owner was measured first and was right about one time in
|
|
167
|
+
* twenty; the rest widened a role over git's lock files, a test's scratch
|
|
168
|
+
* directories and a credential. What the reader needs is the kind and the
|
|
169
|
+
* move that fits it, and `territory` — the part that really is a decision —
|
|
170
|
+
* is what is left. Over every unowned cause, not the twelve shown: one rule
|
|
171
|
+
* needs the whole set (the same name made unique per run in many places).
|
|
172
|
+
*/
|
|
173
|
+
const unowned = [...by.values()].filter((g) => g.standing.kind === "unowned");
|
|
174
|
+
const natures = kindsOf(unowned.map((g) => g.target), { keyDirs: cfg.keyDirs ?? [] });
|
|
175
|
+
piles.unowned.kinds = Object.fromEntries(Object.keys(KINDS).map((k) => [k, { paths: 0, denials: 0 }]));
|
|
176
|
+
for (const g of unowned) {
|
|
177
|
+
g.nature = natures.get(g.target);
|
|
178
|
+
piles.unowned.kinds[g.nature.kind].paths++;
|
|
179
|
+
piles.unowned.kinds[g.nature.kind].denials += g.times;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* What each refusal was about — a file, a key, a network destination or an
|
|
184
|
+
* MCP tool — counted over every cause, not the ones listed. The console
|
|
185
|
+
* shows keys, network and tools apart from files: "no role owns this" is a
|
|
186
|
+
* sentence about paths, and said of a port or a tool it is simply wrong.
|
|
187
|
+
*/
|
|
188
|
+
/**
|
|
189
|
+
* Refusals that are expected (kinds.js expectedOf) — Claude Code's plugin
|
|
190
|
+
* updater — counted apart so the page can say them as the boundary working
|
|
191
|
+
* rather than leave them at the top of the list as something to grant.
|
|
192
|
+
*/
|
|
193
|
+
const expected = { paths: 0, denials: 0 };
|
|
194
|
+
for (const g of by.values()) {
|
|
195
|
+
g.expected = expectedOf(g.target);
|
|
196
|
+
if (!g.expected) continue;
|
|
197
|
+
expected.paths++;
|
|
198
|
+
expected.denials += g.times;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const about = Object.fromEntries(ABOUT.map((k) => [k, { paths: 0, denials: 0 }]));
|
|
202
|
+
for (const g of by.values()) {
|
|
203
|
+
const a = aboutOf(g.kind);
|
|
204
|
+
about[a].paths++;
|
|
205
|
+
about[a].denials += g.times;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
total,
|
|
210
|
+
unowned: piles.unowned.paths,
|
|
211
|
+
about,
|
|
212
|
+
standing: piles,
|
|
213
|
+
expected,
|
|
214
|
+
// The real number of distinct causes, not the length of the list below.
|
|
215
|
+
// The page says "over N distinct paths" and the list is capped at twelve,
|
|
216
|
+
// so taking N from the list reported the cap as if it were the count —
|
|
217
|
+
// a wrong number stated confidently, which is worse than no number.
|
|
218
|
+
distinct: by.size,
|
|
219
|
+
families: families.slice(0, 8),
|
|
220
|
+
causes: [...by.values()]
|
|
221
|
+
.sort((a, b) => b.times - a.times)
|
|
222
|
+
.slice(0, limit)
|
|
223
|
+
.map((g) => ({
|
|
224
|
+
about: aboutOf(g.kind),
|
|
225
|
+
action: g.action,
|
|
226
|
+
target: g.target,
|
|
227
|
+
times: g.times,
|
|
228
|
+
share: total ? g.times / total : 0,
|
|
229
|
+
roles: [...g.roles].sort(),
|
|
230
|
+
standing: g.standing.kind,
|
|
231
|
+
...(g.standing.why && { why: g.standing.why }),
|
|
232
|
+
// Only on an unowned cause: what kind of thing it is, and what to do.
|
|
233
|
+
...(g.nature && { kind: g.nature.kind, hint: g.nature.hint }),
|
|
234
|
+
...(g.expected && { expected: g.expected }),
|
|
235
|
+
owners: g.standing.owners,
|
|
236
|
+
ownersThen: g.ownersThen,
|
|
237
|
+
// How many of the roles that hit this would still hit it. `some` and
|
|
238
|
+
// not `every`: two roles out of three still blocked is still friction,
|
|
239
|
+
// and requiring all of them would quietly retire a live cause the day
|
|
240
|
+
// one role got a grant.
|
|
241
|
+
stillDenied: [...g.roles].filter((r) => cfg.roles[r] && !ask(cfg, r, g.action, g.target).allowed).length,
|
|
242
|
+
})),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** What a log line was about. Lines from before the field existed are files. */
|
|
247
|
+
const ABOUT = ["file", "key", "network", "system", "tool"];
|
|
248
|
+
function aboutOf(kind) {
|
|
249
|
+
return ABOUT.includes(kind) ? kind : "file";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Walls as the console shows them: each with what it was about and, when no
|
|
254
|
+
* role owns the path, the same kind and hint the Denied screen gives it.
|
|
255
|
+
*
|
|
256
|
+
* Without this the two screens contradicted each other about one path: Walls
|
|
257
|
+
* said `.env.local` "has no owner — no role can write it until one claims it",
|
|
258
|
+
* an invitation to give it one, while Denied said it is a credential and never
|
|
259
|
+
* to grant a write to it. The hint is kinds.js's, so it is the same sentence.
|
|
260
|
+
*
|
|
261
|
+
* A copy: the MCP server hands out walls() as it is, and this adds to it.
|
|
262
|
+
*/
|
|
263
|
+
export function describeWalls(cfg, byRole, entries) {
|
|
264
|
+
const kindOfLine = new Map();
|
|
265
|
+
for (const e of entries) if (e.verdict === "denied" && e.target) kindOfLine.set(`${e.role}\u0000${e.action}\u0000${e.target}`, e.kind);
|
|
266
|
+
const standOf = standingOf(cfg);
|
|
267
|
+
const out = {};
|
|
268
|
+
const unowned = [];
|
|
269
|
+
for (const [role, list] of Object.entries(byRole)) {
|
|
270
|
+
out[role] = list.map((w) => {
|
|
271
|
+
const about = aboutOf(kindOfLine.get(`${role}\u0000${w.action}\u0000${w.target}`));
|
|
272
|
+
// An expected wall (walls.js) is not the agent retrying anything.
|
|
273
|
+
const x = { ...w, about, retried: w.expected ? 0 : w.times - 1 };
|
|
274
|
+
// Every file wall, owned or not: a protected path can also lie inside
|
|
275
|
+
// someone's territory, and "owned" must not hide "never granted". The
|
|
276
|
+
// owners are today's, as Denied's are.
|
|
277
|
+
if (about === "file") {
|
|
278
|
+
const s = standOf({ kind: "file", target: w.target });
|
|
279
|
+
x.standing = s.kind;
|
|
280
|
+
x.owners = s.owners ?? [];
|
|
281
|
+
if (s.why) x.why = s.why;
|
|
282
|
+
if (s.kind === "unowned") unowned.push(x);
|
|
283
|
+
}
|
|
284
|
+
return x;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const natures = kindsOf(unowned.map((w) => w.target), { keyDirs: cfg.keyDirs ?? [] });
|
|
288
|
+
for (const w of unowned) Object.assign(w, natures.get(w.target));
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Every role's walls, from one read of the log.
|
|
294
|
+
*
|
|
295
|
+
* `entries` are log lines of any verdict (walls keeps the denied ones); only
|
|
296
|
+
* roles with at least one wall appear. The MCP read the log once per role — 32
|
|
297
|
+
* reads on a real policy — to answer the same question.
|
|
298
|
+
*/
|
|
299
|
+
export function wallsByRole(cfg, entries, { ask } = {}) {
|
|
300
|
+
const denied = entries.filter((e) => e.verdict === "denied");
|
|
301
|
+
return Object.fromEntries(
|
|
302
|
+
Object.keys(cfg.roles)
|
|
303
|
+
.map((r) => [r, walls(cfg, r, { entries: denied, ...(ask && { ask }) })])
|
|
304
|
+
.filter(([, w]) => w.length));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The request queue as a person should read it: pending, each with its handoff
|
|
309
|
+
* decided, and the requests the role has stopped asking for marked. `history`
|
|
310
|
+
* is the whole log, every verdict — a run that was only allowed things is
|
|
311
|
+
* still a run.
|
|
312
|
+
*/
|
|
313
|
+
export function queue(cfg, history, { readOnly = false } = {}) {
|
|
314
|
+
return queueOf(cfg, history, { readOnly });
|
|
315
|
+
}
|