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/render.js
CHANGED
|
@@ -25,6 +25,17 @@ export const C =
|
|
|
25
25
|
}
|
|
26
26
|
: { dim: "", b: "", off: "", red: "", green: "", yellow: "", blue: "" };
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A value chosen by an agent (a target, a grant, an id) made safe to print.
|
|
30
|
+
*
|
|
31
|
+
* `target` and the id built from it are strings the agent picked, and they were
|
|
32
|
+
* written to the terminal raw. Terminal escape sequences (ESC, CR, other C0
|
|
33
|
+
* controls) let such a string repaint the line a person reads before approving.
|
|
34
|
+
* On disk they are already JSON-escaped; this is for the screen. Stripped, not
|
|
35
|
+
* rendered, so what shows is the literal path with the control bytes removed.
|
|
36
|
+
*/
|
|
37
|
+
export const safe = (s) => String(s ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
|
38
|
+
|
|
28
39
|
export const out = (s) => process.stdout.write(s);
|
|
29
40
|
export const err = (s) => process.stderr.write(s);
|
|
30
41
|
|
|
@@ -56,8 +67,16 @@ export function collapseSidecars(paths) {
|
|
|
56
67
|
return out;
|
|
57
68
|
}
|
|
58
69
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
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 } = {}) {
|
|
61
80
|
const lines = [`\n${C.b}${report.where}${C.off}\n\n`];
|
|
62
81
|
const width = Math.max(...report.roles.map((r) => r.name.length), 4);
|
|
63
82
|
|
|
@@ -90,10 +109,10 @@ export function renderReport(report) {
|
|
|
90
109
|
// What the kernel refuses inside a territory, and why. Shown because each one
|
|
91
110
|
// takes something from a territory as written; capped because a monorepo has
|
|
92
111
|
// a hooks directory per package and the reader needs the shape, not all 200.
|
|
93
|
-
if (report.protected?.length) {
|
|
112
|
+
if (report.protected?.length && verbose) {
|
|
94
113
|
const root = report.where ? report.where.replace(/\/[^/]*$/, "/") : "";
|
|
95
114
|
const show = (p) => (root && p.startsWith(root) ? p.slice(root.length) : p.replace(/^\/Users\/[^/]+|^\/home\/[^/]+/, "~"));
|
|
96
|
-
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`);
|
|
97
116
|
const LIMIT = 12;
|
|
98
117
|
for (const e of report.protected.slice(0, LIMIT)) {
|
|
99
118
|
const who = e.roles.length > 3 ? `${e.roles.slice(0, 3).join(", ")} +${e.roles.length - 3}` : e.roles.join(", ");
|
|
@@ -104,6 +123,18 @@ export function renderReport(report) {
|
|
|
104
123
|
lines.push("\n");
|
|
105
124
|
}
|
|
106
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
|
+
|
|
107
138
|
for (const w of report.warnings) {
|
|
108
139
|
lines.push(` ${C.yellow}${w.headline}${C.off}\n`);
|
|
109
140
|
if (w.detail) lines.push(` ${C.dim}${w.detail}${C.off}\n`);
|
|
@@ -125,7 +156,7 @@ export function renderReport(report) {
|
|
|
125
156
|
/** The `explain` answer, as text. Takes what `owners.explain()` returns. */
|
|
126
157
|
export function renderVerdict(role, action, target, verdict) {
|
|
127
158
|
const head = verdict.allowed ? `${C.green}allowed${C.off}` : `${C.yellow}denied${C.off}`;
|
|
128
|
-
return `\n ${head} ${C.b}${role}${C.off} ${action} ${target}\n ${C.dim}${verdict.reason}${C.off}\n\n`;
|
|
159
|
+
return `\n ${head} ${C.b}${role}${C.off} ${action} ${safe(target)}\n ${C.dim}${safe(verdict.reason)}${C.off}\n\n`;
|
|
129
160
|
}
|
|
130
161
|
|
|
131
162
|
/* ── the log ──────────────────────────────────────────────────────────── */
|
|
@@ -149,7 +180,7 @@ export function renderEntry(e) {
|
|
|
149
180
|
// about a run, a reader needs to know which line is which without going to
|
|
150
181
|
// the file — so the one that carries more weight is the one that is marked.
|
|
151
182
|
const from = e.source === "kernel" ? ` ${C.dim}[kernel]${C.off}` : "";
|
|
152
|
-
return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${e.action} ${e.target}${owners}${from}\n`;
|
|
183
|
+
return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${e.action} ${safe(e.target)}${owners}${from}\n`;
|
|
153
184
|
}
|
|
154
185
|
|
|
155
186
|
/* ── the scan ─────────────────────────────────────────────────────────── */
|
|
@@ -166,16 +197,40 @@ export function renderScan(result, keyDirs) {
|
|
|
166
197
|
const { certain, review, skipped, truncated } = result;
|
|
167
198
|
const links = result.links ?? [];
|
|
168
199
|
const where = keyDirs.length ? keyDirs.join(", ") : `${C.yellow}nowhere — [keys] dir is unset${C.off}`;
|
|
169
|
-
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");
|
|
170
208
|
|
|
171
|
-
|
|
172
|
-
|
|
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);
|
|
173
225
|
return lines.join("");
|
|
174
226
|
}
|
|
175
227
|
|
|
176
228
|
if (certain.length) {
|
|
177
|
-
lines.push(` ${C.red}${certain.length} credential(s)${C.off} —
|
|
178
|
-
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
|
+
}
|
|
179
234
|
lines.push("\n");
|
|
180
235
|
}
|
|
181
236
|
|
|
@@ -189,6 +244,18 @@ export function renderScan(result, keyDirs) {
|
|
|
189
244
|
lines.push("\n");
|
|
190
245
|
}
|
|
191
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
|
+
|
|
192
259
|
// Links are their own finding, not a line with a secret-shaped name in it.
|
|
193
260
|
// What is reported is that the link leaves the repo — the destination is
|
|
194
261
|
// named and never opened, because the point is that it exists.
|
|
@@ -206,7 +273,9 @@ export function renderScan(result, keyDirs) {
|
|
|
206
273
|
if (skipped.ignored) quiet.push(`${skipped.ignored} ignored path(s)`);
|
|
207
274
|
if (skipped.protectedDirs) quiet.push(`${skipped.protectedDirs} inside declared key dir(s)`);
|
|
208
275
|
if (quiet.length) lines.push(` ${C.dim}not reported: ${quiet.join(" · ")}${C.off}\n`);
|
|
209
|
-
|
|
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);
|
|
210
279
|
lines.push(` ${C.dim}seisin does not move these. Where a credential lives is your call.${C.off}\n\n`);
|
|
211
280
|
return lines.join("");
|
|
212
281
|
}
|
package/src/requests.js
CHANGED
|
@@ -1,38 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The queue of
|
|
2
|
+
* The queue of requests: what an agent asked for and cannot have yet.
|
|
3
3
|
*
|
|
4
4
|
* ── Two words, kept apart on purpose ──
|
|
5
|
-
* **
|
|
5
|
+
* **Denied** is what the boundary does. **Declined** is what a person does to
|
|
6
6
|
* a request. They used to be one word in one command: an entry read `first
|
|
7
7
|
* refused on src/api/x.ts` and `seisin deny` answered `refused frontend ✕
|
|
8
8
|
* src/api/**`. One means the kernel said no, the other means you did, and a
|
|
9
9
|
* reader had nothing to tell them apart by.
|
|
10
10
|
*
|
|
11
11
|
* The entry says **asked** now, because refused was not always true. Asking for
|
|
12
|
-
* a
|
|
12
|
+
* a path before reaching for it is reasonable, and nothing required a
|
|
13
13
|
* denial to have happened first — so the queue could print a sentence about an
|
|
14
|
-
* event that never occurred, in front of a person about to
|
|
15
|
-
* `seisin run` drops any request the policy does not actually
|
|
14
|
+
* event that never occurred, in front of a person about to grant something.
|
|
15
|
+
* `seisin run` drops any request the policy does not actually deny; what is
|
|
16
16
|
* left is "you cannot have this, and you asked", which holds whether the agent
|
|
17
17
|
* tried or simply asked.
|
|
18
18
|
*
|
|
19
19
|
* A denial already contains everything a request needs — the hook knows the
|
|
20
|
-
* role, the path and the owner — so the
|
|
20
|
+
* role, the path and the owner — so the denial leaves something behind that a
|
|
21
21
|
* person can act on in one command instead of in an editor. See
|
|
22
|
-
* `docs/permission-requests.md` for why
|
|
22
|
+
* `docs/permission-requests.md` for why granting is deliberately not something
|
|
23
23
|
* an agent can do.
|
|
24
24
|
*
|
|
25
25
|
* Append-only, beside the log, for the same reason: a decision that can be
|
|
26
|
-
* rewritten is not evidence. A request that is granted or
|
|
26
|
+
* rewritten is not evidence. A request that is granted or declined is not
|
|
27
27
|
* deleted — it is followed by a line saying what happened to it.
|
|
28
28
|
*/
|
|
29
|
-
import { neverWrites, isGitMetadata, covers } from "./owners.js";
|
|
30
|
-
import {
|
|
29
|
+
import { neverWrites, isGitMetadata, covers, keyHolders, inKeyDir } from "./owners.js";
|
|
30
|
+
import { protectedBy } from "./surface.js";
|
|
31
|
+
import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
|
|
32
|
+
import { randomBytes } from "node:crypto";
|
|
31
33
|
import { dirname, join } from "node:path";
|
|
32
34
|
import { STATE_DIR, tomlString } from "./layout.js";
|
|
33
35
|
import { send } from "./spool.js";
|
|
36
|
+
import { withLock, readEntries } from "./log.js";
|
|
34
37
|
|
|
35
|
-
|
|
38
|
+
const REQUESTS_NAME = "requests.jsonl";
|
|
36
39
|
|
|
37
40
|
export function requestsPath(root) {
|
|
38
41
|
return join(root, STATE_DIR, REQUESTS_NAME);
|
|
@@ -56,14 +59,34 @@ export function keyOf({ role, action, target }) {
|
|
|
56
59
|
return `${role}:${action}:${dir}`;
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
/**
|
|
63
|
+
* A request's id as one shell word, for the commands seisin suggests.
|
|
64
|
+
*
|
|
65
|
+
* The id carries a path an agent chose, so it is single-quoted whole and any
|
|
66
|
+
* quote inside it is closed and escaped: pasting the suggestion runs `seisin
|
|
67
|
+
* grant` on that request and nothing else.
|
|
68
|
+
*/
|
|
69
|
+
export function shellId(key) {
|
|
70
|
+
return `'${String(key).replace(/'/g, `'\\''`)}'`;
|
|
71
|
+
}
|
|
72
|
+
|
|
59
73
|
/** The glob a grant would add, derived from what was asked. */
|
|
60
|
-
export function grantFor({ action, target }) {
|
|
74
|
+
export function grantFor({ action, target }, keyDir = null) {
|
|
61
75
|
const t = typeof target === "string" ? target : "";
|
|
62
76
|
// A key keeps its directory when it has one. Stripping it turned a request
|
|
63
77
|
// for `shared/api.txt` into a grant of `api.txt`, which settingsFor then
|
|
64
|
-
// resolves against the FIRST key directory — so the person
|
|
78
|
+
// resolves against the FIRST key directory — so the person grants one file
|
|
65
79
|
// and another one of the same name is what gets read.
|
|
66
|
-
|
|
80
|
+
//
|
|
81
|
+
// Except the first key directory itself, which is exactly the one a bare
|
|
82
|
+
// name resolves against: `.secrets/stripe.txt` is written `stripe.txt`, the
|
|
83
|
+
// way every hand-written key is. Granting the long form put two spellings of
|
|
84
|
+
// one key in the same file.
|
|
85
|
+
if (action === "read") {
|
|
86
|
+
const first = keyDir ? String(keyDir).replace(/^\.\/+/, "").replace(/\/+$/, "") : null;
|
|
87
|
+
if (first && t.startsWith(first + "/") && !t.slice(first.length + 1).includes("/")) return t.slice(first.length + 1);
|
|
88
|
+
return t.includes("/") ? t : t.replace(/^.*\//, "");
|
|
89
|
+
}
|
|
67
90
|
const dir = t.split("/").slice(0, -1).join("/");
|
|
68
91
|
return dir ? `${dir}/**` : t;
|
|
69
92
|
}
|
|
@@ -72,7 +95,9 @@ function write(file, entry) {
|
|
|
72
95
|
if (send("requests", entry)) return true;
|
|
73
96
|
try {
|
|
74
97
|
mkdirSync(dirname(file), { recursive: true });
|
|
75
|
-
|
|
98
|
+
// The time is this process's, named after the spread so no entry sets it.
|
|
99
|
+
const now = new Date().toISOString();
|
|
100
|
+
appendFileSync(file, JSON.stringify({ at: now, ...entry, at: now }) + "\n");
|
|
76
101
|
return true;
|
|
77
102
|
} catch {
|
|
78
103
|
// Same rule as the log: never take the agent down over bookkeeping.
|
|
@@ -80,8 +105,27 @@ function write(file, entry) {
|
|
|
80
105
|
}
|
|
81
106
|
}
|
|
82
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Who a read request's key belongs to, from the policy as it is now.
|
|
110
|
+
*
|
|
111
|
+
* A key is declared, not owned: nobody writes `.secrets/`, so the owners a
|
|
112
|
+
* write-territory lookup finds for one are always none, and the queue said
|
|
113
|
+
* "(unowned)" about a key another role declares — right before a grant that
|
|
114
|
+
* would share it. Recomputed rather than read from the line, like every other
|
|
115
|
+
* field that decides what a person is shown.
|
|
116
|
+
*/
|
|
117
|
+
export function withDeclarers(config, queue) {
|
|
118
|
+
for (const r of queue) {
|
|
119
|
+
if (r.action !== "read" || typeof r.target !== "string") continue;
|
|
120
|
+
if (!inKeyDir(config, r.target) && r.target.includes("/")) continue;
|
|
121
|
+
r.owners = keyHolders(config, r.target);
|
|
122
|
+
r.declared = true;
|
|
123
|
+
}
|
|
124
|
+
return queue;
|
|
125
|
+
}
|
|
126
|
+
|
|
83
127
|
/** Records that a role asked for something the policy denies it. Safe to call
|
|
84
|
-
* on every denial; `seisin run` drops anything the policy does not
|
|
128
|
+
* on every denial; `seisin run` drops anything the policy does not deny. */
|
|
85
129
|
export function record(file, { role, action, target, owners }) {
|
|
86
130
|
return write(file, { kind: "asked", key: keyOf({ role, action, target }), role, action, target, owners: owners ?? [] });
|
|
87
131
|
}
|
|
@@ -103,6 +147,9 @@ export function settle(file, key, decision, reason = "") {
|
|
|
103
147
|
*
|
|
104
148
|
* It exists so that a refusal to run cannot be invisible. Admission may
|
|
105
149
|
* postpone work; it may not make work disappear from the queue a person reads.
|
|
150
|
+
*
|
|
151
|
+
* Nothing in src/ calls it today — handoff.js decides but nothing records the
|
|
152
|
+
* outcome; the tests do, and pending() keeps reading what it writes.
|
|
106
153
|
*/
|
|
107
154
|
export function recordHandoff(file, key, decision = {}) {
|
|
108
155
|
const { type, role, limit, max, reason, depth, chainId, revision } = decision;
|
|
@@ -123,20 +170,35 @@ export function recordHandoff(file, key, decision = {}) {
|
|
|
123
170
|
* it was asked and what was decided.
|
|
124
171
|
*
|
|
125
172
|
* Reduced from the whole file rather than kept as state, so the file stays the
|
|
126
|
-
* only thing that has to be correct.
|
|
173
|
+
* only thing that has to be correct. The reduction is remembered for as long as
|
|
174
|
+
* the file is unchanged (same inode, size and mtime): the console asks every
|
|
175
|
+
* two seconds, and re-parsing a 2 MB queue each time to find it had not moved
|
|
176
|
+
* was most of the cost of asking. Each call gets its own copies, so a caller
|
|
177
|
+
* marking entries (markStale) cannot mark the next caller's.
|
|
127
178
|
*/
|
|
128
|
-
|
|
129
|
-
|
|
179
|
+
let reduced = null; // { file, ino, size, mtimeMs, all }
|
|
180
|
+
|
|
181
|
+
export function pending(file, { includeSettled = false, keyDirs = null } = {}) {
|
|
182
|
+
let st;
|
|
183
|
+
try { st = statSync(file); } catch { return []; }
|
|
184
|
+
let all;
|
|
185
|
+
if (reduced && reduced.file === file && reduced.ino === st.ino && reduced.size === st.size && reduced.mtimeMs === st.mtimeMs) {
|
|
186
|
+
all = reduced.all;
|
|
187
|
+
} else {
|
|
188
|
+
all = reduce(file);
|
|
189
|
+
reduced = { file, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, all };
|
|
190
|
+
}
|
|
191
|
+
// The grant is re-derived with the policy's first key directory when the
|
|
192
|
+
// caller has one, so a key is proposed in the form hand-written keys use.
|
|
193
|
+
const first = keyDirs?.[0] ?? null;
|
|
194
|
+
const copies = all.map((r) => (first && r.action === "read" ? { ...r, grant: grantFor(r, first) } : { ...r }));
|
|
195
|
+
return includeSettled ? copies : copies.filter((r) => r.state === "pending");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function reduce(file) {
|
|
130
199
|
const byKey = new Map();
|
|
131
200
|
|
|
132
|
-
for (const
|
|
133
|
-
if (!line.trim()) continue;
|
|
134
|
-
let e;
|
|
135
|
-
try {
|
|
136
|
-
e = JSON.parse(line);
|
|
137
|
-
} catch {
|
|
138
|
-
continue; // a half-written line from a killed process is not an error
|
|
139
|
-
}
|
|
201
|
+
for (const e of readEntries(file)) {
|
|
140
202
|
if (!e.key) continue;
|
|
141
203
|
|
|
142
204
|
if (e.kind === "asked") {
|
|
@@ -157,7 +219,8 @@ export function pending(file, { includeSettled = false } = {}) {
|
|
|
157
219
|
*
|
|
158
220
|
* Without this branch the catch-all below would set state = "handoff" and a
|
|
159
221
|
* throttled attempt would drop out of the pending queue — the exact silent
|
|
160
|
-
* disappearance the attempt is recorded to prevent.
|
|
222
|
+
* disappearance the attempt is recorded to prevent. (Only the tests write
|
|
223
|
+
* handoff lines today — see recordHandoff.)
|
|
161
224
|
*/
|
|
162
225
|
if (e.kind === "handoff") {
|
|
163
226
|
seen.handoff = {
|
|
@@ -170,14 +233,13 @@ export function pending(file, { includeSettled = false } = {}) {
|
|
|
170
233
|
seen.state = e.kind; seen.reason = e.reason ?? ""; seen.decided = e.at;
|
|
171
234
|
}
|
|
172
235
|
|
|
173
|
-
|
|
174
|
-
return includeSettled ? all : all.filter((r) => r.state === "pending");
|
|
236
|
+
return [...byKey.values()];
|
|
175
237
|
}
|
|
176
238
|
|
|
177
239
|
/**
|
|
178
240
|
* How many runs of the role, after the last time it asked, make a request old.
|
|
179
241
|
*
|
|
180
|
-
* A request is recorded again on every
|
|
242
|
+
* A request is recorded again on every denial, so one the role still needs
|
|
181
243
|
* keeps a fresh `last`. One whose need went away — the work moved somewhere the
|
|
182
244
|
* role may write, or the task ended — stops being asked while the role keeps
|
|
183
245
|
* running. Three runs is enough to say "not since" and few enough that a queue
|
|
@@ -221,7 +283,7 @@ export function runsAfter(entries, since, gapMs = RUN_GAP_MS) {
|
|
|
221
283
|
* reordering would shift the numbers `grant <n>` is typed against while
|
|
222
284
|
* nobody decided anything. The mark says what is true — the role ran N times
|
|
223
285
|
* since and did not ask again — and leaves the rest to whoever reads it.
|
|
224
|
-
* `entries` are log lines (`read(logPath(root))`)
|
|
286
|
+
* `entries` are log lines (`read(logPath(root))`).
|
|
225
287
|
*/
|
|
226
288
|
export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_MS } = {}) {
|
|
227
289
|
const byRole = new Map(); // role -> its log lines
|
|
@@ -236,35 +298,13 @@ export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_M
|
|
|
236
298
|
return queue;
|
|
237
299
|
}
|
|
238
300
|
|
|
239
|
-
/**
|
|
240
|
-
* Adds a granted glob to a role in the config text, with its provenance.
|
|
241
|
-
*
|
|
242
|
-
* Edits the text rather than re-emitting the file, because a config someone
|
|
243
|
-
* wrote has comments and an order that mean something, and a tool that
|
|
244
|
-
* reformats it on every grant is a tool people stop letting near it.
|
|
245
|
-
*/
|
|
246
|
-
/**
|
|
247
|
-
* Adds a granted glob to a role in the config text, with its provenance.
|
|
248
|
-
*
|
|
249
|
-
* Edits the text rather than re-emitting the file, because a config someone
|
|
250
|
-
* wrote has comments and an order that mean something, and a tool that
|
|
251
|
-
* reformats it on every grant is a tool people stop letting near it.
|
|
252
|
-
*
|
|
253
|
-
* The edit is bounded to the role's own section, and that is not tidiness. The
|
|
254
|
-
* first version searched from the role header to the next `writes =` anywhere
|
|
255
|
-
* in the file, so approving for a role that had no `writes` line wrote the
|
|
256
|
-
* grant into the NEXT role — with a comment saying who it was for. A human
|
|
257
|
-
* approved one thing and the file recorded another. In a permission tool that
|
|
258
|
-
* is the worst possible bug, and it is not exotic: a role with only `keys`
|
|
259
|
-
* declared is an ordinary config.
|
|
260
|
-
*/
|
|
261
301
|
/**
|
|
262
302
|
* Refuses a grant that `never_writes` would cancel, before anything is written.
|
|
263
303
|
*
|
|
264
304
|
* A request filed before the subtraction existed can still be in the queue.
|
|
265
|
-
*
|
|
305
|
+
* Granting it would add the path to `writes` and change nothing — denyWrite
|
|
266
306
|
* wins — so the person would believe they granted something the kernel still
|
|
267
|
-
*
|
|
307
|
+
* denies. The two settings contradict each other, and which one should win is the
|
|
268
308
|
* decision; seisin will not make it by writing one of them silently.
|
|
269
309
|
*/
|
|
270
310
|
export function refuseIfBarred(config, request) {
|
|
@@ -276,6 +316,18 @@ export function refuseIfBarred(config, request) {
|
|
|
276
316
|
`${request.target} is under never_writes of ${request.role} ("${hit}"), which wins over ` +
|
|
277
317
|
`writes. Granting it would change nothing. If the subtraction is wrong, remove that ` +
|
|
278
318
|
`entry from [roles.${request.role}]; otherwise decline this request.`);
|
|
319
|
+
// A protected file stays denied whatever `writes` says: the kernel is given
|
|
320
|
+
// it as a deny, and explain answers the same. Granting `web/.vscode/**`
|
|
321
|
+
// used to add the line and change nothing — a grant that reads as done.
|
|
322
|
+
const guard = protectedBy(config, request.target, { role: request.role });
|
|
323
|
+
if (guard)
|
|
324
|
+
throw new Error(guard.family
|
|
325
|
+
? `${request.target} is protected (${guard.why}). A grant cannot open it: ${request.role} ` +
|
|
326
|
+
`writes it only if the policy hands it the family, control_files = ["${guard.family}"] ` +
|
|
327
|
+
`under [roles.${request.role}], inside its own territory. Edit the policy if that is ` +
|
|
328
|
+
`what you mean; otherwise decline this request.`
|
|
329
|
+
: `${request.target} is protected (${guard.why}) and no role can ever be granted it. ` +
|
|
330
|
+
`Decline this request.`);
|
|
279
331
|
// Only a repository the role does not write: its own `.git` is its own business.
|
|
280
332
|
if (isGitMetadata(request.target) && !(role?.writes ?? []).some((g) => covers(g, request.target)))
|
|
281
333
|
throw new Error(
|
|
@@ -283,6 +335,93 @@ export function refuseIfBarred(config, request) {
|
|
|
283
335
|
`rewrite another owner's history. Decline it; a role that needs to commit gets a worktree of its own.`);
|
|
284
336
|
}
|
|
285
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Apply one policy edit under a lock, and write it atomically.
|
|
340
|
+
*
|
|
341
|
+
* `grant` and the console both did read → applyGrant → write with no lock, so
|
|
342
|
+
* two approvals racing (a person at the CLI and one on the console, or two
|
|
343
|
+
* terminals) lost one edit: the queue marked both granted while `seisin.toml`
|
|
344
|
+
* kept only the last writer's change (measured: 39 of 40 concurrent pairs). The
|
|
345
|
+
* lock serialises them — it already survives a writer that died holding it
|
|
346
|
+
* (see log.js) — and the write goes to a temp file then renames over the config,
|
|
347
|
+
* so a crash mid-write cannot leave a half-written, partly-privileged policy.
|
|
348
|
+
*
|
|
349
|
+
* `mutate(toml)` returns `{ toml, changed }`. Nothing is written when unchanged.
|
|
350
|
+
* `after(result)` runs once the file is written, still under the lock — for
|
|
351
|
+
* the queue line that records the decision, so a decision and its edit are
|
|
352
|
+
* one step to anyone else taking this lock.
|
|
353
|
+
*/
|
|
354
|
+
export function editPolicy(config, mutate, { waitMs = 10000, after = null } = {}) {
|
|
355
|
+
return withLock(policyLockBase(config), () => {
|
|
356
|
+
const before = readFileSync(config.path, "utf8");
|
|
357
|
+
const result = mutate(before);
|
|
358
|
+
if (result.changed) writePolicy(config, result.toml);
|
|
359
|
+
after?.(result);
|
|
360
|
+
return result;
|
|
361
|
+
}, { waitMs });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Where the policy's lock lives: inside `.seisin/`, never beside the policy.
|
|
366
|
+
*
|
|
367
|
+
* It was `seisin.toml.lock`, next to the file. Any role that writes the repo
|
|
368
|
+
* root — every `--observe` run, any `writes = ["**"]` — could create that name
|
|
369
|
+
* holding a live pid and hold up every grant and every decline for as long as
|
|
370
|
+
* it liked. `.seisin/` is denied to every role (surface.js, parentInputs), so
|
|
371
|
+
* nothing confined can hold this one. withLock adds the `.lock`.
|
|
372
|
+
*/
|
|
373
|
+
export function policyLockBase(config) {
|
|
374
|
+
const dir = join(config.root, STATE_DIR);
|
|
375
|
+
mkdirSync(dir, { recursive: true });
|
|
376
|
+
return join(dir, "policy");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Write the policy through a temp file that nobody could have prepared.
|
|
381
|
+
*
|
|
382
|
+
* The temp was `seisin.toml.tmp-<pid>` beside the policy: a predictable name,
|
|
383
|
+
* in a directory roles can write, opened by writeFileSync — which follows a
|
|
384
|
+
* symlink. A role could plant that name as a link into its own territory, and
|
|
385
|
+
* the next grant made the policy itself a symlink the role could rewrite.
|
|
386
|
+
* Now the temp sits in `.seisin/`, has a random name, and is opened with `wx`
|
|
387
|
+
* (O_CREAT|O_EXCL), which refuses anything already there, a link included.
|
|
388
|
+
*
|
|
389
|
+
* A `.seisin/` on another filesystem (a symlink a person made) cannot be
|
|
390
|
+
* renamed across; then the temp goes beside the policy, still random and still
|
|
391
|
+
* exclusive, which is what defeats a planted name.
|
|
392
|
+
*/
|
|
393
|
+
function writePolicy(config, text) {
|
|
394
|
+
const name = `seisin.toml.tmp-${randomBytes(12).toString("hex")}`;
|
|
395
|
+
const stateTmp = join(config.root, STATE_DIR, name);
|
|
396
|
+
writeFileSync(stateTmp, text, { flag: "wx" });
|
|
397
|
+
try {
|
|
398
|
+
renameSync(stateTmp, config.path);
|
|
399
|
+
return;
|
|
400
|
+
} catch (e) {
|
|
401
|
+
try { unlinkSync(stateTmp); } catch {}
|
|
402
|
+
if (e.code !== "EXDEV") throw e;
|
|
403
|
+
}
|
|
404
|
+
const near = join(dirname(config.path), `.${name}`);
|
|
405
|
+
writeFileSync(near, text, { flag: "wx" });
|
|
406
|
+
try { renameSync(near, config.path); }
|
|
407
|
+
catch (e) { try { unlinkSync(near); } catch {} throw e; }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Adds a granted glob to a role in the config text, with its provenance.
|
|
412
|
+
*
|
|
413
|
+
* Edits the text rather than re-emitting the file, because a config someone
|
|
414
|
+
* wrote has comments and an order that mean something, and a tool that
|
|
415
|
+
* reformats it on every grant is a tool people stop letting near it.
|
|
416
|
+
*
|
|
417
|
+
* The edit is bounded to the role's own section, and that is not tidiness. The
|
|
418
|
+
* first version searched from the role header to the next `writes =` anywhere
|
|
419
|
+
* in the file, so granting for a role that had no `writes` line wrote the
|
|
420
|
+
* grant into the NEXT role — with a comment saying who it was for. A person
|
|
421
|
+
* granted one thing and the file recorded another. In a permission layer that
|
|
422
|
+
* is the worst possible bug, and it is not exotic: a role with only `keys`
|
|
423
|
+
* declared is an ordinary config.
|
|
424
|
+
*/
|
|
286
425
|
export function applyGrant(toml, request, note = "") {
|
|
287
426
|
const field = request.action === "read" ? "keys" : "writes";
|
|
288
427
|
const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
|
|
@@ -295,43 +434,82 @@ export function applyGrant(toml, request, note = "") {
|
|
|
295
434
|
const to = next ? from + next.index : toml.length;
|
|
296
435
|
const section = toml.slice(from, to);
|
|
297
436
|
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
if (!m)
|
|
437
|
+
const open = new RegExp(`^${field}\\s*=\\s*\\[`, "m").exec(section);
|
|
438
|
+
if (!open)
|
|
301
439
|
throw new Error(
|
|
302
440
|
`[roles.${request.role}] has no ${field} list to grant into. ` +
|
|
303
|
-
`Add \`${field} = []\` to that section first — seisin will not write it into another role's
|
|
441
|
+
`Add \`${field} = []\` to that section first — seisin will not write it into another role's table.`
|
|
304
442
|
);
|
|
305
443
|
|
|
306
444
|
// The subset has no escapes, so a value carrying a quote or a newline cannot
|
|
307
445
|
// be written down at all. The strict parser would reject the result rather
|
|
308
446
|
// than widen anything — but leaving someone with a config that no longer
|
|
309
|
-
// loads, after they
|
|
447
|
+
// loads, after they granted something, is its own kind of broken.
|
|
310
448
|
tomlString(request.grant); // refuses what the format cannot hold
|
|
311
449
|
|
|
312
|
-
const
|
|
313
|
-
|
|
450
|
+
const start = open.index + open[0].length;
|
|
451
|
+
const list = scanList(section, start);
|
|
452
|
+
if (list.items.includes(request.grant)) return { toml, changed: false };
|
|
314
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Appended, never rebuilt.
|
|
456
|
+
*
|
|
457
|
+
* The list used to be re-written from every quoted string between its
|
|
458
|
+
* brackets — comments included. `"src/web/**", # was "src/**"` came back as
|
|
459
|
+
* two grants, so granting anything also granted what a comment mentioned,
|
|
460
|
+
* and the reason a person typed (quoted in the provenance comment) became
|
|
461
|
+
* a path on the next grant. Every earlier provenance comment was lost on
|
|
462
|
+
* the way. Now the items are the strings outside comments, what is there is
|
|
463
|
+
* left as it is, and the new entry goes last. The reason is set off with
|
|
464
|
+
* «», never a double quote, so no older reader can take it for a value.
|
|
465
|
+
*/
|
|
466
|
+
let inner = section.slice(start, list.end).replace(/\s+$/, "");
|
|
467
|
+
if (list.lastItemEnd !== null && !list.commaAfterLast) {
|
|
468
|
+
const at = list.lastItemEnd - start;
|
|
469
|
+
inner = inner.slice(0, at) + "," + inner.slice(at);
|
|
470
|
+
}
|
|
315
471
|
const stamp = `# granted ${new Date().toISOString().slice(0, 10)} · asked ${request.times}×` +
|
|
316
|
-
`${note ? ` ·
|
|
317
|
-
const
|
|
318
|
-
.map((v) => ` "${v}"${v === request.grant ? `, ${stamp}` : ","}`)
|
|
319
|
-
.join("\n")
|
|
320
|
-
.replace(/,(\s*#[^\n]*)?$/, "$1");
|
|
321
|
-
|
|
322
|
-
const edited = section.replace(line, `$1[\n${body}\n]`);
|
|
472
|
+
`${note ? ` · «${cleanReason(note)}»` : ""}`;
|
|
473
|
+
const edited = section.slice(0, start) + `${inner}\n "${request.grant}" ${stamp}\n]` + section.slice(list.end + 1);
|
|
323
474
|
return { toml: toml.slice(0, from) + edited + toml.slice(to), changed: true };
|
|
324
475
|
}
|
|
325
476
|
|
|
326
477
|
/**
|
|
327
|
-
* The
|
|
478
|
+
* The strings of a TOML array that starts at `start` (just past its `[`),
|
|
479
|
+
* skipping comments, and where it closes. The subset has no escapes, so a
|
|
480
|
+
* string runs to the next double quote.
|
|
481
|
+
*/
|
|
482
|
+
function scanList(text, start) {
|
|
483
|
+
const items = [];
|
|
484
|
+
let lastItemEnd = null;
|
|
485
|
+
let commaAfterLast = false;
|
|
486
|
+
for (let i = start; i < text.length; i++) {
|
|
487
|
+
const c = text[i];
|
|
488
|
+
if (c === "#") { const nl = text.indexOf("\n", i); if (nl === -1) break; i = nl; continue; }
|
|
489
|
+
if (c === '"') {
|
|
490
|
+
const close = text.indexOf('"', i + 1);
|
|
491
|
+
if (close === -1) break;
|
|
492
|
+
items.push(text.slice(i + 1, close));
|
|
493
|
+
lastItemEnd = close + 1;
|
|
494
|
+
commaAfterLast = false;
|
|
495
|
+
i = close;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (c === ",") { commaAfterLast = true; continue; }
|
|
499
|
+
if (c === "]") return { items, end: i, lastItemEnd, commaAfterLast };
|
|
500
|
+
}
|
|
501
|
+
throw new Error(`the ${text.slice(0, 40).trim()}… list never closes; fix the config by hand before granting`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The reason a person typed, made safe to put in a config file.
|
|
328
506
|
*
|
|
329
507
|
* A reason is free text typed by a person, and a person can be talked into
|
|
330
508
|
* typing something — "paste this as the reason" is a plausible thing for an
|
|
331
509
|
* agent to suggest. Newlines in a comment would end the comment, and what
|
|
332
510
|
* follows is parsed as TOML. The strict parser rejects the result rather than
|
|
333
511
|
* widening anything, so the worst case is a config that no longer loads, but a
|
|
334
|
-
*
|
|
512
|
+
* policy that can be broken by a sentence is still broken.
|
|
335
513
|
*/
|
|
336
514
|
export function cleanReason(s) {
|
|
337
515
|
return String(s ?? "")
|