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/owners.js
CHANGED
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
* exception, and they are handled separately below.
|
|
12
12
|
*/
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
-
import { join } from "node:path";
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
15
|
+
import { isAbsolute, join } from "node:path";
|
|
16
16
|
import { protectedBy } from "./surface.js";
|
|
17
|
+
import { entriesOf, parseKey, BUILTIN } from "./keys.js";
|
|
18
|
+
import { WILD, fromCwd, toRepoRelative } from "./paths.js";
|
|
19
|
+
import { AGENTS, AGENT_CREDENTIALS, CREDENTIAL_HOMES, expand } from "./grants.js";
|
|
20
|
+
import { keptFor, shutByTerritory } from "./territory.js";
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
|
|
@@ -43,9 +47,23 @@ import { protectedBy } from "./surface.js";
|
|
|
43
47
|
export function covers(glob, path) {
|
|
44
48
|
const p = normalized(path);
|
|
45
49
|
const m = matcherOf(glob);
|
|
50
|
+
/**
|
|
51
|
+
* Above the root only for a glob that climbs there itself.
|
|
52
|
+
*
|
|
53
|
+
* `../../etc/passwd` is a path the kernel refuses to every role, and `**`
|
|
54
|
+
* matched it as a string, so `explain` answered "inside territory" for it.
|
|
55
|
+
* A territory reaches outside the repo only by saying so — the
|
|
56
|
+
* `../shared/lab/dev.md` a role writes beside its own directory — and
|
|
57
|
+
* that one still matches, because both sides start with the same `..`.
|
|
58
|
+
*/
|
|
59
|
+
if (climbs(p) && !m.climbs) return false;
|
|
46
60
|
// The path itself, or anything beneath it. The `/` is load-bearing: without
|
|
47
61
|
// it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
|
|
48
62
|
if (m.prefix !== null) return p === m.prefix || p.startsWith(m.prefix + "/");
|
|
63
|
+
// The literal head of the glob, before its first wildcard, has to be the
|
|
64
|
+
// head of the path too. Most pairs fail here, for the price of a
|
|
65
|
+
// startsWith — `check` asks ~400k of them on a real policy.
|
|
66
|
+
if (!p.startsWith(m.head)) return false;
|
|
49
67
|
return matchGlob(m.glob, p);
|
|
50
68
|
}
|
|
51
69
|
|
|
@@ -69,7 +87,8 @@ function matcherOf(glob) {
|
|
|
69
87
|
if (m) return m;
|
|
70
88
|
let g = normalize(glob);
|
|
71
89
|
if (glob.endsWith("/")) g += "/**";
|
|
72
|
-
|
|
90
|
+
const wild = WILD.test(g);
|
|
91
|
+
m = { prefix: wild ? null : g, glob: wild ? g : null, climbs: climbs(g), head: wild ? headOf(g) : "" };
|
|
73
92
|
if (GLOBS.size >= MAX_CACHED) GLOBS.clear();
|
|
74
93
|
GLOBS.set(glob, m);
|
|
75
94
|
return m;
|
|
@@ -84,8 +103,15 @@ function normalized(path) {
|
|
|
84
103
|
return p;
|
|
85
104
|
}
|
|
86
105
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
106
|
+
/**
|
|
107
|
+
* What every path a glob matches starts with: its text up to the first
|
|
108
|
+
* wildcard, minus a final `/` — `src/api/**` matches `src/api` itself, and
|
|
109
|
+
* `a/**\/b` matches `a/b`.
|
|
110
|
+
*/
|
|
111
|
+
const headOf = (g) => g.slice(0, g.search(WILD)).replace(/\/$/, "");
|
|
112
|
+
|
|
113
|
+
/** Does a normalised path start above the repo root? */
|
|
114
|
+
const climbs = (p) => p === ".." || p.startsWith("../");
|
|
89
115
|
|
|
90
116
|
/**
|
|
91
117
|
* One spelling per file, before anybody decides anything about it.
|
|
@@ -97,8 +123,10 @@ const WILD = /[*?[\]]/;
|
|
|
97
123
|
* every sentence seisin said about it was wrong, which is the half of the tool
|
|
98
124
|
* that is actually ours.
|
|
99
125
|
*
|
|
100
|
-
* A path that climbs above the repo root
|
|
101
|
-
*
|
|
126
|
+
* A path that climbs above the repo root keeps its leading `..` segments
|
|
127
|
+
* (`src/../../x` is `../x`). `covers` matches such a path only against a glob
|
|
128
|
+
* that climbs out too, so outside the repo has no owner unless a territory
|
|
129
|
+
* names that place explicitly.
|
|
102
130
|
*/
|
|
103
131
|
export function normalize(s) {
|
|
104
132
|
const flat = s.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/+$/, "");
|
|
@@ -111,22 +139,9 @@ export function normalize(s) {
|
|
|
111
139
|
return up.join("/");
|
|
112
140
|
}
|
|
113
141
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
* The wildcards are parked on sentinels before `*` is expanded, because
|
|
118
|
-
* expanding `**` first produces a `.*` whose own `*` the next pass would
|
|
119
|
-
* rewrite again. Getting that order wrong is silent: the regex still compiles
|
|
120
|
-
* and quietly matches the wrong set of files.
|
|
121
|
-
*
|
|
122
|
-
* `src/api/**` also matches `src/api` itself. Without that, the directory a
|
|
123
|
-
* role was just granted comes back ownerless, which reads as a bug every time.
|
|
124
|
-
*
|
|
125
|
-
* Dotfiles are matched like any other name: `*.env` covers `.env`. Shell globs
|
|
126
|
-
* hide them by default, and most glob libraries copy that. A permission tool
|
|
127
|
-
* must not: covering one file too many costs an argument, covering one too few
|
|
128
|
-
* is the hole. When the two readings disagree, this one takes the wider set.
|
|
129
|
-
*/
|
|
142
|
+
/** matchGlob's memo, shared between calls (see there). */
|
|
143
|
+
let MEMO = new Uint8Array(4096);
|
|
144
|
+
|
|
130
145
|
/**
|
|
131
146
|
* Does `glob` cover `path`? A linear matcher, not a regular expression.
|
|
132
147
|
*
|
|
@@ -141,11 +156,31 @@ export function normalize(s) {
|
|
|
141
156
|
* Same wildcards as before: `?` one non-`/`, `*` a run of non-`/`, `**` any run
|
|
142
157
|
* including `/`, `**\/` zero or more whole directories, a trailing `/**` the
|
|
143
158
|
* subtree. Characters are compared literally; nothing is treated as regex.
|
|
159
|
+
*
|
|
160
|
+
* `src/api/**` also matches `src/api` itself. Without that, the directory a
|
|
161
|
+
* role was just granted comes back ownerless, which reads as a bug every time.
|
|
162
|
+
*
|
|
163
|
+
* Dotfiles are matched like any other name: `*.env` covers `.env`. Shell globs
|
|
164
|
+
* hide them by default, and most glob libraries copy that. A permission tool
|
|
165
|
+
* must not: covering one file too many costs an argument, covering one too few
|
|
166
|
+
* is the hole. When the two readings disagree, this one takes the wider set.
|
|
144
167
|
*/
|
|
145
168
|
function matchGlob(glob, path) {
|
|
146
169
|
const G = glob.length, P = path.length;
|
|
147
|
-
// memo[gi * (P+1) + pi]: 0 unknown, 1 true, 2 false. One flat array,
|
|
148
|
-
|
|
170
|
+
// memo[gi * (P+1) + pi]: 0 unknown, 1 true, 2 false. One flat array, kept
|
|
171
|
+
// between calls and cleared over the part this call uses: allocating it
|
|
172
|
+
// fresh was most of the cost of a call that fails on its second character.
|
|
173
|
+
// Nothing re-enters matchGlob while it runs, so sharing it is safe.
|
|
174
|
+
// An outsized pair gets its own array, so one long path does not pin
|
|
175
|
+
// megabytes for the life of a server.
|
|
176
|
+
const size = (G + 1) * (P + 1);
|
|
177
|
+
let memo;
|
|
178
|
+
if (size > 1 << 20) memo = new Uint8Array(size);
|
|
179
|
+
else {
|
|
180
|
+
if (MEMO.length < size) MEMO = new Uint8Array(Math.max(size, MEMO.length * 2));
|
|
181
|
+
memo = MEMO;
|
|
182
|
+
memo.fill(0, 0, size);
|
|
183
|
+
}
|
|
149
184
|
const at = (gi, pi) => {
|
|
150
185
|
for (;;) {
|
|
151
186
|
const key = gi * (P + 1) + pi;
|
|
@@ -210,11 +245,55 @@ export function ownersOf(config, path) {
|
|
|
210
245
|
if (!config.root || !path.startsWith(root)) return [];
|
|
211
246
|
path = path.slice(root.length);
|
|
212
247
|
}
|
|
248
|
+
// A private folder has one owner, whatever else covers it: every other role
|
|
249
|
+
// is given a denyWrite on it (srt.js), and denyWrite wins over any territory.
|
|
250
|
+
const mine = privateOwner(config, path);
|
|
213
251
|
return Object.values(config.roles)
|
|
214
252
|
.filter((r) => r.writes.some((g) => covers(g, path)) && !neverWrites(r, path, config))
|
|
253
|
+
.filter((r) => !mine || r.name === mine.role)
|
|
215
254
|
.map((r) => r.name);
|
|
216
255
|
}
|
|
217
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The private folder that holds `path` — `{ role, entry }` — or null.
|
|
259
|
+
*
|
|
260
|
+
* `path` is repo-relative, or absolute inside the repo. One answer for the
|
|
261
|
+
* settings, `explain`, the hook and `check`, so the denies the kernel is given
|
|
262
|
+
* and the sentence about them cannot drift apart.
|
|
263
|
+
*/
|
|
264
|
+
export function privateOwner(config, path) {
|
|
265
|
+
let p = String(path ?? "");
|
|
266
|
+
if (p.startsWith("/")) {
|
|
267
|
+
const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
|
|
268
|
+
if (!config.root || !p.startsWith(root)) return null;
|
|
269
|
+
p = p.slice(root.length);
|
|
270
|
+
}
|
|
271
|
+
for (const r of Object.values(config.roles ?? {}))
|
|
272
|
+
for (const entry of r.private ?? [])
|
|
273
|
+
if (covers(entry.replace(/\/\*\*$/, "").replace(/\/+$/, ""), p)) return { role: r.name, entry };
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The answer for `role` touching another role's private folder, or null. */
|
|
278
|
+
function privateAnswer(config, role, target, read = false) {
|
|
279
|
+
const hit = privateOwner(config, target);
|
|
280
|
+
if (!hit) return null;
|
|
281
|
+
// The owner's write goes on through the ordinary questions (never_writes
|
|
282
|
+
// still subtracts inside it); its read is answered here, because "every
|
|
283
|
+
// role reads it" is exactly what is no longer true.
|
|
284
|
+
if (hit.role === role)
|
|
285
|
+
return read
|
|
286
|
+
? { allowed: true, owners: [role], privateTo: role,
|
|
287
|
+
reason: `${target} is in ${role}'s private folder (private = ["${hit.entry}"]): ${role} reads it, and no other role does` }
|
|
288
|
+
: null;
|
|
289
|
+
return {
|
|
290
|
+
allowed: false, owners: [hit.role], privateTo: hit.role,
|
|
291
|
+
reason: `${target} is in ${hit.role}'s private folder (private = ["${hit.entry}"]): only ${hit.role} ` +
|
|
292
|
+
`reads or writes it, and every other role is denied both, in any read mode. ` +
|
|
293
|
+
`There is nothing to grant — ask ${hit.role} for what you need from it`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
218
297
|
/**
|
|
219
298
|
* Where a refused target stands against the policy as it is now.
|
|
220
299
|
*
|
|
@@ -238,9 +317,17 @@ export function standing(config, entry) {
|
|
|
238
317
|
const target = entry.target;
|
|
239
318
|
if (entry.kind === "network")
|
|
240
319
|
return { kind: "outside", owners: [], why: "a port or a socket, which no role owns" };
|
|
320
|
+
if (entry.kind === "system")
|
|
321
|
+
return { kind: "outside", owners: [], why: "a system service, which no role owns" };
|
|
241
322
|
if (entry.kind === "key")
|
|
242
323
|
return { kind: "outside", owners: [], why: "a key directory, closed on purpose" };
|
|
243
324
|
const guard = protectedBy(config, target);
|
|
325
|
+
// A family the policy hands to a role is protected from everybody else and
|
|
326
|
+
// owned by that role — if it is also that role's territory.
|
|
327
|
+
if (guard?.family) {
|
|
328
|
+
const holders = ownersOf(config, target).filter((o) => config.roles[o]?.controlFiles?.includes(guard.family));
|
|
329
|
+
if (holders.length) return { kind: "owned", owners: holders };
|
|
330
|
+
}
|
|
244
331
|
if (guard) return { kind: "protected", owners: [], why: guard.why };
|
|
245
332
|
if (target.startsWith("/")) {
|
|
246
333
|
const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
|
|
@@ -323,6 +410,127 @@ export function neverWrites(role, path, config = null) {
|
|
|
323
410
|
return enforcedNeverWrites(config, role).find((g) => covers(g, path)) ?? null;
|
|
324
411
|
}
|
|
325
412
|
|
|
413
|
+
/** A key dir as the policy's paths spell it: no leading `./`, no trailing `/`. */
|
|
414
|
+
const dirSpelling = (d) => String(d).replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
415
|
+
|
|
416
|
+
/** Is the repo-relative `rel` one of the key directories, or inside one? */
|
|
417
|
+
export function inKeyDir(config, rel) {
|
|
418
|
+
return (config.keyDirs ?? []).some((d) => {
|
|
419
|
+
const k = dirSpelling(d);
|
|
420
|
+
const r = k.startsWith("/") ? relFromRoot(config, k) : k;
|
|
421
|
+
return r !== null && (rel === r || rel.startsWith(r + "/"));
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function relFromRoot(config, abs) {
|
|
426
|
+
const root = config.root ?? "";
|
|
427
|
+
return abs === root ? "." : abs.startsWith(root + "/") ? abs.slice(root.length + 1) : null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* How a key is written in a role's `keys` list: relative to the first key
|
|
432
|
+
* directory when it lives there, with its directory otherwise — the same form
|
|
433
|
+
* settingsFor and keyHolders resolve back.
|
|
434
|
+
*/
|
|
435
|
+
export function keyName(config, target) {
|
|
436
|
+
const t = String(target ?? "");
|
|
437
|
+
const first = config.keyDirs?.length ? dirSpelling(config.keyDirs[0]) : null;
|
|
438
|
+
return first && t.startsWith(first + "/") ? t.slice(first.length + 1) : t;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* A read of a FILE — a path, not a key name — and whether the role gets it.
|
|
443
|
+
*
|
|
444
|
+
* Reads are open by design: every role reads everything outside the `[keys]`
|
|
445
|
+
* dirs (and, under `isolate`, outside the credential homes). `explain` used to
|
|
446
|
+
* send every read through the key question, so `explain dev read .env` said
|
|
447
|
+
* "denied — no role declares .env" about a file every role could read. A wrong
|
|
448
|
+
* no from the tool whose job is saying what the kernel will do. Now a path
|
|
449
|
+
* inside a key directory is a key question; anything else is answered as the
|
|
450
|
+
* open read it is, with how to make it a key.
|
|
451
|
+
*/
|
|
452
|
+
/**
|
|
453
|
+
* An agent's sign-in file: closed to every run but a run of that agent, which
|
|
454
|
+
* is a fact about the command and not the role (AGENT_CREDENTIALS, grants.js).
|
|
455
|
+
* explain has no command, so it answers for the ordinary case and names the
|
|
456
|
+
* exception. Nothing to grant either way, and the sentence says so: the advice
|
|
457
|
+
* it replaced was "add it to a role's keys", which would hand a ChatGPT token
|
|
458
|
+
* to a role by policy.
|
|
459
|
+
*/
|
|
460
|
+
function signInAnswer(role, target) {
|
|
461
|
+
if (!String(target ?? "").startsWith("/")) return null;
|
|
462
|
+
const agent = AGENTS.find((a) => AGENT_CREDENTIALS[a].map(expand).includes(target));
|
|
463
|
+
if (!agent) return null;
|
|
464
|
+
return {
|
|
465
|
+
allowed: false, owners: [], signIn: agent,
|
|
466
|
+
reason: `${target} is ${agent}'s sign-in: only a run whose command is ${agent} reads it ` +
|
|
467
|
+
`(seisin run ${role} -- ${agent} …), and the commands that run inside it. ` +
|
|
468
|
+
`Any other command run as ${role} is denied it, and there is nothing to grant`,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function explainFileRead(config, role, rel, credentialHomes = []) {
|
|
473
|
+
// Only a key directory makes a key: settingsFor refuses a key declared
|
|
474
|
+
// anywhere else, so nothing outside one is denied by being a key.
|
|
475
|
+
if (inKeyDir(config, rel)) return explainPolicy(config, role, "read", rel);
|
|
476
|
+
const level = config.isolate === true ? "home" : config.isolate;
|
|
477
|
+
if ((level === "credentials" || level === "home") && rel.startsWith("/")) {
|
|
478
|
+
const hit = credentialHomes.find((h) => rel === h || rel.startsWith(h + "/"));
|
|
479
|
+
if (hit)
|
|
480
|
+
return {
|
|
481
|
+
allowed: false, owners: [],
|
|
482
|
+
reason: `${rel} is under ${hit}, which isolate = "${level}" closes to every role`,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const signIn = signInAnswer(role, rel);
|
|
486
|
+
if (signIn) return signIn;
|
|
487
|
+
const shut = privateAnswer(config, role, rel, true);
|
|
488
|
+
if (shut) return shut;
|
|
489
|
+
if (config.read === "territory" && rel.startsWith("/") &&
|
|
490
|
+
shutByTerritory(rel, keptFor(config, config.roles[role])))
|
|
491
|
+
return {
|
|
492
|
+
allowed: false, owners: [], readTerritory: true,
|
|
493
|
+
reason: `${rel} is outside what ${role} keeps readable under read = "territory". ` +
|
|
494
|
+
`Declare it in reads, or declare the tool that needs it in toolchain`,
|
|
495
|
+
};
|
|
496
|
+
const dirs = (config.keyDirs ?? []).map(dirSpelling);
|
|
497
|
+
return {
|
|
498
|
+
allowed: true, owners: [], open: true,
|
|
499
|
+
reason: dirs.length
|
|
500
|
+
? `${rel} is outside every [keys] dir (${dirs.join(", ")}), so every role reads it. ` +
|
|
501
|
+
`Move it into ${dirs[0]}/ to make it a key`
|
|
502
|
+
: `no [keys] dir is declared, so every role reads every file. ` +
|
|
503
|
+
`Declare one under [keys] and move ${rel} into it to make it a key`,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* What a read is about: a key, named the way the policy names keys, or a file.
|
|
509
|
+
*
|
|
510
|
+
* `explain dev read stripe` asks about a key — a bare name some role declares,
|
|
511
|
+
* or one sitting in a key directory. Everything else is a path, resolved from
|
|
512
|
+
* where you stand, and inside a key directory it is a key question again.
|
|
513
|
+
*/
|
|
514
|
+
export function readTarget(config, target, cwd = process.cwd()) {
|
|
515
|
+
// A reference is a key by its spelling, never a path: resolving
|
|
516
|
+
// `keychain://x` from where you stand printed `keychain:/x` and answered
|
|
517
|
+
// a question about a file nobody asked about.
|
|
518
|
+
if (refOf(target)) return { key: true, target };
|
|
519
|
+
if (!target.includes("/") && !isAbsolute(target)) {
|
|
520
|
+
const declared = keyHolders(config, target).length > 0;
|
|
521
|
+
const bare = (s) => s.replace(/\.[^.]+$/, "");
|
|
522
|
+
const inDir = (config.keyDirs ?? []).some((d) => {
|
|
523
|
+
const dir = isAbsolute(d) ? d : join(config.root, d);
|
|
524
|
+
if (existsSync(join(dir, target))) return true;
|
|
525
|
+
try { return readdirSync(dir).some((f) => bare(f) === target); } catch { return false; }
|
|
526
|
+
});
|
|
527
|
+
const here = fromCwd(config, target, cwd);
|
|
528
|
+
const isFileHere = existsSync(here) && !inKeyDir(config, toRepoRelative(config, here));
|
|
529
|
+
if ((declared || inDir) && !isFileHere) return { key: true, target };
|
|
530
|
+
}
|
|
531
|
+
return { key: false, target: toRepoRelative(config, fromCwd(config, target, cwd)) };
|
|
532
|
+
}
|
|
533
|
+
|
|
326
534
|
/** Every role allowed to read `key`, matched with or without its extension. */
|
|
327
535
|
export function keyHolders(config, key) {
|
|
328
536
|
const dirs = config.keyDirs ?? [];
|
|
@@ -349,19 +557,88 @@ export function keyHolders(config, key) {
|
|
|
349
557
|
// netlify` — so it stays, and only for an unqualified question.
|
|
350
558
|
const loose = !key.includes("/");
|
|
351
559
|
|
|
560
|
+
/**
|
|
561
|
+
* Only path keys. A reference — `keychain://x`, `NAME=file://.secrets/a#X` —
|
|
562
|
+
* is resolved by the parent and delivered as a value; the role is never
|
|
563
|
+
* given the file. Comparing the raw strings let `explain` say "declares
|
|
564
|
+
* a.env" about a role whose key merely pointed into it, a yes the kernel
|
|
565
|
+
* would refuse.
|
|
566
|
+
*/
|
|
352
567
|
return Object.values(config.roles)
|
|
353
|
-
.filter((r) => r.
|
|
568
|
+
.filter((r) => fileKeys(r).some((k) =>
|
|
354
569
|
resolve(k) === wanted || (loose && bare(k) === bare(key))))
|
|
355
570
|
.map((r) => r.name);
|
|
356
571
|
}
|
|
357
572
|
|
|
573
|
+
/** `scheme://…`, optionally `NAME=scheme://…`, read as a key entry — or null. */
|
|
574
|
+
function refOf(target) {
|
|
575
|
+
try {
|
|
576
|
+
const e = parseKey(target);
|
|
577
|
+
return e.kind === "ref" ? e : null;
|
|
578
|
+
} catch {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
358
583
|
/**
|
|
359
|
-
*
|
|
584
|
+
* A reference key is a question about the policy, not about the disk.
|
|
360
585
|
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
586
|
+
* Two facts answer it: whether the role lists the reference in its `keys`,
|
|
587
|
+
* and which provider turns it into a value. The role never reads where the
|
|
588
|
+
* secret lives — the parent resolves it before the sandbox starts and hands
|
|
589
|
+
* over the value — so there is no path to normalise and no key directory to
|
|
590
|
+
* be inside of. Matched on scheme and reference; the variable name in front is
|
|
591
|
+
* how it is delivered, not which secret it is.
|
|
364
592
|
*/
|
|
593
|
+
function explainRef(config, role, ref) {
|
|
594
|
+
const spelled = `${ref.scheme}://${ref.ref}`;
|
|
595
|
+
const declaring = (r) => {
|
|
596
|
+
try {
|
|
597
|
+
return entriesOf(r).find((e) => e.kind === "ref" && e.scheme === ref.scheme && e.ref === ref.ref);
|
|
598
|
+
} catch {
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
const holders = Object.values(config.roles).filter(declaring).map((r) => r.name);
|
|
603
|
+
const provider = BUILTIN.has(ref.scheme)
|
|
604
|
+
? `seisin's built-in ${ref.scheme}:// provider`
|
|
605
|
+
: Object.hasOwn(config.keyProviders ?? {}, ref.scheme) ? `[keys.providers.${ref.scheme}]` : null;
|
|
606
|
+
if (!provider)
|
|
607
|
+
return {
|
|
608
|
+
allowed: false, owners: holders, key: "ref",
|
|
609
|
+
reason: `${spelled} uses the "${ref.scheme}" scheme and no [keys.providers.${ref.scheme}] is declared, ` +
|
|
610
|
+
`so nothing resolves it — declare the provider before a role can hold this key`,
|
|
611
|
+
};
|
|
612
|
+
const mine = config.roles[role] && declaring(config.roles[role]);
|
|
613
|
+
if (mine)
|
|
614
|
+
return {
|
|
615
|
+
allowed: true, owners: holders, key: "ref", provider,
|
|
616
|
+
reason: `${role} declares ${mine.raw}: the parent resolves it through ${provider} and hands ` +
|
|
617
|
+
`${role} the value as ${mine.name} — the role never reads where it is kept`,
|
|
618
|
+
};
|
|
619
|
+
if (!holders.length)
|
|
620
|
+
return {
|
|
621
|
+
allowed: false, owners: [], key: "ref", provider,
|
|
622
|
+
reason: `no role declares ${spelled} (${provider} would resolve it) — ` +
|
|
623
|
+
`add it to a role's keys: [roles.${role}] keys = ["${ref.raw}"]`,
|
|
624
|
+
};
|
|
625
|
+
return {
|
|
626
|
+
allowed: false, owners: holders, key: "ref", provider,
|
|
627
|
+
reason: `${spelled} belongs to ${holders.join(", ")}, through ${provider}`,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** The keys of `role` that grant a read: the path ones. A malformed entry grants nothing. */
|
|
632
|
+
function fileKeys(role) {
|
|
633
|
+
let entries;
|
|
634
|
+
try {
|
|
635
|
+
entries = entriesOf(role);
|
|
636
|
+
} catch {
|
|
637
|
+
return [];
|
|
638
|
+
}
|
|
639
|
+
return entries.filter((e) => e.kind === "file").map((e) => e.raw);
|
|
640
|
+
}
|
|
641
|
+
|
|
365
642
|
/**
|
|
366
643
|
* A connection the kernel refused: `tcp:<port>` or a unix socket path.
|
|
367
644
|
*
|
|
@@ -425,14 +702,32 @@ function explainMcp(config, role, name) {
|
|
|
425
702
|
: `${role} declares mcp = [], no MCP servers` };
|
|
426
703
|
}
|
|
427
704
|
|
|
428
|
-
|
|
705
|
+
/**
|
|
706
|
+
* The sentence a blocked agent should read.
|
|
707
|
+
*
|
|
708
|
+
* Three outcomes, and the third is the one worth having: a path nobody owns is
|
|
709
|
+
* not a permission problem, it is a hole in the map. Saying so is more useful
|
|
710
|
+
* than denying quietly, and it is the only way the hole ever gets fixed.
|
|
711
|
+
*/
|
|
712
|
+
function explainPolicy(config, role, action, target) {
|
|
429
713
|
if (action === "connect") return explainConnect(config, role, target);
|
|
430
714
|
if (action === "mcp") return explainMcp(config, role, target);
|
|
431
715
|
if (action === "read") {
|
|
716
|
+
const ref = refOf(target);
|
|
717
|
+
if (ref) return explainRef(config, role, ref);
|
|
718
|
+
const signIn = signInAnswer(role, target);
|
|
719
|
+
if (signIn) return signIn;
|
|
432
720
|
const holders = keyHolders(config, target);
|
|
433
721
|
if (holders.includes(role)) return { allowed: true, owners: holders, reason: `${role} declares ${target}` };
|
|
434
|
-
if (holders.length === 0)
|
|
435
|
-
|
|
722
|
+
if (holders.length === 0) {
|
|
723
|
+
// A key is written as a name relative to the key directory — `stripe.txt`,
|
|
724
|
+
// not `.secrets/stripe.txt` — so the advice says the name to write.
|
|
725
|
+
const name = keyName(config, target);
|
|
726
|
+
return {
|
|
727
|
+
allowed: false, owners: [],
|
|
728
|
+
reason: `no role declares ${target} — add "${name}" to a role's keys: [roles.<name>] keys = ["${name}"]`,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
436
731
|
return { allowed: false, owners: holders, reason: `${target} belongs to ${holders.join(", ")}` };
|
|
437
732
|
}
|
|
438
733
|
|
|
@@ -442,11 +737,18 @@ export function explain(config, role, action, target) {
|
|
|
442
737
|
*
|
|
443
738
|
* The policy, seisin's state, the key directories, what the parent executes
|
|
444
739
|
* or reads for a key, and the files that make git or Claude Code run
|
|
445
|
-
* something outside the
|
|
740
|
+
* something outside the sandbox. The kernel denies all of them to every role —
|
|
446
741
|
* surface.js has the list and the reasons — so saying "belongs to dev" about
|
|
447
742
|
* `seisin.toml` sent a request to a person that no grant could satisfy.
|
|
448
743
|
*/
|
|
449
|
-
const guard = protectedBy(config, target);
|
|
744
|
+
const guard = protectedBy(config, target, { role });
|
|
745
|
+
if (guard?.family)
|
|
746
|
+
return {
|
|
747
|
+
allowed: false, owners, protected: guard.why, family: guard.family,
|
|
748
|
+
reason: `${target} is protected (${guard.why}). A role writes it only when the policy hands it ` +
|
|
749
|
+
`that family: control_files = ["${guard.family}"] under [roles.${role}], and inside its own ` +
|
|
750
|
+
`territory. That is a person's decision about the policy, not a grant to request`,
|
|
751
|
+
};
|
|
450
752
|
if (guard)
|
|
451
753
|
return {
|
|
452
754
|
allowed: false, owners, protected: guard.why,
|
|
@@ -460,13 +762,15 @@ export function explain(config, role, action, target) {
|
|
|
460
762
|
* approves it, and the subtraction is undone from the other side without
|
|
461
763
|
* anyone deciding to undo it.
|
|
462
764
|
*/
|
|
765
|
+
const shut = privateAnswer(config, role, target);
|
|
766
|
+
if (shut) return shut;
|
|
463
767
|
const r = config.roles[role];
|
|
464
768
|
const barred = r && r.writes.some((g) => covers(g, target)) ? neverWrites(r, target, config) : null;
|
|
465
769
|
if (barred)
|
|
466
770
|
return {
|
|
467
771
|
allowed: false, owners, neverWrites: barred,
|
|
468
772
|
reason: `${target} is denied by never_writes of ${role} ("${barred}") — ` +
|
|
469
|
-
`a subtraction written into the policy, not a missing
|
|
773
|
+
`a subtraction written into the policy, not a missing grant`,
|
|
470
774
|
};
|
|
471
775
|
if (owners.includes(role)) return { allowed: true, owners, reason: `${target} is inside ${role}'s territory` };
|
|
472
776
|
/**
|
|
@@ -477,17 +781,32 @@ export function explain(config, role, action, target) {
|
|
|
477
781
|
* else's history. Codex keeps `.git` read-only under a writable root for the
|
|
478
782
|
* same reason; nono and Gemini grant the whole of it. Here it stays refused
|
|
479
783
|
* and is said for what it is — not "belongs to X, ask them", which filed a
|
|
480
|
-
* request per lock file (327 of them for one index.lock
|
|
481
|
-
* that nobody could sensibly
|
|
784
|
+
* request per lock file (327 of them for one index.lock in one multi-repo workspace)
|
|
785
|
+
* that nobody could sensibly grant. The requests side skips it too.
|
|
482
786
|
*/
|
|
483
787
|
if (isGitMetadata(target))
|
|
484
788
|
return {
|
|
485
789
|
allowed: false, owners, gitMetadata: true,
|
|
486
790
|
reason: `${target} is git's own bookkeeping in a repository ${role} does not write — ` +
|
|
487
|
-
`not
|
|
791
|
+
`not something to request. Read what you need (git log, git show, git ls-remote) ` +
|
|
488
792
|
`or work in a worktree of your own`,
|
|
489
793
|
};
|
|
490
794
|
if (owners.length === 0)
|
|
491
795
|
return { allowed: false, owners: [], reason: `${target} has no owner — no role can write it until one claims it` };
|
|
492
796
|
return { allowed: false, owners, reason: `${target} belongs to ${owners.join(", ")}` };
|
|
493
797
|
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* One public answer for CLI, MCP and library callers.
|
|
801
|
+
*
|
|
802
|
+
* Reads first decide whether the target is a declared key or a file, then ask
|
|
803
|
+
* the same territory/isolation question. Callers may supply their cwd; servers
|
|
804
|
+
* use the policy root, while the CLI uses the directory it is standing in.
|
|
805
|
+
*/
|
|
806
|
+
export function explain(config, role, action, target, cwd = config.root) {
|
|
807
|
+
if (action !== "read") return explainPolicy(config, role, action, target);
|
|
808
|
+
const asked = readTarget(config, target, cwd);
|
|
809
|
+
return asked.key
|
|
810
|
+
? explainPolicy(config, role, "read", asked.target)
|
|
811
|
+
: explainFileRead(config, role, asked.target, CREDENTIAL_HOMES.map(expand));
|
|
812
|
+
}
|
package/src/paths.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* project refuses. Found by the read-only review of 2026-09-22.
|
|
13
13
|
*/
|
|
14
14
|
import { realpathSync } from "node:fs";
|
|
15
|
-
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
15
|
+
import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* `p` with its deepest existing ancestor resolved through symlinks and the rest
|
|
@@ -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,47 @@ export function toRepoRelative(config, target) {
|
|
|
48
56
|
if (inside(realRoot, realTarget)) return relative(realRoot, realTarget) || ".";
|
|
49
57
|
return target;
|
|
50
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One spelling for a path, so two of them can be compared with `===`.
|
|
62
|
+
*
|
|
63
|
+
* Repo-relative and normalized inside the repo (`./a/`, `a/../a` and the
|
|
64
|
+
* absolute path through either symlinked spelling of the root are all `a`);
|
|
65
|
+
* absolute and resolved through its existing ancestors outside it. Used where
|
|
66
|
+
* a path a caller gave must match the path a denial names exactly — never as
|
|
67
|
+
* a prefix and never as a glob.
|
|
68
|
+
*/
|
|
69
|
+
export function samePath(config, target) {
|
|
70
|
+
if (typeof target !== "string" || !target.trim()) return null;
|
|
71
|
+
const rel = toRepoRelative(config, isAbsolute(target) ? normalize(target) : target);
|
|
72
|
+
if (isAbsolute(rel)) return realAncestor(normalize(rel)).replace(/(.)\/+$/, "$1");
|
|
73
|
+
return normalize(rel).replace(/\/+$/, "") || ".";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A path typed at a prompt, made absolute the way a shell user means it:
|
|
78
|
+
* against the directory they are standing in. Relative arguments used to be
|
|
79
|
+
* read against the policy's root, so `seisin explain web write app.js` from
|
|
80
|
+
* web/src asked about ./app.js at the root and answered "no owner".
|
|
81
|
+
*
|
|
82
|
+
* Only when the working directory is inside the repo. From anywhere else —
|
|
83
|
+
* `SEISIN_CONFIG` pointing at a policy elsewhere, a test harness — a relative
|
|
84
|
+
* path keeps meaning what it always meant: relative to the root.
|
|
85
|
+
*/
|
|
86
|
+
export function fromCwd(config, target, cwd = process.cwd()) {
|
|
87
|
+
if (typeof target !== "string" || isAbsolute(target)) return target;
|
|
88
|
+
const root = config.root;
|
|
89
|
+
const inside = (r, t) => t === r || t.startsWith(r + sep);
|
|
90
|
+
// The cwd and the root compared in the same spelling: as written, or both
|
|
91
|
+
// through their symlinks (on macOS /tmp is /private/tmp).
|
|
92
|
+
let base = root, here = cwd;
|
|
93
|
+
if (!inside(root, cwd)) {
|
|
94
|
+
base = realAncestor(root);
|
|
95
|
+
here = realAncestor(cwd);
|
|
96
|
+
if (!inside(base, here)) return target;
|
|
97
|
+
}
|
|
98
|
+
const rel = relative(base, join(here, target));
|
|
99
|
+
if (rel === "") return root;
|
|
100
|
+
if (rel === ".." || rel.startsWith(".." + sep)) return join(here, target);
|
|
101
|
+
return join(root, rel);
|
|
102
|
+
}
|