seisin 0.1.1 → 0.3.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 +287 -339
- package/package.json +7 -6
- package/src/cli.js +17 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/init.js +1 -1
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +8 -3
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +180 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +30 -0
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +310 -5
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +45 -5
- package/src/index.js +12 -0
- package/src/inspect.js +332 -9
- package/src/intake.js +215 -0
- package/src/keys.js +585 -0
- package/src/log.js +96 -2
- package/src/mcp.js +87 -11
- package/src/nested.js +86 -0
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +63 -1
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +263 -19
- package/src/spool.js +98 -20
- package/src/srt.js +148 -203
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +184 -0
- package/ui/favicon.svg +1 -1
- package/ui/index.html +585 -56
package/src/inspect.js
CHANGED
|
@@ -8,11 +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 { resolve, dirname, basename, relative } from "node:path";
|
|
12
|
-
import { realpathSync, lstatSync, readdirSync } from "node:fs";
|
|
13
|
-
import { ownersOf } from "./owners.js";
|
|
11
|
+
import { resolve, dirname, basename, relative, join, delimiter } from "node:path";
|
|
12
|
+
import { realpathSync, lstatSync, readdirSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { ownersOf, covers, enforcedNeverWrites } from "./owners.js";
|
|
14
|
+
import { ROLE_KEYS } from "./config.js";
|
|
15
|
+
import { entriesOf } from "./keys.js";
|
|
16
|
+
import { SHAPES } from "./scan.js";
|
|
14
17
|
import { wired } from "./commands/wire.js";
|
|
15
18
|
import { RUNTIME_WRITES, CREDENTIAL_HOMES, expand, settingsFor } from "./srt.js";
|
|
19
|
+
import { protections, resolveExecutable } from "./surface.js";
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* A report on one config: the roles, and every way the map lies.
|
|
@@ -27,8 +31,27 @@ export function inspect(config, only = null, where = config.path) {
|
|
|
27
31
|
|
|
28
32
|
return {
|
|
29
33
|
where,
|
|
30
|
-
roles: roles.map((r) => ({ name: r.name, writes: r.writes, keys: r.keys })),
|
|
34
|
+
roles: roles.map((r) => ({ name: r.name, writes: r.writes, keys: r.keys, neverWrites: r.neverWrites ?? [], localBinding: r.localBinding === true, localPorts: r.localPorts ?? [], mcp: r.mcp ?? null, trustd: r.trustd === true })),
|
|
35
|
+
/**
|
|
36
|
+
* The provider commands this config would run, listed because they are the
|
|
37
|
+
* one thing in a `seisin.toml` that **executes**, and it executes in the
|
|
38
|
+
* parent, unsandboxed, as you. Everything else in the file only describes a
|
|
39
|
+
* boundary. `check` runs nothing, so this is where you see them first — and
|
|
40
|
+
* seeing them first is the whole point when the file came with a repo you
|
|
41
|
+
* cloned rather than one you wrote.
|
|
42
|
+
*/
|
|
43
|
+
providers: Object.values(config.keyProviders ?? {}).map((p) => ({
|
|
44
|
+
name: p.name, command: p.command, mode: p.mode,
|
|
45
|
+
})),
|
|
31
46
|
shared: sharedPaths(config, roles),
|
|
47
|
+
/**
|
|
48
|
+
* What the kernel will refuse a role inside its own territory, and why:
|
|
49
|
+
* programs installed where a role writes, the hooks and settings of each
|
|
50
|
+
* project, provider scripts and `file://` targets. Each takes something
|
|
51
|
+
* away from a territory as written, so it is shown rather than applied
|
|
52
|
+
* quietly. See surface.js for the family and the measurements.
|
|
53
|
+
*/
|
|
54
|
+
protected: protections(config, roles),
|
|
32
55
|
warnings: warningsFor(config, roles),
|
|
33
56
|
limits: LIMITS,
|
|
34
57
|
};
|
|
@@ -56,7 +79,7 @@ const LIMITS = [
|
|
|
56
79
|
"equivalent to read here — bubblewrap does not log refusals, and the runtime's substitute " +
|
|
57
80
|
"is not reachable from outside it. The boundary holds exactly as well; what you lose is " +
|
|
58
81
|
"seeing it work. Run `seisin wire` so the hook records what it can. The ask is upstream: " +
|
|
59
|
-
"github.com/
|
|
82
|
+
"github.com/carlostapiacl/seisin/blob/main/docs/upstream/cli-violations.md",
|
|
60
83
|
}]),
|
|
61
84
|
{
|
|
62
85
|
kind: "keys-only-what-you-declared",
|
|
@@ -76,7 +99,25 @@ const LIMITS = [
|
|
|
76
99
|
"library can read them; nothing reaches a caller that runs the binary, which is what " +
|
|
77
100
|
"seisin does. So for an agent whose endpoints are not published, the options are to " +
|
|
78
101
|
"find them another way or drop the network restriction for it. The ask is upstream: " +
|
|
79
|
-
"github.com/
|
|
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.",
|
|
80
121
|
},
|
|
81
122
|
{
|
|
82
123
|
kind: "unlink-uncovered",
|
|
@@ -86,10 +127,127 @@ const LIMITS = [
|
|
|
86
127
|
// package, so for most readers it named a file they do not have.
|
|
87
128
|
"writes and deletes are one permission to the kernel, so `rm` inside a " +
|
|
88
129
|
"role's own folders succeeds. Closing it needs denyUnlink in the sandbox " +
|
|
89
|
-
"runtime: github.com/
|
|
130
|
+
"runtime: github.com/carlostapiacl/seisin#how-it-holds",
|
|
90
131
|
},
|
|
91
132
|
];
|
|
92
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
|
+
|
|
93
251
|
/**
|
|
94
252
|
* Paths more than one role claims.
|
|
95
253
|
*
|
|
@@ -102,7 +260,21 @@ export function sharedPaths(config, roles = Object.values(config.roles)) {
|
|
|
102
260
|
for (const r of roles)
|
|
103
261
|
for (const glob of r.writes)
|
|
104
262
|
if (ownersOf(config, glob.replace(/\/\*\*$/, "")).length > 1) seen.add(glob);
|
|
105
|
-
|
|
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)$/, ""))));
|
|
106
278
|
}
|
|
107
279
|
|
|
108
280
|
/**
|
|
@@ -147,6 +319,58 @@ function homeReachWarning(config) {
|
|
|
147
319
|
|
|
148
320
|
|
|
149
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
|
+
|
|
150
374
|
function warningsFor(config, roles) {
|
|
151
375
|
const warnings = [];
|
|
152
376
|
|
|
@@ -168,6 +392,34 @@ function warningsFor(config, roles) {
|
|
|
168
392
|
});
|
|
169
393
|
}
|
|
170
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
|
+
});
|
|
171
423
|
const shared = sharedPaths(config, roles);
|
|
172
424
|
|
|
173
425
|
if (shared.length)
|
|
@@ -400,13 +652,84 @@ function warningsFor(config, roles) {
|
|
|
400
652
|
});
|
|
401
653
|
}
|
|
402
654
|
|
|
403
|
-
|
|
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")))
|
|
404
697
|
warnings.push({
|
|
405
698
|
kind: "keys-unscoped",
|
|
406
699
|
headline: "keys are listed but [keys] dir is unset — nothing will be scoped",
|
|
407
700
|
detail: null,
|
|
408
701
|
});
|
|
409
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
|
+
|
|
410
733
|
return warnings;
|
|
411
734
|
}
|
|
412
735
|
|
package/src/intake.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parent's intake: what arrives during a run, checked, and written down.
|
|
3
|
+
*
|
|
4
|
+
* Two sources, and they are not equally trustworthy. The hook runs inside the
|
|
5
|
+
* box, so everything it sends is a claim by the process being recorded. The
|
|
6
|
+
* kernel is the thing that actually refused. Both end up as log lines and,
|
|
7
|
+
* sometimes, as requests in front of a person; this module is where each is
|
|
8
|
+
* recomputed from the policy rather than believed.
|
|
9
|
+
*
|
|
10
|
+
* Every line written here carries two fields that make it evidence rather
|
|
11
|
+
* than a sentence:
|
|
12
|
+
*
|
|
13
|
+
* - `run`, the id of the run that produced it — the same id as the nonce in
|
|
14
|
+
* the kernel's command tag and the run's private directory (rundir.js).
|
|
15
|
+
* Before, the log had no run marker and "how many runs since" was guessed
|
|
16
|
+
* from 10-minute gaps.
|
|
17
|
+
* - `policy`, a hash of the policy file the run STARTED with. The kernel
|
|
18
|
+
* enforces that one for the whole run, whatever happens to the file
|
|
19
|
+
* afterwards, and `review` reading today's policy over last week's lines
|
|
20
|
+
* could not tell a grant made since from one that was always there.
|
|
21
|
+
*
|
|
22
|
+
* Pulled out of run.js, which held both handlers inline between the spawn and
|
|
23
|
+
* the signal handling — 180 lines of policy arithmetic inside the launcher.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { toRepoRelative } from "./paths.js";
|
|
29
|
+
import { explain, ownersOf } from "./owners.js";
|
|
30
|
+
import { append, logPath } from "./log.js";
|
|
31
|
+
import { record, requestsPath } from "./requests.js";
|
|
32
|
+
import { inScope, scopeOf, reachedForContent } from "./violations.js";
|
|
33
|
+
|
|
34
|
+
/** A hash of the policy file as it is now, short: it names a version, it proves nothing. */
|
|
35
|
+
export function policyId(config) {
|
|
36
|
+
try {
|
|
37
|
+
return createHash("sha256").update(readFileSync(config.path)).digest("hex").slice(0, 12);
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The handlers for one run.
|
|
45
|
+
*
|
|
46
|
+
* `fromHook(to, entry)` is the spool's sink; `fromKernel(denial)` is
|
|
47
|
+
* watchDenials' callback; `stats` counts what the kernel said that was left
|
|
48
|
+
* out on purpose, for the line at the end of the run.
|
|
49
|
+
*/
|
|
50
|
+
export function intake({ config, role, runId, observe = false, settings, notify }) {
|
|
51
|
+
const stamp = { run: runId.slice(0, 8), policy: policyId(config) };
|
|
52
|
+
const log = (entry) => append(logPath(config.root), { ...entry, ...stamp });
|
|
53
|
+
const ask = (asked) => { record(requestsPath(config.root), asked); notify?.maybe(asked); };
|
|
54
|
+
|
|
55
|
+
const scope = scopeOf(settings, config.root);
|
|
56
|
+
const keyDirs = (config.keyDirs ?? []).map((d) => (d.startsWith("/") ? d : join(config.root, d)));
|
|
57
|
+
const connects = new Set(); // one line per refused target per run
|
|
58
|
+
const stats = { offPolicy: 0, walks: 0 };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Nothing from inside the box is taken at its word.
|
|
62
|
+
*
|
|
63
|
+
* The sender is the process being recorded, so every field it supplies is
|
|
64
|
+
* a claim. Two of them matter. `role` decides whose request this is — left
|
|
65
|
+
* alone, a frontend agent could file one as backend and wait for a human to
|
|
66
|
+
* approve it. `owners` decides who the queue says it belongs to, and the
|
|
67
|
+
* parent can work that out itself from the policy.
|
|
68
|
+
*
|
|
69
|
+
* So the role is overwritten with the role of this run, the owners are
|
|
70
|
+
* recomputed, and anything shaped wrong is dropped. Forging a *log* line is
|
|
71
|
+
* noise. Forging a *request* is a sentence placed in front of a person for
|
|
72
|
+
* approval, and that is a different thing entirely.
|
|
73
|
+
*/
|
|
74
|
+
function fromHook(to, entry) {
|
|
75
|
+
if (!entry || typeof entry !== "object") return;
|
|
76
|
+
if (entry.action !== "read" && entry.action !== "write") return;
|
|
77
|
+
if (typeof entry.target !== "string" || !entry.target.trim()) return;
|
|
78
|
+
|
|
79
|
+
if (to === "log") {
|
|
80
|
+
// The same treatment the queue already got. The role was overwritten but
|
|
81
|
+
// everything else was passed through, so a process inside the box could
|
|
82
|
+
// write "allowed" lines for paths it never touched — which would not move
|
|
83
|
+
// the boundary, but would move `review`, and `init --from-observations`
|
|
84
|
+
// builds a policy out of exactly this.
|
|
85
|
+
if (!["allowed", "denied", "observed"].includes(entry.verdict)) return;
|
|
86
|
+
/**
|
|
87
|
+
* The verdict is the hook's account of what it did, so it stays — but it
|
|
88
|
+
* is checked. The parent works out what the policy this run started with
|
|
89
|
+
* says, and when the two differ the line carries `disputed` with the
|
|
90
|
+
* parent's answer. Two causes, both worth seeing: the policy file changed
|
|
91
|
+
* since the run started (the hook reads it fresh, the kernel does not), or
|
|
92
|
+
* the line was not written by the hook at all.
|
|
93
|
+
*/
|
|
94
|
+
const expected = observe
|
|
95
|
+
? "observed"
|
|
96
|
+
: explain(config, role, entry.action, entry.target).allowed ? "allowed" : "denied";
|
|
97
|
+
return void log({
|
|
98
|
+
at: entry.at,
|
|
99
|
+
role,
|
|
100
|
+
tool: String(entry.tool ?? "").slice(0, 40),
|
|
101
|
+
action: entry.action,
|
|
102
|
+
kind: entry.kind === "key" ? "key" : "file",
|
|
103
|
+
target: entry.target.slice(0, 1000),
|
|
104
|
+
verdict: entry.verdict,
|
|
105
|
+
owners: ownersOf(config, entry.target), // recomputed, never taken
|
|
106
|
+
reason: String(entry.reason ?? "").slice(0, 500),
|
|
107
|
+
...(expected !== entry.verdict ? { disputed: expected } : {}),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The policy has to actually refuse it, or it does not belong in a queue.
|
|
113
|
+
*
|
|
114
|
+
* Nothing checked. A process inside the box could file a request for a path
|
|
115
|
+
* its own role already owns, and the queue would print *first refused on
|
|
116
|
+
* …* about something that was never refused — a sentence placed in front of
|
|
117
|
+
* a person for approval, describing an event that did not happen.
|
|
118
|
+
*
|
|
119
|
+
* Same treatment as `owners` above: the claim is recomputed here rather
|
|
120
|
+
* than believed. It is the policy answering a question about itself, which
|
|
121
|
+
* is arithmetic, and it is the parent asking — the one process the confined
|
|
122
|
+
* side cannot reach.
|
|
123
|
+
*
|
|
124
|
+
* This does not require the agent to have *tried*. Asking for a permission
|
|
125
|
+
* before reaching for it is a reasonable thing to do, and the wording says
|
|
126
|
+
* "asked" rather than "refused" for exactly that case. What it rules out is
|
|
127
|
+
* a request for something that is not refused at all — nor for a
|
|
128
|
+
* `never_writes` path, git's metadata, or a protected one, none of which a
|
|
129
|
+
* grant can open.
|
|
130
|
+
*/
|
|
131
|
+
const v = explain(config, role, entry.action, entry.target);
|
|
132
|
+
if (v.allowed || v.neverWrites || v.gitMetadata || v.protected) return;
|
|
133
|
+
// The owners explain found: for a key that is who declares it, not who
|
|
134
|
+
// writes the directory it sits in — the queue said "owned by dev" about
|
|
135
|
+
// backend's key because dev writes `**`.
|
|
136
|
+
ask({ role, action: entry.action, target: entry.target, owners: v.owners });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function fromKernel(d) {
|
|
140
|
+
/**
|
|
141
|
+
* A refused connection: logged, never queued.
|
|
142
|
+
*
|
|
143
|
+
* Once per target per run, because the thing that dials a closed port is
|
|
144
|
+
* usually a readiness loop, and a loop polling a database every half second
|
|
145
|
+
* would otherwise write the log the size of the wait. And no request: a port
|
|
146
|
+
* is not a territory anyone owns, so there is nobody to hand it to — the
|
|
147
|
+
* sentence says what the policy would have to change instead.
|
|
148
|
+
*/
|
|
149
|
+
if (d.action === "connect") {
|
|
150
|
+
if (connects.has(d.path)) return;
|
|
151
|
+
connects.add(d.path);
|
|
152
|
+
const v = explain(config, role, "connect", d.path);
|
|
153
|
+
log({
|
|
154
|
+
at: new Date().toISOString(), role, tool: "kernel", source: "kernel",
|
|
155
|
+
action: "connect", kind: "network", target: d.path, verdict: "denied",
|
|
156
|
+
owners: [], reason: d.operation, ...(v.listed ? { listed: true } : {}),
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!inScope(d.path, scope)) { stats.offPolicy++; return; }
|
|
161
|
+
if (!reachedForContent(d)) { stats.walks++; return; }
|
|
162
|
+
// Relative inside the repo, absolute outside it. A role can be refused at
|
|
163
|
+
// ~/.ssh under `isolate`, and "../../../.ssh/id_rsa" would be a worse
|
|
164
|
+
// answer to "what was refused" than the path itself.
|
|
165
|
+
const rel = toRepoRelative(config, d.path);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A refusal the hook never saw still leaves a request behind.
|
|
169
|
+
*
|
|
170
|
+
* Without this the two halves of the record disagree in the worst
|
|
171
|
+
* direction: `review` would show a role stopped repeatedly on a directory
|
|
172
|
+
* while the queue held nothing to approve, so the one refusal a person most
|
|
173
|
+
* needed to see — the one that escaped the hook — would be the one with no
|
|
174
|
+
* way to act on it.
|
|
175
|
+
*
|
|
176
|
+
* There is no double counting to avoid. When the hook catches something it
|
|
177
|
+
* denies the tool call outright and the command never runs, so the kernel
|
|
178
|
+
* never sees it. These are the ones that got past it, which is the whole
|
|
179
|
+
* reason this exists. Inside the repo only: `~/.ssh` is refused on purpose
|
|
180
|
+
* under `isolate` and is not a territory anyone is meant to ask for.
|
|
181
|
+
*/
|
|
182
|
+
const verdict = rel !== d.path && d.action === "write"
|
|
183
|
+
? explain(config, role, "write", rel)
|
|
184
|
+
: null;
|
|
185
|
+
const barred = verdict?.neverWrites ?? null;
|
|
186
|
+
const unaskable = verdict?.gitMetadata === true || verdict?.protected != null;
|
|
187
|
+
// A request for something the policy already grants cannot be approved into
|
|
188
|
+
// anything: it is misattribution or a kernel/policy mismatch, and either way
|
|
189
|
+
// the line in the log is the evidence, not a question for a person. The hook
|
|
190
|
+
// path has always had this check; the kernel path did not, which is how a
|
|
191
|
+
// role ended up asking for its own territory.
|
|
192
|
+
const granted = verdict?.allowed === true;
|
|
193
|
+
if (rel !== d.path && !barred && !unaskable && !granted)
|
|
194
|
+
ask({ role, action: d.action, target: rel, owners: ownersOf(config, rel) });
|
|
195
|
+
|
|
196
|
+
log({
|
|
197
|
+
at: new Date().toISOString(),
|
|
198
|
+
role,
|
|
199
|
+
tool: "kernel",
|
|
200
|
+
source: "kernel",
|
|
201
|
+
action: d.action,
|
|
202
|
+
kind: keyDirs.some((k) => d.path === k || d.path.startsWith(k + "/")) ? "key" : "file",
|
|
203
|
+
target: rel,
|
|
204
|
+
verdict: "denied",
|
|
205
|
+
owners: ownersOf(config, rel),
|
|
206
|
+
reason: d.operation,
|
|
207
|
+
// So the log can tell a subtraction doing its job from a missing permission,
|
|
208
|
+
// and a protected file from a territory somebody could be asked for.
|
|
209
|
+
...(barred ? { neverWrites: barred } : {}),
|
|
210
|
+
...(verdict?.protected ? { protected: verdict.protected } : {}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { fromHook, fromKernel, stats };
|
|
215
|
+
}
|