seisin 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -67
- package/package.json +3 -3
- package/src/cli.js +145 -46
- package/src/commands/check.js +3 -2
- package/src/commands/explain.js +34 -13
- package/src/commands/hook.js +14 -4
- package/src/commands/init.js +126 -30
- package/src/commands/log.js +55 -10
- package/src/commands/requests.js +124 -39
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +174 -39
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +25 -6
- package/src/commands/whose.js +7 -6
- package/src/commands/wire.js +156 -17
- package/src/config.js +261 -27
- package/src/controls.js +244 -0
- package/src/diagnose.js +35 -33
- package/src/env.js +11 -8
- package/src/grants.js +15 -7
- package/src/help.js +159 -0
- package/src/hook.js +49 -19
- package/src/index.js +4 -4
- package/src/inspect.js +137 -71
- package/src/intake.js +24 -16
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +312 -55
- package/src/mcp.js +96 -43
- package/src/notify.js +15 -10
- package/src/owners.js +323 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +81 -12
- package/src/requests.js +250 -72
- package/src/review.js +42 -21
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +395 -205
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +203 -42
- package/src/uilink.js +10 -4
- package/src/views.js +289 -0
- package/src/violations.js +104 -16
- package/src/walls.js +19 -42
- package/src/worktree.js +3 -11
- package/ui/index.html +1366 -529
package/src/owners.js
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
* agents have to read each other's code to do anything useful. Keys are the
|
|
11
11
|
* exception, and they are handled separately below.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
15
|
+
import { isAbsolute, join } from "node:path";
|
|
15
16
|
import { protectedBy } from "./surface.js";
|
|
17
|
+
import { entriesOf } from "./keys.js";
|
|
18
|
+
import { WILD, fromCwd, toRepoRelative } from "./paths.js";
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
|
|
@@ -42,10 +45,24 @@ import { protectedBy } from "./surface.js";
|
|
|
42
45
|
export function covers(glob, path) {
|
|
43
46
|
const p = normalized(path);
|
|
44
47
|
const m = matcherOf(glob);
|
|
48
|
+
/**
|
|
49
|
+
* Above the root only for a glob that climbs there itself.
|
|
50
|
+
*
|
|
51
|
+
* `../../etc/passwd` is a path the kernel refuses to every role, and `**`
|
|
52
|
+
* matched it as a string, so `explain` answered "inside territory" for it.
|
|
53
|
+
* A territory reaches outside the repo only by saying so — the
|
|
54
|
+
* `../shared/lab/dev.md` a role writes beside its own directory — and
|
|
55
|
+
* that one still matches, because both sides start with the same `..`.
|
|
56
|
+
*/
|
|
57
|
+
if (climbs(p) && !m.climbs) return false;
|
|
45
58
|
// The path itself, or anything beneath it. The `/` is load-bearing: without
|
|
46
59
|
// it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
|
|
47
60
|
if (m.prefix !== null) return p === m.prefix || p.startsWith(m.prefix + "/");
|
|
48
|
-
|
|
61
|
+
// The literal head of the glob, before its first wildcard, has to be the
|
|
62
|
+
// head of the path too. Most pairs fail here, for the price of a
|
|
63
|
+
// startsWith — `check` asks ~400k of them on a real policy.
|
|
64
|
+
if (!p.startsWith(m.head)) return false;
|
|
65
|
+
return matchGlob(m.glob, p);
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
/**
|
|
@@ -68,7 +85,8 @@ function matcherOf(glob) {
|
|
|
68
85
|
if (m) return m;
|
|
69
86
|
let g = normalize(glob);
|
|
70
87
|
if (glob.endsWith("/")) g += "/**";
|
|
71
|
-
|
|
88
|
+
const wild = WILD.test(g);
|
|
89
|
+
m = { prefix: wild ? null : g, glob: wild ? g : null, climbs: climbs(g), head: wild ? headOf(g) : "" };
|
|
72
90
|
if (GLOBS.size >= MAX_CACHED) GLOBS.clear();
|
|
73
91
|
GLOBS.set(glob, m);
|
|
74
92
|
return m;
|
|
@@ -83,8 +101,15 @@ function normalized(path) {
|
|
|
83
101
|
return p;
|
|
84
102
|
}
|
|
85
103
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
104
|
+
/**
|
|
105
|
+
* What every path a glob matches starts with: its text up to the first
|
|
106
|
+
* wildcard, minus a final `/` — `src/api/**` matches `src/api` itself, and
|
|
107
|
+
* `a/**\/b` matches `a/b`.
|
|
108
|
+
*/
|
|
109
|
+
const headOf = (g) => g.slice(0, g.search(WILD)).replace(/\/$/, "");
|
|
110
|
+
|
|
111
|
+
/** Does a normalised path start above the repo root? */
|
|
112
|
+
const climbs = (p) => p === ".." || p.startsWith("../");
|
|
88
113
|
|
|
89
114
|
/**
|
|
90
115
|
* One spelling per file, before anybody decides anything about it.
|
|
@@ -96,8 +121,10 @@ const WILD = /[*?[\]]/;
|
|
|
96
121
|
* every sentence seisin said about it was wrong, which is the half of the tool
|
|
97
122
|
* that is actually ours.
|
|
98
123
|
*
|
|
99
|
-
* A path that climbs above the repo root
|
|
100
|
-
*
|
|
124
|
+
* A path that climbs above the repo root keeps its leading `..` segments
|
|
125
|
+
* (`src/../../x` is `../x`). `covers` matches such a path only against a glob
|
|
126
|
+
* that climbs out too, so outside the repo has no owner unless a territory
|
|
127
|
+
* names that place explicitly.
|
|
101
128
|
*/
|
|
102
129
|
export function normalize(s) {
|
|
103
130
|
const flat = s.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/+$/, "");
|
|
@@ -110,13 +137,23 @@ export function normalize(s) {
|
|
|
110
137
|
return up.join("/");
|
|
111
138
|
}
|
|
112
139
|
|
|
140
|
+
/** matchGlob's memo, shared between calls (see there). */
|
|
141
|
+
let MEMO = new Uint8Array(4096);
|
|
142
|
+
|
|
113
143
|
/**
|
|
114
|
-
*
|
|
144
|
+
* Does `glob` cover `path`? A linear matcher, not a regular expression.
|
|
145
|
+
*
|
|
146
|
+
* The regex form (`**` → `.*`, `**\/` → `(?:.*\/)?`) put several unbounded `.*`
|
|
147
|
+
* next to literals, and on a long path that does not match, the engine
|
|
148
|
+
* backtracks catastrophically: measured on a crafted glob, 9 `**` took ~26 s
|
|
149
|
+
* and 11 hung for over a minute. Since a request's target — which an agent
|
|
150
|
+
* chooses — becomes a glob here (`grantFor`), that is a denial-of-service on the
|
|
151
|
+
* hook and on `explain`. This matcher is memoised on (glob index, path index),
|
|
152
|
+
* so its cost is bounded by their product and no input backtracks.
|
|
115
153
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* and quietly matches the wrong set of files.
|
|
154
|
+
* Same wildcards as before: `?` one non-`/`, `*` a run of non-`/`, `**` any run
|
|
155
|
+
* including `/`, `**\/` zero or more whole directories, a trailing `/**` the
|
|
156
|
+
* subtree. Characters are compared literally; nothing is treated as regex.
|
|
120
157
|
*
|
|
121
158
|
* `src/api/**` also matches `src/api` itself. Without that, the directory a
|
|
122
159
|
* role was just granted comes back ownerless, which reads as a bug every time.
|
|
@@ -126,18 +163,63 @@ export function normalize(s) {
|
|
|
126
163
|
* must not: covering one file too many costs an argument, covering one too few
|
|
127
164
|
* is the hole. When the two readings disagree, this one takes the wider set.
|
|
128
165
|
*/
|
|
129
|
-
function
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
166
|
+
function matchGlob(glob, path) {
|
|
167
|
+
const G = glob.length, P = path.length;
|
|
168
|
+
// memo[gi * (P+1) + pi]: 0 unknown, 1 true, 2 false. One flat array, kept
|
|
169
|
+
// between calls and cleared over the part this call uses: allocating it
|
|
170
|
+
// fresh was most of the cost of a call that fails on its second character.
|
|
171
|
+
// Nothing re-enters matchGlob while it runs, so sharing it is safe.
|
|
172
|
+
// An outsized pair gets its own array, so one long path does not pin
|
|
173
|
+
// megabytes for the life of a server.
|
|
174
|
+
const size = (G + 1) * (P + 1);
|
|
175
|
+
let memo;
|
|
176
|
+
if (size > 1 << 20) memo = new Uint8Array(size);
|
|
177
|
+
else {
|
|
178
|
+
if (MEMO.length < size) MEMO = new Uint8Array(Math.max(size, MEMO.length * 2));
|
|
179
|
+
memo = MEMO;
|
|
180
|
+
memo.fill(0, 0, size);
|
|
181
|
+
}
|
|
182
|
+
const at = (gi, pi) => {
|
|
183
|
+
for (;;) {
|
|
184
|
+
const key = gi * (P + 1) + pi;
|
|
185
|
+
const seen = memo[key];
|
|
186
|
+
if (seen) return seen === 1;
|
|
187
|
+
let res;
|
|
188
|
+
if (gi === G) { res = pi === P; }
|
|
189
|
+
else if (glob[gi] === "*" && glob[gi + 1] === "*") {
|
|
190
|
+
if (glob[gi + 2] === "/") {
|
|
191
|
+
// `**/` = zero or more whole directories: empty, or any chars ending
|
|
192
|
+
// at a `/` then the rest.
|
|
193
|
+
if (at(gi + 3, pi)) res = true;
|
|
194
|
+
else if (pi < P) {
|
|
195
|
+
if (path[pi] === "/" && at(gi + 3, pi + 1)) res = true;
|
|
196
|
+
else { memo[key] = 2; pi++; continue; } // consume one char, stay on `**/`
|
|
197
|
+
} else res = false;
|
|
198
|
+
} else {
|
|
199
|
+
// `**` = any run including `/` (also covers `**` at the very end,
|
|
200
|
+
// where `at(gi+2, pi)` requires pi to reach the end).
|
|
201
|
+
if (at(gi + 2, pi)) res = true;
|
|
202
|
+
else if (pi < P) { memo[key] = 2; pi++; continue; } // consume one char, stay on `**`
|
|
203
|
+
else res = false;
|
|
204
|
+
}
|
|
205
|
+
} else if (glob[gi] === "/" && glob[gi + 1] === "*" && glob[gi + 2] === "*" && gi + 3 === G) {
|
|
206
|
+
// trailing `/**`: the directory itself, or anything beneath it.
|
|
207
|
+
res = pi === P || path[pi] === "/";
|
|
208
|
+
} else if (glob[gi] === "*") {
|
|
209
|
+
// `*` = a run of non-`/`.
|
|
210
|
+
if (at(gi + 1, pi)) res = true;
|
|
211
|
+
else if (pi < P && path[pi] !== "/") { memo[key] = 2; pi++; continue; }
|
|
212
|
+
else res = false;
|
|
213
|
+
} else if (glob[gi] === "?") {
|
|
214
|
+
res = pi < P && path[pi] !== "/" && at(gi + 1, pi + 1);
|
|
215
|
+
} else {
|
|
216
|
+
res = pi < P && glob[gi] === path[pi] && at(gi + 1, pi + 1);
|
|
217
|
+
}
|
|
218
|
+
memo[key] = res ? 1 : 2;
|
|
219
|
+
return res;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
return at(0, 0);
|
|
141
223
|
}
|
|
142
224
|
|
|
143
225
|
/** Is this path inside a `.git` directory (git's metadata, not a working file)? */
|
|
@@ -166,6 +248,88 @@ export function ownersOf(config, path) {
|
|
|
166
248
|
.map((r) => r.name);
|
|
167
249
|
}
|
|
168
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Where a refused target stands against the policy as it is now.
|
|
253
|
+
*
|
|
254
|
+
* One of four, and only the last one is a decision somebody has to make:
|
|
255
|
+
*
|
|
256
|
+
* outside not a place in the map at all — a port, a key directory, or a
|
|
257
|
+
* path outside the repository, which no `writes` reaches
|
|
258
|
+
* protected closed to every role on purpose (surface.js); the boundary
|
|
259
|
+
* working, with nothing to grant
|
|
260
|
+
* owned some role's territory today
|
|
261
|
+
* unowned in the repository, ownable, and nobody claims it
|
|
262
|
+
*
|
|
263
|
+
* Computed from the policy, never from the `owners` a log line carries. That
|
|
264
|
+
* value is what was true the day of the refusal; every grant made since makes
|
|
265
|
+
* it more wrong, so a screen reading it gets worse the more seisin is used the
|
|
266
|
+
* way it is meant to be. Measured on one deployment: the console said 1,178 of
|
|
267
|
+
* 1,486 refused paths had no owner; against the policy of that day it was 316.
|
|
268
|
+
* The rest had been claimed since, were protected, or were not paths at all.
|
|
269
|
+
*/
|
|
270
|
+
export function standing(config, entry) {
|
|
271
|
+
const target = entry.target;
|
|
272
|
+
if (entry.kind === "network")
|
|
273
|
+
return { kind: "outside", owners: [], why: "a port or a socket, which no role owns" };
|
|
274
|
+
if (entry.kind === "key")
|
|
275
|
+
return { kind: "outside", owners: [], why: "a key directory, closed on purpose" };
|
|
276
|
+
const guard = protectedBy(config, target);
|
|
277
|
+
// A family the policy hands to a role is protected from everybody else and
|
|
278
|
+
// owned by that role — if it is also that role's territory.
|
|
279
|
+
if (guard?.family) {
|
|
280
|
+
const holders = ownersOf(config, target).filter((o) => config.roles[o]?.controlFiles?.includes(guard.family));
|
|
281
|
+
if (holders.length) return { kind: "owned", owners: holders };
|
|
282
|
+
}
|
|
283
|
+
if (guard) return { kind: "protected", owners: [], why: guard.why };
|
|
284
|
+
if (target.startsWith("/")) {
|
|
285
|
+
const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
|
|
286
|
+
if (!config.root || !target.startsWith(root))
|
|
287
|
+
return { kind: "outside", owners: [], why: "outside the repository, where no role's territory reaches" };
|
|
288
|
+
}
|
|
289
|
+
const owners = ownersOf(config, target);
|
|
290
|
+
return { kind: owners.length ? "owned" : "unowned", owners };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `standing`, remembered while the policy stays the same.
|
|
295
|
+
*
|
|
296
|
+
* The console asks for its whole state every two seconds, and a real log has
|
|
297
|
+
* well over a thousand distinct refused paths: classifying all of them costs
|
|
298
|
+
* most of a second each time (measured: ~400 ms in protectedBy, ~335 ms in
|
|
299
|
+
* ownersOf), on a call that already took about that long. What a path's
|
|
300
|
+
* standing depends on is the policy file, so that is the key. It also depends,
|
|
301
|
+
* a little, on the disk — on Linux a control file is protected only once it
|
|
302
|
+
* exists — so the memory is dropped after a minute as well, which bounds how
|
|
303
|
+
* stale an answer can be without paying for it on every poll.
|
|
304
|
+
*
|
|
305
|
+
* A config that was not read from a file (tests, embedders) gets a memory for
|
|
306
|
+
* this call only.
|
|
307
|
+
*/
|
|
308
|
+
const STANDING_TTL = 60_000;
|
|
309
|
+
let standingMemo = { key: null, at: 0, map: new Map() };
|
|
310
|
+
|
|
311
|
+
export function standingOf(config, { now = Date.now() } = {}) {
|
|
312
|
+
let map;
|
|
313
|
+
let key = null;
|
|
314
|
+
try {
|
|
315
|
+
if (config.path) key = `${config.path}\u0000${createHash("sha256").update(readFileSync(config.path)).digest("hex")}`;
|
|
316
|
+
} catch {
|
|
317
|
+
key = null;
|
|
318
|
+
}
|
|
319
|
+
if (key === null) map = new Map();
|
|
320
|
+
else {
|
|
321
|
+
if (standingMemo.key !== key || now - standingMemo.at > STANDING_TTL)
|
|
322
|
+
standingMemo = { key, at: now, map: new Map() };
|
|
323
|
+
map = standingMemo.map;
|
|
324
|
+
}
|
|
325
|
+
return (entry) => {
|
|
326
|
+
const k = `${entry.kind ?? ""}\u0000${entry.target}`;
|
|
327
|
+
let s = map.get(k);
|
|
328
|
+
if (!s) map.set(k, (s = standing(config, entry)));
|
|
329
|
+
return s;
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
169
333
|
/**
|
|
170
334
|
* The `never_writes` entries the kernel will actually be given for this role.
|
|
171
335
|
*
|
|
@@ -198,6 +362,92 @@ export function neverWrites(role, path, config = null) {
|
|
|
198
362
|
return enforcedNeverWrites(config, role).find((g) => covers(g, path)) ?? null;
|
|
199
363
|
}
|
|
200
364
|
|
|
365
|
+
/** A key dir as the policy's paths spell it: no leading `./`, no trailing `/`. */
|
|
366
|
+
const dirSpelling = (d) => String(d).replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
367
|
+
|
|
368
|
+
/** Is the repo-relative `rel` one of the key directories, or inside one? */
|
|
369
|
+
export function inKeyDir(config, rel) {
|
|
370
|
+
return (config.keyDirs ?? []).some((d) => {
|
|
371
|
+
const k = dirSpelling(d);
|
|
372
|
+
const r = k.startsWith("/") ? relFromRoot(config, k) : k;
|
|
373
|
+
return r !== null && (rel === r || rel.startsWith(r + "/"));
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function relFromRoot(config, abs) {
|
|
378
|
+
const root = config.root ?? "";
|
|
379
|
+
return abs === root ? "." : abs.startsWith(root + "/") ? abs.slice(root.length + 1) : null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* How a key is written in a role's `keys` list: relative to the first key
|
|
384
|
+
* directory when it lives there, with its directory otherwise — the same form
|
|
385
|
+
* settingsFor and keyHolders resolve back.
|
|
386
|
+
*/
|
|
387
|
+
export function keyName(config, target) {
|
|
388
|
+
const t = String(target ?? "");
|
|
389
|
+
const first = config.keyDirs?.length ? dirSpelling(config.keyDirs[0]) : null;
|
|
390
|
+
return first && t.startsWith(first + "/") ? t.slice(first.length + 1) : t;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A read of a FILE — a path, not a key name — and whether the role gets it.
|
|
395
|
+
*
|
|
396
|
+
* Reads are open by design: every role reads everything outside the `[keys]`
|
|
397
|
+
* dirs (and, under `isolate`, outside the credential homes). `explain` used to
|
|
398
|
+
* send every read through the key question, so `explain dev read .env` said
|
|
399
|
+
* "denied — no role declares .env" about a file every role could read. A wrong
|
|
400
|
+
* no from the tool whose job is saying what the kernel will do. Now a path
|
|
401
|
+
* inside a key directory is a key question; anything else is answered as the
|
|
402
|
+
* open read it is, with how to make it a key.
|
|
403
|
+
*/
|
|
404
|
+
export function explainFileRead(config, role, rel, credentialHomes = []) {
|
|
405
|
+
// Only a key directory makes a key: settingsFor refuses a key declared
|
|
406
|
+
// anywhere else, so nothing outside one is denied by being a key.
|
|
407
|
+
if (inKeyDir(config, rel)) return explain(config, role, "read", rel);
|
|
408
|
+
const level = config.isolate === true ? "home" : config.isolate;
|
|
409
|
+
if ((level === "credentials" || level === "home") && rel.startsWith("/")) {
|
|
410
|
+
const hit = credentialHomes.find((h) => rel === h || rel.startsWith(h + "/"));
|
|
411
|
+
if (hit)
|
|
412
|
+
return {
|
|
413
|
+
allowed: false, owners: [],
|
|
414
|
+
reason: `${rel} is under ${hit}, which isolate = "${level}" closes to every role`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const dirs = (config.keyDirs ?? []).map(dirSpelling);
|
|
418
|
+
return {
|
|
419
|
+
allowed: true, owners: [], open: true,
|
|
420
|
+
reason: dirs.length
|
|
421
|
+
? `${rel} is outside every [keys] dir (${dirs.join(", ")}), so every role reads it. ` +
|
|
422
|
+
`Move it into ${dirs[0]}/ to make it a key`
|
|
423
|
+
: `no [keys] dir is declared, so every role reads every file. ` +
|
|
424
|
+
`Declare one under [keys] and move ${rel} into it to make it a key`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* What a read is about: a key, named the way the policy names keys, or a file.
|
|
430
|
+
*
|
|
431
|
+
* `explain dev read stripe` asks about a key — a bare name some role declares,
|
|
432
|
+
* or one sitting in a key directory. Everything else is a path, resolved from
|
|
433
|
+
* where you stand, and inside a key directory it is a key question again.
|
|
434
|
+
*/
|
|
435
|
+
export function readTarget(config, target, cwd = process.cwd()) {
|
|
436
|
+
if (!target.includes("/") && !isAbsolute(target)) {
|
|
437
|
+
const declared = keyHolders(config, target).length > 0;
|
|
438
|
+
const bare = (s) => s.replace(/\.[^.]+$/, "");
|
|
439
|
+
const inDir = (config.keyDirs ?? []).some((d) => {
|
|
440
|
+
const dir = isAbsolute(d) ? d : join(config.root, d);
|
|
441
|
+
if (existsSync(join(dir, target))) return true;
|
|
442
|
+
try { return readdirSync(dir).some((f) => bare(f) === target); } catch { return false; }
|
|
443
|
+
});
|
|
444
|
+
const here = fromCwd(config, target, cwd);
|
|
445
|
+
const isFileHere = existsSync(here) && !inKeyDir(config, toRepoRelative(config, here));
|
|
446
|
+
if ((declared || inDir) && !isFileHere) return { key: true, target };
|
|
447
|
+
}
|
|
448
|
+
return { key: false, target: toRepoRelative(config, fromCwd(config, target, cwd)) };
|
|
449
|
+
}
|
|
450
|
+
|
|
201
451
|
/** Every role allowed to read `key`, matched with or without its extension. */
|
|
202
452
|
export function keyHolders(config, key) {
|
|
203
453
|
const dirs = config.keyDirs ?? [];
|
|
@@ -224,19 +474,30 @@ export function keyHolders(config, key) {
|
|
|
224
474
|
// netlify` — so it stays, and only for an unqualified question.
|
|
225
475
|
const loose = !key.includes("/");
|
|
226
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Only path keys. A reference — `keychain://x`, `NAME=file://.secrets/a#X` —
|
|
479
|
+
* is resolved by the parent and delivered as a value; the role is never
|
|
480
|
+
* given the file. Comparing the raw strings let `explain` say "declares
|
|
481
|
+
* a.env" about a role whose key merely pointed into it, a yes the kernel
|
|
482
|
+
* would refuse.
|
|
483
|
+
*/
|
|
227
484
|
return Object.values(config.roles)
|
|
228
|
-
.filter((r) => r.
|
|
485
|
+
.filter((r) => fileKeys(r).some((k) =>
|
|
229
486
|
resolve(k) === wanted || (loose && bare(k) === bare(key))))
|
|
230
487
|
.map((r) => r.name);
|
|
231
488
|
}
|
|
232
489
|
|
|
233
|
-
/**
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
490
|
+
/** The keys of `role` that grant a read: the path ones. A malformed entry grants nothing. */
|
|
491
|
+
function fileKeys(role) {
|
|
492
|
+
let entries;
|
|
493
|
+
try {
|
|
494
|
+
entries = entriesOf(role);
|
|
495
|
+
} catch {
|
|
496
|
+
return [];
|
|
497
|
+
}
|
|
498
|
+
return entries.filter((e) => e.kind === "file").map((e) => e.raw);
|
|
499
|
+
}
|
|
500
|
+
|
|
240
501
|
/**
|
|
241
502
|
* A connection the kernel refused: `tcp:<port>` or a unix socket path.
|
|
242
503
|
*
|
|
@@ -300,14 +561,28 @@ function explainMcp(config, role, name) {
|
|
|
300
561
|
: `${role} declares mcp = [], no MCP servers` };
|
|
301
562
|
}
|
|
302
563
|
|
|
564
|
+
/**
|
|
565
|
+
* The sentence a blocked agent should read.
|
|
566
|
+
*
|
|
567
|
+
* Three outcomes, and the third is the one worth having: a path nobody owns is
|
|
568
|
+
* not a permission problem, it is a hole in the map. Saying so is more useful
|
|
569
|
+
* than denying quietly, and it is the only way the hole ever gets fixed.
|
|
570
|
+
*/
|
|
303
571
|
export function explain(config, role, action, target) {
|
|
304
572
|
if (action === "connect") return explainConnect(config, role, target);
|
|
305
573
|
if (action === "mcp") return explainMcp(config, role, target);
|
|
306
574
|
if (action === "read") {
|
|
307
575
|
const holders = keyHolders(config, target);
|
|
308
576
|
if (holders.includes(role)) return { allowed: true, owners: holders, reason: `${role} declares ${target}` };
|
|
309
|
-
if (holders.length === 0)
|
|
310
|
-
|
|
577
|
+
if (holders.length === 0) {
|
|
578
|
+
// A key is written as a name relative to the key directory — `stripe.txt`,
|
|
579
|
+
// not `.secrets/stripe.txt` — so the advice says the name to write.
|
|
580
|
+
const name = keyName(config, target);
|
|
581
|
+
return {
|
|
582
|
+
allowed: false, owners: [],
|
|
583
|
+
reason: `no role declares ${target} — add "${name}" to a role's keys: [roles.<name>] keys = ["${name}"]`,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
311
586
|
return { allowed: false, owners: holders, reason: `${target} belongs to ${holders.join(", ")}` };
|
|
312
587
|
}
|
|
313
588
|
|
|
@@ -317,11 +592,18 @@ export function explain(config, role, action, target) {
|
|
|
317
592
|
*
|
|
318
593
|
* The policy, seisin's state, the key directories, what the parent executes
|
|
319
594
|
* or reads for a key, and the files that make git or Claude Code run
|
|
320
|
-
* something outside the
|
|
595
|
+
* something outside the sandbox. The kernel denies all of them to every role —
|
|
321
596
|
* surface.js has the list and the reasons — so saying "belongs to dev" about
|
|
322
597
|
* `seisin.toml` sent a request to a person that no grant could satisfy.
|
|
323
598
|
*/
|
|
324
|
-
const guard = protectedBy(config, target);
|
|
599
|
+
const guard = protectedBy(config, target, { role });
|
|
600
|
+
if (guard?.family)
|
|
601
|
+
return {
|
|
602
|
+
allowed: false, owners, protected: guard.why, family: guard.family,
|
|
603
|
+
reason: `${target} is protected (${guard.why}). A role writes it only when the policy hands it ` +
|
|
604
|
+
`that family: control_files = ["${guard.family}"] under [roles.${role}], and inside its own ` +
|
|
605
|
+
`territory. That is a person's decision about the policy, not a grant to request`,
|
|
606
|
+
};
|
|
325
607
|
if (guard)
|
|
326
608
|
return {
|
|
327
609
|
allowed: false, owners, protected: guard.why,
|
|
@@ -341,7 +623,7 @@ export function explain(config, role, action, target) {
|
|
|
341
623
|
return {
|
|
342
624
|
allowed: false, owners, neverWrites: barred,
|
|
343
625
|
reason: `${target} is denied by never_writes of ${role} ("${barred}") — ` +
|
|
344
|
-
`a subtraction written into the policy, not a missing
|
|
626
|
+
`a subtraction written into the policy, not a missing grant`,
|
|
345
627
|
};
|
|
346
628
|
if (owners.includes(role)) return { allowed: true, owners, reason: `${target} is inside ${role}'s territory` };
|
|
347
629
|
/**
|
|
@@ -352,14 +634,14 @@ export function explain(config, role, action, target) {
|
|
|
352
634
|
* else's history. Codex keeps `.git` read-only under a writable root for the
|
|
353
635
|
* same reason; nono and Gemini grant the whole of it. Here it stays refused
|
|
354
636
|
* and is said for what it is — not "belongs to X, ask them", which filed a
|
|
355
|
-
* request per lock file (327 of them for one index.lock
|
|
356
|
-
* that nobody could sensibly
|
|
637
|
+
* request per lock file (327 of them for one index.lock in one multi-repo workspace)
|
|
638
|
+
* that nobody could sensibly grant. The requests side skips it too.
|
|
357
639
|
*/
|
|
358
640
|
if (isGitMetadata(target))
|
|
359
641
|
return {
|
|
360
642
|
allowed: false, owners, gitMetadata: true,
|
|
361
643
|
reason: `${target} is git's own bookkeeping in a repository ${role} does not write — ` +
|
|
362
|
-
`not
|
|
644
|
+
`not something to request. Read what you need (git log, git show, git ls-remote) ` +
|
|
363
645
|
`or work in a worktree of your own`,
|
|
364
646
|
};
|
|
365
647
|
if (owners.length === 0)
|
package/src/paths.js
CHANGED
|
@@ -23,7 +23,8 @@ export function realAncestor(p) {
|
|
|
23
23
|
let head = p, tail = "";
|
|
24
24
|
while (head !== dirname(head)) {
|
|
25
25
|
try {
|
|
26
|
-
|
|
26
|
+
// `.native`: the disk's own case on APFS — see realOrSelf in grants.js.
|
|
27
|
+
const real = realpathSync.native(head);
|
|
27
28
|
return tail ? join(real, tail) : real;
|
|
28
29
|
} catch {
|
|
29
30
|
tail = tail ? join(basename(head), tail) : basename(head);
|
|
@@ -33,6 +34,13 @@ export function realAncestor(p) {
|
|
|
33
34
|
return p;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The four characters the glob matcher treats as wildcards. Here, in a module
|
|
39
|
+
* that imports nothing of seisin's, so that owners.js and grants.js share one
|
|
40
|
+
* definition without importing each other.
|
|
41
|
+
*/
|
|
42
|
+
export const WILD = /[*?[\]]/;
|
|
43
|
+
|
|
36
44
|
/**
|
|
37
45
|
* Repo-relative when `target` is inside the repo, spelled either way; exactly as
|
|
38
46
|
* written when it is relative already or lies outside. A path outside keeps its
|
|
@@ -48,3 +56,31 @@ export function toRepoRelative(config, target) {
|
|
|
48
56
|
if (inside(realRoot, realTarget)) return relative(realRoot, realTarget) || ".";
|
|
49
57
|
return target;
|
|
50
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A path typed at a prompt, made absolute the way a shell user means it:
|
|
62
|
+
* against the directory they are standing in. Relative arguments used to be
|
|
63
|
+
* read against the policy's root, so `seisin explain web write app.js` from
|
|
64
|
+
* web/src asked about ./app.js at the root and answered "no owner".
|
|
65
|
+
*
|
|
66
|
+
* Only when the working directory is inside the repo. From anywhere else —
|
|
67
|
+
* `SEISIN_CONFIG` pointing at a policy elsewhere, a test harness — a relative
|
|
68
|
+
* path keeps meaning what it always meant: relative to the root.
|
|
69
|
+
*/
|
|
70
|
+
export function fromCwd(config, target, cwd = process.cwd()) {
|
|
71
|
+
if (typeof target !== "string" || isAbsolute(target)) return target;
|
|
72
|
+
const root = config.root;
|
|
73
|
+
const inside = (r, t) => t === r || t.startsWith(r + sep);
|
|
74
|
+
// The cwd and the root compared in the same spelling: as written, or both
|
|
75
|
+
// through their symlinks (on macOS /tmp is /private/tmp).
|
|
76
|
+
let base = root, here = cwd;
|
|
77
|
+
if (!inside(root, cwd)) {
|
|
78
|
+
base = realAncestor(root);
|
|
79
|
+
here = realAncestor(cwd);
|
|
80
|
+
if (!inside(base, here)) return target;
|
|
81
|
+
}
|
|
82
|
+
const rel = relative(base, join(here, target));
|
|
83
|
+
if (rel === "") return root;
|
|
84
|
+
if (rel === ".." || rel.startsWith(".." + sep)) return join(here, target);
|
|
85
|
+
return join(root, rel);
|
|
86
|
+
}
|
package/src/redact.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* README spends its time avoiding.
|
|
21
21
|
*/
|
|
22
22
|
import { Transform } from "node:stream";
|
|
23
|
+
import { StringDecoder } from "node:string_decoder";
|
|
23
24
|
|
|
24
25
|
/** Reads the values this role may read. Unreadable or huge files are skipped. */
|
|
25
26
|
export function secretsOf(settings, readFile) {
|
|
@@ -49,10 +50,18 @@ export function secretsOf(settings, readFile) {
|
|
|
49
50
|
* It carries the tail of each chunk forward, because a value split across a
|
|
50
51
|
* buffer boundary would otherwise sail through — which is the one case a
|
|
51
52
|
* naive implementation always gets wrong and never notices.
|
|
53
|
+
*
|
|
54
|
+
* Bytes are decoded with a StringDecoder that lives as long as the stream. A
|
|
55
|
+
* chunk boundary falls wherever the pipe put it, including inside a multibyte
|
|
56
|
+
* character: decoding each chunk on its own turned `año` into `a��o`, and a
|
|
57
|
+
* secret with an `ñ` in it split that way was never seen whole, so it was
|
|
58
|
+
* never masked. The same care applies to the cut below, which must not leave
|
|
59
|
+
* half of a surrogate pair on either side.
|
|
52
60
|
*/
|
|
53
61
|
export function redactor(secrets, label = "redacted") {
|
|
54
62
|
if (secrets.length === 0) return null;
|
|
55
63
|
const longest = secrets[0].length;
|
|
64
|
+
const decoder = new StringDecoder("utf8");
|
|
56
65
|
let tail = "";
|
|
57
66
|
|
|
58
67
|
return new Transform({
|
|
@@ -61,20 +70,25 @@ export function redactor(secrets, label = "redacted") {
|
|
|
61
70
|
// — masking only the part about to be emitted — lets a secret that
|
|
62
71
|
// straddles the cut through in two innocent halves. Written that way
|
|
63
72
|
// first; the split-buffer test caught it on the first run.
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
74
|
+
const masked = mask(tail + text, secrets, label);
|
|
75
|
+
let cut = masked.length - Math.min(longest - 1, masked.length);
|
|
76
|
+
if (cut > 0 && isHighSurrogate(masked.charCodeAt(cut - 1))) cut--;
|
|
77
|
+
if (cut > 0) this.push(masked.slice(0, cut));
|
|
78
|
+
tail = masked.slice(cut);
|
|
68
79
|
done();
|
|
69
80
|
},
|
|
70
81
|
flush(done) {
|
|
71
|
-
|
|
82
|
+
const rest = mask(tail + decoder.end(), secrets, label);
|
|
83
|
+
if (rest) this.push(rest);
|
|
72
84
|
tail = "";
|
|
73
85
|
done();
|
|
74
86
|
},
|
|
75
87
|
});
|
|
76
88
|
}
|
|
77
89
|
|
|
90
|
+
const isHighSurrogate = (c) => c >= 0xd800 && c <= 0xdbff;
|
|
91
|
+
|
|
78
92
|
function mask(text, secrets, label) {
|
|
79
93
|
let out = text;
|
|
80
94
|
for (const s of secrets) out = out.split(s).join(`‹${label}›`);
|