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/serve.js
CHANGED
|
@@ -11,174 +11,125 @@
|
|
|
11
11
|
* key each role can read, which is a map of where the credentials live.
|
|
12
12
|
*/
|
|
13
13
|
import { createServer } from "node:http";
|
|
14
|
-
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
16
16
|
import { join, dirname } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { loadConfig } from "./config.js";
|
|
19
|
-
import { read, logPath } from "./log.js";
|
|
19
|
+
import { read, logPath, logSegments } from "./log.js";
|
|
20
20
|
import { settingsFor } from "./srt.js";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
21
|
+
import { describeHandoff } from "./handoff.js";
|
|
22
|
+
import { pending, requestsPath, settle, applyGrant, refuseIfBarred, editPolicy, cleanReason, asFileGrant, lastAgentOf, fileOnlyNote } from "./requests.js";
|
|
23
|
+
import { causesOf, describeWalls, parseSince, queue, verdicts, wallsByRole } from "./views.js";
|
|
24
|
+
import { planEdit, hashOf } from "./controls.js";
|
|
25
|
+
import { inspect } from "./inspect.js";
|
|
26
|
+
|
|
27
|
+
// Moved to views.js, which the MCP server shares; kept importable from here.
|
|
28
|
+
export { causesOf } from "./views.js";
|
|
24
29
|
|
|
25
30
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* Read fresh rather than cached because the file on disk is the truth and the
|
|
31
|
-
* page is a view of it — someone editing seisin.toml in an editor should see
|
|
32
|
-
* the console change, not wonder why it disagrees.
|
|
33
|
+
* `?since=<ISO date>`, or null for no filter. Anything that does not parse is
|
|
34
|
+
* ignored rather than refused: a bad filter should show everything, not an error.
|
|
33
35
|
*/
|
|
36
|
+
export function sinceOf(url) {
|
|
37
|
+
return parseSince(new URLSearchParams((url ?? "").split("?")[1] ?? "").get("since"));
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* Called `causes` and not `friction`: `seisin review` already has a `friction`
|
|
38
|
-
* that counts something narrower — repeated denials that are not about keys —
|
|
39
|
-
* and two screens of one product reporting different numbers under one label
|
|
40
|
-
* is the kind of thing nobody notices until they are compared.
|
|
41
|
+
* The files the state is made of, as they are on disk now: the policy's text,
|
|
42
|
+
* and the size and mtime of every log segment and of the queue.
|
|
41
43
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* It was read fresh on every request, because the file on disk is the truth
|
|
45
|
+
* and the page is a view of it — someone editing seisin.toml in an editor
|
|
46
|
+
* should see the console change. That still holds: anything that changes one
|
|
47
|
+
* of these files changes the signature. What no longer happens is recomputing
|
|
48
|
+
* 1–2 s of causes and walls every two seconds to find nothing had moved
|
|
49
|
+
* (measured on a multi-repo workspace's log, 7,728 lines, 32 roles).
|
|
50
|
+
*/
|
|
51
|
+
const memo = { path: null, hash: null, bucket: null, cfg: null, logSig: null, all: null, derived: new Map(), bodies: new Map() };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How long a loaded config and what was derived from it are trusted.
|
|
47
55
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
56
|
+
* Not everything the page shows is a function of these files. Some of it is
|
|
57
|
+
* the disk around them — a key directory that became a symlink, a provider's
|
|
58
|
+
* executable that appeared, a control file that now exists (settingsFor, the
|
|
59
|
+
* walls' "would still be refused today") — and with the key made of file
|
|
60
|
+
* signatures alone, that stayed as it was first seen for the server's life,
|
|
61
|
+
* defeating the one-minute memory views.verdicts keeps for the same reason.
|
|
62
|
+
* A minute is that memory's own span.
|
|
51
63
|
*/
|
|
52
|
-
|
|
53
|
-
const by = new Map();
|
|
54
|
-
for (const e of entries) {
|
|
55
|
-
if (e.verdict !== "denied" || !e.target || !e.action) continue;
|
|
56
|
-
const k = `${e.action}\u0000${e.target}`;
|
|
57
|
-
const g = by.get(k) ?? { action: e.action, target: e.target, kind: e.kind, times: 0, roles: new Set(), ownersThen: e.owners ?? [] };
|
|
58
|
-
g.times++;
|
|
59
|
-
g.roles.add(e.role);
|
|
60
|
-
by.set(k, g);
|
|
61
|
-
}
|
|
62
|
-
const total = [...by.values()].reduce((n, g) => n + g.times, 0);
|
|
64
|
+
const MEMO_SPAN = 60_000;
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
* path rather than the path.
|
|
67
|
-
*
|
|
68
|
-
* Grouping by path alone gets the most important reading backwards. Measured
|
|
69
|
-
* here: six of the top seven causes were `.git/index.lock` in six different
|
|
70
|
-
* repositories, no single one above 17% — so "no cause dominates" is what the
|
|
71
|
-
* arithmetic says and the opposite of what is true. Three quarters of the
|
|
72
|
-
* window was one *kind* of thing, and that is a tooling problem with a
|
|
73
|
-
* mechanical fix, not a territory question anybody needs to rule on.
|
|
74
|
-
*
|
|
75
|
-
* By last segment, and nothing cleverer. A regex that recognised lock files,
|
|
76
|
-
* caches and build outputs would be a list of guesses about other people's
|
|
77
|
-
* toolchains that quietly goes stale; a repeated filename is a fact about
|
|
78
|
-
* this log.
|
|
79
|
-
*/
|
|
80
|
-
const fams = new Map();
|
|
81
|
-
for (const g of by.values()) {
|
|
82
|
-
const name = g.target.split("/").filter(Boolean).pop() ?? g.target;
|
|
83
|
-
const f = fams.get(name) ?? { name, times: 0, paths: 0, where: [], roles: new Set() };
|
|
84
|
-
f.times += g.times;
|
|
85
|
-
f.paths++;
|
|
86
|
-
// The places, so the console can show what a name is made of instead of
|
|
87
|
-
// asserting a percentage the reader has to take on faith.
|
|
88
|
-
f.where.push({ target: g.target, times: g.times });
|
|
89
|
-
for (const r of g.roles) f.roles.add(r);
|
|
90
|
-
fams.set(name, f);
|
|
91
|
-
}
|
|
92
|
-
const families = [...fams.values()]
|
|
93
|
-
.sort((a, b) => b.times - a.times)
|
|
94
|
-
.map((f) => ({
|
|
95
|
-
name: f.name, times: f.times, paths: f.paths,
|
|
96
|
-
share: total ? f.times / total : 0,
|
|
97
|
-
roles: [...f.roles].sort(),
|
|
98
|
-
where: f.where.sort((a, b) => b.times - a.times).slice(0, 20),
|
|
99
|
-
}));
|
|
66
|
+
/** How many causes the page is sent; it shows twelve and says how many more. */
|
|
67
|
+
const CAUSES_SHOWN = 200;
|
|
100
68
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const standOf = standingOf(cfg);
|
|
122
|
-
const piles = Object.fromEntries(["unowned", "owned", "protected", "outside"].map((k) => [k, { paths: 0, denials: 0 }]));
|
|
123
|
-
for (const g of by.values()) {
|
|
124
|
-
const s = standOf({ kind: g.kind, target: g.target });
|
|
125
|
-
g.standing = s;
|
|
126
|
-
piles[s.kind].paths++;
|
|
127
|
-
piles[s.kind].denials += g.times;
|
|
69
|
+
function statSig(p) {
|
|
70
|
+
try { const s = statSync(p); return `${p}:${s.ino}:${s.size}:${s.mtimeMs}`; } catch { return `${p}:-`; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function snapshot(configPath) {
|
|
74
|
+
const text = readFileSync(configPath, "utf8");
|
|
75
|
+
const hash = createHash("sha256").update(text).digest("hex");
|
|
76
|
+
const bucket = Math.floor(Date.now() / MEMO_SPAN);
|
|
77
|
+
if (memo.path !== configPath || memo.hash !== hash || memo.bucket !== bucket) {
|
|
78
|
+
Object.assign(memo, { path: configPath, hash, bucket, cfg: loadConfig(configPath), logSig: null, all: null });
|
|
79
|
+
memo.derived.clear();
|
|
80
|
+
memo.bodies.clear();
|
|
81
|
+
}
|
|
82
|
+
const cfg = memo.cfg;
|
|
83
|
+
const file = logPath(cfg.root);
|
|
84
|
+
const logSig = logSegments(file).map(statSig).join("|");
|
|
85
|
+
if (logSig !== memo.logSig) {
|
|
86
|
+
memo.all = read(file);
|
|
87
|
+
memo.logSig = logSig;
|
|
88
|
+
memo.derived.clear();
|
|
128
89
|
}
|
|
90
|
+
return { cfg, text, all: memo.all, key: `${hash}|${bucket}|${logSig}|${statSig(requestsPath(cfg.root))}` };
|
|
91
|
+
}
|
|
129
92
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// The page says "over N distinct paths" and the list is capped at twelve,
|
|
136
|
-
// so taking N from the list reported the cap as if it were the count —
|
|
137
|
-
// a wrong number stated confidently, which is worse than no number.
|
|
138
|
-
distinct: by.size,
|
|
139
|
-
families: families.slice(0, 8),
|
|
140
|
-
causes: [...by.values()]
|
|
141
|
-
.sort((a, b) => b.times - a.times)
|
|
142
|
-
.slice(0, 12)
|
|
143
|
-
.map((g) => ({
|
|
144
|
-
action: g.action,
|
|
145
|
-
target: g.target,
|
|
146
|
-
times: g.times,
|
|
147
|
-
share: total ? g.times / total : 0,
|
|
148
|
-
roles: [...g.roles].sort(),
|
|
149
|
-
standing: g.standing.kind,
|
|
150
|
-
...(g.standing.why && { why: g.standing.why }),
|
|
151
|
-
owners: g.standing.owners,
|
|
152
|
-
ownersThen: g.ownersThen,
|
|
153
|
-
// How many of the roles that hit this would still hit it. `some` and
|
|
154
|
-
// not `every`: two roles out of three still blocked is still friction,
|
|
155
|
-
// and requiring all of them would quietly retire a live cause the day
|
|
156
|
-
// one role got a grant.
|
|
157
|
-
stillRefused: [...g.roles].filter((r) => cfg.roles[r] && !explain(cfg, r, g.action, g.target).allowed).length,
|
|
158
|
-
})),
|
|
159
|
-
};
|
|
93
|
+
/** Keeps the last few entries of a memo map. */
|
|
94
|
+
function remember(map, key, value, max = 4) {
|
|
95
|
+
map.set(key, value);
|
|
96
|
+
while (map.size > max) map.delete(map.keys().next().value);
|
|
97
|
+
return value;
|
|
160
98
|
}
|
|
161
99
|
|
|
162
100
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
101
|
+
* Everything the page needs.
|
|
102
|
+
*
|
|
103
|
+
* Without each role's generated settings: they were 70 % of the 749 KB the
|
|
104
|
+
* page downloaded every two seconds and it drew none of them. One role's are
|
|
105
|
+
* served on demand by /api/settings.
|
|
165
106
|
*/
|
|
166
|
-
export function
|
|
167
|
-
const
|
|
168
|
-
const t = raw ? Date.parse(raw) : NaN;
|
|
169
|
-
return Number.isNaN(t) ? null : new Date(t).toISOString();
|
|
170
|
-
}
|
|
107
|
+
export function state(configPath, { since = null, snap = snapshot(configPath) } = {}) {
|
|
108
|
+
const { cfg, text, all } = snap;
|
|
171
109
|
|
|
172
|
-
export function state(configPath, { since = null } = {}) {
|
|
173
|
-
const cfg = loadConfig(configPath);
|
|
174
110
|
// The date filter narrows everything read from the log: activity, causes,
|
|
175
111
|
// walls and the per-role counts. Not the request queue, which is what is
|
|
176
|
-
// waiting now whatever the window.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
112
|
+
// waiting now whatever the window. (An entry with no `at` is kept, as
|
|
113
|
+
// read({ since }) always did.)
|
|
114
|
+
const derived = memo.derived.get(since) ?? remember(memo.derived, since, (() => {
|
|
115
|
+
const win = since ? all.filter((e) => !(e.at < since)) : all;
|
|
116
|
+
const blocks = new Map();
|
|
117
|
+
for (const e of win) if (e.verdict === "denied") blocks.set(e.role, (blocks.get(e.role) ?? 0) + 1);
|
|
118
|
+
const ask = verdicts(cfg);
|
|
119
|
+
return {
|
|
120
|
+
blocks,
|
|
121
|
+
log: win.slice(-60).reverse(),
|
|
122
|
+
// The whole window, not a tail. "All" used to mean the last 4000 lines, so a
|
|
123
|
+
// 30-day window could show more denials than "all" did (6k against 4k on a
|
|
124
|
+
// real log). Every count on the page comes from this list.
|
|
125
|
+
// More than the MCP's twelve: the page lists twelve and offers the rest,
|
|
126
|
+
// rather than cutting the list without saying so.
|
|
127
|
+
causes: causesOf(cfg, win, { ask, limit: CAUSES_SHOWN }),
|
|
128
|
+
// What each role keeps being refused AND would still be refused today.
|
|
129
|
+
// Empty for a role that has hit nothing twice, which is most of them.
|
|
130
|
+
walls: describeWalls(cfg, wallsByRole(cfg, win, { ask }), win),
|
|
131
|
+
};
|
|
132
|
+
})());
|
|
182
133
|
|
|
183
134
|
const roles = Object.values(cfg.roles).map((r) => ({
|
|
184
135
|
name: r.name,
|
|
@@ -186,48 +137,88 @@ export function state(configPath, { since = null } = {}) {
|
|
|
186
137
|
keys: r.keys,
|
|
187
138
|
env: r.env,
|
|
188
139
|
neverWrites: r.neverWritesDeclared ?? r.neverWrites ?? [],
|
|
140
|
+
private: r.private ?? [],
|
|
189
141
|
localBinding: r.localBinding === true,
|
|
190
142
|
localPorts: r.localPorts ?? [],
|
|
191
143
|
mcp: r.mcp ?? null,
|
|
192
144
|
trustd: r.trustd === true,
|
|
145
|
+
controlFiles: r.controlFiles ?? [],
|
|
146
|
+
reads: r.reads ?? [],
|
|
147
|
+
toolchain: r.toolchain ?? [],
|
|
148
|
+
verify: r.verify,
|
|
149
|
+
canary: [...all].reverse().find((e) => e.event === "canary" && e.role === r.name) ?? null,
|
|
193
150
|
// Counted over the same window as the causes, so the KPI and the screen it
|
|
194
151
|
// links to agree.
|
|
195
|
-
blocks:
|
|
196
|
-
settings: settingsFor(cfg, r.name),
|
|
152
|
+
blocks: derived.blocks.get(r.name) ?? 0,
|
|
197
153
|
}));
|
|
198
154
|
|
|
199
|
-
// Read once for every role's walls, rather than once per role.
|
|
200
|
-
const whole = read(logPath(cfg.root), { verdict: "denied", since });
|
|
201
|
-
// Staleness is about the role's whole history, never the window: a request
|
|
202
|
-
// is not old just because the filter hides the runs that made it old.
|
|
203
|
-
const history = since ? read(logPath(cfg.root), { verdict: "denied" }) : whole;
|
|
204
155
|
return {
|
|
205
156
|
root: cfg.root,
|
|
206
157
|
config: cfg.path,
|
|
207
|
-
|
|
158
|
+
// Staleness is about the role's whole history, never the window: a request
|
|
159
|
+
// is not old just because the filter hides the runs that made it old. The
|
|
160
|
+
// whole log, every verdict, as `seisin requests` and the MCP read it — this
|
|
161
|
+
// counted runs over denials only, and marked differently from both.
|
|
162
|
+
// A write keyed by a folder can be granted as its one file instead; the
|
|
163
|
+
// line that choice carries is said here, once, so the page and the CLI
|
|
164
|
+
// say the same thing about Claude Code.
|
|
165
|
+
requests: queue(cfg, all).map((r) => ({
|
|
166
|
+
...r,
|
|
167
|
+
// Said by the server in the same words as the CLI, so the page never
|
|
168
|
+
// works out where a request goes on its own (handoff.js).
|
|
169
|
+
handoffNote: describeHandoff(r.handoff, r.attempt, r.chainStopped),
|
|
170
|
+
...(r.action === "write" && r.grant !== r.target ? { fileNote: fileOnlyNote(r.role, lastAgentOf(all, r.role)) } : {}),
|
|
171
|
+
})),
|
|
208
172
|
since,
|
|
209
173
|
// The file itself, not a regeneration of it. The page can render a policy
|
|
210
174
|
// from its own model, and that model has no comments — so showing it under
|
|
211
175
|
// the heading "seisin.toml" next to "copy this back" invites someone to
|
|
212
176
|
// paste away the provenance a grant just wrote.
|
|
213
|
-
toml:
|
|
177
|
+
toml: text,
|
|
214
178
|
keyDirs: cfg.keyDirs,
|
|
179
|
+
protect: { instructions: cfg.protect?.instructions === true },
|
|
180
|
+
// What the control-files preview is computed against; a save sends it back
|
|
181
|
+
// so a policy edited in between is not overwritten by a stale decision.
|
|
182
|
+
base: hashOf(text),
|
|
215
183
|
allowedDomains: cfg.allowedDomains,
|
|
184
|
+
read: cfg.read,
|
|
185
|
+
isolate: cfg.isolate,
|
|
186
|
+
warnings: inspect(cfg).warnings,
|
|
216
187
|
roles,
|
|
217
|
-
causes:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
walls: Object.fromEntries(
|
|
221
|
-
Object.keys(cfg.roles)
|
|
222
|
-
.map((r) => [r, walls(cfg, r, { entries: whole })])
|
|
223
|
-
.filter(([, w]) => w.length)),
|
|
224
|
-
log: entries.slice(-60).reverse(),
|
|
188
|
+
causes: derived.causes,
|
|
189
|
+
walls: derived.walls,
|
|
190
|
+
log: derived.log,
|
|
225
191
|
live: true,
|
|
226
192
|
};
|
|
227
193
|
}
|
|
228
194
|
|
|
229
195
|
/**
|
|
230
|
-
*
|
|
196
|
+
* `state` as the response body, the same string for as long as nothing on disk
|
|
197
|
+
* moved — and a tag naming it, so a poll that already has it gets a 304 and
|
|
198
|
+
* no body at all.
|
|
199
|
+
*/
|
|
200
|
+
function stateBody(configPath, since) {
|
|
201
|
+
// One snapshot for the tag and the body. Each used to take its own, so a
|
|
202
|
+
// file that moved between the two calls got a body filed under the tag of
|
|
203
|
+
// what was there before — and a 304 for it until something else moved.
|
|
204
|
+
const snap = snapshot(configPath);
|
|
205
|
+
const k = `${snap.key}|${since}`;
|
|
206
|
+
return memo.bodies.get(k) ?? remember(memo.bodies, k, {
|
|
207
|
+
body: JSON.stringify(state(configPath, { since, snap })),
|
|
208
|
+
etag: `"${createHash("sha256").update(k).digest("hex").slice(0, 24)}"`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One role's generated sandbox settings — what `seisin run` would hand the runtime. */
|
|
213
|
+
function settingsOf(configPath, role) {
|
|
214
|
+
const { cfg } = snapshot(configPath);
|
|
215
|
+
if (typeof role !== "string" || !Object.hasOwn(cfg.roles, role))
|
|
216
|
+
throw Object.assign(new Error(`unknown role ${JSON.stringify(role)}`), { status: 404 });
|
|
217
|
+
return settingsFor(cfg, role);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Grant or decline one pending request, as a person.
|
|
231
222
|
*
|
|
232
223
|
* This is the only thing in seisin that writes policy, and it lives here rather
|
|
233
224
|
* than in the MCP server for one reason: an agent cannot reach this. Measured,
|
|
@@ -236,88 +227,238 @@ export function state(configPath, { since = null } = {}) {
|
|
|
236
227
|
* an exception for loopback. That is what lets the console hold the half the
|
|
237
228
|
* MCP server deliberately does not.
|
|
238
229
|
*/
|
|
239
|
-
function decide(configPath, { key, decision, reason }) {
|
|
240
|
-
const cfg = loadConfig(configPath);
|
|
241
|
-
const file = requestsPath(cfg.root);
|
|
230
|
+
function decide(configPath, { key, decision, reason, scope = "folder" }) {
|
|
242
231
|
/**
|
|
243
232
|
* By the request's id, never by its place in the queue.
|
|
244
233
|
*
|
|
245
234
|
* It took a row number, counted against the queue as it stood when the
|
|
246
235
|
* request arrived — not when the person read the page. Agents keep writing
|
|
247
236
|
* to that queue, and another channel can settle an entry in between, so
|
|
248
|
-
* "
|
|
249
|
-
* reason typed for something else. Re-sending the POST
|
|
237
|
+
* "grant #2" could grant whatever had moved into second place, with the
|
|
238
|
+
* reason typed for something else. Re-sending the POST granted the next one.
|
|
250
239
|
* The CLI stopped doing this long ago; the console had not.
|
|
251
240
|
*/
|
|
252
|
-
if (typeof key !== "string" || !key) throw
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (decision !== "granted" && decision !== "
|
|
256
|
-
throw
|
|
241
|
+
if (typeof key !== "string" || !key) throw bad("key is required — the id of the request on screen");
|
|
242
|
+
// A person declines; the boundary denies (docs/glossary.md). One word each,
|
|
243
|
+
// here and in the queue.
|
|
244
|
+
if (decision !== "granted" && decision !== "declined")
|
|
245
|
+
throw bad(`decision must be granted or declined`);
|
|
246
|
+
/**
|
|
247
|
+
* A reason, always. A decision from the console used to be one click with an
|
|
248
|
+
* optional box beside it, so most grants reached seisin.toml with no word of
|
|
249
|
+
* why — and the provenance comment is the only record a later reader has.
|
|
250
|
+
* The CLI can still be terse; a click is too easy to be.
|
|
251
|
+
*/
|
|
252
|
+
if (typeof reason !== "string" || !cleanReason(reason))
|
|
253
|
+
throw bad("a reason is required — it is written next to the decision");
|
|
254
|
+
// It grants the folder the request is keyed by (docs/api.md asked, docs/**
|
|
255
|
+
// granted), and the page says so before the click — or, with scope "file",
|
|
256
|
+
// the one file that was asked for, as a literal path. The queue stays keyed
|
|
257
|
+
// by folder; the settle line records the file, so a later ask for a sibling
|
|
258
|
+
// reopens the request instead of folding into it (requests.js, reduce).
|
|
259
|
+
if (scope !== "folder" && scope !== "file") throw bad(`scope must be folder or file`);
|
|
260
|
+
const cfg = loadConfig(configPath);
|
|
261
|
+
const file = requestsPath(cfg.root);
|
|
257
262
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Looked up, applied and settled under one lock — the policy's, the one
|
|
265
|
+
* every grant takes (editPolicy). The lookup used to happen before it and
|
|
266
|
+
* the settle after it, so the console and `seisin grant` could both find the
|
|
267
|
+
* same request pending and both act on it. A decline takes the lock as well,
|
|
268
|
+
* though it writes no policy, for the same reason.
|
|
269
|
+
*
|
|
270
|
+
* The config is edited as text, so comments and order survive, and it is
|
|
271
|
+
* written before the queue is settled: if the write throws, the request is
|
|
272
|
+
* still open rather than marked done against a file that never changed.
|
|
273
|
+
*/
|
|
274
|
+
let req = null;
|
|
275
|
+
let result = null;
|
|
276
|
+
try {
|
|
277
|
+
result = editPolicy(cfg, (before) => {
|
|
278
|
+
const current = loadConfig(configPath, before);
|
|
279
|
+
req = pending(file, { keyDirs: current.keyDirs }).find((r) => r.key === key);
|
|
280
|
+
if (!req) throw bad(`request ${key} is no longer pending — reload to see the queue as it is now`);
|
|
281
|
+
if (decision !== "granted") return { toml: before, changed: false };
|
|
282
|
+
if (scope === "file") req = asFileGrant(req);
|
|
283
|
+
refuseIfBarred(current, req);
|
|
284
|
+
return applyGrant(before, req, reason);
|
|
285
|
+
}, { after: () => settle(file, req.key, decision, reason, { only: req.only }) });
|
|
286
|
+
} catch (e) {
|
|
287
|
+
if (e.code === "ELOCKED") throw Object.assign(new Error("another grant is in progress — try again in a moment"), { status: 503 });
|
|
288
|
+
throw e;
|
|
271
289
|
}
|
|
272
|
-
|
|
273
|
-
|
|
290
|
+
return {
|
|
291
|
+
ok: true, role: req.role, grant: req.grant, decision,
|
|
292
|
+
// The line the grant wrote, and where, so the page can say exactly what
|
|
293
|
+
// changed instead of "done". Null when nothing was written (a decline, or
|
|
294
|
+
// a grant the policy already held).
|
|
295
|
+
line: decision === "granted" && result?.changed ? grantLine(result.toml, req.grant) : null,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Where a grant landed in the policy: the text of its line and its number. */
|
|
300
|
+
function grantLine(toml, grant) {
|
|
301
|
+
const lines = toml.split("\n");
|
|
302
|
+
for (let i = lines.length - 1; i >= 0; i--)
|
|
303
|
+
if (lines[i].includes(`"${grant}"`) && lines[i].includes("# granted")) return { text: lines[i].trim(), number: i + 1 };
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The lines an edit changes, and where: what the preview shows before a save
|
|
309
|
+
* and the confirmation names after it. The file is edited in one place, so a
|
|
310
|
+
* common head and tail leave exactly the lines that moved.
|
|
311
|
+
*/
|
|
312
|
+
export function lineDiff(before, after) {
|
|
313
|
+
const a = before.split("\n"), b = after.split("\n");
|
|
314
|
+
let head = 0;
|
|
315
|
+
while (head < a.length && head < b.length && a[head] === b[head]) head++;
|
|
316
|
+
let tail = 0;
|
|
317
|
+
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
|
|
318
|
+
return { at: head + 1, removed: a.slice(head, a.length - tail), added: b.slice(head, b.length - tail) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Hand a role a family of control files, or protect instruction files — the
|
|
323
|
+
* second thing a person can change from the console, and the same kind of
|
|
324
|
+
* write as a grant: through editPolicy, under the policy's lock, atomically,
|
|
325
|
+
* with a stamp saying it came from here and when.
|
|
326
|
+
*
|
|
327
|
+
* `dryRun` answers what the edit would do — the denyWrite entries each role's
|
|
328
|
+
* profile gains or loses, and who could then write each family — and writes
|
|
329
|
+
* nothing. The page shows that and asks before it sends the real one. `base`
|
|
330
|
+
* is the hash of the text the preview was computed from: a policy that moved
|
|
331
|
+
* in between (a grant, an editor) is a 409, not a save over a decision the
|
|
332
|
+
* person never saw.
|
|
333
|
+
*
|
|
334
|
+
* One edit per call: `{ role, families }` for a role, or `{ protect }` for
|
|
335
|
+
* `[protect] instructions`. The families are config.js's own list; asking
|
|
336
|
+
* for `.claude` or git hooks is a 400, as it is in the file.
|
|
337
|
+
*/
|
|
338
|
+
function controlFiles(configPath, args, { lockWaitMs = 10000 } = {}) {
|
|
339
|
+
const { role, families, protect, reason, dryRun, base } = args;
|
|
340
|
+
const forRole = role !== undefined || families !== undefined;
|
|
341
|
+
if (forRole === (protect !== undefined))
|
|
342
|
+
throw bad("send either { role, families } or { protect } — one edit at a time");
|
|
343
|
+
if (reason !== undefined && typeof reason !== "string") throw bad("reason must be text");
|
|
344
|
+
// Strictly a boolean: "true" as a string must not be read as a save.
|
|
345
|
+
if (dryRun !== undefined && typeof dryRun !== "boolean") throw bad("dryRun must be true or false");
|
|
346
|
+
if (base !== undefined && typeof base !== "string") throw bad("base must be the hash the preview returned");
|
|
347
|
+
const edit = forRole ? { kind: "role", role, families, reason } : { kind: "protect", on: protect, reason };
|
|
348
|
+
const cfg = loadConfig(configPath);
|
|
349
|
+
// The roles an edit reaches, and of those the ones whose profile it leaves
|
|
350
|
+
// as it is — named, so a preview that lists two roles does not read as
|
|
351
|
+
// "the other five were forgotten".
|
|
352
|
+
const reached = () => edit.kind === "role" ? [edit.role] : Object.keys(cfg.roles);
|
|
353
|
+
const unaffected = (p) => p.changed ? reached(p).filter((r) => !p.diff.some((d) => d.role === r)) : [];
|
|
354
|
+
if (dryRun === true) {
|
|
355
|
+
const before = readFileSync(configPath, "utf8");
|
|
356
|
+
const { toml, ...plan } = planEdit(cfg, before, edit);
|
|
357
|
+
return { ok: true, dryRun: true, ...plan, unaffected: unaffected(plan), lines: plan.changed ? lineDiff(before, toml) : null };
|
|
358
|
+
}
|
|
359
|
+
let plan = null;
|
|
360
|
+
let lines = null;
|
|
361
|
+
try {
|
|
362
|
+
editPolicy(cfg, (before) => {
|
|
363
|
+
if (base !== undefined && hashOf(before) !== base)
|
|
364
|
+
throw Object.assign(new Error("the policy changed since this preview — look at it again before saving"), { status: 409 });
|
|
365
|
+
// Planned against the text under the lock, and against a config loaded
|
|
366
|
+
// from it: a grant that landed a moment ago is kept, not overwritten.
|
|
367
|
+
plan = planEdit(loadConfig(configPath, before), before, edit);
|
|
368
|
+
if (plan.changed) lines = lineDiff(before, plan.toml);
|
|
369
|
+
return { toml: plan.toml, changed: plan.changed };
|
|
370
|
+
}, { waitMs: lockWaitMs });
|
|
371
|
+
} catch (e) {
|
|
372
|
+
if (e.code === "ELOCKED") throw Object.assign(new Error("another edit of the policy is in progress — try again in a moment"), { status: 503 });
|
|
373
|
+
throw e;
|
|
374
|
+
}
|
|
375
|
+
const { toml, ...rest } = plan;
|
|
376
|
+
return { ok: true, dryRun: false, ...rest, unaffected: unaffected(plan), lines };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** An error that is the request's fault. */
|
|
380
|
+
function bad(message) {
|
|
381
|
+
return Object.assign(new Error(message), { status: 400 });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The status for an error thrown while answering: its own when it has one; a
|
|
386
|
+
* failed read or write of a file (it carries a syscall) is the server's; what
|
|
387
|
+
* is left — a bad field, a grant the policy refuses — is the request's.
|
|
388
|
+
*/
|
|
389
|
+
function statusOf(e) {
|
|
390
|
+
if (Number.isInteger(e?.status)) return e.status;
|
|
391
|
+
return e?.syscall ? 500 : 400;
|
|
274
392
|
}
|
|
275
393
|
|
|
276
|
-
/** The body of a POST, capped: this endpoint takes three short fields. */
|
|
277
394
|
/**
|
|
278
395
|
* A request body, capped. The cap is per route: one decision is a few hundred
|
|
279
396
|
* bytes, but "decline all" carries every key on screen — 109 of them were
|
|
280
397
|
* 9.3 KB, over the old flat 4 KB, and the connection was cut with nothing but
|
|
281
|
-
* "Failed to fetch" in the browser. Found
|
|
398
|
+
* "Failed to fetch" in the browser. Found on first real use.
|
|
399
|
+
*
|
|
400
|
+
* Over the cap it is refused with 413, not cut: the caller answers first and
|
|
401
|
+
* the connection is closed after the answer has gone (see `answer`).
|
|
282
402
|
*/
|
|
283
403
|
function readBody(req, max = 4096) {
|
|
284
404
|
return new Promise((ok, fail) => {
|
|
285
405
|
let s = "";
|
|
406
|
+
let over = false;
|
|
407
|
+
req.setEncoding("utf8");
|
|
286
408
|
req.on("data", (c) => {
|
|
409
|
+
if (over) return;
|
|
287
410
|
s += c;
|
|
288
|
-
if (s
|
|
411
|
+
if (Buffer.byteLength(s) > max) {
|
|
412
|
+
over = true;
|
|
413
|
+
s = "";
|
|
414
|
+
fail(Object.assign(new Error(`body too large — this route takes at most ${max} bytes`), { status: 413 }));
|
|
415
|
+
}
|
|
289
416
|
});
|
|
290
|
-
req.on("end", () => ok(s));
|
|
417
|
+
req.on("end", () => { if (!over) ok(s); });
|
|
418
|
+
req.on("error", (e) => { if (!over) fail(Object.assign(e, { status: 400 })); });
|
|
291
419
|
});
|
|
292
420
|
}
|
|
293
421
|
|
|
294
422
|
/**
|
|
295
|
-
*
|
|
423
|
+
* Decline every request the page was showing, in one go.
|
|
296
424
|
*
|
|
297
|
-
* Only
|
|
425
|
+
* Only declining. There is no "grant all" and there will not be: a grant is a
|
|
298
426
|
* decision about one path and one owner, and doing forty of them without
|
|
299
427
|
* reading them is the permissive default this tool exists to stand against.
|
|
300
|
-
*
|
|
428
|
+
* Declining in bulk is safe — it grants nothing and changes no policy — and it
|
|
301
429
|
* is what a queue full of noise from a bug already fixed needs.
|
|
302
430
|
*
|
|
303
431
|
* By key, not "whatever is pending now": a request that arrived after the page
|
|
304
|
-
* drew is one the person has not seen, and it stays.
|
|
432
|
+
* drew is one the person has not seen, and it stays. Under the policy lock,
|
|
433
|
+
* like a single decision, so a grant from another channel cannot land between
|
|
434
|
+
* the lookup and the settle.
|
|
305
435
|
*/
|
|
306
436
|
function declineAll(configPath, { keys, reason }) {
|
|
307
|
-
if (!Array.isArray(keys) || !keys.length) throw
|
|
437
|
+
if (!Array.isArray(keys) || !keys.length) throw bad("keys must list the requests on screen");
|
|
438
|
+
if (typeof reason !== "string" || !cleanReason(reason))
|
|
439
|
+
throw bad("a reason is required — it is written next to every request declined");
|
|
308
440
|
const cfg = loadConfig(configPath);
|
|
309
441
|
const file = requestsPath(cfg.root);
|
|
310
442
|
const wanted = new Set(keys.map(String));
|
|
311
443
|
let declined = 0;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
444
|
+
try {
|
|
445
|
+
editPolicy(cfg, (before) => ({ toml: before, changed: false }), {
|
|
446
|
+
after: () => {
|
|
447
|
+
for (const req of pending(file)) {
|
|
448
|
+
if (!wanted.has(req.key)) continue;
|
|
449
|
+
settle(file, req.key, "declined", reason);
|
|
450
|
+
declined++;
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
} catch (e) {
|
|
455
|
+
if (e.code === "ELOCKED") throw Object.assign(new Error("another grant is in progress — try again in a moment"), { status: 503 });
|
|
456
|
+
throw e;
|
|
316
457
|
}
|
|
317
458
|
return { ok: true, declined };
|
|
318
459
|
}
|
|
319
460
|
|
|
320
|
-
export function serve(configPath, port = 4178) {
|
|
461
|
+
export function serve(configPath, port = 4178, { lockWaitMs = 10000 } = {}) {
|
|
321
462
|
const page = join(HERE, "..", "ui", "index.html");
|
|
322
463
|
if (!existsSync(page)) throw new Error("the console is missing from this install");
|
|
323
464
|
|
|
@@ -327,7 +468,7 @@ export function serve(configPath, port = 4178) {
|
|
|
327
468
|
* Loopback is not a boundary against the browser: any site the operator has
|
|
328
469
|
* open can POST to 127.0.0.1. It cannot read this token, and asking for it in
|
|
329
470
|
* a custom header also forces a preflight that this server never answers. So
|
|
330
|
-
* the page can
|
|
471
|
+
* the page can grant and a tab from somewhere else cannot.
|
|
331
472
|
*/
|
|
332
473
|
const token = randomBytes(24).toString("hex");
|
|
333
474
|
|
|
@@ -391,42 +532,67 @@ export function serve(configPath, port = 4178) {
|
|
|
391
532
|
res.writeHead(403, { "content-type": "application/json" });
|
|
392
533
|
return res.end(JSON.stringify({ error: "bad or missing token — open the URL `seisin ui` printed" }));
|
|
393
534
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
535
|
+
/**
|
|
536
|
+
* A JSON answer. On an error the connection is closed once the answer has
|
|
537
|
+
* gone: a body refused half-read would otherwise be parsed as the next
|
|
538
|
+
* request on a keep-alive socket.
|
|
539
|
+
*/
|
|
540
|
+
const answer = (status, obj, close = false) => {
|
|
541
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...(close && { connection: "close" }) });
|
|
542
|
+
res.end(JSON.stringify(obj), () => { if (close) req.socket?.destroy(); });
|
|
543
|
+
};
|
|
544
|
+
const post = async (max, fn) => {
|
|
545
|
+
let body;
|
|
546
|
+
try { body = await readBody(req, max); }
|
|
547
|
+
catch (e) { return answer(e.status ?? 400, { error: e.message }, true); }
|
|
406
548
|
try {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return
|
|
549
|
+
let args;
|
|
550
|
+
try { args = JSON.parse(body || "{}"); } catch { throw bad("the body is not JSON"); }
|
|
551
|
+
return answer(200, fn(args ?? {}));
|
|
410
552
|
} catch (e) {
|
|
411
|
-
|
|
412
|
-
return res.end(JSON.stringify({ error: e.message }));
|
|
553
|
+
return answer(statusOf(e), { error: e.message });
|
|
413
554
|
}
|
|
414
|
-
}
|
|
555
|
+
};
|
|
556
|
+
// ~85 bytes per key: 1 MB is ten thousand requests on one screen.
|
|
557
|
+
if (req.method === "POST" && pathOf(req.url) === "/api/decline-all")
|
|
558
|
+
return post(1024 * 1024, (args) => declineAll(configPath, args));
|
|
559
|
+
if (req.method === "POST" && pathOf(req.url) === "/api/decide")
|
|
560
|
+
return post(4096, (args) => decide(configPath, args));
|
|
561
|
+
if (req.method === "POST" && pathOf(req.url) === "/api/control-files")
|
|
562
|
+
return post(4096, (args) => controlFiles(configPath, args, { lockWaitMs }));
|
|
415
563
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
416
564
|
res.writeHead(405).end("method not allowed");
|
|
417
565
|
return;
|
|
418
566
|
}
|
|
419
|
-
if (pathOf(req.url)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
res.
|
|
425
|
-
|
|
567
|
+
if (pathOf(req.url).startsWith("/api/")) {
|
|
568
|
+
// HEAD computed the whole state and threw the body away — a request that
|
|
569
|
+
// cost what a GET does and answered nothing. The API is GET only.
|
|
570
|
+
if (req.method !== "GET") {
|
|
571
|
+
res.writeHead(405, { allow: "GET" });
|
|
572
|
+
return res.end();
|
|
573
|
+
}
|
|
574
|
+
if (pathOf(req.url) === "/api/state") {
|
|
575
|
+
let got;
|
|
576
|
+
try {
|
|
577
|
+
got = stateBody(configPath, sinceOf(req.url));
|
|
578
|
+
} catch (e) {
|
|
579
|
+
return answer(500, { error: e.message });
|
|
580
|
+
}
|
|
581
|
+
// The page polls, so tell every cache in between to stay out of it;
|
|
582
|
+
// the page itself sends the tag back and is told when nothing moved.
|
|
583
|
+
const head = { "content-type": "application/json", "cache-control": "no-store", etag: got.etag };
|
|
584
|
+
if (req.headers["if-none-match"] === got.etag) { res.writeHead(304, head); return res.end(); }
|
|
585
|
+
res.writeHead(200, head);
|
|
586
|
+
return res.end(got.body);
|
|
587
|
+
}
|
|
588
|
+
if (pathOf(req.url) === "/api/settings") {
|
|
589
|
+
try {
|
|
590
|
+
const role = new URLSearchParams(req.url.split("?")[1] ?? "").get("role");
|
|
591
|
+
return answer(200, { role, settings: settingsOf(configPath, role) });
|
|
592
|
+
} catch (e) {
|
|
593
|
+
return answer(e.status ?? 500, { error: e.message });
|
|
594
|
+
}
|
|
426
595
|
}
|
|
427
|
-
// The page polls, so tell every cache in between to stay out of it.
|
|
428
|
-
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
429
|
-
return res.end(body);
|
|
430
596
|
}
|
|
431
597
|
if (pathOf(req.url) === "/" || pathOf(req.url) === "/index.html") {
|
|
432
598
|
// No token in here. It used to be inlined, which handed it to anything
|