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/inspect.js
CHANGED
|
@@ -8,15 +8,15 @@
|
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
import { unknownRole } from "./suggest.js";
|
|
12
|
+
import { protections } from "./surface.js";
|
|
13
|
+
import { LIMITS } from "./inspect/limits.js";
|
|
14
|
+
import { sharedPaths } from "./inspect/shared.js";
|
|
15
|
+
import { codexSignIn } from "./inspect/codex.js";
|
|
16
|
+
import { warningsFor } from "./inspect/warnings.js";
|
|
17
|
+
|
|
18
|
+
// The warnings live in src/inspect/, one family per file; this is the entry point.
|
|
19
|
+
export { sharedPaths, codexSignIn };
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* A report on one config: the roles, and every way the map lies.
|
|
@@ -26,12 +26,18 @@ import { protections, resolveExecutable } from "./surface.js";
|
|
|
26
26
|
* answering it with silence is how a typo becomes a belief.
|
|
27
27
|
*/
|
|
28
28
|
export function inspect(config, only = null, where = config.path) {
|
|
29
|
-
if (only && !config.roles[only]) throw
|
|
29
|
+
if (only && !config.roles[only]) throw unknownRole(config, only);
|
|
30
30
|
const roles = only ? [config.roles[only]] : Object.values(config.roles);
|
|
31
|
+
// Once: it is listed on its own and again as a warning, and it is the most
|
|
32
|
+
// expensive thing check computes.
|
|
33
|
+
const shared = sharedPaths(config, roles);
|
|
31
34
|
|
|
32
35
|
return {
|
|
33
36
|
where,
|
|
34
|
-
roles: roles.map((r) => ({ name: r.name, writes: r.writes, keys: r.keys,
|
|
37
|
+
roles: roles.map((r) => ({ name: r.name, writes: r.writes, keys: r.keys,
|
|
38
|
+
reads: r.reads ?? [], toolchain: r.toolchain ?? [], verify: r.verify,
|
|
39
|
+
neverWrites: r.neverWrites ?? [], private: r.private ?? [], localBinding: r.localBinding === true,
|
|
40
|
+
localPorts: r.localPorts ?? [], mcp: r.mcp ?? null, trustd: r.trustd === true })),
|
|
35
41
|
/**
|
|
36
42
|
* The provider commands this config would run, listed because they are the
|
|
37
43
|
* one thing in a `seisin.toml` that **executes**, and it executes in the
|
|
@@ -43,758 +49,16 @@ export function inspect(config, only = null, where = config.path) {
|
|
|
43
49
|
providers: Object.values(config.keyProviders ?? {}).map((p) => ({
|
|
44
50
|
name: p.name, command: p.command, mode: p.mode,
|
|
45
51
|
})),
|
|
46
|
-
shared
|
|
52
|
+
shared,
|
|
47
53
|
/**
|
|
48
|
-
* What the kernel will
|
|
54
|
+
* What the kernel will deny a role inside its own territory, and why:
|
|
49
55
|
* programs installed where a role writes, the hooks and settings of each
|
|
50
56
|
* project, provider scripts and `file://` targets. Each takes something
|
|
51
57
|
* away from a territory as written, so it is shown rather than applied
|
|
52
58
|
* quietly. See surface.js for the family and the measurements.
|
|
53
59
|
*/
|
|
54
60
|
protected: protections(config, roles),
|
|
55
|
-
warnings: warningsFor(config, roles),
|
|
61
|
+
warnings: warningsFor(config, roles, shared, { scoped: !!only }),
|
|
56
62
|
limits: LIMITS,
|
|
57
63
|
};
|
|
58
64
|
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* What the tool cannot do at all — as opposed to `warnings`, which are things
|
|
62
|
-
* wrong with *this* config.
|
|
63
|
-
*
|
|
64
|
-
* Kept apart on purpose. A limit printed as a warning reads as something the
|
|
65
|
-
* operator could fix, and after the third run it reads as nothing at all.
|
|
66
|
-
*
|
|
67
|
-
* These are the sentences someone needs before they trust the map on screen
|
|
68
|
-
* more than it deserves, so `check` prints them every time rather than behind a
|
|
69
|
-
* flag: the moment to learn that deletes are not covered is while you are
|
|
70
|
-
* looking at the policy, not afterwards.
|
|
71
|
-
*/
|
|
72
|
-
const LIMITS = [
|
|
73
|
-
...(process.platform === "darwin" ? [] : [{
|
|
74
|
-
kind: "kernel-denials-unreadable",
|
|
75
|
-
headline: "refusals by the kernel are not recorded on this platform",
|
|
76
|
-
detail:
|
|
77
|
-
"on macOS `seisin run` reads them out of the system log, so a denial the hook never " +
|
|
78
|
-
"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 refusals, and the runtime's substitute " +
|
|
80
|
-
"is not reachable from outside it. The boundary holds exactly as well; what you lose is " +
|
|
81
|
-
"seeing it work. Run `seisin wire` so the hook records what it can. The ask is upstream: " +
|
|
82
|
-
"github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
|
|
83
|
-
}]),
|
|
84
|
-
{
|
|
85
|
-
kind: "keys-only-what-you-declared",
|
|
86
|
-
headline: "key isolation covers the directory you declared, and nothing else",
|
|
87
|
-
detail:
|
|
88
|
-
"a credential sitting in the repo outside [keys] dir is ordinary readable " +
|
|
89
|
-
"content to every role. Run `seisin scan` to find them — check does not, " +
|
|
90
|
-
"because reading the whole tree on every invocation is how a command stops " +
|
|
91
|
-
"being run.",
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
kind: "observe-cannot-see-the-network",
|
|
95
|
-
headline: "`--observe` opens the filesystem, never the network",
|
|
96
|
-
detail:
|
|
97
|
-
"so it cannot tell you which domains an agent needs. The runtime does record every " +
|
|
98
|
-
"refusal the proxy makes — host, port and reason — but only a caller that embeds the " +
|
|
99
|
-
"library can read them; nothing reaches a caller that runs the binary, which is what " +
|
|
100
|
-
"seisin does. So for an agent whose endpoints are not published, the options are to " +
|
|
101
|
-
"find them another way or drop the network restriction for it. The ask is upstream: " +
|
|
102
|
-
"github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
|
|
103
|
-
},
|
|
104
|
-
...(process.platform === "linux" ? [{
|
|
105
|
-
kind: "control-files-linux",
|
|
106
|
-
headline: "a project's hooks and settings are protected only where they already exist",
|
|
107
|
-
detail:
|
|
108
|
-
"bubblewrap denies a path by mounting over it, and for a path that is not there it would " +
|
|
109
|
-
"create it on your disk. So on Linux a role can still create a .git/hooks entry or a " +
|
|
110
|
-
".claude/settings.json in a project of its territory that had none. On macOS the kernel " +
|
|
111
|
-
"refuses creating them too.",
|
|
112
|
-
}] : []),
|
|
113
|
-
{
|
|
114
|
-
kind: "shared-scratch",
|
|
115
|
-
headline: "roles share ~/.cache, ~/.local/share and the temp dir unless isolate = \"home\"",
|
|
116
|
-
detail:
|
|
117
|
-
"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 box reads from those places — a " +
|
|
119
|
-
"cache it executes, a tool not on PATH — is writable by every role. isolate = \"home\" " +
|
|
120
|
-
"gives each role its own.",
|
|
121
|
-
},
|
|
122
|
-
{
|
|
123
|
-
kind: "unlink-uncovered",
|
|
124
|
-
headline: "a role can delete inside its own territory",
|
|
125
|
-
detail:
|
|
126
|
-
// The path that used to be here ships with the repo and not with the npm
|
|
127
|
-
// package, so for most readers it named a file they do not have.
|
|
128
|
-
"writes and deletes are one permission to the kernel, so `rm` inside a " +
|
|
129
|
-
"role's own folders succeeds. Closing it needs denyUnlink in the sandbox " +
|
|
130
|
-
"runtime: github.com/carlostapiacl/seisin#how-it-holds",
|
|
131
|
-
},
|
|
132
|
-
];
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* What is wrong with a role table's keys, as opposed to its paths.
|
|
136
|
-
*
|
|
137
|
-
* Two things, both about `never_writes` and both ways a subtraction can look
|
|
138
|
-
* written and not be:
|
|
139
|
-
*
|
|
140
|
-
* - a key seisin does not know. It used to be dropped without a word, which
|
|
141
|
-
* for `writes` is a role with less than it thinks, and for a misspelt
|
|
142
|
-
* `never_writes` is a role with MORE than its owner thinks — the one
|
|
143
|
-
* direction a permission file must not fail in. Named with the nearest
|
|
144
|
-
* known key when there is one close enough to be the intended spelling.
|
|
145
|
-
* - a `never_writes` entry that no `writes` of the same role covers. It
|
|
146
|
-
* subtracts from nothing, so it is either a typo in the path or a rule
|
|
147
|
-
* that stopped applying when the territory moved. Either way it reads as
|
|
148
|
-
* protection and is not.
|
|
149
|
-
*
|
|
150
|
-
* Silent when `never_writes` is absent. Absent is valid; a check that asks for
|
|
151
|
-
* the key would turn an optional subtraction into a required migration.
|
|
152
|
-
*/
|
|
153
|
-
function roleKeyWarnings(roles, config = null) {
|
|
154
|
-
const warnings = [];
|
|
155
|
-
for (const r of roles) {
|
|
156
|
-
const enforced = new Set(enforcedNeverWrites(config, r));
|
|
157
|
-
const skipped = (r.neverWritesDeclared ?? r.neverWrites ?? []).filter((g) => !enforced.has(g));
|
|
158
|
-
if (skipped.length)
|
|
159
|
-
warnings.push({
|
|
160
|
-
kind: "never-writes-not-enforced-here",
|
|
161
|
-
headline: `${r.name}: never_writes ${skipped.join(" ")} — not enforced on Linux until it exists`,
|
|
162
|
-
detail: "bubblewrap denies a path by mounting over it, and would create a missing one on the host " +
|
|
163
|
-
"for the whole run — for a lock file, locking everyone else out, and for good if the role is " +
|
|
164
|
-
"killed. seisin skips those, and explain says allowed for them, so the sentence matches the kernel.",
|
|
165
|
-
});
|
|
166
|
-
for (const k of r.unknownKeys ?? []) {
|
|
167
|
-
const near = nearest(k, ROLE_KEYS);
|
|
168
|
-
warnings.push({
|
|
169
|
-
kind: "unknown-role-key",
|
|
170
|
-
headline: `${r.name}: unknown key "${k}" in [roles.${r.name}] — ignored` +
|
|
171
|
-
(near ? `. Did you mean "${near}"?` : ""),
|
|
172
|
-
detail: `Known keys: ${ROLE_KEYS.join(", ")}. ` +
|
|
173
|
-
(near === "never_writes"
|
|
174
|
-
? "As written this subtracts nothing: the role can still write every path it lists."
|
|
175
|
-
: "Nothing in it takes effect."),
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
// macOS only, and not by omission: on Linux the runtime removes the network
|
|
179
|
-
// namespace, so every role already has a loopback of its own — it can serve
|
|
180
|
-
// and reach its own server with or without the key, and reaches nothing on
|
|
181
|
-
// the host either way (measured in Docker, Debian 12, bwrap 0.8.0).
|
|
182
|
-
if (r.localBinding && process.platform === "darwin")
|
|
183
|
-
warnings.push({
|
|
184
|
-
kind: "local-binding-reaches-localhost",
|
|
185
|
-
headline: `${r.name}: local_binding lets it connect to every port on localhost, not only listen`,
|
|
186
|
-
detail: "The runtime grants bind on any interface, inbound, and outbound to localhost:*. " +
|
|
187
|
-
"Anything listening on this machine without authentication — a local control API, a " +
|
|
188
|
-
"database, a dev server — is within this role's reach, outside its territory.",
|
|
189
|
-
});
|
|
190
|
-
// Both at once is a config that says "these ports" and grants "every port".
|
|
191
|
-
// macOS only: on Linux local_binding reaches nothing on the host, so there
|
|
192
|
-
// local_ports is the only one of the two that does anything.
|
|
193
|
-
if (r.localBinding && r.localPorts?.length && process.platform === "darwin")
|
|
194
|
-
warnings.push({
|
|
195
|
-
kind: "local-ports-moot",
|
|
196
|
-
headline: `${r.name}: local_ports names ${r.localPorts.join(", ")}, but local_binding already opens every localhost port`,
|
|
197
|
-
detail: "local_binding grants outbound to localhost:* in the kernel profile, so the port list " +
|
|
198
|
-
"restricts nothing for this role. Drop local_binding if the role only needs to reach " +
|
|
199
|
-
"those ports; keep it only if the role has to listen.",
|
|
200
|
-
});
|
|
201
|
-
// trustd is a macOS service; on Linux the key changes nothing.
|
|
202
|
-
if (r.trustd && process.platform === "darwin")
|
|
203
|
-
warnings.push({
|
|
204
|
-
kind: "trustd-open",
|
|
205
|
-
headline: `${r.name}: trustd = true opens com.apple.trustd.agent, a path out that the domain list does not see`,
|
|
206
|
-
detail: "trustd verifies certificates for Go (before 1.27) and Dart on macOS, and to do it fetches, " +
|
|
207
|
-
"outside the sandbox, the URLs a certificate carries. The runtime calls it an exfiltration vector; " +
|
|
208
|
-
"no public demonstration exists. Go 1.27+ does not need it: seisin sets SSL_CERT_FILE.",
|
|
209
|
-
});
|
|
210
|
-
for (const g of r.neverWrites ?? []) {
|
|
211
|
-
if (r.writes.some((w) => covers(w, g) || covers(w, g.replace(/\/\*\*$/, ""))))
|
|
212
|
-
continue;
|
|
213
|
-
warnings.push({
|
|
214
|
-
kind: "never-writes-subtracts-nothing",
|
|
215
|
-
headline: `${r.name}: never_writes "${g}" is not inside any of its writes`,
|
|
216
|
-
detail: "It denies something that was already denied, so it protects nothing. " +
|
|
217
|
-
"Check the path, or drop the entry if the territory moved.",
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
return warnings;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** The candidate within edit distance 3 of `word`, or null. */
|
|
225
|
-
function nearest(word, candidates) {
|
|
226
|
-
// Spellings of the same idea in another vocabulary, checked first because
|
|
227
|
-
// letters mislead here: `no_writes` is three edits from `writes`, and
|
|
228
|
-
// suggesting the grant for a misspelt subtraction is the worst answer.
|
|
229
|
-
if (/deny|never|no_?write/i.test(word)) return "never_writes";
|
|
230
|
-
let best = null, bestD = 4;
|
|
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];
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Paths more than one role claims.
|
|
253
|
-
*
|
|
254
|
-
* Overlap is allowed and sometimes right — a hotfix role deliberately reaches
|
|
255
|
-
* into the territory of the role that normally owns a repo. It is reported so
|
|
256
|
-
* that it stays a decision somebody made rather than a thing that drifted.
|
|
257
|
-
*/
|
|
258
|
-
export function sharedPaths(config, roles = Object.values(config.roles)) {
|
|
259
|
-
const seen = new Set();
|
|
260
|
-
for (const r of roles)
|
|
261
|
-
for (const glob of r.writes)
|
|
262
|
-
if (ownersOf(config, glob.replace(/\/\*\*$/, "")).length > 1) seen.add(glob);
|
|
263
|
-
/**
|
|
264
|
-
* A shared database counts once, not four times.
|
|
265
|
-
*
|
|
266
|
-
* `writes` carries the sidecar expansion so that the grant and the sentence
|
|
267
|
-
* agree (see config.js), and this list read straight off it turned nine
|
|
268
|
-
* shared things into thirty-three — the same name with three suffixes,
|
|
269
|
-
* announced as if it were three more decisions to make. The expansion exists
|
|
270
|
-
* to stop a policy saying one thing four times; a warning that undoes that
|
|
271
|
-
* is the feature leaking into the place it was meant to clean up.
|
|
272
|
-
*
|
|
273
|
-
* A sidecar shared WITHOUT its database still counts on its own, because
|
|
274
|
-
* that is a real and odd thing to have.
|
|
275
|
-
*/
|
|
276
|
-
const todas = new Set(seen);
|
|
277
|
-
return [...seen].filter((p) => !(/-(wal|shm|journal)$/.test(p) && todas.has(p.replace(/-(wal|shm|journal)$/, ""))));
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* What a role can still read in YOUR home, which is the limit people are most
|
|
282
|
-
* surprised by.
|
|
283
|
-
*
|
|
284
|
-
* `~/.ssh`, `~/.aws/credentials`, `~/.npmrc` and `~/.config/gh/hosts.yml` are
|
|
285
|
-
* ordinary readable files to every role unless `[runtime] isolate` says
|
|
286
|
-
* otherwise, and nothing on screen said so. Reads outside the key directories
|
|
287
|
-
* being open is a deliberate trade - an agent that cannot read the machine
|
|
288
|
-
* cannot work - but a trade nobody was shown is not a trade they made.
|
|
289
|
-
*
|
|
290
|
-
* It reports the level rather than pushing one. `credentials` closes those four
|
|
291
|
-
* and leaves the agent logged in; `home` closes the agent's own directories too
|
|
292
|
-
* and costs the session. Which of the two you want is the threat model, which
|
|
293
|
-
* is why there is no default beyond off.
|
|
294
|
-
*/
|
|
295
|
-
function homeReachWarning(config) {
|
|
296
|
-
const level = config.isolate === true ? "home" : config.isolate;
|
|
297
|
-
if (level === "home") return null; // nothing left to say
|
|
298
|
-
|
|
299
|
-
const where = CREDENTIAL_HOMES.slice(0, 4).join(" ");
|
|
300
|
-
return level === "credentials"
|
|
301
|
-
? {
|
|
302
|
-
kind: "home-partly-open",
|
|
303
|
-
headline: "your credentials are closed to every role; the agent's own directories are not",
|
|
304
|
-
detail:
|
|
305
|
-
`isolate = "credentials" denies ${where} and the rest. What stays open is ` +
|
|
306
|
-
"~/.claude and ~/.codex, which is what keeps the agent logged in, so the roles " +
|
|
307
|
-
'share one session and can read each other. `isolate = "home"` closes those ' +
|
|
308
|
-
"too, and then every CLI in the box will ask you to log in again.",
|
|
309
|
-
}
|
|
310
|
-
: {
|
|
311
|
-
kind: "home-open",
|
|
312
|
-
headline: `every role can read ${where} and the rest of your home`,
|
|
313
|
-
detail:
|
|
314
|
-
"reads outside the [keys] dir are open on purpose - an agent that cannot read the " +
|
|
315
|
-
"machine cannot work - but that includes the places credentials live. " +
|
|
316
|
-
'`[runtime] isolate = "credentials"` closes them, and the agent stays logged in.',
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
/** Every way this policy does not hold, each with what to do about it. */
|
|
322
|
-
/**
|
|
323
|
-
* Credential-shaped names at the top of the repository, and nothing deeper.
|
|
324
|
-
*
|
|
325
|
-
* One `readdir`, no recursion, conventional names only. It exists to answer
|
|
326
|
-
* "does this repo plausibly have secrets" fast enough to run on every `check`,
|
|
327
|
-
* not to find them — `seisin scan` is the one that looks properly.
|
|
328
|
-
*/
|
|
329
|
-
const CREDENTIAL_NAMES = /^(\.env(\..+)?|\.secrets|secrets|\.netrc|.*\.(pem|key|p12|pfx))$/i;
|
|
330
|
-
|
|
331
|
-
function credentialish(root) {
|
|
332
|
-
let entries;
|
|
333
|
-
try {
|
|
334
|
-
entries = readdirSync(root, { withFileTypes: true });
|
|
335
|
-
} catch {
|
|
336
|
-
return [];
|
|
337
|
-
}
|
|
338
|
-
const found = new Set(entries.filter((e) => CREDENTIAL_NAMES.test(e.name)).map((e) => e.name));
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* And the ones whose name gives nothing away.
|
|
342
|
-
*
|
|
343
|
-
* The name test misses `credentials.txt`, `tokens.conf`, `config.local` —
|
|
344
|
-
* which a field review found by writing a test file this did not catch, and
|
|
345
|
-
* then blaming the test. The test was fine; the detector was name-only.
|
|
346
|
-
*
|
|
347
|
-
* So the root's small text files are also read, with the same shapes
|
|
348
|
-
* `seisin scan` uses. Root only, small only, and it stops at the first hit
|
|
349
|
-
* per file: this runs on every `check` and the full walk is `scan`'s job.
|
|
350
|
-
*/
|
|
351
|
-
const CERTAIN = SHAPES.filter(([c]) => c === "certain").map(([, , re]) => re);
|
|
352
|
-
for (const e of entries) {
|
|
353
|
-
if (found.size >= 4) break;
|
|
354
|
-
if (!e.isFile() || found.has(e.name)) continue;
|
|
355
|
-
try {
|
|
356
|
-
const p = join(root, e.name);
|
|
357
|
-
if (statSync(p).size > 64 * 1024) continue;
|
|
358
|
-
const text = readFileSync(p, "utf8");
|
|
359
|
-
if (CERTAIN.some((re) => re.test(text))) found.add(e.name);
|
|
360
|
-
} catch {
|
|
361
|
-
/* unreadable, or not text: not our business here */
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
return [...found].sort().slice(0, 4);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/** Is this a command the shell would find? Walks PATH; asks no shell. */
|
|
368
|
-
function onPath(cmd) {
|
|
369
|
-
if (cmd.includes("/")) return existsSync(cmd);
|
|
370
|
-
const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
371
|
-
return dirs.some((d) => existsSync(join(d, cmd)));
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function warningsFor(config, roles) {
|
|
375
|
-
const warnings = [];
|
|
376
|
-
|
|
377
|
-
// A pattern the kernel cannot be given is not a policy, it is a sentence that
|
|
378
|
-
// reads like one. `run` refuses it; `check` exists precisely so you find that
|
|
379
|
-
// out while reading the map rather than mid-turn.
|
|
380
|
-
for (const r of roles) {
|
|
381
|
-
try {
|
|
382
|
-
settingsFor(config, r.name);
|
|
383
|
-
} catch (e) {
|
|
384
|
-
// Whatever settingsFor refuses, `run` will refuse too. The label used to
|
|
385
|
-
// say "unenforceable-glob" for every one of them, which mislabelled the
|
|
386
|
-
// key-outside-its-directory error as a glob problem — a warning that
|
|
387
|
-
// names the wrong cause sends the reader to the wrong line.
|
|
388
|
-
warnings.push({
|
|
389
|
-
kind: "cannot-be-enforced",
|
|
390
|
-
headline: `${r.name}: ${e.message.split("\n")[0]}`,
|
|
391
|
-
detail: e.message.split("\n").slice(1).map((s) => s.trim()).filter(Boolean).join(" "),
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
warnings.push(...roleKeyWarnings(roles, config));
|
|
396
|
-
|
|
397
|
-
// A provider the parent could not find where no role writes. `run` refuses
|
|
398
|
-
// it with the same sentence; here it is found before anybody runs anything.
|
|
399
|
-
for (const [scheme, p] of Object.entries(config.keyProviders ?? {})) {
|
|
400
|
-
const cmd = p.command?.[0];
|
|
401
|
-
if (!cmd || cmd.includes("/")) continue;
|
|
402
|
-
try { resolveExecutable(cmd, config); } catch (e) {
|
|
403
|
-
if (!/a role can write/.test(e.message)) continue;
|
|
404
|
-
warnings.push({
|
|
405
|
-
kind: "provider-in-territory",
|
|
406
|
-
headline: `the ${scheme}:// provider "${cmd}" is only found where a role can write`,
|
|
407
|
-
detail: e.message.split("\n").slice(1).map((l) => l.trim()).join(" "),
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// PATH entries that are not absolute resolve against wherever the parent is
|
|
413
|
-
// standing, which is the repo. They are ignored for every lookup seisin makes
|
|
414
|
-
// outside the box; said here because a lookup that skips them can find a
|
|
415
|
-
// different program than your shell does.
|
|
416
|
-
const unanchored = (process.env.PATH ?? "").split(delimiter).filter((d) => !d || !d.startsWith("/"));
|
|
417
|
-
if (unanchored.length)
|
|
418
|
-
warnings.push({
|
|
419
|
-
kind: "relative-path",
|
|
420
|
-
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 box.",
|
|
422
|
-
});
|
|
423
|
-
const shared = sharedPaths(config, roles);
|
|
424
|
-
|
|
425
|
-
if (shared.length)
|
|
426
|
-
warnings.push({
|
|
427
|
-
kind: "shared",
|
|
428
|
-
headline: `${shared.length} path(s) claimed by more than one role: ${shared.join(" ")}`,
|
|
429
|
-
detail: "Overlap is allowed — seisin will name every owner. It is listed so it stays a decision.",
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
// Scratch is granted to every role, so a repo sitting inside it is writable
|
|
433
|
-
// by all of them no matter what the territory says. Saying so is the
|
|
434
|
-
// difference between a limitation and a trap — and it is not hypothetical:
|
|
435
|
-
// the test fixture lived in the temp dir and made a boundary test pass for
|
|
436
|
-
// the wrong reason.
|
|
437
|
-
// Both sides resolved, or the comparison is a coin flip. `expand()` follows
|
|
438
|
-
// symlinks — it has to, because on macOS /tmp is a link to /private/tmp and a
|
|
439
|
-
// grant written as /tmp grants nothing — so the root has to be followed too.
|
|
440
|
-
// Without this a repo at /tmp/x is never reported as sitting in scratch,
|
|
441
|
-
// which is the one case where the warning matters most.
|
|
442
|
-
const scratch = (config.runtimeWrites ?? RUNTIME_WRITES).map(expand);
|
|
443
|
-
const root = realOrSelf(resolve(config.root));
|
|
444
|
-
const inside = scratch.filter((s) => root === s || root.startsWith(s + "/"));
|
|
445
|
-
if (inside.length)
|
|
446
|
-
warnings.push({
|
|
447
|
-
kind: "scratch",
|
|
448
|
-
headline: `this repo lives inside shared scratch space (${inside[0]})`,
|
|
449
|
-
detail: "Every role can write scratch, so territory does not hold here. Move the repo, or set [runtime] writes = [].",
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* A role that can write code and cannot reach any inference API.
|
|
454
|
-
*
|
|
455
|
-
* Reported three separate times, by three different people, with the same
|
|
456
|
-
* shape each time: a list of what agents need, written somewhere that does
|
|
457
|
-
* not know which agent will run. The last instance cost six bench runs that
|
|
458
|
-
* finished in eight seconds with `403 Connection blocked by network
|
|
459
|
-
* allowlist` — which in a results table reads as "this model cannot do the
|
|
460
|
-
* task", not as a policy error.
|
|
461
|
-
*
|
|
462
|
-
* What is deliberately NOT here: a registry of agents and their endpoints.
|
|
463
|
-
* That is the same defect in a new place, and the reporter said so before I
|
|
464
|
-
* could. The list below is only ever consulted to decide whether to print a
|
|
465
|
-
* sentence, so being wrong about it costs a false warning — never a broken
|
|
466
|
-
* run. That asymmetry is the whole reason it is allowed to exist, and the
|
|
467
|
-
* warning says out loud that the list is ours and may not know your agent.
|
|
468
|
-
*/
|
|
469
|
-
const INFERENCE = /(anthropic|openai|googleapis|generativelanguage|bedrock|azure|openrouter|mistral|groq|together|deepseek|x\.ai|cohere|ollama|localhost|127\.0\.0\.1)/i;
|
|
470
|
-
for (const r of roles) {
|
|
471
|
-
const domains = r.network ?? config.allowedDomains ?? [];
|
|
472
|
-
if (domains.some((d) => INFERENCE.test(d))) continue;
|
|
473
|
-
warnings.push({
|
|
474
|
-
kind: "no-model-endpoint",
|
|
475
|
-
headline: domains.length
|
|
476
|
-
? `${r.name} has ${domains.length} allowed domain(s) and none looks like a model API`
|
|
477
|
-
: `${r.name} may reach no network at all`,
|
|
478
|
-
detail:
|
|
479
|
-
"an agent that cannot reach its own model fails at startup with a 403 from the " +
|
|
480
|
-
"egress proxy, which reads as a broken install or a bad model rather than a policy. " +
|
|
481
|
-
"This check knows a handful of providers and will not know yours — it is a question, " +
|
|
482
|
-
"not a verdict.",
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Without the hook, half the record is missing rather than all of it: on macOS
|
|
487
|
-
// `seisin run` reads the kernel's own refusals, so denials still land and
|
|
488
|
-
// still queue a request. What goes unrecorded is everything the kernel
|
|
489
|
-
// allowed — which is the half `init --from-observations` is built out of, and
|
|
490
|
-
// the half that tells "nothing was denied" apart from "nobody was watching".
|
|
491
|
-
// The one limit that is about YOUR machine rather than about the repo.
|
|
492
|
-
const reach = homeReachWarning(config);
|
|
493
|
-
if (reach) warnings.push(reach);
|
|
494
|
-
|
|
495
|
-
if (!wired(config.root))
|
|
496
|
-
warnings.push({
|
|
497
|
-
kind: "hook-not-wired",
|
|
498
|
-
headline: "only denials are being recorded: the agent has not been told to run the hook",
|
|
499
|
-
detail:
|
|
500
|
-
process.platform === "darwin"
|
|
501
|
-
? "the boundary holds either way, and refusals are still logged and still queue a " +
|
|
502
|
-
"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 dead and " +
|
|
504
|
-
"`init --from-observations` has nothing to build from. Run `seisin wire` once in " +
|
|
505
|
-
"this repo."
|
|
506
|
-
: "the boundary holds either way — but on this platform the kernel's refusals are " +
|
|
507
|
-
"not readable either, so log, watch, requests, grant and review all read a record " +
|
|
508
|
-
"that nobody is writing. Run `seisin wire` once in this repo.",
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
// A territory that leaves the repo is supported — a role can own its handover
|
|
512
|
-
// note one level up — but it is a different promise from "this repo, divided",
|
|
513
|
-
// and it should be a sentence somebody chose rather than a line that drifted.
|
|
514
|
-
const loose = []; // roles granting individual files
|
|
515
|
-
const listed = []; // role + folder pairs that enumerate
|
|
516
|
-
|
|
517
|
-
for (const r of roles) {
|
|
518
|
-
const out = r.writes.filter((w) => w.startsWith("/") || w.startsWith("../"));
|
|
519
|
-
if (out.length)
|
|
520
|
-
warnings.push({
|
|
521
|
-
kind: "territory-outside-repo",
|
|
522
|
-
headline: `${r.name} writes outside this repo: ${out.join(" ")}`,
|
|
523
|
-
detail: "the sandbox will grant it. Nothing here checks what lives there.",
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* A file granted by name does not come with its neighbours.
|
|
529
|
-
*
|
|
530
|
-
* Measured, in production, and it survived two human reviews of the policy:
|
|
531
|
-
* a role was granted its database file and still could not write the
|
|
532
|
-
* database. The database opens a second file beside the first — a journal —
|
|
533
|
-
* and the sibling was outside the grant, so the write failed in the
|
|
534
|
-
* database's own words ("readonly") rather than as a permission error.
|
|
535
|
-
*
|
|
536
|
-
* What is deliberately NOT here: a list of which programs write which
|
|
537
|
-
* siblings. That would ask seisin to know about databases, and the next
|
|
538
|
-
* question is why it does not know about a lock file, the temporary file of
|
|
539
|
-
* an atomic write, or an editor's backup. The list has no end and every entry
|
|
540
|
-
* is a guess about a repo nobody foresaw. The general form needs no list: the
|
|
541
|
-
* kernel grants a path and everything under it, so a grant on a file covers
|
|
542
|
-
* the file and nothing else. Whether a tool will want a neighbour is the
|
|
543
|
-
* reader's to know; that the neighbour is not granted is arithmetic.
|
|
544
|
-
*/
|
|
545
|
-
for (const r of roles) {
|
|
546
|
-
const { files, subtrees } = territoryOf(config, r);
|
|
547
|
-
// A file inside one of the role's own subtrees has its siblings covered by
|
|
548
|
-
// that subtree, so naming it changes nothing and warns about nothing.
|
|
549
|
-
const alone = files.filter((f) => !subtrees.some((s) => under(dirname(f.abs), s)));
|
|
550
|
-
if (alone.length)
|
|
551
|
-
loose.push({ role: r.name, n: alone.length, of: r.writes.length, globs: alone.map((f) => f.glob) });
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* A territory written as a list of files is a photograph of the folder.
|
|
556
|
-
*
|
|
557
|
-
* A sandbox cannot say "the source files in this folder" — one level down
|
|
558
|
-
* is not a prefix, and `src/*` is refused above for exactly that reason,
|
|
559
|
-
* correctly. So a territory that means that has to be written as the files
|
|
560
|
-
* that exist today, and the day a file is added the policy is one behind:
|
|
561
|
-
* the owning role cannot write its own new file, and nothing fails loudly.
|
|
562
|
-
*
|
|
563
|
-
* Reported as a count, never as a verdict. "Comparable" here means only
|
|
564
|
-
* "a regular file in the same folder" — not the same extension, not the
|
|
565
|
-
* same shape of name, because either of those is a guess about what the
|
|
566
|
-
* territory *meant*, and a guess about intent is the thing this check
|
|
567
|
-
* exists to avoid. The unnamed files are listed by name instead, so the
|
|
568
|
-
* reader can see in one glance whether the gap is an oversight or a
|
|
569
|
-
* decision. seisin cannot tell, and does not pretend to.
|
|
570
|
-
*
|
|
571
|
-
* Silent when a folder has fewer than two named files (one file is a
|
|
572
|
-
* grant, not an enumeration) and when the folder is also granted as a
|
|
573
|
-
* subtree (then the list is decorative).
|
|
574
|
-
*/
|
|
575
|
-
const byDir = new Map();
|
|
576
|
-
for (const f of alone) {
|
|
577
|
-
const dir = dirname(f.abs);
|
|
578
|
-
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
579
|
-
byDir.get(dir).push(basename(f.abs));
|
|
580
|
-
}
|
|
581
|
-
for (const [dir, named] of byDir) {
|
|
582
|
-
if (named.length < 2) continue;
|
|
583
|
-
const onDisk = regularFilesIn(dir);
|
|
584
|
-
const unnamed = onDisk.filter((n) => !named.includes(n));
|
|
585
|
-
if (!unnamed.length) continue;
|
|
586
|
-
const shown = relative(resolve(config.root), dir);
|
|
587
|
-
const more = unnamed.length > 8 ? ` … and ${unnamed.length - 8} more` : "";
|
|
588
|
-
listed.push({ role: r.name, named: named.length, total: onDisk.length,
|
|
589
|
-
where: shown ? shown + "/" : "the repo root", unnamed });
|
|
590
|
-
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/**
|
|
595
|
-
* One warning per finding, not one per role.
|
|
596
|
-
*
|
|
597
|
-
* These were emitted inside the role loop, which reads fine on the two-role
|
|
598
|
-
* fixture they were written against and falls apart on a real policy: 30
|
|
599
|
-
* roles produced 39 of these lines and took `seisin check` from 30 lines to
|
|
600
|
-
* **297**. That is the defect the detail text below is about — a check nobody
|
|
601
|
-
* finishes reading protects nobody — reached from the other side.
|
|
602
|
-
*
|
|
603
|
-
* So the count leads and a few examples follow. Whoever wants the full list
|
|
604
|
-
* has `seisin check <role>`, which is the question they were asking anyway.
|
|
605
|
-
*/
|
|
606
|
-
if (loose.length) {
|
|
607
|
-
const shown = loose.slice(0, 3).map((l) => `${l.role} (${l.n} of ${l.of})`).join(", ");
|
|
608
|
-
const more = loose.length > 3 ? `, and ${loose.length - 3} more` : "";
|
|
609
|
-
warnings.push({
|
|
610
|
-
kind: "siblings-uncovered",
|
|
611
|
-
headline:
|
|
612
|
-
`${loose.length} role(s) grant individual files rather than folders: ${shown}${more}`,
|
|
613
|
-
detail:
|
|
614
|
-
"the kernel grants exactly that path. Anything a tool creates beside it — a " +
|
|
615
|
-
"database's journal, a lock file, the temporary file of an atomic write — is a " +
|
|
616
|
-
"sibling, and a sibling is outside the grant; the failure arrives in the tool's " +
|
|
617
|
-
"own words, not as a permission error. Grant the folder where the tool needs " +
|
|
618
|
-
"neighbours. `seisin check <role>` names the paths for one role.",
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
if (listed.length) {
|
|
623
|
-
const shown = listed.slice(0, 3)
|
|
624
|
-
.map((l) => `${l.role} names ${l.named} of ${l.total} in ${l.where}`).join("; ");
|
|
625
|
-
const more = listed.length > 3 ? `; and ${listed.length - 3} more` : "";
|
|
626
|
-
warnings.push({
|
|
627
|
-
kind: "enumerated-territory",
|
|
628
|
-
headline: `${listed.length} territory(ies) list some files of a folder: ${shown}${more}`,
|
|
629
|
-
detail:
|
|
630
|
-
`not named, in the first of them: ${listed[0].unnamed.slice(0, 8).join(" ")}` +
|
|
631
|
-
`${listed[0].unnamed.length > 8 ? ` … and ${listed[0].unnamed.length - 8} more` : ""}` +
|
|
632
|
-
`. A sandbox grants a path, ` +
|
|
633
|
-
`not "the files of this kind here", so a territory that means that is the list of files ` +
|
|
634
|
-
`that existed when it was written — the next one added lands outside it, and nothing ` +
|
|
635
|
-
`fails loudly. Whether these are left out on purpose is not something a count can tell.`,
|
|
636
|
-
});
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// `env` is a hole in the [keys] model, on purpose: some tools only take a
|
|
640
|
-
// credential through the environment. It should still be loud, because it is
|
|
641
|
-
// the one place a secret reaches a role without being a declared key.
|
|
642
|
-
const SECRETISH = /(TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTH|COOKIE|SESSION|PRIVATE|CREDENTIAL)/i;
|
|
643
|
-
for (const r of roles) {
|
|
644
|
-
const risky = (r.env ?? []).filter((n) => SECRETISH.test(n));
|
|
645
|
-
if (risky.length)
|
|
646
|
-
warnings.push({
|
|
647
|
-
kind: "secret-through-env",
|
|
648
|
-
headline: `${r.name} receives ${risky.join(", ")} through the environment`,
|
|
649
|
-
detail:
|
|
650
|
-
"that bypasses [keys] entirely: it is not scoped, not redacted by name, and not " +
|
|
651
|
-
"visible in the key map. Declare it as a key file where the tool allows one.",
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
/**
|
|
656
|
-
* A policy with no credential floor at all, in a repo that has credentials.
|
|
657
|
-
*
|
|
658
|
-
* `[keys] dir` is what produces `denyRead`. Without it the emitted settings
|
|
659
|
-
* carry an empty deny list, so every role reads every secret in the
|
|
660
|
-
* repository — and until this warning, nothing said so. The existing
|
|
661
|
-
* `keys-unscoped` only fires when a role *lists* keys, which is the case
|
|
662
|
-
* where somebody already thought about it.
|
|
663
|
-
*
|
|
664
|
-
* The dangerous shape is the other one, and it is not hypothetical: a policy
|
|
665
|
-
* generated by a script. The deployment this was built against generates its
|
|
666
|
-
* `seisin.toml` from a Python program, and declares `[keys] dir` mostly so
|
|
667
|
-
* `scan` skips those directories. A generator that dropped that one line
|
|
668
|
-
* would hand every role the credential tree and produce a clean `check`.
|
|
669
|
-
*
|
|
670
|
-
* Credit where it is due: this is `L-01` of a code review of another
|
|
671
|
-
* project's contract type, which makes its credential floor impossible to
|
|
672
|
-
* construct empty. That is the right shape and it is a bigger change than a
|
|
673
|
-
* warning. This is the cheap half — and the review's claim that the file
|
|
674
|
-
* path was safe turned out to be generous: it was not.
|
|
675
|
-
*
|
|
676
|
-
* Deliberately NOT a full `seisin scan`: that walks the tree and `check` has
|
|
677
|
-
* to stay instant. A shallow look at the repository root catches the
|
|
678
|
-
* conventional places and costs one `readdir`.
|
|
679
|
-
*/
|
|
680
|
-
if ((config.keyDirs ?? []).length === 0) {
|
|
681
|
-
const found = credentialish(config.root);
|
|
682
|
-
if (found.length)
|
|
683
|
-
warnings.push({
|
|
684
|
-
kind: "no-key-floor",
|
|
685
|
-
headline: `no [keys] dir is declared, and this repo has ${found.join(", ")} — every role can read them`,
|
|
686
|
-
detail:
|
|
687
|
-
"Without a key directory the emitted policy denies no reads at all. Declare the " +
|
|
688
|
-
"directory your secrets live in, and the roles that may read each one.",
|
|
689
|
-
});
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
// Only a key that is a PATH needs a key directory. A config whose secrets all
|
|
693
|
-
// live in a vault has no `[keys] dir` and nothing wrong with it, and warning
|
|
694
|
-
// there would be the tool insisting on the arrangement it was built for.
|
|
695
|
-
if (config.keyDirs.length === 0 &&
|
|
696
|
-
Object.values(config.roles).some((r) => entriesOf(r).some((k) => k.kind === "file")))
|
|
697
|
-
warnings.push({
|
|
698
|
-
kind: "keys-unscoped",
|
|
699
|
-
headline: "keys are listed but [keys] dir is unset — nothing will be scoped",
|
|
700
|
-
detail: null,
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
/**
|
|
704
|
-
* A provider whose command is not on PATH.
|
|
705
|
-
*
|
|
706
|
-
* This is the half of validating references that can be done without
|
|
707
|
-
* resolving one — which is the whole requirement: a broken policy has to be
|
|
708
|
-
* visible in `check`, not on the first run, and certainly not by asking
|
|
709
|
-
* somebody's keychain for a password to find out. The scheme and the
|
|
710
|
-
* provider are checked when the config loads, so by the time we are here
|
|
711
|
-
* they exist; what is left is whether the command does.
|
|
712
|
-
*
|
|
713
|
-
* A warning and not an error: the binary can legitimately be missing on the
|
|
714
|
-
* machine reading the policy and present on the one that runs it, which is
|
|
715
|
-
* every CI checkout.
|
|
716
|
-
*/
|
|
717
|
-
const used = new Set();
|
|
718
|
-
for (const r of Object.values(config.roles))
|
|
719
|
-
for (const k of entriesOf(r)) if (k.kind === "ref") used.add(k.scheme);
|
|
720
|
-
for (const scheme of [...used].sort()) {
|
|
721
|
-
const provider = config.keyProviders?.[scheme];
|
|
722
|
-
if (!provider || onPath(provider.command[0])) continue;
|
|
723
|
-
warnings.push({
|
|
724
|
-
kind: "provider-missing",
|
|
725
|
-
headline: `the ${scheme} provider runs "${provider.command[0]}", which is not on PATH`,
|
|
726
|
-
detail:
|
|
727
|
-
"every key of that scheme will stop the run rather than resolve. Nothing is " +
|
|
728
|
-
"substituted for a key that did not resolve, so this fails closed — but it fails " +
|
|
729
|
-
"at the start of a turn, not here.",
|
|
730
|
-
});
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
return warnings;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* A role's territory, sorted by what the kernel will do with each line.
|
|
738
|
-
*
|
|
739
|
-
* Three shapes get through `settingsFor` (see EXPRESSIBLE in srt.js): the whole
|
|
740
|
-
* repo, a subtree `x/**`, and a literal path. The literal path is the one the
|
|
741
|
-
* text cannot settle on its own — `data/base.sqlite` and `data` are both
|
|
742
|
-
* literal, and to the kernel the second is a subtree because it is a directory.
|
|
743
|
-
* So the disk is asked, once per line: a regular file is a file, a directory is
|
|
744
|
-
* a subtree, and anything else — absent, a symlink, a socket — goes in neither
|
|
745
|
-
* count rather than being guessed at. That is one `lstat` per declared path,
|
|
746
|
-
* bounded by the length of the policy and not the size of the repo, which is
|
|
747
|
-
* the line `check` stays on.
|
|
748
|
-
*
|
|
749
|
-
* Subtrees come back as the absolute prefix the kernel will be handed, so that
|
|
750
|
-
* "is this file's folder inside that grant" is the same comparison the kernel
|
|
751
|
-
* makes — a prefix — and not a second reading of the glob.
|
|
752
|
-
*/
|
|
753
|
-
function territoryOf(config, role) {
|
|
754
|
-
const root = resolve(config.root);
|
|
755
|
-
const abs = (p) => resolve(root, p);
|
|
756
|
-
const files = [];
|
|
757
|
-
const subtrees = [];
|
|
758
|
-
for (const glob of role.writes) {
|
|
759
|
-
if (glob === "**" || glob.endsWith("/**")) {
|
|
760
|
-
subtrees.push(abs(glob === "**" ? "." : glob.slice(0, -3)));
|
|
761
|
-
continue;
|
|
762
|
-
}
|
|
763
|
-
if (/[*?[\]]/.test(glob)) continue; // already refused above as cannot-be-enforced
|
|
764
|
-
const s = lstatOrNull(abs(glob));
|
|
765
|
-
if (s?.isFile()) files.push({ glob, abs: abs(glob) });
|
|
766
|
-
else if (s?.isDirectory()) subtrees.push(abs(glob));
|
|
767
|
-
}
|
|
768
|
-
return { files, subtrees };
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
/** Is `p` the prefix, or somewhere beneath it. The kernel's own test, in one line. */
|
|
772
|
-
function under(p, prefix) {
|
|
773
|
-
return p === prefix || p.startsWith(prefix + "/");
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/** The regular files directly inside `dir`, by name. Not folders, not links, not deeper. */
|
|
777
|
-
function regularFilesIn(dir) {
|
|
778
|
-
try {
|
|
779
|
-
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isFile()).map((d) => d.name).sort();
|
|
780
|
-
} catch {
|
|
781
|
-
return [];
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
function lstatOrNull(p) {
|
|
786
|
-
try {
|
|
787
|
-
return lstatSync(p);
|
|
788
|
-
} catch {
|
|
789
|
-
return null;
|
|
790
|
-
}
|
|
791
|
-
}
|
|
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
|
-
}
|