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/hook.js
CHANGED
|
@@ -28,6 +28,18 @@ const FILE_TOOLS = {
|
|
|
28
28
|
Read: "read", NotebookRead: "read",
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The tools {@link targetsOf} has anything to say about, as a hook matcher.
|
|
33
|
+
*
|
|
34
|
+
* `seisin wire` used to install PreToolUse with `"*"`, so Claude Code started
|
|
35
|
+
* this hook for Grep, Glob and TodoWrite too — a whole Node start-up, measured
|
|
36
|
+
* at 218 ms, to return nothing. Built from the same table targetsOf reads, so
|
|
37
|
+
* a tool added there is matched here. Anchored, because Claude Code reads a
|
|
38
|
+
* matcher as a regular expression and an unanchored `Write` matches
|
|
39
|
+
* `TodoWrite`.
|
|
40
|
+
*/
|
|
41
|
+
export const TOOL_MATCHER = `^(${[...Object.keys(FILE_TOOLS), "Bash"].join("|")}|mcp__.*)$`;
|
|
42
|
+
|
|
31
43
|
/**
|
|
32
44
|
* Paths a shell command is going to write.
|
|
33
45
|
*
|
|
@@ -44,7 +56,7 @@ const WRITERS = /(?:^|\s|;|&&|\|\|)(?:cp|mv|install|tee|touch|mkdir|rm|rmdir|tru
|
|
|
44
56
|
* The split is on the double underscore: a server name carries single ones
|
|
45
57
|
* (`claude_ai_Gmail`) and so does a tool name (`execute_sql`), so the server is
|
|
46
58
|
* everything between `mcp__` and the next `__`. Shared with the parent's intake,
|
|
47
|
-
* which recomputes the same verdict rather than trusting the line from the
|
|
59
|
+
* which recomputes the same verdict rather than trusting the line from the sandbox.
|
|
48
60
|
*/
|
|
49
61
|
export function mcpServer(tool) {
|
|
50
62
|
const m = typeof tool === "string" && /^mcp__(.+?)__(.+)$/.exec(tool);
|
|
@@ -52,15 +64,19 @@ export function mcpServer(tool) {
|
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
export function targetsOf(tool, input = {}) {
|
|
67
|
+
// The event is whatever the agent's harness sent. `tool_input: null` or a
|
|
68
|
+
// path that is a number used to throw below, and a throwing hook exits 2 —
|
|
69
|
+
// which Claude Code reads as "block". Malformed means "nothing to say".
|
|
70
|
+
if (!input || typeof input !== "object") input = {};
|
|
55
71
|
const kind = FILE_TOOLS[tool];
|
|
56
72
|
if (kind) {
|
|
57
|
-
const p = input.file_path
|
|
58
|
-
return p ? [{ action: kind, path: p }] : [];
|
|
73
|
+
const p = [input.file_path, input.path, input.notebook_path].find((x) => x != null);
|
|
74
|
+
return typeof p === "string" && p ? [{ action: kind, path: p }] : [];
|
|
59
75
|
}
|
|
60
76
|
/**
|
|
61
77
|
* An MCP tool call names a resource, not a path: `mcp__<server>__<tool>`.
|
|
62
78
|
*
|
|
63
|
-
* The kernel never sees it — the MCP server runs outside the
|
|
79
|
+
* The kernel never sees it — the MCP server runs outside the sandbox — so a call
|
|
64
80
|
* that goes unrecorded here goes unrecorded everywhere. Measured 2026-09-24:
|
|
65
81
|
* the event reaches PreToolUse with the tool's own arguments as `tool_input`,
|
|
66
82
|
* which is exactly why the file-tool code above returns nothing for it. We
|
|
@@ -132,14 +148,14 @@ function kindOf(config, path) {
|
|
|
132
148
|
}
|
|
133
149
|
|
|
134
150
|
/**
|
|
135
|
-
* The half of the refusal that nothing inside the
|
|
151
|
+
* The half of the refusal that nothing inside the sandbox could otherwise know.
|
|
136
152
|
*
|
|
137
153
|
* `ask()` has already filed a request by the time this sentence is built — and
|
|
138
154
|
* until now it did so silently, so an agent could not tell the person who sent
|
|
139
155
|
* it that anything was pending. It closes two loops the bare refusal leaves
|
|
140
156
|
* open: retrying, because the queue deduplicates and a second attempt only
|
|
141
157
|
* raises a counter; and waiting, because nothing inside the sandbox can
|
|
142
|
-
*
|
|
158
|
+
* grant.
|
|
143
159
|
*
|
|
144
160
|
* It deliberately says nothing about the MCP server. The hook cannot know
|
|
145
161
|
* whether one is configured — `wire` writes `.claude/settings.json` and MCP
|
|
@@ -167,6 +183,15 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
167
183
|
const file = logPath(config.root);
|
|
168
184
|
const queue = requestsPath(config.root);
|
|
169
185
|
const verdicts = [];
|
|
186
|
+
// How many times the first refusal here was given BEFORE this attempt. Read
|
|
187
|
+
// before the entry is written, because where the entry goes decides whether
|
|
188
|
+
// a read afterwards sees it: to the file outside `seisin run`, but to the
|
|
189
|
+
// parent's socket inside it — and that line is not on disk until the parent
|
|
190
|
+
// gets to it, so counting afterwards came out one behind there.
|
|
191
|
+
let before = null;
|
|
192
|
+
const priorOnce = (action, target) => {
|
|
193
|
+
if (before === null) before = timesHit(file, role, action, target);
|
|
194
|
+
};
|
|
170
195
|
|
|
171
196
|
for (const t of targets) {
|
|
172
197
|
// A tool call, resolved to its MCP server. "Whose is it" is the question
|
|
@@ -179,6 +204,7 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
179
204
|
const v = t.server
|
|
180
205
|
? explain(config, role, "mcp", t.server)
|
|
181
206
|
: { allowed: false, owners: [], mcp: true, reason: `${t.tool} is not a recognisable mcp__<server>__<tool> name` };
|
|
207
|
+
if (!observe && !v.allowed) priorOnce("use", t.tool);
|
|
182
208
|
now(file, {
|
|
183
209
|
role, tool: event.tool_name, action: "use", kind: "tool", target: t.tool,
|
|
184
210
|
verdict: observe ? "observed" : v.allowed ? "allowed" : "denied",
|
|
@@ -202,6 +228,7 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
202
228
|
if (t.action === "read" && kind !== "key") continue;
|
|
203
229
|
|
|
204
230
|
const v = explain(config, role, kind === "key" ? "read" : "write", rel);
|
|
231
|
+
if (!observe && !v.allowed) priorOnce(kind === "key" ? "read" : t.action, rel);
|
|
205
232
|
|
|
206
233
|
now(file, {
|
|
207
234
|
role,
|
|
@@ -215,13 +242,15 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
215
242
|
});
|
|
216
243
|
// A denial already carries everything a request needs, so leave one behind:
|
|
217
244
|
// the refusal stops being a dead end and becomes something a person can act
|
|
218
|
-
// on in one command. Observing records nothing to
|
|
245
|
+
// on in one command. Observing records nothing to grant — there was no
|
|
219
246
|
// denial to answer. See docs/permission-requests.md.
|
|
220
247
|
//
|
|
221
248
|
// Except a `never_writes` refusal. That one is the policy saying no on
|
|
222
249
|
// purpose; a request for it asks a person to undo a subtraction somebody
|
|
223
250
|
// wrote, and approving it is how the subtraction disappears unnoticed.
|
|
224
|
-
|
|
251
|
+
// Nor a protected file: no grant opens one (the kernel keeps refusing it),
|
|
252
|
+
// which is why `seisin run` never queued those either (intake.js).
|
|
253
|
+
if (!observe && !v.allowed && !v.neverWrites && !v.gitMetadata && !v.protected)
|
|
225
254
|
ask(queue, { role, action: kind === "key" ? "read" : t.action, target: rel, owners: v.owners ?? [] });
|
|
226
255
|
|
|
227
256
|
// The action as it was RECORDED, not as the tool named it: a read of a key
|
|
@@ -242,19 +271,20 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
242
271
|
* How many times this exact denial has already been handed to this role.
|
|
243
272
|
*
|
|
244
273
|
* The sentence below is correct every time and that has not been enough:
|
|
245
|
-
* measured on a
|
|
246
|
-
* same role had already been
|
|
274
|
+
* measured on a multi-repo workspace, 25% of all denials were a repeat of something the
|
|
275
|
+
* same role had already been denied, one of them nineteen times. Correct
|
|
247
276
|
* and *heard* are different properties, and only the first was being
|
|
248
|
-
* measured. So the
|
|
277
|
+
* measured. So the denial carries its own history: the second time, it says
|
|
249
278
|
* it is the second time.
|
|
250
279
|
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
280
|
+
* The earlier refusals, read before this one was recorded, plus this one —
|
|
281
|
+
* the same number whether the entry went to the file or to the socket. Silent
|
|
282
|
+
* on the first — a counter that says "1×" on every first refusal is noise on
|
|
283
|
+
* the turn where the sentence is already doing its job.
|
|
254
284
|
*/
|
|
255
|
-
const
|
|
256
|
-
const again =
|
|
257
|
-
? ` You have been denied this ${
|
|
285
|
+
const times = (before ?? 0) + 1;
|
|
286
|
+
const again = times > 1
|
|
287
|
+
? ` You have been denied this ${times} times now; it is not going to work on the ${ordinal(times + 1)} try.`
|
|
258
288
|
: "";
|
|
259
289
|
|
|
260
290
|
// The hook advises; it does not enforce. Returning `deny` here stops the call
|
|
@@ -271,10 +301,10 @@ export function decide(config, role, event, { observe = false, now = append, ask
|
|
|
271
301
|
// told "no" retries; an agent told whose it is asks, or moves on.
|
|
272
302
|
denied.neverWrites
|
|
273
303
|
? `${denied.reason}. Do not ask for it: nothing was queued, because the answer is already written down.` + again
|
|
274
|
-
: denied.gitMetadata
|
|
304
|
+
: denied.gitMetadata || denied.protected
|
|
275
305
|
? `${denied.reason}. Nothing was queued.` + again
|
|
276
306
|
: denied.mcp
|
|
277
|
-
? `${denied.reason}. Nothing was queued: which MCP servers a role may load is set in its mcp list, a policy change a person makes, not
|
|
307
|
+
? `${denied.reason}. Nothing was queued: which MCP servers a role may load is set in its mcp list, a policy change a person makes, not something they grant from the queue.` + again
|
|
278
308
|
: (denied.owners?.length
|
|
279
309
|
? wasRead
|
|
280
310
|
? `${denied.target} is declared for ${denied.owners.join(", ")}, not ${role}. Ask for what you need from it rather than reading the key.`
|
package/src/index.js
CHANGED
|
@@ -18,7 +18,7 @@ export { findConfig, loadConfig, parseToml } from "./config.js";
|
|
|
18
18
|
/** Where seisin's own files live. */
|
|
19
19
|
export { CONFIG_NAME, STATE_DIR, LOG_NAME } from "./layout.js";
|
|
20
20
|
|
|
21
|
-
/** Who owns a path, and the sentence to hand a
|
|
21
|
+
/** Who owns a path, and the sentence to hand a denied agent. */
|
|
22
22
|
export { covers, ownersOf, keyHolders, explain } from "./owners.js";
|
|
23
23
|
|
|
24
24
|
/** A policy, resolved into sandbox settings and a child environment. */
|
|
@@ -34,7 +34,7 @@ export { buildEnv, BASE as BASE_ENV, DEFAULTS as DEFAULT_ENV } from "./env.js";
|
|
|
34
34
|
*/
|
|
35
35
|
export { parseKey, defaultName, entriesOf, MODES, RESERVED_ENV } from "./keys.js";
|
|
36
36
|
|
|
37
|
-
/** What a role keeps being
|
|
37
|
+
/** What a role keeps being denied, recomputed against the policy as it stands. */
|
|
38
38
|
export { walls, wasted, MIN_HITS } from "./walls.js";
|
|
39
39
|
|
|
40
40
|
/** The report `check` prints, without the printing. */
|
|
@@ -47,11 +47,11 @@ export { scan, SHAPES, DEFAULT_IGNORE } from "./scan.js";
|
|
|
47
47
|
export { read as readLog, logPath, observed, generalise } from "./log.js";
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* The queue of
|
|
50
|
+
* The queue of requests waiting on a person.
|
|
51
51
|
*
|
|
52
52
|
* `pending` and `grantFor` are public because reading the queue and drafting a
|
|
53
53
|
* proposal are things a caller — including an MCP server — legitimately does.
|
|
54
|
-
* `settle` and `applyGrant` are NOT exported:
|
|
54
|
+
* `settle` and `applyGrant` are NOT exported: granting is not a tool call, and
|
|
55
55
|
* the surface should make that hard to get wrong rather than merely document
|
|
56
56
|
* it. See docs/permission-requests.md.
|
|
57
57
|
*/
|
package/src/inspect.js
CHANGED
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
* does — each one is a way the policy silently does not hold — so they belong
|
|
9
9
|
* where they can be exercised directly.
|
|
10
10
|
*/
|
|
11
|
+
import { unknownRole } from "./suggest.js";
|
|
11
12
|
import { resolve, dirname, basename, relative, join, delimiter } from "node:path";
|
|
12
|
-
import {
|
|
13
|
+
import { lstatSync, readdirSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
13
14
|
import { ownersOf, covers, enforcedNeverWrites } from "./owners.js";
|
|
14
|
-
import { ROLE_KEYS } from "./config.js";
|
|
15
|
+
import { ROLE_KEYS, closest } from "./config.js";
|
|
15
16
|
import { entriesOf } from "./keys.js";
|
|
16
17
|
import { SHAPES } from "./scan.js";
|
|
17
|
-
import { wired } from "./commands/wire.js";
|
|
18
|
+
import { wired, broadlyWired } from "./commands/wire.js";
|
|
18
19
|
import { RUNTIME_WRITES, CREDENTIAL_HOMES, expand, settingsFor } from "./srt.js";
|
|
20
|
+
import { realOrSelf } from "./grants.js";
|
|
19
21
|
import { protections, resolveExecutable } from "./surface.js";
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -26,8 +28,11 @@ import { protections, resolveExecutable } from "./surface.js";
|
|
|
26
28
|
* answering it with silence is how a typo becomes a belief.
|
|
27
29
|
*/
|
|
28
30
|
export function inspect(config, only = null, where = config.path) {
|
|
29
|
-
if (only && !config.roles[only]) throw
|
|
31
|
+
if (only && !config.roles[only]) throw unknownRole(config, only);
|
|
30
32
|
const roles = only ? [config.roles[only]] : Object.values(config.roles);
|
|
33
|
+
// Once: it is listed on its own and again as a warning, and it is the most
|
|
34
|
+
// expensive thing check computes.
|
|
35
|
+
const shared = sharedPaths(config, roles);
|
|
31
36
|
|
|
32
37
|
return {
|
|
33
38
|
where,
|
|
@@ -43,16 +48,16 @@ export function inspect(config, only = null, where = config.path) {
|
|
|
43
48
|
providers: Object.values(config.keyProviders ?? {}).map((p) => ({
|
|
44
49
|
name: p.name, command: p.command, mode: p.mode,
|
|
45
50
|
})),
|
|
46
|
-
shared
|
|
51
|
+
shared,
|
|
47
52
|
/**
|
|
48
|
-
* What the kernel will
|
|
53
|
+
* What the kernel will deny a role inside its own territory, and why:
|
|
49
54
|
* programs installed where a role writes, the hooks and settings of each
|
|
50
55
|
* project, provider scripts and `file://` targets. Each takes something
|
|
51
56
|
* away from a territory as written, so it is shown rather than applied
|
|
52
57
|
* quietly. See surface.js for the family and the measurements.
|
|
53
58
|
*/
|
|
54
59
|
protected: protections(config, roles),
|
|
55
|
-
warnings: warningsFor(config, roles),
|
|
60
|
+
warnings: warningsFor(config, roles, shared),
|
|
56
61
|
limits: LIMITS,
|
|
57
62
|
};
|
|
58
63
|
}
|
|
@@ -61,8 +66,8 @@ export function inspect(config, only = null, where = config.path) {
|
|
|
61
66
|
* What the tool cannot do at all — as opposed to `warnings`, which are things
|
|
62
67
|
* wrong with *this* config.
|
|
63
68
|
*
|
|
64
|
-
* Kept apart on purpose. A limit printed as a warning reads as something
|
|
65
|
-
*
|
|
69
|
+
* Kept apart on purpose. A limit printed as a warning reads as something a
|
|
70
|
+
* person could fix, and after the third run it reads as nothing at all.
|
|
66
71
|
*
|
|
67
72
|
* These are the sentences someone needs before they trust the map on screen
|
|
68
73
|
* more than it deserves, so `check` prints them every time rather than behind a
|
|
@@ -72,11 +77,11 @@ export function inspect(config, only = null, where = config.path) {
|
|
|
72
77
|
const LIMITS = [
|
|
73
78
|
...(process.platform === "darwin" ? [] : [{
|
|
74
79
|
kind: "kernel-denials-unreadable",
|
|
75
|
-
headline: "
|
|
80
|
+
headline: "denials by the kernel are not recorded on this platform",
|
|
76
81
|
detail:
|
|
77
82
|
"on macOS `seisin run` reads them out of the system log, so a denial the hook never " +
|
|
78
83
|
"saw still lands in `seisin log` with its owner and still queues a request. There is no " +
|
|
79
|
-
"equivalent to read here — bubblewrap does not log
|
|
84
|
+
"equivalent to read here — bubblewrap does not log denials, and the runtime's substitute " +
|
|
80
85
|
"is not reachable from outside it. The boundary holds exactly as well; what you lose is " +
|
|
81
86
|
"seeing it work. Run `seisin wire` so the hook records what it can. The ask is upstream: " +
|
|
82
87
|
"github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
|
|
@@ -95,7 +100,7 @@ const LIMITS = [
|
|
|
95
100
|
headline: "`--observe` opens the filesystem, never the network",
|
|
96
101
|
detail:
|
|
97
102
|
"so it cannot tell you which domains an agent needs. The runtime does record every " +
|
|
98
|
-
"
|
|
103
|
+
"denial the proxy makes — host, port and reason — but only a caller that embeds the " +
|
|
99
104
|
"library can read them; nothing reaches a caller that runs the binary, which is what " +
|
|
100
105
|
"seisin does. So for an agent whose endpoints are not published, the options are to " +
|
|
101
106
|
"find them another way or drop the network restriction for it. The ask is upstream: " +
|
|
@@ -108,14 +113,14 @@ const LIMITS = [
|
|
|
108
113
|
"bubblewrap denies a path by mounting over it, and for a path that is not there it would " +
|
|
109
114
|
"create it on your disk. So on Linux a role can still create a .git/hooks entry or a " +
|
|
110
115
|
".claude/settings.json in a project of its territory that had none. On macOS the kernel " +
|
|
111
|
-
"
|
|
116
|
+
"denies creating them too.",
|
|
112
117
|
}] : []),
|
|
113
118
|
{
|
|
114
119
|
kind: "shared-scratch",
|
|
115
120
|
headline: "roles share ~/.cache, ~/.local/share and the temp dir unless isolate = \"home\"",
|
|
116
121
|
detail:
|
|
117
122
|
"programs installed there that are on your PATH are protected, and so are Claude Code's " +
|
|
118
|
-
"settings and plugins. Anything else a program outside the
|
|
123
|
+
"settings and plugins. Anything else a program outside the sandbox reads from those places — a " +
|
|
119
124
|
"cache it executes, a tool not on PATH — is writable by every role. isolate = \"home\" " +
|
|
120
125
|
"gives each role its own.",
|
|
121
126
|
},
|
|
@@ -137,18 +142,18 @@ const LIMITS = [
|
|
|
137
142
|
* Two things, both about `never_writes` and both ways a subtraction can look
|
|
138
143
|
* written and not be:
|
|
139
144
|
*
|
|
140
|
-
* - a
|
|
145
|
+
* - a setting seisin does not know. It used to be dropped without a word, which
|
|
141
146
|
* for `writes` is a role with less than it thinks, and for a misspelt
|
|
142
147
|
* `never_writes` is a role with MORE than its owner thinks — the one
|
|
143
|
-
* direction a
|
|
144
|
-
* known
|
|
148
|
+
* direction a policy must not fail in. Named with the nearest
|
|
149
|
+
* known setting when there is one close enough to be the intended spelling.
|
|
145
150
|
* - a `never_writes` entry that no `writes` of the same role covers. It
|
|
146
151
|
* subtracts from nothing, so it is either a typo in the path or a rule
|
|
147
152
|
* that stopped applying when the territory moved. Either way it reads as
|
|
148
153
|
* protection and is not.
|
|
149
154
|
*
|
|
150
155
|
* Silent when `never_writes` is absent. Absent is valid; a check that asks for
|
|
151
|
-
* the
|
|
156
|
+
* the setting would turn an optional subtraction into a required migration.
|
|
152
157
|
*/
|
|
153
158
|
function roleKeyWarnings(roles, config = null) {
|
|
154
159
|
const warnings = [];
|
|
@@ -167,14 +172,27 @@ function roleKeyWarnings(roles, config = null) {
|
|
|
167
172
|
const near = nearest(k, ROLE_KEYS);
|
|
168
173
|
warnings.push({
|
|
169
174
|
kind: "unknown-role-key",
|
|
170
|
-
headline: `${r.name}: unknown
|
|
175
|
+
headline: `${r.name}: unknown setting "${k}" in [roles.${r.name}] — ignored` +
|
|
171
176
|
(near ? `. Did you mean "${near}"?` : ""),
|
|
172
|
-
detail: `Known
|
|
177
|
+
detail: `Known settings: ${ROLE_KEYS.join(", ")}. ` +
|
|
173
178
|
(near === "never_writes"
|
|
174
179
|
? "As written this subtracts nothing: the role can still write every path it lists."
|
|
175
180
|
: "Nothing in it takes effect."),
|
|
176
181
|
});
|
|
177
182
|
}
|
|
183
|
+
// A family handed to a role is a hole in the protection on purpose, and
|
|
184
|
+
// said on every check so it stays a decision somebody can see.
|
|
185
|
+
for (const f of r.controlFiles ?? [])
|
|
186
|
+
warnings.push({
|
|
187
|
+
kind: "control-files-handed",
|
|
188
|
+
headline: f === "ide"
|
|
189
|
+
? `${r.name}: control_files = ["ide"] — may edit .vscode/, .cursor/ and .windsurf/ in its territory`
|
|
190
|
+
: `${r.name}: control_files = ["instructions"] — may edit CLAUDE.md, AGENTS.md and their kin in its territory`,
|
|
191
|
+
detail: f === "ide"
|
|
192
|
+
? "Your editor applies what it writes there — tasks, settings, auto-approval, MCP servers — outside the sandbox. " +
|
|
193
|
+
"Hand it only to a role that maintains those files, and review its changes to them."
|
|
194
|
+
: "The next agent session reads what it writes there as its instructions, whichever role that session is.",
|
|
195
|
+
});
|
|
178
196
|
// macOS only, and not by omission: on Linux the runtime removes the network
|
|
179
197
|
// namespace, so every role already has a loopback of its own — it can serve
|
|
180
198
|
// and reach its own server with or without the key, and reaches nothing on
|
|
@@ -227,25 +245,7 @@ function nearest(word, candidates) {
|
|
|
227
245
|
// letters mislead here: `no_writes` is three edits from `writes`, and
|
|
228
246
|
// suggesting the grant for a misspelt subtraction is the worst answer.
|
|
229
247
|
if (/deny|never|no_?write/i.test(word)) return "never_writes";
|
|
230
|
-
|
|
231
|
-
for (const c of candidates) {
|
|
232
|
-
const d = distance(word.toLowerCase(), c);
|
|
233
|
-
if (d < bestD) { best = c; bestD = d; }
|
|
234
|
-
}
|
|
235
|
-
return best;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function distance(a, b) {
|
|
239
|
-
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
240
|
-
for (let i = 1; i <= a.length; i++) {
|
|
241
|
-
let prev = row[0]; row[0] = i;
|
|
242
|
-
for (let j = 1; j <= b.length; j++) {
|
|
243
|
-
const cur = row[j];
|
|
244
|
-
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
245
|
-
prev = cur;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
return row[b.length];
|
|
248
|
+
return closest(word, candidates);
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
/**
|
|
@@ -256,10 +256,17 @@ function distance(a, b) {
|
|
|
256
256
|
* that it stays a decision somebody made rather than a thing that drifted.
|
|
257
257
|
*/
|
|
258
258
|
export function sharedPaths(config, roles = Object.values(config.roles)) {
|
|
259
|
+
// Asked once per distinct glob: a real policy repeats them (1,332 writes,
|
|
260
|
+
// 303 distinct in a multi-repo workspace), and each question is every glob of every
|
|
261
|
+
// role again.
|
|
259
262
|
const seen = new Set();
|
|
263
|
+
const asked = new Map();
|
|
260
264
|
for (const r of roles)
|
|
261
|
-
for (const glob of r.writes)
|
|
262
|
-
|
|
265
|
+
for (const glob of r.writes) {
|
|
266
|
+
let many = asked.get(glob);
|
|
267
|
+
if (many === undefined) asked.set(glob, (many = ownersOf(config, glob.replace(/\/\*\*$/, "")).length > 1));
|
|
268
|
+
if (many) seen.add(glob);
|
|
269
|
+
}
|
|
263
270
|
/**
|
|
264
271
|
* A shared database counts once, not four times.
|
|
265
272
|
*
|
|
@@ -305,7 +312,7 @@ function homeReachWarning(config) {
|
|
|
305
312
|
`isolate = "credentials" denies ${where} and the rest. What stays open is ` +
|
|
306
313
|
"~/.claude and ~/.codex, which is what keeps the agent logged in, so the roles " +
|
|
307
314
|
'share one session and can read each other. `isolate = "home"` closes those ' +
|
|
308
|
-
"too, and then every CLI in the
|
|
315
|
+
"too, and then every CLI in the sandbox will ask you to log in again.",
|
|
309
316
|
}
|
|
310
317
|
: {
|
|
311
318
|
kind: "home-open",
|
|
@@ -318,7 +325,6 @@ function homeReachWarning(config) {
|
|
|
318
325
|
}
|
|
319
326
|
|
|
320
327
|
|
|
321
|
-
/** Every way this policy does not hold, each with what to do about it. */
|
|
322
328
|
/**
|
|
323
329
|
* Credential-shaped names at the top of the repository, and nothing deeper.
|
|
324
330
|
*
|
|
@@ -364,19 +370,28 @@ function credentialish(root) {
|
|
|
364
370
|
return [...found].sort().slice(0, 4);
|
|
365
371
|
}
|
|
366
372
|
|
|
367
|
-
/**
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
373
|
+
/**
|
|
374
|
+
* Would the parent find this command? The same lookup `run` makes
|
|
375
|
+
* (resolveExecutable), so check does not call "missing" a program that is
|
|
376
|
+
* only in a directory a role writes — that has its own warning, above.
|
|
377
|
+
*/
|
|
378
|
+
function onPath(cmd, config) {
|
|
379
|
+
try {
|
|
380
|
+
// A path is resolved against the policy and not looked up, so it is there
|
|
381
|
+
// only if it exists.
|
|
382
|
+
return existsSync(resolveExecutable(cmd, config));
|
|
383
|
+
} catch (e) {
|
|
384
|
+
return /a role can write/.test(e.message);
|
|
385
|
+
}
|
|
372
386
|
}
|
|
373
387
|
|
|
374
|
-
|
|
388
|
+
/** Every way this policy does not hold, each with what to do about it. */
|
|
389
|
+
function warningsFor(config, roles, shared = sharedPaths(config, roles)) {
|
|
375
390
|
const warnings = [];
|
|
376
391
|
|
|
377
392
|
// A pattern the kernel cannot be given is not a policy, it is a sentence that
|
|
378
393
|
// reads like one. `run` refuses it; `check` exists precisely so you find that
|
|
379
|
-
// out while reading the map rather than mid-
|
|
394
|
+
// out while reading the map rather than mid-run.
|
|
380
395
|
for (const r of roles) {
|
|
381
396
|
try {
|
|
382
397
|
settingsFor(config, r.name);
|
|
@@ -411,17 +426,15 @@ function warningsFor(config, roles) {
|
|
|
411
426
|
|
|
412
427
|
// PATH entries that are not absolute resolve against wherever the parent is
|
|
413
428
|
// standing, which is the repo. They are ignored for every lookup seisin makes
|
|
414
|
-
// outside the
|
|
429
|
+
// outside the sandbox; said here because a lookup that skips them can find a
|
|
415
430
|
// different program than your shell does.
|
|
416
431
|
const unanchored = (process.env.PATH ?? "").split(delimiter).filter((d) => !d || !d.startsWith("/"));
|
|
417
432
|
if (unanchored.length)
|
|
418
433
|
warnings.push({
|
|
419
434
|
kind: "relative-path",
|
|
420
435
|
headline: `PATH has ${unanchored.length} relative entr${unanchored.length === 1 ? "y" : "ies"} (${unanchored.map((d) => JSON.stringify(d)).join(", ")})`,
|
|
421
|
-
detail: "They resolve inside the repo, where roles write, so seisin skips them when it looks for a program to run outside the
|
|
436
|
+
detail: "They resolve inside the repo, where roles write, so seisin skips them when it looks for a program to run outside the sandbox.",
|
|
422
437
|
});
|
|
423
|
-
const shared = sharedPaths(config, roles);
|
|
424
|
-
|
|
425
438
|
if (shared.length)
|
|
426
439
|
warnings.push({
|
|
427
440
|
kind: "shared",
|
|
@@ -484,7 +497,7 @@ function warningsFor(config, roles) {
|
|
|
484
497
|
}
|
|
485
498
|
|
|
486
499
|
// Without the hook, half the record is missing rather than all of it: on macOS
|
|
487
|
-
// `seisin run` reads the kernel's own
|
|
500
|
+
// `seisin run` reads the kernel's own denials, so they still land and
|
|
488
501
|
// still queue a request. What goes unrecorded is everything the kernel
|
|
489
502
|
// allowed — which is the half `init --from-observations` is built out of, and
|
|
490
503
|
// the half that tells "nothing was denied" apart from "nobody was watching".
|
|
@@ -498,16 +511,26 @@ function warningsFor(config, roles) {
|
|
|
498
511
|
headline: "only denials are being recorded: the agent has not been told to run the hook",
|
|
499
512
|
detail:
|
|
500
513
|
process.platform === "darwin"
|
|
501
|
-
? "the boundary holds either way, and
|
|
514
|
+
? "the boundary holds either way, and denials are still logged and still queue a " +
|
|
502
515
|
"request — `seisin run` reads them from the kernel. What is missing is every " +
|
|
503
|
-
"action that was allowed, so `review` cannot tell you which grants are
|
|
516
|
+
"action that was allowed, so `review` cannot tell you which grants are unused and " +
|
|
504
517
|
"`init --from-observations` has nothing to build from. Run `seisin wire` once in " +
|
|
505
518
|
"this repo."
|
|
506
|
-
: "the boundary holds either way — but on this platform the kernel's
|
|
519
|
+
: "the boundary holds either way — but on this platform the kernel's denials are " +
|
|
507
520
|
"not readable either, so log, watch, requests, grant and review all read a record " +
|
|
508
521
|
"that nobody is writing. Run `seisin wire` once in this repo.",
|
|
509
522
|
});
|
|
510
523
|
|
|
524
|
+
else if (broadlyWired(config.root))
|
|
525
|
+
warnings.push({
|
|
526
|
+
kind: "hook-matcher-broad",
|
|
527
|
+
headline: "the hook runs on every tool call: it was wired before 0.5.0 with the matcher \"*\"",
|
|
528
|
+
detail:
|
|
529
|
+
"nothing is lost — every tool the hook reads is still covered — but each Read, Glob " +
|
|
530
|
+
"and TodoWrite starts a process that has nothing to say. Run `seisin wire` again: it " +
|
|
531
|
+
"narrows seisin's own entry in place and leaves everyone else's hooks alone.",
|
|
532
|
+
});
|
|
533
|
+
|
|
511
534
|
// A territory that leaves the repo is supported — a role can own its handover
|
|
512
535
|
// note one level up — but it is a different promise from "this repo, divided",
|
|
513
536
|
// and it should be a sentence somebody chose rather than a line that drifted.
|
|
@@ -524,10 +547,63 @@ function warningsFor(config, roles) {
|
|
|
524
547
|
});
|
|
525
548
|
}
|
|
526
549
|
|
|
550
|
+
/**
|
|
551
|
+
* A territory that is not on disk.
|
|
552
|
+
*
|
|
553
|
+
* A proposal written for another repo — or one typo — reads as a working
|
|
554
|
+
* policy: `check` printed the map and the first sign of anything wrong was a
|
|
555
|
+
* denial inside the agent. Said per glob, as a warning and never a failure:
|
|
556
|
+
* a role can own a folder it is about to create.
|
|
557
|
+
*/
|
|
558
|
+
for (const r of roles) {
|
|
559
|
+
for (const g of r.writes) {
|
|
560
|
+
if (g === "**" || g.startsWith("/") || g.startsWith("../")) continue;
|
|
561
|
+
const fixed = [];
|
|
562
|
+
for (const part of g.split("/")) {
|
|
563
|
+
if (/[*?[\]]/.test(part)) break;
|
|
564
|
+
fixed.push(part);
|
|
565
|
+
}
|
|
566
|
+
if (!fixed.length || existsSync(join(config.root, ...fixed))) continue;
|
|
567
|
+
warnings.push({
|
|
568
|
+
kind: "writes-match-nothing",
|
|
569
|
+
headline: `${r.name} writes ${g} — matches nothing in this repo`,
|
|
570
|
+
detail: "fine if the role is about to create it; otherwise the territory points at a folder that is not there.",
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* A territory inside a key directory.
|
|
577
|
+
*
|
|
578
|
+
* Seen in use: a role's whole territory sat under the folder declared as
|
|
579
|
+
* `[keys] dir`, which is closed to every role, so the role could write
|
|
580
|
+
* nothing at all — and `check` printed its territory as if it held. A key
|
|
581
|
+
* directory is read by the roles that declare a key in it and written by
|
|
582
|
+
* none; a folder a role must write belongs somewhere else.
|
|
583
|
+
*/
|
|
584
|
+
const keyRoots = (config.keyDirs ?? []).map((d) => d.replace(/\/+$/, "")).filter(Boolean);
|
|
585
|
+
for (const r of roles) {
|
|
586
|
+
for (const g of r.writes) {
|
|
587
|
+
const fixed = [];
|
|
588
|
+
for (const part of g.split("/")) {
|
|
589
|
+
if (/[*?[\]]/.test(part)) break;
|
|
590
|
+
fixed.push(part);
|
|
591
|
+
}
|
|
592
|
+
const at = fixed.join("/");
|
|
593
|
+
const dir = at && keyRoots.find((d) => at === d || at.startsWith(d + "/"));
|
|
594
|
+
if (!dir) continue;
|
|
595
|
+
warnings.push({
|
|
596
|
+
kind: "territory-in-key-dir",
|
|
597
|
+
headline: `${r.name} writes ${g} — inside the key directory ${dir}, which no role can write`,
|
|
598
|
+
detail: "a key directory is read by the roles that declare its keys and written by none. Move what this role writes out of it, or drop the territory.",
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
527
603
|
/**
|
|
528
604
|
* A file granted by name does not come with its neighbours.
|
|
529
605
|
*
|
|
530
|
-
* Measured, in production, and it survived two
|
|
606
|
+
* Measured, in production, and it survived two reviews of the policy by people:
|
|
531
607
|
* a role was granted its database file and still could not write the
|
|
532
608
|
* database. The database opens a second file beside the first — a journal —
|
|
533
609
|
* and the sibling was outside the grant, so the write failed in the
|
|
@@ -584,7 +660,6 @@ function warningsFor(config, roles) {
|
|
|
584
660
|
const unnamed = onDisk.filter((n) => !named.includes(n));
|
|
585
661
|
if (!unnamed.length) continue;
|
|
586
662
|
const shown = relative(resolve(config.root), dir);
|
|
587
|
-
const more = unnamed.length > 8 ? ` … and ${unnamed.length - 8} more` : "";
|
|
588
663
|
listed.push({ role: r.name, named: named.length, total: onDisk.length,
|
|
589
664
|
where: shown ? shown + "/" : "the repo root", unnamed });
|
|
590
665
|
|
|
@@ -719,14 +794,14 @@ function warningsFor(config, roles) {
|
|
|
719
794
|
for (const k of entriesOf(r)) if (k.kind === "ref") used.add(k.scheme);
|
|
720
795
|
for (const scheme of [...used].sort()) {
|
|
721
796
|
const provider = config.keyProviders?.[scheme];
|
|
722
|
-
if (!provider || onPath(provider.command[0])) continue;
|
|
797
|
+
if (!provider || onPath(provider.command[0], config)) continue;
|
|
723
798
|
warnings.push({
|
|
724
799
|
kind: "provider-missing",
|
|
725
800
|
headline: `the ${scheme} provider runs "${provider.command[0]}", which is not on PATH`,
|
|
726
801
|
detail:
|
|
727
802
|
"every key of that scheme will stop the run rather than resolve. Nothing is " +
|
|
728
803
|
"substituted for a key that did not resolve, so this fails closed — but it fails " +
|
|
729
|
-
"at the start of a
|
|
804
|
+
"at the start of a run, not here.",
|
|
730
805
|
});
|
|
731
806
|
}
|
|
732
807
|
|
|
@@ -789,12 +864,3 @@ function lstatOrNull(p) {
|
|
|
789
864
|
return null;
|
|
790
865
|
}
|
|
791
866
|
}
|
|
792
|
-
|
|
793
|
-
/** The path with symlinks followed, or the path itself if it is not on disk. */
|
|
794
|
-
function realOrSelf(p) {
|
|
795
|
-
try {
|
|
796
|
-
return realpathSync(p);
|
|
797
|
-
} catch {
|
|
798
|
-
return p;
|
|
799
|
-
}
|
|
800
|
-
}
|