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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { resolve, dirname, basename, relative, join } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { territoryOf, implicitSidecar, under, regularFilesIn } from "./files.js";
|
|
4
|
+
import { covers } from "../owners.js";
|
|
5
|
+
|
|
6
|
+
export function layoutWarnings(config, roles, scoped) {
|
|
7
|
+
const warnings = [];
|
|
8
|
+
// A territory that leaves the repo is supported — a role can own its handover
|
|
9
|
+
// note one level up — but it is a different promise from "this repo, divided",
|
|
10
|
+
// and it should be a sentence somebody chose rather than a line that drifted.
|
|
11
|
+
const loose = []; // roles granting individual files
|
|
12
|
+
const listed = []; // role + folder pairs that enumerate
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A private folder inside another role's territory. Allowed — it is how a
|
|
16
|
+
* folder is taken out of a wide `writes = ["**"]` — but that role loses it,
|
|
17
|
+
* reading included, and nothing in its own table says so.
|
|
18
|
+
*/
|
|
19
|
+
for (const r of roles)
|
|
20
|
+
for (const p of r.private ?? []) {
|
|
21
|
+
const dir = p.replace(/\/\*\*$/, "").replace(/\/+$/, "").replace(/^\.\//, "");
|
|
22
|
+
for (const o of Object.values(config.roles)) {
|
|
23
|
+
if (o.name === r.name) continue;
|
|
24
|
+
const g = (o.writesDeclared ?? o.writes).find((w) => covers(w, dir));
|
|
25
|
+
if (g)
|
|
26
|
+
warnings.push({
|
|
27
|
+
kind: "private-inside-territory",
|
|
28
|
+
headline: `${r.name}: private "${p}" is inside ${o.name}'s writes ("${g}") — ${o.name} can neither read nor write it`,
|
|
29
|
+
detail: `Every role but ${r.name} is denied the folder, and the deny wins over ${o.name}'s territory. ` +
|
|
30
|
+
`If ${o.name} needs what is in it, it asks ${r.name}; if it should keep it, the folder is not private.`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const r of roles) {
|
|
36
|
+
const out = r.writes.filter((w) => w.startsWith("/") || w.startsWith("../"));
|
|
37
|
+
if (out.length)
|
|
38
|
+
warnings.push({
|
|
39
|
+
kind: "territory-outside-repo",
|
|
40
|
+
headline: `${r.name} writes outside this repo: ${out.join(" ")}`,
|
|
41
|
+
detail: "the sandbox will grant it. Nothing here checks what lives there.",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A territory that is not on disk.
|
|
47
|
+
*
|
|
48
|
+
* A proposal written for another repo — or one typo — reads as a working
|
|
49
|
+
* policy: `check` printed the map and the first sign of anything wrong was a
|
|
50
|
+
* denial inside the agent. Said per glob, as a warning and never a failure:
|
|
51
|
+
* a role can own a folder it is about to create.
|
|
52
|
+
*/
|
|
53
|
+
for (const r of roles) {
|
|
54
|
+
for (const g of r.writes) {
|
|
55
|
+
if (g === "**" || g.startsWith("/") || g.startsWith("../")) continue;
|
|
56
|
+
// A database's sidecars come with it (config.js) and exist only while it
|
|
57
|
+
// is open: absent on disk is their normal state, not a typo to report.
|
|
58
|
+
if (implicitSidecar(r, g)) continue;
|
|
59
|
+
const fixed = [];
|
|
60
|
+
for (const part of g.split("/")) {
|
|
61
|
+
if (/[*?[\]]/.test(part)) break;
|
|
62
|
+
fixed.push(part);
|
|
63
|
+
}
|
|
64
|
+
if (!fixed.length || existsSync(join(config.root, ...fixed))) continue;
|
|
65
|
+
warnings.push({
|
|
66
|
+
kind: "writes-match-nothing",
|
|
67
|
+
headline: `${r.name} writes ${g} — matches nothing in this repo`,
|
|
68
|
+
detail: "fine if the role is about to create it; otherwise the territory points at a folder that is not there.",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A territory inside a key directory.
|
|
75
|
+
*
|
|
76
|
+
* Seen in use: a role's whole territory sat under the folder declared as
|
|
77
|
+
* `[keys] dir`, which is closed to every role, so the role could write
|
|
78
|
+
* nothing at all — and `check` printed its territory as if it held. A key
|
|
79
|
+
* directory is read by the roles that declare a key in it and written by
|
|
80
|
+
* none; a folder a role must write belongs somewhere else.
|
|
81
|
+
*/
|
|
82
|
+
const keyRoots = (config.keyDirs ?? []).map((d) => d.replace(/\/+$/, "")).filter(Boolean);
|
|
83
|
+
for (const r of roles) {
|
|
84
|
+
for (const g of r.writes) {
|
|
85
|
+
const fixed = [];
|
|
86
|
+
for (const part of g.split("/")) {
|
|
87
|
+
if (/[*?[\]]/.test(part)) break;
|
|
88
|
+
fixed.push(part);
|
|
89
|
+
}
|
|
90
|
+
const at = fixed.join("/");
|
|
91
|
+
const dir = at && keyRoots.find((d) => at === d || at.startsWith(d + "/"));
|
|
92
|
+
if (!dir) continue;
|
|
93
|
+
warnings.push({
|
|
94
|
+
kind: "territory-in-key-dir",
|
|
95
|
+
headline: `${r.name} writes ${g} — inside the key directory ${dir}, which no role can write`,
|
|
96
|
+
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.",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A file granted by name does not come with its neighbours.
|
|
103
|
+
*
|
|
104
|
+
* Measured, in production, and it survived two reviews of the policy by people:
|
|
105
|
+
* a role was granted its database file and still could not write the
|
|
106
|
+
* database. The database opens a second file beside the first — a journal —
|
|
107
|
+
* and the sibling was outside the grant, so the write failed in the
|
|
108
|
+
* database's own words ("readonly") rather than as a permission error.
|
|
109
|
+
*
|
|
110
|
+
* What is deliberately NOT here: a list of which programs write which
|
|
111
|
+
* siblings. That would ask seisin to know about databases, and the next
|
|
112
|
+
* question is why it does not know about a lock file, the temporary file of
|
|
113
|
+
* an atomic write, or an editor's backup. The list has no end and every entry
|
|
114
|
+
* is a guess about a repo nobody foresaw. The general form needs no list: the
|
|
115
|
+
* kernel grants a path and everything under it, so a grant on a file covers
|
|
116
|
+
* the file and nothing else. Whether a tool will want a neighbour is the
|
|
117
|
+
* reader's to know; that the neighbour is not granted is arithmetic.
|
|
118
|
+
*/
|
|
119
|
+
for (const r of roles) {
|
|
120
|
+
const { files, subtrees } = territoryOf(config, r);
|
|
121
|
+
// A file inside one of the role's own subtrees has its siblings covered by
|
|
122
|
+
// that subtree, so naming it changes nothing and warns about nothing.
|
|
123
|
+
const alone = files.filter((f) => !subtrees.some((s) => under(dirname(f.abs), s)));
|
|
124
|
+
if (alone.length)
|
|
125
|
+
loose.push({ role: r.name, n: alone.length, of: r.writes.filter((g) => !implicitSidecar(r, g)).length,
|
|
126
|
+
globs: alone.map((f) => f.glob) });
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A territory written as a list of files is a photograph of the folder.
|
|
131
|
+
*
|
|
132
|
+
* A sandbox cannot say "the source files in this folder" — one level down
|
|
133
|
+
* is not a prefix, and `src/*` is refused above for exactly that reason,
|
|
134
|
+
* correctly. So a territory that means that has to be written as the files
|
|
135
|
+
* that exist today, and the day a file is added the policy is one behind:
|
|
136
|
+
* the owning role cannot write its own new file, and nothing fails loudly.
|
|
137
|
+
*
|
|
138
|
+
* Reported as a count, never as a verdict. "Comparable" here means only
|
|
139
|
+
* "a regular file in the same folder" — not the same extension, not the
|
|
140
|
+
* same shape of name, because either of those is a guess about what the
|
|
141
|
+
* territory *meant*, and a guess about intent is the thing this check
|
|
142
|
+
* exists to avoid. The unnamed files are listed by name instead, so the
|
|
143
|
+
* reader can see in one glance whether the gap is an oversight or a
|
|
144
|
+
* decision. seisin cannot tell, and does not pretend to.
|
|
145
|
+
*
|
|
146
|
+
* Silent when a folder has fewer than two named files (one file is a
|
|
147
|
+
* grant, not an enumeration) and when the folder is also granted as a
|
|
148
|
+
* subtree (then the list is decorative).
|
|
149
|
+
*/
|
|
150
|
+
const byDir = new Map();
|
|
151
|
+
for (const f of alone) {
|
|
152
|
+
const dir = dirname(f.abs);
|
|
153
|
+
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
154
|
+
byDir.get(dir).push(basename(f.abs));
|
|
155
|
+
}
|
|
156
|
+
for (const [dir, named] of byDir) {
|
|
157
|
+
if (named.length < 2) continue;
|
|
158
|
+
const onDisk = regularFilesIn(dir);
|
|
159
|
+
const unnamed = onDisk.filter((n) => !named.includes(n));
|
|
160
|
+
if (!unnamed.length) continue;
|
|
161
|
+
const shown = relative(resolve(config.root), dir);
|
|
162
|
+
listed.push({ role: r.name, named: named.length, total: onDisk.length,
|
|
163
|
+
where: shown ? shown + "/" : "the repo root", unnamed });
|
|
164
|
+
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One warning per finding, not one per role.
|
|
170
|
+
*
|
|
171
|
+
* These were emitted inside the role loop, which reads fine on the two-role
|
|
172
|
+
* fixture they were written against and falls apart on a real policy: 30
|
|
173
|
+
* roles produced 39 of these lines and took `seisin check` from 30 lines to
|
|
174
|
+
* **297**. That is the defect the detail text below is about — a check nobody
|
|
175
|
+
* finishes reading protects nobody — reached from the other side.
|
|
176
|
+
*
|
|
177
|
+
* So the count leads and a few examples follow. Whoever wants the full list
|
|
178
|
+
* has `seisin check <role>`, which is the question they were asking anyway.
|
|
179
|
+
*/
|
|
180
|
+
if (loose.length) {
|
|
181
|
+
const shown = loose.slice(0, 3).map((l) => `${l.role} (${l.n} of ${l.of})`).join(", ");
|
|
182
|
+
const more = loose.length > 3 ? `, and ${loose.length - 3} more` : "";
|
|
183
|
+
warnings.push({
|
|
184
|
+
kind: "siblings-uncovered",
|
|
185
|
+
headline:
|
|
186
|
+
`${loose.length} role(s) grant individual files rather than folders: ${shown}${more}`,
|
|
187
|
+
detail:
|
|
188
|
+
"the kernel grants exactly that path. Anything a tool creates beside it — a " +
|
|
189
|
+
"database's journal, a lock file, the temporary file of an atomic write — is a " +
|
|
190
|
+
"sibling, and a sibling is outside the grant; the failure arrives in the tool's " +
|
|
191
|
+
"own words, not as a permission error. Grant the folder where the tool needs " +
|
|
192
|
+
"neighbours. " +
|
|
193
|
+
(scoped
|
|
194
|
+
? `The files: ${loose[0].globs.join(" ")}.`
|
|
195
|
+
: "`seisin check <role>` names the files for one role."),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The one sibling that is not a guess: Claude Code's own.
|
|
201
|
+
*
|
|
202
|
+
* Claude Code's Edit and Write do not write the file. They write
|
|
203
|
+
* `<file>.tmp.<pid>.<12 hex>` beside it and rename it over, so a role that
|
|
204
|
+
* holds a file by name and not its folder is refused at the temporary file
|
|
205
|
+
* on every edit — EPERM, and the agent goes around it with a shell (measured:
|
|
206
|
+
* 4 EPERM in one real run). No grant can name that file: its name changes on
|
|
207
|
+
* every edit, and a write grant takes no glob (the runtime drops them; seisin
|
|
208
|
+
* refuses one above). So the choice is the folder, or the shell — said per
|
|
209
|
+
* role, which is where the decision is made.
|
|
210
|
+
*
|
|
211
|
+
* Said whatever agent the role runs: check cannot know, and a role that runs
|
|
212
|
+
* something else reads one line it can skip. Three roles by name and the rest
|
|
213
|
+
* as a count, for the reason the warning above gives.
|
|
214
|
+
*/
|
|
215
|
+
for (const l of loose.slice(0, 3)) {
|
|
216
|
+
// A file at the root has the whole repo for a folder, which is not a
|
|
217
|
+
// suggestion: then the move is to put it in a folder of its own.
|
|
218
|
+
const dirs = [...new Set(l.globs.map((g) => dirname(g)).filter((d) => d !== "."))].map((d) => `${d}/**`);
|
|
219
|
+
const fix = dirs.length
|
|
220
|
+
? `Either grant the folder (${dirs.join(", ")})`
|
|
221
|
+
: "Its folder is the repo root, so either move it into a folder the role can own whole and grant that";
|
|
222
|
+
warnings.push({
|
|
223
|
+
kind: "claude-atomic-write",
|
|
224
|
+
headline: `${l.role}: writes ${l.globs.slice(0, 3).join(" ")}${l.globs.length > 3 ? ` +${l.globs.length - 3}` : ""} by name — ` +
|
|
225
|
+
"Claude Code's Edit and Write on it fail (EPERM)",
|
|
226
|
+
detail:
|
|
227
|
+
"Claude Code edits a file by writing <file>.tmp.<pid>.<random> beside it and renaming it over; " +
|
|
228
|
+
"the temporary file is outside a grant on the file, and no grant can name it — its name changes " +
|
|
229
|
+
`on every edit and a write grant takes no glob. ${fix}, ` +
|
|
230
|
+
"or accept that the agent writes it with its shell (a redirect writes the file itself) instead of Edit. " +
|
|
231
|
+
"A role that does not run Claude Code can ignore this.",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (loose.length > 3)
|
|
235
|
+
warnings.push({
|
|
236
|
+
kind: "claude-atomic-write",
|
|
237
|
+
headline: `${loose.length - 3} more role(s) write files by name, where Claude Code's Edit and Write fail: seisin check <role>`,
|
|
238
|
+
detail: "",
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (listed.length) {
|
|
242
|
+
const shown = listed.slice(0, 3)
|
|
243
|
+
.map((l) => `${l.role} names ${l.named} of ${l.total} in ${l.where}`).join("; ");
|
|
244
|
+
const more = listed.length > 3 ? `; and ${listed.length - 3} more` : "";
|
|
245
|
+
warnings.push({
|
|
246
|
+
kind: "enumerated-territory",
|
|
247
|
+
headline: `${listed.length} territory(ies) list some files of a folder: ${shown}${more}`,
|
|
248
|
+
detail:
|
|
249
|
+
`not named, in the first of them: ${listed[0].unnamed.slice(0, 8).join(" ")}` +
|
|
250
|
+
`${listed[0].unnamed.length > 8 ? ` … and ${listed[0].unnamed.length - 8} more` : ""}` +
|
|
251
|
+
`. A sandbox grants a path, ` +
|
|
252
|
+
`not "the files of this kind here", so a territory that means that is the list of files ` +
|
|
253
|
+
`that existed when it was written — the next one added lands outside it, and nothing ` +
|
|
254
|
+
`fails loudly. Whether these are left out on purpose is not something a count can tell.`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return warnings;
|
|
258
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the tool cannot do at all — as opposed to `warnings`, which are things
|
|
3
|
+
* wrong with *this* config.
|
|
4
|
+
*
|
|
5
|
+
* Kept apart on purpose. A limit printed as a warning reads as something a
|
|
6
|
+
* person could fix, and after the third run it reads as nothing at all.
|
|
7
|
+
*
|
|
8
|
+
* They used to close `check --verbose`, six paragraphs under every repo's
|
|
9
|
+
* findings, identical in each one — so the part of the output about *this*
|
|
10
|
+
* repo was the part nobody reached. `check` now names how many there are and
|
|
11
|
+
* where to read them: `seisin help limits` (help.js prints this list).
|
|
12
|
+
* `inspect()` still returns them, so the MCP hands them out with the map.
|
|
13
|
+
*/
|
|
14
|
+
export const LIMITS = [
|
|
15
|
+
...(process.platform !== "darwin" ? [] : [{
|
|
16
|
+
kind: "kernel-denials-can-drop",
|
|
17
|
+
headline: "a flooded system log loses kernel denials before seisin reads them",
|
|
18
|
+
detail:
|
|
19
|
+
"the kernel's denials reach `seisin run` through `log stream`, and when something floods " +
|
|
20
|
+
"the unified log (thousands of lines a second) the stream drops messages before it filters " +
|
|
21
|
+
"them. The boundary holds; the denial files no request and is not in `seisin log`. A run that " +
|
|
22
|
+
"lost messages says so when it ends and logs `log-dropped`; `seisin check --system-log` " +
|
|
23
|
+
"measures it now and names who writes the most. The system's own copy (`log show`) does not " +
|
|
24
|
+
"help: it lacks the tag that ties a denial to its run.",
|
|
25
|
+
}]),
|
|
26
|
+
...(process.platform === "darwin" ? [] : [{
|
|
27
|
+
kind: "kernel-denials-unreadable",
|
|
28
|
+
headline: "denials by the kernel are not recorded on this platform",
|
|
29
|
+
detail:
|
|
30
|
+
"on macOS `seisin run` reads them out of the system log, so a denial the hook never " +
|
|
31
|
+
"saw still lands in `seisin log` with its owner and still queues a request. There is no " +
|
|
32
|
+
"equivalent to read here — bubblewrap does not log denials, and the runtime's substitute " +
|
|
33
|
+
"is not reachable from outside it. The boundary holds exactly as well; what you lose is " +
|
|
34
|
+
"seeing it work. Run `seisin wire` so the hook records what it can. The ask is upstream: " +
|
|
35
|
+
"github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
|
|
36
|
+
}]),
|
|
37
|
+
{
|
|
38
|
+
kind: "keys-only-what-you-declared",
|
|
39
|
+
headline: "key isolation covers the directory you declared, and nothing else",
|
|
40
|
+
detail:
|
|
41
|
+
"a credential sitting in the repo outside [keys] dir is ordinary readable " +
|
|
42
|
+
"content to every role. Run `seisin scan` to find them — check does not, " +
|
|
43
|
+
"because reading the whole tree on every invocation is how a command stops " +
|
|
44
|
+
"being run.",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
kind: "observe-cannot-see-the-network",
|
|
48
|
+
headline: "`--observe` opens the filesystem, never the network",
|
|
49
|
+
detail:
|
|
50
|
+
"so it cannot tell you which domains an agent needs. The runtime does record every " +
|
|
51
|
+
"denial the proxy makes — host, port and reason — but only a caller that embeds the " +
|
|
52
|
+
"library can read them; nothing reaches a caller that runs the binary, which is what " +
|
|
53
|
+
"seisin does. So for an agent whose endpoints are not published, the options are to " +
|
|
54
|
+
"find them another way or drop the network restriction for it. The ask is upstream: " +
|
|
55
|
+
"github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
|
|
56
|
+
},
|
|
57
|
+
...(process.platform === "linux" ? [{
|
|
58
|
+
kind: "control-files-linux",
|
|
59
|
+
headline: "a project's hooks and settings are protected only where they already exist",
|
|
60
|
+
detail:
|
|
61
|
+
"bubblewrap denies a path by mounting over it, and for a path that is not there it would " +
|
|
62
|
+
"create it on your disk. So on Linux a role can still create a .git/hooks entry or a " +
|
|
63
|
+
".claude/settings.json in a project of its territory that had none. On macOS the kernel " +
|
|
64
|
+
"denies creating them too.",
|
|
65
|
+
}] : []),
|
|
66
|
+
{
|
|
67
|
+
kind: "shared-scratch",
|
|
68
|
+
headline: "roles share ~/.cache, ~/.local/share and the temp dir unless isolate = \"home\"",
|
|
69
|
+
detail:
|
|
70
|
+
"programs installed there that are on your PATH are protected, and so are Claude Code's " +
|
|
71
|
+
"settings and plugins. Anything else a program outside the sandbox reads from those places — a " +
|
|
72
|
+
"cache it executes, a tool not on PATH — is writable by every role. isolate = \"home\" " +
|
|
73
|
+
"gives each role its own.",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
kind: "agent-sign-in",
|
|
77
|
+
headline: "~/.claude goes only to runs of claude, ~/.codex only to runs of codex",
|
|
78
|
+
detail:
|
|
79
|
+
"seisin reads the agent off the command after `--`, and a run is denied the other " +
|
|
80
|
+
"agents' sign-in files (~/.codex/auth.json, ~/.claude/.credentials.json). What a run " +
|
|
81
|
+
"of an agent can still read is its own: Codex and every command Codex runs share one " +
|
|
82
|
+
"sandbox, so with a ChatGPT sign-in kept in ~/.codex/auth.json they can read those tokens. " +
|
|
83
|
+
"Codex keeps them in the keychain instead with cli_auth_credentials_store = \"keyring\" " +
|
|
84
|
+
"in its config. A wrapper script is not followed: `seisin run <role> --agent codex -- ./script` " +
|
|
85
|
+
"names it. The protected paths `seisin check --verbose` lists are for every agent at once, " +
|
|
86
|
+
"because a role can be run as any of them.",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
kind: "unlink-uncovered",
|
|
90
|
+
headline: "a role can delete inside its own territory",
|
|
91
|
+
detail:
|
|
92
|
+
// The path that used to be here ships with the repo and not with the npm
|
|
93
|
+
// package, so for most readers it named a file they do not have.
|
|
94
|
+
"writes and deletes are one permission to the kernel, so `rm` inside a " +
|
|
95
|
+
"role's own folders succeeds. Closing it needs denyUnlink in the sandbox " +
|
|
96
|
+
"runtime: github.com/carlostapiacl/seisin#how-it-holds",
|
|
97
|
+
},
|
|
98
|
+
];
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { resolve, delimiter, join } from "node:path";
|
|
2
|
+
import { realOrSelf, runtimeWritesFor, toWritePath } from "../grants.js";
|
|
3
|
+
import { realAncestor } from "../paths.js";
|
|
4
|
+
import { wired, broadlyWired } from "../commands/wire.js";
|
|
5
|
+
|
|
6
|
+
export function relativePathWarnings() {
|
|
7
|
+
const warnings = [];
|
|
8
|
+
// PATH entries that are not absolute resolve against wherever the parent is
|
|
9
|
+
// standing, which is the repo. They are ignored for every lookup seisin makes
|
|
10
|
+
// outside the sandbox; said here because a lookup that skips them can find a
|
|
11
|
+
// different program than your shell does.
|
|
12
|
+
const unanchored = (process.env.PATH ?? "").split(delimiter).filter((d) => !d || !d.startsWith("/"));
|
|
13
|
+
if (unanchored.length)
|
|
14
|
+
warnings.push({
|
|
15
|
+
kind: "relative-path",
|
|
16
|
+
headline: `PATH has ${unanchored.length} relative entr${unanchored.length === 1 ? "y" : "ies"} (${unanchored.map((d) => JSON.stringify(d)).join(", ")})`,
|
|
17
|
+
detail: "They resolve inside the repo, where roles write, so seisin skips them when it looks for a program to run outside the sandbox.",
|
|
18
|
+
});
|
|
19
|
+
return warnings;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function scratchWarnings(config) {
|
|
23
|
+
const warnings = [];
|
|
24
|
+
// Scratch is granted to every role, so a repo sitting inside it is writable
|
|
25
|
+
// by all of them no matter what the territory says. Saying so is the
|
|
26
|
+
// difference between a limitation and a trap — and it is not hypothetical:
|
|
27
|
+
// the test fixture lived in the temp dir and made a boundary test pass for
|
|
28
|
+
// the wrong reason, and a role in a toy repo under /tmp wrote outside its
|
|
29
|
+
// `writes` with nothing on screen to say why.
|
|
30
|
+
// Both sides resolved, or the comparison is a coin flip. `expand()` follows
|
|
31
|
+
// symlinks — it has to, because on macOS /tmp is a link to /private/tmp and a
|
|
32
|
+
// grant written as /tmp grants nothing — so the root has to be followed too.
|
|
33
|
+
// Without this a repo at /tmp/x is never reported as sitting in scratch,
|
|
34
|
+
// which is the one case where the warning matters most.
|
|
35
|
+
//
|
|
36
|
+
// The scratch a role is really given, not the default list: `[runtime]
|
|
37
|
+
// writes` when the policy sets it, and none of it under `isolate = "home"`,
|
|
38
|
+
// where each role gets a home of its own instead (grants.js, writePathsOf).
|
|
39
|
+
const level = config.isolate === true ? "home" : config.isolate;
|
|
40
|
+
const scratch = level === "home" ? [] : runtimeWritesFor(config, undefined);
|
|
41
|
+
const under = (p) => scratch.find((s) => p === s || p.startsWith(s + "/"));
|
|
42
|
+
const fix = (s) => `Move it out of ${s}, or set [runtime] writes to a list that leaves ${s} out ` +
|
|
43
|
+
`(\`[runtime] writes = []\` gives no role any shared scratch).`;
|
|
44
|
+
const root = realOrSelf(resolve(config.root));
|
|
45
|
+
const around = under(root);
|
|
46
|
+
if (around) {
|
|
47
|
+
warnings.push({
|
|
48
|
+
kind: "scratch",
|
|
49
|
+
headline: `every role can write the whole repo: it lives inside ${around}, which every role gets as scratch`,
|
|
50
|
+
detail: "Territory does not hold here — a role's writes is no limit when the directory around the repo " +
|
|
51
|
+
`is granted to all of them. ${fix(around)}`,
|
|
52
|
+
});
|
|
53
|
+
return warnings;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The repo is outside scratch, and part of a territory still is: a `writes`
|
|
57
|
+
* entry that goes through a symlink (`out -> /tmp/out`) or up and out with
|
|
58
|
+
* `..`. The kernel enforces on the real path, so that part of the role's
|
|
59
|
+
* territory is every role's. Said per resolved path, once.
|
|
60
|
+
*/
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
for (const r of Object.values(config.roles)) {
|
|
63
|
+
for (const g of r.writes ?? []) {
|
|
64
|
+
let real;
|
|
65
|
+
try { real = realAncestor(join(config.root, toWritePath(g))); } catch { continue; }
|
|
66
|
+
const s = under(real);
|
|
67
|
+
if (!s || seen.has(real)) continue;
|
|
68
|
+
seen.add(real);
|
|
69
|
+
warnings.push({
|
|
70
|
+
kind: "territory-in-scratch",
|
|
71
|
+
headline: `every role can write ${r.name}'s ${g}: it resolves to ${real}, inside ${s}, which every role gets as scratch`,
|
|
72
|
+
detail: `That part of ${r.name}'s territory does not hold — the kernel enforces on the real path. ${fix(s)}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return warnings;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function hookWarnings(config) {
|
|
80
|
+
const warnings = [];
|
|
81
|
+
if (!wired(config.root))
|
|
82
|
+
warnings.push({
|
|
83
|
+
kind: "hook-not-wired",
|
|
84
|
+
headline: "only denials are being recorded: the agent has not been told to run the hook",
|
|
85
|
+
detail:
|
|
86
|
+
process.platform === "darwin"
|
|
87
|
+
? "the boundary holds either way, and denials are still logged and still queue a " +
|
|
88
|
+
"request — `seisin run` reads them from the kernel. What is missing is every " +
|
|
89
|
+
"action that was allowed, so `review` cannot tell you which grants are unused and " +
|
|
90
|
+
"`init --from-observations` has nothing to build from. Run `seisin wire` once in " +
|
|
91
|
+
"this repo."
|
|
92
|
+
: "the boundary holds either way — but on this platform the kernel's denials are " +
|
|
93
|
+
"not readable either, so log, watch, requests, grant and review all read a record " +
|
|
94
|
+
"that nobody is writing. Run `seisin wire` once in this repo.",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
else if (broadlyWired(config.root))
|
|
98
|
+
warnings.push({
|
|
99
|
+
kind: "hook-matcher-broad",
|
|
100
|
+
headline: "the hook runs on every tool call: it was wired before 0.5.0 with the matcher \"*\"",
|
|
101
|
+
detail:
|
|
102
|
+
"nothing is lost — every tool the hook reads is still covered — but each Read, Glob " +
|
|
103
|
+
"and TodoWrite starts a process that has nothing to say. Run `seisin wire` again: it " +
|
|
104
|
+
"narrows seisin's own entry in place and leaves everyone else's hooks alone.",
|
|
105
|
+
});
|
|
106
|
+
return warnings;
|
|
107
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function modelEndpointWarnings(config, roles) {
|
|
2
|
+
const warnings = [];
|
|
3
|
+
/**
|
|
4
|
+
* A role that can write code and cannot reach any inference API.
|
|
5
|
+
*
|
|
6
|
+
* Reported three separate times, by three different people, with the same
|
|
7
|
+
* shape each time: a list of what agents need, written somewhere that does
|
|
8
|
+
* not know which agent will run. The last instance cost six bench runs that
|
|
9
|
+
* finished in eight seconds with `403 Connection blocked by network
|
|
10
|
+
* allowlist` — which in a results table reads as "this model cannot do the
|
|
11
|
+
* task", not as a policy error.
|
|
12
|
+
*
|
|
13
|
+
* What is deliberately NOT here: a registry of agents and their endpoints.
|
|
14
|
+
* That is the same defect in a new place, and the reporter said so before I
|
|
15
|
+
* could. The list below is only ever consulted to decide whether to print a
|
|
16
|
+
* sentence, so being wrong about it costs a false warning — never a broken
|
|
17
|
+
* run. That asymmetry is the whole reason it is allowed to exist, and the
|
|
18
|
+
* warning says out loud that the list is ours and may not know your agent.
|
|
19
|
+
*/
|
|
20
|
+
const INFERENCE = /(anthropic|openai|googleapis|generativelanguage|bedrock|azure|openrouter|mistral|groq|together|deepseek|x\.ai|cohere|ollama|localhost|127\.0\.0\.1)/i;
|
|
21
|
+
for (const r of roles) {
|
|
22
|
+
const domains = r.network ?? config.allowedDomains ?? [];
|
|
23
|
+
if (domains.some((d) => INFERENCE.test(d))) continue;
|
|
24
|
+
warnings.push({
|
|
25
|
+
kind: "no-model-endpoint",
|
|
26
|
+
headline: domains.length
|
|
27
|
+
? `${r.name} has ${domains.length} allowed domain(s) and none looks like a model API`
|
|
28
|
+
: `${r.name} may reach no network at all`,
|
|
29
|
+
detail:
|
|
30
|
+
"an agent that cannot reach its own model fails at startup with a 403 from the " +
|
|
31
|
+
"egress proxy, which reads as a broken install or a bad model rather than a policy. " +
|
|
32
|
+
"This check knows a handful of providers and will not know yours — it is a question, " +
|
|
33
|
+
"not a verdict.",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return warnings;
|
|
37
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { entriesOf } from "../keys.js";
|
|
3
|
+
import { resolveExecutable } from "../surface.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Would the parent find this command? The same lookup `run` makes
|
|
7
|
+
* (resolveExecutable), so check does not call "missing" a program that is
|
|
8
|
+
* only in a directory a role writes — that has its own warning, above.
|
|
9
|
+
*/
|
|
10
|
+
function onPath(cmd, config) {
|
|
11
|
+
try {
|
|
12
|
+
// A path is resolved against the policy and not looked up, so it is there
|
|
13
|
+
// only if it exists.
|
|
14
|
+
return existsSync(resolveExecutable(cmd, config));
|
|
15
|
+
} catch (e) {
|
|
16
|
+
return /a role can write/.test(e.message);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function providerInTerritoryWarnings(config) {
|
|
21
|
+
const warnings = [];
|
|
22
|
+
// A provider the parent could not find where no role writes. `run` refuses
|
|
23
|
+
// it with the same sentence; here it is found before anybody runs anything.
|
|
24
|
+
for (const [scheme, p] of Object.entries(config.keyProviders ?? {})) {
|
|
25
|
+
const cmd = p.command?.[0];
|
|
26
|
+
if (!cmd || cmd.includes("/")) continue;
|
|
27
|
+
try { resolveExecutable(cmd, config); } catch (e) {
|
|
28
|
+
if (!/a role can write/.test(e.message)) continue;
|
|
29
|
+
warnings.push({
|
|
30
|
+
kind: "provider-in-territory",
|
|
31
|
+
headline: `the ${scheme}:// provider "${cmd}" is only found where a role can write`,
|
|
32
|
+
detail: e.message.split("\n").slice(1).map((l) => l.trim()).join(" "),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return warnings;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function providerMissingWarnings(config) {
|
|
40
|
+
const warnings = [];
|
|
41
|
+
/**
|
|
42
|
+
* A provider whose command is not on PATH.
|
|
43
|
+
*
|
|
44
|
+
* This is the half of validating references that can be done without
|
|
45
|
+
* resolving one — which is the whole requirement: a broken policy has to be
|
|
46
|
+
* visible in `check`, not on the first run, and certainly not by asking
|
|
47
|
+
* somebody's keychain for a password to find out. The scheme and the
|
|
48
|
+
* provider are checked when the config loads, so by the time we are here
|
|
49
|
+
* they exist; what is left is whether the command does.
|
|
50
|
+
*
|
|
51
|
+
* A warning and not an error: the binary can legitimately be missing on the
|
|
52
|
+
* machine reading the policy and present on the one that runs it, which is
|
|
53
|
+
* every CI checkout.
|
|
54
|
+
*/
|
|
55
|
+
const used = new Set();
|
|
56
|
+
for (const r of Object.values(config.roles))
|
|
57
|
+
for (const k of entriesOf(r)) if (k.kind === "ref") used.add(k.scheme);
|
|
58
|
+
for (const scheme of [...used].sort()) {
|
|
59
|
+
const provider = config.keyProviders?.[scheme];
|
|
60
|
+
if (!provider || onPath(provider.command[0], config)) continue;
|
|
61
|
+
warnings.push({
|
|
62
|
+
kind: "provider-missing",
|
|
63
|
+
headline: `the ${scheme} provider runs "${provider.command[0]}", which is not on PATH`,
|
|
64
|
+
detail:
|
|
65
|
+
"every key of that scheme will stop the run rather than resolve. Nothing is " +
|
|
66
|
+
"substituted for a key that did not resolve, so this fails closed — but it fails " +
|
|
67
|
+
"at the start of a run, not here.",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return warnings;
|
|
71
|
+
}
|