seisin 0.4.2 → 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 +142 -54
- 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 -38
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +109 -32
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +159 -37
- 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 +187 -25
- 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 +20 -15
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +236 -51
- package/src/mcp.js +42 -28
- package/src/notify.js +13 -8
- package/src/owners.js +198 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +68 -10
- package/src/requests.js +173 -72
- package/src/review.js +30 -17
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +377 -229
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +191 -44
- 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 +1356 -537
package/src/owners.js
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
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 } from "./keys.js";
|
|
18
|
+
import { WILD, fromCwd, toRepoRelative } from "./paths.js";
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
|
|
@@ -43,9 +45,23 @@ import { protectedBy } from "./surface.js";
|
|
|
43
45
|
export function covers(glob, path) {
|
|
44
46
|
const p = normalized(path);
|
|
45
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;
|
|
46
58
|
// The path itself, or anything beneath it. The `/` is load-bearing: without
|
|
47
59
|
// it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
|
|
48
60
|
if (m.prefix !== null) return p === m.prefix || p.startsWith(m.prefix + "/");
|
|
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;
|
|
49
65
|
return matchGlob(m.glob, p);
|
|
50
66
|
}
|
|
51
67
|
|
|
@@ -69,7 +85,8 @@ function matcherOf(glob) {
|
|
|
69
85
|
if (m) return m;
|
|
70
86
|
let g = normalize(glob);
|
|
71
87
|
if (glob.endsWith("/")) g += "/**";
|
|
72
|
-
|
|
88
|
+
const wild = WILD.test(g);
|
|
89
|
+
m = { prefix: wild ? null : g, glob: wild ? g : null, climbs: climbs(g), head: wild ? headOf(g) : "" };
|
|
73
90
|
if (GLOBS.size >= MAX_CACHED) GLOBS.clear();
|
|
74
91
|
GLOBS.set(glob, m);
|
|
75
92
|
return m;
|
|
@@ -84,8 +101,15 @@ function normalized(path) {
|
|
|
84
101
|
return p;
|
|
85
102
|
}
|
|
86
103
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
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("../");
|
|
89
113
|
|
|
90
114
|
/**
|
|
91
115
|
* One spelling per file, before anybody decides anything about it.
|
|
@@ -97,8 +121,10 @@ const WILD = /[*?[\]]/;
|
|
|
97
121
|
* every sentence seisin said about it was wrong, which is the half of the tool
|
|
98
122
|
* that is actually ours.
|
|
99
123
|
*
|
|
100
|
-
* A path that climbs above the repo root
|
|
101
|
-
*
|
|
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.
|
|
102
128
|
*/
|
|
103
129
|
export function normalize(s) {
|
|
104
130
|
const flat = s.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/+$/, "");
|
|
@@ -111,22 +137,9 @@ export function normalize(s) {
|
|
|
111
137
|
return up.join("/");
|
|
112
138
|
}
|
|
113
139
|
|
|
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
|
-
*/
|
|
140
|
+
/** matchGlob's memo, shared between calls (see there). */
|
|
141
|
+
let MEMO = new Uint8Array(4096);
|
|
142
|
+
|
|
130
143
|
/**
|
|
131
144
|
* Does `glob` cover `path`? A linear matcher, not a regular expression.
|
|
132
145
|
*
|
|
@@ -141,11 +154,31 @@ export function normalize(s) {
|
|
|
141
154
|
* Same wildcards as before: `?` one non-`/`, `*` a run of non-`/`, `**` any run
|
|
142
155
|
* including `/`, `**\/` zero or more whole directories, a trailing `/**` the
|
|
143
156
|
* subtree. Characters are compared literally; nothing is treated as regex.
|
|
157
|
+
*
|
|
158
|
+
* `src/api/**` also matches `src/api` itself. Without that, the directory a
|
|
159
|
+
* role was just granted comes back ownerless, which reads as a bug every time.
|
|
160
|
+
*
|
|
161
|
+
* Dotfiles are matched like any other name: `*.env` covers `.env`. Shell globs
|
|
162
|
+
* hide them by default, and most glob libraries copy that. A permission tool
|
|
163
|
+
* must not: covering one file too many costs an argument, covering one too few
|
|
164
|
+
* is the hole. When the two readings disagree, this one takes the wider set.
|
|
144
165
|
*/
|
|
145
166
|
function matchGlob(glob, path) {
|
|
146
167
|
const G = glob.length, P = path.length;
|
|
147
|
-
// memo[gi * (P+1) + pi]: 0 unknown, 1 true, 2 false. One flat array,
|
|
148
|
-
|
|
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
|
+
}
|
|
149
182
|
const at = (gi, pi) => {
|
|
150
183
|
for (;;) {
|
|
151
184
|
const key = gi * (P + 1) + pi;
|
|
@@ -241,6 +274,12 @@ export function standing(config, entry) {
|
|
|
241
274
|
if (entry.kind === "key")
|
|
242
275
|
return { kind: "outside", owners: [], why: "a key directory, closed on purpose" };
|
|
243
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
|
+
}
|
|
244
283
|
if (guard) return { kind: "protected", owners: [], why: guard.why };
|
|
245
284
|
if (target.startsWith("/")) {
|
|
246
285
|
const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
|
|
@@ -323,6 +362,92 @@ export function neverWrites(role, path, config = null) {
|
|
|
323
362
|
return enforcedNeverWrites(config, role).find((g) => covers(g, path)) ?? null;
|
|
324
363
|
}
|
|
325
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
|
+
|
|
326
451
|
/** Every role allowed to read `key`, matched with or without its extension. */
|
|
327
452
|
export function keyHolders(config, key) {
|
|
328
453
|
const dirs = config.keyDirs ?? [];
|
|
@@ -349,19 +474,30 @@ export function keyHolders(config, key) {
|
|
|
349
474
|
// netlify` — so it stays, and only for an unqualified question.
|
|
350
475
|
const loose = !key.includes("/");
|
|
351
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
|
+
*/
|
|
352
484
|
return Object.values(config.roles)
|
|
353
|
-
.filter((r) => r.
|
|
485
|
+
.filter((r) => fileKeys(r).some((k) =>
|
|
354
486
|
resolve(k) === wanted || (loose && bare(k) === bare(key))))
|
|
355
487
|
.map((r) => r.name);
|
|
356
488
|
}
|
|
357
489
|
|
|
358
|
-
/**
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
+
|
|
365
501
|
/**
|
|
366
502
|
* A connection the kernel refused: `tcp:<port>` or a unix socket path.
|
|
367
503
|
*
|
|
@@ -425,14 +561,28 @@ function explainMcp(config, role, name) {
|
|
|
425
561
|
: `${role} declares mcp = [], no MCP servers` };
|
|
426
562
|
}
|
|
427
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
|
+
*/
|
|
428
571
|
export function explain(config, role, action, target) {
|
|
429
572
|
if (action === "connect") return explainConnect(config, role, target);
|
|
430
573
|
if (action === "mcp") return explainMcp(config, role, target);
|
|
431
574
|
if (action === "read") {
|
|
432
575
|
const holders = keyHolders(config, target);
|
|
433
576
|
if (holders.includes(role)) return { allowed: true, owners: holders, reason: `${role} declares ${target}` };
|
|
434
|
-
if (holders.length === 0)
|
|
435
|
-
|
|
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
|
+
}
|
|
436
586
|
return { allowed: false, owners: holders, reason: `${target} belongs to ${holders.join(", ")}` };
|
|
437
587
|
}
|
|
438
588
|
|
|
@@ -442,11 +592,18 @@ export function explain(config, role, action, target) {
|
|
|
442
592
|
*
|
|
443
593
|
* The policy, seisin's state, the key directories, what the parent executes
|
|
444
594
|
* or reads for a key, and the files that make git or Claude Code run
|
|
445
|
-
* something outside the
|
|
595
|
+
* something outside the sandbox. The kernel denies all of them to every role —
|
|
446
596
|
* surface.js has the list and the reasons — so saying "belongs to dev" about
|
|
447
597
|
* `seisin.toml` sent a request to a person that no grant could satisfy.
|
|
448
598
|
*/
|
|
449
|
-
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
|
+
};
|
|
450
607
|
if (guard)
|
|
451
608
|
return {
|
|
452
609
|
allowed: false, owners, protected: guard.why,
|
|
@@ -466,7 +623,7 @@ export function explain(config, role, action, target) {
|
|
|
466
623
|
return {
|
|
467
624
|
allowed: false, owners, neverWrites: barred,
|
|
468
625
|
reason: `${target} is denied by never_writes of ${role} ("${barred}") — ` +
|
|
469
|
-
`a subtraction written into the policy, not a missing
|
|
626
|
+
`a subtraction written into the policy, not a missing grant`,
|
|
470
627
|
};
|
|
471
628
|
if (owners.includes(role)) return { allowed: true, owners, reason: `${target} is inside ${role}'s territory` };
|
|
472
629
|
/**
|
|
@@ -477,14 +634,14 @@ export function explain(config, role, action, target) {
|
|
|
477
634
|
* else's history. Codex keeps `.git` read-only under a writable root for the
|
|
478
635
|
* same reason; nono and Gemini grant the whole of it. Here it stays refused
|
|
479
636
|
* 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
|
|
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.
|
|
482
639
|
*/
|
|
483
640
|
if (isGitMetadata(target))
|
|
484
641
|
return {
|
|
485
642
|
allowed: false, owners, gitMetadata: true,
|
|
486
643
|
reason: `${target} is git's own bookkeeping in a repository ${role} does not write — ` +
|
|
487
|
-
`not
|
|
644
|
+
`not something to request. Read what you need (git log, git show, git ls-remote) ` +
|
|
488
645
|
`or work in a worktree of your own`,
|
|
489
646
|
};
|
|
490
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}›`);
|
package/src/render.js
CHANGED
|
@@ -67,8 +67,16 @@ export function collapseSidecars(paths) {
|
|
|
67
67
|
return out;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
70
|
+
/**
|
|
71
|
+
* The `check` report, as text. Takes the object `inspect()` returns.
|
|
72
|
+
*
|
|
73
|
+
* The map and one line per warning first; the paragraphs behind `verbose`.
|
|
74
|
+
* On a three-role repo the full report ran to forty lines of explanation under
|
|
75
|
+
* a six-line map, and the one warning a person could act on sat in the middle.
|
|
76
|
+
* Nothing is dropped: the protected paths, each warning's reason and the
|
|
77
|
+
* standing limits are one flag away, and the footer says how many.
|
|
78
|
+
*/
|
|
79
|
+
export function renderReport(report, { verbose = true } = {}) {
|
|
72
80
|
const lines = [`\n${C.b}${report.where}${C.off}\n\n`];
|
|
73
81
|
const width = Math.max(...report.roles.map((r) => r.name.length), 4);
|
|
74
82
|
|
|
@@ -101,10 +109,10 @@ export function renderReport(report) {
|
|
|
101
109
|
// What the kernel refuses inside a territory, and why. Shown because each one
|
|
102
110
|
// takes something from a territory as written; capped because a monorepo has
|
|
103
111
|
// a hooks directory per package and the reader needs the shape, not all 200.
|
|
104
|
-
if (report.protected?.length) {
|
|
112
|
+
if (report.protected?.length && verbose) {
|
|
105
113
|
const root = report.where ? report.where.replace(/\/[^/]*$/, "/") : "";
|
|
106
114
|
const show = (p) => (root && p.startsWith(root) ? p.slice(root.length) : p.replace(/^\/Users\/[^/]+|^\/home\/[^/]+/, "~"));
|
|
107
|
-
lines.push(` ${C.b}protected${C.off} ${C.dim}— inside a territory,
|
|
115
|
+
lines.push(` ${C.b}protected${C.off} ${C.dim}— inside a territory, denied anyway: something outside the sandbox runs or reads it${C.off}\n`);
|
|
108
116
|
const LIMIT = 12;
|
|
109
117
|
for (const e of report.protected.slice(0, LIMIT)) {
|
|
110
118
|
const who = e.roles.length > 3 ? `${e.roles.slice(0, 3).join(", ")} +${e.roles.length - 3}` : e.roles.join(", ");
|
|
@@ -115,6 +123,18 @@ export function renderReport(report) {
|
|
|
115
123
|
lines.push("\n");
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
if (!verbose) {
|
|
127
|
+
for (const w of report.warnings) lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
|
|
128
|
+
if (report.warnings.length) lines.push("\n");
|
|
129
|
+
const more = [
|
|
130
|
+
report.protected?.length ? `${report.protected.length} protected path(s)` : "",
|
|
131
|
+
report.warnings.some((w) => w.detail) ? "why each warning matters" : "",
|
|
132
|
+
report.limits?.length ? `${report.limits.length} standing limit(s)` : "",
|
|
133
|
+
].filter(Boolean);
|
|
134
|
+
if (more.length) lines.push(` ${C.dim}seisin check --verbose: ${more.join(", ")}${C.off}\n\n`);
|
|
135
|
+
return lines.join("");
|
|
136
|
+
}
|
|
137
|
+
|
|
118
138
|
for (const w of report.warnings) {
|
|
119
139
|
lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
|
|
120
140
|
if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);
|
|
@@ -177,16 +197,40 @@ export function renderScan(result, keyDirs) {
|
|
|
177
197
|
const { certain, review, skipped, truncated } = result;
|
|
178
198
|
const links = result.links ?? [];
|
|
179
199
|
const where = keyDirs.length ? keyDirs.join(", ") : `${C.yellow}nowhere — [keys] dir is unset${C.off}`;
|
|
180
|
-
const lines = [`\n ${C.dim}protected: ${where}${C.off}\n
|
|
200
|
+
const lines = [`\n ${C.dim}protected: ${where}${C.off}\n`];
|
|
201
|
+
// Scoped to the territories: say where it looked, and how to look everywhere.
|
|
202
|
+
if (result.scope) {
|
|
203
|
+
const shown = result.scope.slice(0, 10).map(safe).join(", ");
|
|
204
|
+
const more = result.scope.length > 10 ? ` and ${result.scope.length - 10} more` : "";
|
|
205
|
+
lines.push(` ${C.dim}scanned: where the territories are — ${shown}${more}. ${C.b}seisin scan --all${C.off}${C.dim} walks the whole folder${C.off}\n`);
|
|
206
|
+
}
|
|
207
|
+
lines.push("\n");
|
|
181
208
|
|
|
182
|
-
|
|
183
|
-
|
|
209
|
+
// A pruned checkout is part of what a clean result means, so it is said
|
|
210
|
+
// even when there is nothing else to say — in yellow, not dim: every role
|
|
211
|
+
// can still read it, and a dim line under a green verdict read as clean.
|
|
212
|
+
const paths = result.nestedPaths ?? [];
|
|
213
|
+
let nested = "";
|
|
214
|
+
if (skipped.nested) {
|
|
215
|
+
nested = ` ${C.yellow}not scanned: ${skipped.nested} nested checkout(s)${C.off} — another repository each; every role can still read them. Scan one with ${C.b}cd <dir> && seisin scan${C.off}\n`;
|
|
216
|
+
for (const p of paths.slice(0, 15)) nested += ` ${safe(p)}\n`;
|
|
217
|
+
if (paths.length > 15) nested += ` ${C.dim}… and ${paths.length - 15} more${C.off}\n`;
|
|
218
|
+
nested += "\n";
|
|
219
|
+
}
|
|
220
|
+
const named = result.named ?? [];
|
|
221
|
+
if (certain.length === 0 && review.length === 0 && links.length === 0 && named.length === 0) {
|
|
222
|
+
lines.push(skipped.nested
|
|
223
|
+
? ` nothing credential-shaped in what was scanned ${C.dim}(outside the declared directories)${C.off}\n\n`
|
|
224
|
+
: ` ${C.green}nothing credential-shaped outside the declared directories${C.off}\n\n`, nested);
|
|
184
225
|
return lines.join("");
|
|
185
226
|
}
|
|
186
227
|
|
|
187
228
|
if (certain.length) {
|
|
188
|
-
lines.push(` ${C.red}${certain.length} credential(s)${C.off} —
|
|
189
|
-
for (const h of certain)
|
|
229
|
+
lines.push(` ${C.red}${certain.length} credential(s)${C.off} — issued shapes, or a password in a URL: a credential, not a word that mentions one\n\n`);
|
|
230
|
+
for (const h of certain) {
|
|
231
|
+
const whose = h.owners?.length ? ` ${C.dim}in ${h.owners.map(safe).join(", ")}'s territory${C.off}` : "";
|
|
232
|
+
lines.push(` ${C.b}${safe(h.file)}${C.off}${C.dim}:${h.line}${C.off} ${h.shape}${whose}\n`);
|
|
233
|
+
}
|
|
190
234
|
lines.push("\n");
|
|
191
235
|
}
|
|
192
236
|
|
|
@@ -200,6 +244,18 @@ export function renderScan(result, keyDirs) {
|
|
|
200
244
|
lines.push("\n");
|
|
201
245
|
}
|
|
202
246
|
|
|
247
|
+
// By name, not by content, and said that way: a .env of DEBUG=1 is on this
|
|
248
|
+
// list too. What it is for is the .env nobody declared.
|
|
249
|
+
if (named.length) {
|
|
250
|
+
lines.push(` ${C.yellow}${named.length} file(s) that usually hold secrets${C.off} — by their name, not certain; every role reads them\n\n`);
|
|
251
|
+
for (const h of named.slice(0, 15)) {
|
|
252
|
+
const whose = h.owners?.length ? ` ${C.dim}in ${h.owners.map(safe).join(", ")}'s territory${C.off}` : "";
|
|
253
|
+
lines.push(` ${C.b}${safe(h.file)}${C.off}${whose}\n`);
|
|
254
|
+
}
|
|
255
|
+
if (named.length > 15) lines.push(` ${C.dim}… and ${named.length - 15} more${C.off}\n`);
|
|
256
|
+
lines.push("\n");
|
|
257
|
+
}
|
|
258
|
+
|
|
203
259
|
// Links are their own finding, not a line with a secret-shaped name in it.
|
|
204
260
|
// What is reported is that the link leaves the repo — the destination is
|
|
205
261
|
// named and never opened, because the point is that it exists.
|
|
@@ -217,7 +273,9 @@ export function renderScan(result, keyDirs) {
|
|
|
217
273
|
if (skipped.ignored) quiet.push(`${skipped.ignored} ignored path(s)`);
|
|
218
274
|
if (skipped.protectedDirs) quiet.push(`${skipped.protectedDirs} inside declared key dir(s)`);
|
|
219
275
|
if (quiet.length) lines.push(` ${C.dim}not reported: ${quiet.join(" · ")}${C.off}\n`);
|
|
220
|
-
|
|
276
|
+
// Only the ones to review are capped; a certain hit is always listed above.
|
|
277
|
+
if (truncated) lines.push(` ${C.yellow}${result.omitted ?? "more"} more to review were not listed — the cap applies to review, never to certain hits${C.off}\n`);
|
|
278
|
+
if (nested) lines.push(nested);
|
|
221
279
|
lines.push(` ${C.dim}seisin does not move these. Where a credential lives is your call.${C.off}\n\n`);
|
|
222
280
|
return lines.join("");
|
|
223
281
|
}
|