seisin 0.5.0 → 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 +385 -129
- package/package.json +13 -5
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +47 -23
- package/src/commands/check.js +154 -3
- package/src/commands/explain.js +2 -5
- package/src/commands/hook.js +7 -3
- package/src/commands/init.js +57 -50
- package/src/commands/requests.js +31 -56
- package/src/commands/review.js +33 -6
- package/src/commands/run.js +332 -110
- package/src/commands/walls.js +39 -7
- package/src/commands/whose.js +2 -1
- package/src/config.js +321 -15
- package/src/controls.js +5 -5
- package/src/diagnose.js +67 -16
- package/src/env.js +3 -0
- package/src/grants.js +107 -2
- package/src/handoff.js +285 -10
- package/src/help.js +39 -19
- package/src/hook.js +59 -30
- package/src/index.d.ts +217 -0
- package/src/index.js +20 -12
- 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 +13 -815
- package/src/intake.js +131 -21
- package/src/keys.js +223 -67
- package/src/kinds.js +42 -0
- package/src/lines.js +26 -0
- package/src/log.js +7 -2
- package/src/mcp.js +28 -43
- package/src/nested.js +12 -3
- package/src/notify.js +53 -12
- package/src/owners.js +165 -3
- package/src/paths.js +17 -1
- package/src/provider-run.js +81 -0
- package/src/redact.js +34 -15
- package/src/render.js +35 -9
- package/src/requests.js +363 -45
- package/src/review.js +21 -9
- package/src/rundir.js +29 -8
- 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 +7 -3
- package/src/serve.js +35 -17
- package/src/spool.js +8 -17
- package/src/srt.js +263 -33
- package/src/surface.js +129 -16
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +15 -6
- package/src/views.js +40 -14
- package/src/violations.js +143 -7
- package/src/walls.js +62 -9
- package/ui/index.html +85 -13
- package/ui/favicon.svg +0 -1
package/src/requests.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
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
|
-
* refused on src/api/x.ts` and
|
|
8
|
-
* src/api/**`. One means the kernel said no, the other means you
|
|
9
|
-
* reader had nothing to tell them apart by.
|
|
7
|
+
* refused on src/api/x.ts` and the command that declined it answered `refused
|
|
8
|
+
* frontend ✕ src/api/**`. One means the kernel said no, the other means you
|
|
9
|
+
* did, and a reader had nothing to tell them apart by. The file kept the
|
|
10
|
+
* boundary's word for a decline until 0.6.0; see migrateQueue.
|
|
10
11
|
*
|
|
11
12
|
* The entry says **asked** now, because refused was not always true. Asking for
|
|
12
13
|
* a path before reaching for it is reasonable, and nothing required a
|
|
@@ -19,21 +20,23 @@
|
|
|
19
20
|
* A denial already contains everything a request needs — the hook knows the
|
|
20
21
|
* role, the path and the owner — so the denial leaves something behind that a
|
|
21
22
|
* person can act on in one command instead of in an editor. See
|
|
22
|
-
* `docs/
|
|
23
|
+
* `docs/requests.md` for why granting is deliberately not something
|
|
23
24
|
* an agent can do.
|
|
24
25
|
*
|
|
25
26
|
* Append-only, beside the log, for the same reason: a decision that can be
|
|
26
27
|
* rewritten is not evidence. A request that is granted or declined is not
|
|
27
28
|
* deleted — it is followed by a line saying what happened to it.
|
|
28
29
|
*/
|
|
29
|
-
import { neverWrites, isGitMetadata, covers,
|
|
30
|
+
import { neverWrites, isGitMetadata, covers, privateOwner } from "./owners.js";
|
|
31
|
+
import { decideHandoff, chainOf, isKeyRequest } from "./handoff.js";
|
|
30
32
|
import { protectedBy } from "./surface.js";
|
|
31
|
-
import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
|
|
33
|
+
import { appendFileSync, chmodSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
|
|
32
34
|
import { randomBytes } from "node:crypto";
|
|
33
35
|
import { dirname, join } from "node:path";
|
|
34
36
|
import { STATE_DIR, tomlString } from "./layout.js";
|
|
35
37
|
import { send } from "./spool.js";
|
|
36
38
|
import { withLock, readEntries } from "./log.js";
|
|
39
|
+
import { loadConfig, policyId } from "./config.js";
|
|
37
40
|
|
|
38
41
|
const REQUESTS_NAME = "requests.jsonl";
|
|
39
42
|
|
|
@@ -97,7 +100,9 @@ function write(file, entry) {
|
|
|
97
100
|
mkdirSync(dirname(file), { recursive: true });
|
|
98
101
|
// The time is this process's, named after the spread so no entry sets it.
|
|
99
102
|
const now = new Date().toISOString();
|
|
100
|
-
|
|
103
|
+
// Under the queue's lock, as the log's lines are under the log's: the one
|
|
104
|
+
// rewrite the queue ever gets (migrateQueue) cannot then miss this line.
|
|
105
|
+
withLock(file, () => appendFileSync(file, JSON.stringify({ at: now, ...entry, at: now }) + "\n"));
|
|
101
106
|
return true;
|
|
102
107
|
} catch {
|
|
103
108
|
// Same rule as the log: never take the agent down over bookkeeping.
|
|
@@ -106,33 +111,149 @@ function write(file, entry) {
|
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
/**
|
|
109
|
-
* Who
|
|
114
|
+
* Who each request goes to, decided now (handoff.js), and whose it is.
|
|
110
115
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
+
* Every reader of the queue goes through here — `seisin requests`, the end of
|
|
117
|
+
* `seisin run`, the notification, the console, the MCP — so they cannot
|
|
118
|
+
* disagree about where a request goes, and none of them works it out on its
|
|
119
|
+
* own. `owners` is replaced by the decision's: the owners stored on the line
|
|
120
|
+
* are who owned the path when it was denied, which is evidence; who owns it
|
|
121
|
+
* now is the policy's answer, and that is the one shown. For a key it is who
|
|
122
|
+
* declares it — nobody writes `.secrets/`, and the queue used to say
|
|
123
|
+
* "(unowned)" about a key another role declares, right before a grant that
|
|
124
|
+
* would share it.
|
|
125
|
+
*
|
|
126
|
+
* `handoff` is the decision as decideHandoff returns it: `type` is route,
|
|
127
|
+
* human, resolved, cycle or depth, and a route names `role`. It is the field
|
|
128
|
+
* something outside seisin reads to act on; seisin itself starts nothing.
|
|
116
129
|
*/
|
|
117
|
-
export function
|
|
130
|
+
export function withHandoffs(config, queue, revision = policyId(config)) {
|
|
131
|
+
// Reading the queue must not be the thing that fails: a caller holding only
|
|
132
|
+
// a root gets every request decided as nobody's, which is what it can know.
|
|
133
|
+
const policy = config?.roles ? config : { ...config, roles: {} };
|
|
118
134
|
for (const r of queue) {
|
|
119
|
-
if (r.
|
|
120
|
-
|
|
121
|
-
r.
|
|
122
|
-
r.
|
|
135
|
+
if (typeof r.role !== "string" || typeof r.target !== "string") continue;
|
|
136
|
+
r.ownersThen = r.owners;
|
|
137
|
+
r.handoff = decideHandoff({ request: r, config: policy, chain: chainOf(r), revision });
|
|
138
|
+
r.owners = r.handoff.owners ?? [];
|
|
139
|
+
if (isKeyRequest(config, r)) r.declared = true;
|
|
140
|
+
}
|
|
141
|
+
// Each request's chain is named by the request it started from. Where a
|
|
142
|
+
// later request in that chain stopped — a cycle, too deep, a person's call —
|
|
143
|
+
// the one it started from says so: seisin cannot know whether the role that
|
|
144
|
+
// took it finished, but it knows the chain did not end in a handoff.
|
|
145
|
+
const stoppedIn = new Map();
|
|
146
|
+
for (const q of queue) {
|
|
147
|
+
const id = q.chain?.id;
|
|
148
|
+
if (!id || id === q.key || !["cycle", "depth", "human"].includes(q.handoff?.type)) continue;
|
|
149
|
+
stoppedIn.set(id, [...(stoppedIn.get(id) ?? []), q.key]);
|
|
123
150
|
}
|
|
151
|
+
for (const r of queue) if (stoppedIn.has(r.key)) r.chainStopped = stoppedIn.get(r.key);
|
|
124
152
|
return queue;
|
|
125
153
|
}
|
|
126
154
|
|
|
127
|
-
/**
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
|
|
155
|
+
/**
|
|
156
|
+
* The pending queue as a person and an agent read it: keys in the policy's own
|
|
157
|
+
* form, each request's handoff decided against the policy as it stands, and —
|
|
158
|
+
* given the log — the ones the role stopped asking for marked. `readOnly` is
|
|
159
|
+
* pending()'s: the MCP server reads the queue and never migrates it.
|
|
160
|
+
*/
|
|
161
|
+
export function queueOf(config, history = null, { readOnly = false } = {}) {
|
|
162
|
+
const queue = withHandoffs(config, pending(requestsPath(config.root), { keyDirs: config.keyDirs, readOnly }));
|
|
163
|
+
return history ? markStale(queue, history) : queue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The queue as data: what `seisin requests --json` prints and `seisin_requests`
|
|
168
|
+
* returns, one shape for both.
|
|
169
|
+
*
|
|
170
|
+
* `handoff` is the field to act on. It is decided by seisin from the policy as
|
|
171
|
+
* it stands (handoff.js) and carried whole, so whatever reads it — an agent, a
|
|
172
|
+
* script that starts roles — is reading a decision and not making one: a route
|
|
173
|
+
* names the role, and `seisin run <role> --handoff <id>` refuses any other.
|
|
174
|
+
*/
|
|
175
|
+
export function queueData(queue) {
|
|
176
|
+
return {
|
|
177
|
+
pending: queue.map((r, i) => ({
|
|
178
|
+
number: i + 1, id: r.key, role: r.role, action: r.action, target: r.target,
|
|
179
|
+
wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
|
|
180
|
+
handoff: r.handoff ?? null,
|
|
181
|
+
...(r.attempt ? { attempt: r.attempt } : {}),
|
|
182
|
+
...(r.chainStopped ? { chainStopped: r.chainStopped } : {}),
|
|
183
|
+
...(r.stale ? { stale: `not asked again in ${r.stale.runs} runs of ${r.role} since` } : {}),
|
|
184
|
+
})),
|
|
185
|
+
};
|
|
131
186
|
}
|
|
132
187
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Records that a role asked for something the policy denies it. Safe to call
|
|
190
|
+
* on every denial; `seisin run` drops anything the policy does not deny.
|
|
191
|
+
*
|
|
192
|
+
* `chain` is set only by a run started for a handoff (`seisin run --handoff`),
|
|
193
|
+
* from the decision the parent checked before starting it — never from
|
|
194
|
+
* anything the sandbox sent. A line without one starts a chain of its own.
|
|
195
|
+
*/
|
|
196
|
+
export function record(file, { role, action, target, owners, chain = null }) {
|
|
197
|
+
return write(file, {
|
|
198
|
+
kind: "asked", key: keyOf({ role, action, target }), role, action, target, owners: owners ?? [],
|
|
199
|
+
...(chain && { chain: { id: chain.id, depth: chain.depth, visited: chain.visited } }),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Records a decision. `by` is always a person; there is no other caller.
|
|
205
|
+
*
|
|
206
|
+
* `only` is the one file a grant covered when the person chose it over the
|
|
207
|
+
* folder the request is keyed by. The key stays the folder's, so the queue
|
|
208
|
+
* needs to know: a later ask for a sibling is a new question, not this one
|
|
209
|
+
* asked again (see reduce).
|
|
210
|
+
*/
|
|
211
|
+
export function settle(file, key, decision, reason = "", { only = null } = {}) {
|
|
212
|
+
return write(file, { kind: decision, key, reason, ...(only ? { only } : {}) });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The same request, granting the one file it was asked over instead of the
|
|
217
|
+
* folder it is keyed by.
|
|
218
|
+
*
|
|
219
|
+
* Writes only: a key is already one file. The grant is the target exactly as
|
|
220
|
+
* the request carries it, so it reaches the policy as a literal path and goes
|
|
221
|
+
* through the same refuseIfBarred and applyGrant as the folder does.
|
|
222
|
+
*/
|
|
223
|
+
export function asFileGrant(request) {
|
|
224
|
+
if (request.action === "read")
|
|
225
|
+
throw new Error(`${request.key} is a key, and a key is already one file — grant it without --file`);
|
|
226
|
+
if (request.action !== "write")
|
|
227
|
+
throw new Error(`only a write can be granted as one file, and ${request.key} is a ${request.action}`);
|
|
228
|
+
const t = typeof request.target === "string" ? request.target : "";
|
|
229
|
+
if (!t || t.endsWith("/") || t.includes("*"))
|
|
230
|
+
throw new Error(`${JSON.stringify(t)} is not one file — grant the folder instead`);
|
|
231
|
+
return { ...request, grant: t, only: t };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The agent a role ran last, as its last canary line recorded it, or null.
|
|
236
|
+
*
|
|
237
|
+
* Asked for one reason: a literal file in `writes` is useless to Claude Code,
|
|
238
|
+
* whose Write and Edit save to a temporary beside the file and rename over it
|
|
239
|
+
* (docs/first-day.md). Null when the role never ran or ran before the canary
|
|
240
|
+
* line carried the agent — the caller then says it conditionally.
|
|
241
|
+
*/
|
|
242
|
+
export function lastAgentOf(entries, role) {
|
|
243
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
244
|
+
const e = entries[i];
|
|
245
|
+
if (e?.event === "canary" && e.role === role) return typeof e.agent === "string" ? e.agent : null;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** One line on what a file-only grant does to a role that runs Claude Code, or "". */
|
|
251
|
+
export function fileOnlyNote(role, agent) {
|
|
252
|
+
const why = "Claude Code's Write and Edit save to a temporary beside the file and rename it over, " +
|
|
253
|
+
"and the temporary is outside a one-file grant (EPERM)";
|
|
254
|
+
if (agent === "claude") return `${role} runs Claude Code: ${why} — grant the folder if it edits this file.`;
|
|
255
|
+
if (agent) return "";
|
|
256
|
+
return `If ${role} runs Claude Code, a one-file grant will not let it edit: ${why}.`;
|
|
136
257
|
}
|
|
137
258
|
|
|
138
259
|
/**
|
|
@@ -148,8 +269,9 @@ export function settle(file, key, decision, reason = "") {
|
|
|
148
269
|
* It exists so that a refusal to run cannot be invisible. Admission may
|
|
149
270
|
* postpone work; it may not make work disappear from the queue a person reads.
|
|
150
271
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
272
|
+
* `seisin run --handoff` writes one when it starts the receiver; whatever
|
|
273
|
+
* else starts roles writes one when it holds one back. It reaches a request
|
|
274
|
+
* as `attempt`, beside the decision, and never as `state`.
|
|
153
275
|
*/
|
|
154
276
|
export function recordHandoff(file, key, decision = {}) {
|
|
155
277
|
const { type, role, limit, max, reason, depth, chainId, revision } = decision;
|
|
@@ -178,14 +300,28 @@ export function recordHandoff(file, key, decision = {}) {
|
|
|
178
300
|
*/
|
|
179
301
|
let reduced = null; // { file, ino, size, mtimeMs, all }
|
|
180
302
|
|
|
181
|
-
|
|
303
|
+
/**
|
|
304
|
+
* `readOnly` is for the MCP server, which opens nothing for writing: a queue
|
|
305
|
+
* that still needs migrating is an error there, never a rewrite (migrateQueue).
|
|
306
|
+
*/
|
|
307
|
+
export function pending(file, { includeSettled = false, keyDirs = null, readOnly = false } = {}) {
|
|
182
308
|
let st;
|
|
183
309
|
try { st = statSync(file); } catch { return []; }
|
|
184
310
|
let all;
|
|
185
311
|
if (reduced && reduced.file === file && reduced.ino === st.ino && reduced.size === st.size && reduced.mtimeMs === st.mtimeMs) {
|
|
186
312
|
all = reduced.all;
|
|
187
313
|
} else {
|
|
188
|
-
|
|
314
|
+
let entries = readEntries(file);
|
|
315
|
+
// Twice at most: a line an older seisin appended while the first pass ran
|
|
316
|
+
// is caught by the second; one still there after that is an error, not a guess.
|
|
317
|
+
for (let pass = 0; pass < 2 && entries.some(isOldDecline); pass++) {
|
|
318
|
+
if (readOnly) throw unmigrated(file, entries.filter(isOldDecline).length);
|
|
319
|
+
migrateQueue(file);
|
|
320
|
+
st = statSync(file);
|
|
321
|
+
entries = readEntries(file);
|
|
322
|
+
}
|
|
323
|
+
if (entries.some(isOldDecline)) throw unmigrated(file, entries.filter(isOldDecline).length);
|
|
324
|
+
all = reduce(entries);
|
|
189
325
|
reduced = { file, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, all };
|
|
190
326
|
}
|
|
191
327
|
// The grant is re-derived with the policy's first key directory when the
|
|
@@ -195,20 +331,40 @@ export function pending(file, { includeSettled = false, keyDirs = null } = {}) {
|
|
|
195
331
|
return includeSettled ? copies : copies.filter((r) => r.state === "pending");
|
|
196
332
|
}
|
|
197
333
|
|
|
198
|
-
function reduce(
|
|
334
|
+
function reduce(entries) {
|
|
199
335
|
const byKey = new Map();
|
|
200
336
|
|
|
201
|
-
for (const e of
|
|
337
|
+
for (const e of entries) {
|
|
202
338
|
if (!e.key) continue;
|
|
203
339
|
|
|
204
340
|
if (e.kind === "asked") {
|
|
205
341
|
const seen = byKey.get(e.key);
|
|
206
|
-
|
|
207
|
-
|
|
342
|
+
/**
|
|
343
|
+
* A grant of one file answered that file, not the folder the key names.
|
|
344
|
+
* A sibling asked after it is a new question: reopened with that target,
|
|
345
|
+
* so it reaches the queue instead of folding into a settled request that
|
|
346
|
+
* nobody reads again. The same file asked again folds as before.
|
|
347
|
+
*/
|
|
348
|
+
if (seen && seen.state === "granted" && seen.only?.length && typeof e.target === "string" &&
|
|
349
|
+
!seen.only.includes(e.target)) {
|
|
350
|
+
Object.assign(seen, {
|
|
351
|
+
target: e.target, owners: e.owners ?? [], grant: grantFor(e),
|
|
352
|
+
times: 1, first: e.at, last: e.at, state: "pending", reason: "",
|
|
353
|
+
});
|
|
354
|
+
delete seen.decided;
|
|
355
|
+
delete seen.chain;
|
|
356
|
+
delete seen.attempt;
|
|
357
|
+
keepChain(seen, e);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (seen) { seen.times++; seen.last = e.at; keepChain(seen, e); continue; }
|
|
361
|
+
const fresh = {
|
|
208
362
|
key: e.key, role: e.role, action: e.action, target: e.target,
|
|
209
363
|
owners: e.owners ?? [], grant: grantFor(e),
|
|
210
364
|
times: 1, first: e.at, last: e.at, state: "pending", reason: "",
|
|
211
|
-
}
|
|
365
|
+
};
|
|
366
|
+
keepChain(fresh, e);
|
|
367
|
+
byKey.set(e.key, fresh);
|
|
212
368
|
continue;
|
|
213
369
|
}
|
|
214
370
|
const seen = byKey.get(e.key);
|
|
@@ -219,11 +375,12 @@ function reduce(file) {
|
|
|
219
375
|
*
|
|
220
376
|
* Without this branch the catch-all below would set state = "handoff" and a
|
|
221
377
|
* throttled attempt would drop out of the pending queue — the exact silent
|
|
222
|
-
* disappearance the attempt is recorded to prevent.
|
|
223
|
-
* handoff
|
|
378
|
+
* disappearance the attempt is recorded to prevent. It is `attempt`, not
|
|
379
|
+
* `handoff`: the decision is recomputed on every read (withHandoffs), and
|
|
380
|
+
* what was tried is a different fact from where it goes.
|
|
224
381
|
*/
|
|
225
382
|
if (e.kind === "handoff") {
|
|
226
|
-
seen.
|
|
383
|
+
seen.attempt = {
|
|
227
384
|
outcome: e.outcome, role: e.role, limit: e.limit, max: e.max,
|
|
228
385
|
reason: e.reason, depth: e.depth, chainId: e.chainId, at: e.at,
|
|
229
386
|
};
|
|
@@ -231,11 +388,143 @@ function reduce(file) {
|
|
|
231
388
|
}
|
|
232
389
|
|
|
233
390
|
seen.state = e.kind; seen.reason = e.reason ?? ""; seen.decided = e.at;
|
|
391
|
+
// Kept across reopenings: every file granted one at a time stays answered.
|
|
392
|
+
if (e.kind === "granted") {
|
|
393
|
+
if (typeof e.only === "string") seen.only = [...(seen.only ?? []), e.only];
|
|
394
|
+
else delete seen.only;
|
|
395
|
+
}
|
|
234
396
|
}
|
|
235
397
|
|
|
236
398
|
return [...byKey.values()];
|
|
237
399
|
}
|
|
238
400
|
|
|
401
|
+
/**
|
|
402
|
+
* A decline as seisin wrote it before 0.6.0: `kind: "denied"`.
|
|
403
|
+
*
|
|
404
|
+
* A person declines; the boundary denies (docs/glossary.md). The queue stored
|
|
405
|
+
* the boundary's word for the person's answer, and 0.6.0 stores "declined".
|
|
406
|
+
* Nothing reads the old value as a decline: a queue that holds one is
|
|
407
|
+
* rewritten once by migrateQueue, or refused where it cannot be.
|
|
408
|
+
*/
|
|
409
|
+
const OLD_DECLINE = "denied";
|
|
410
|
+
|
|
411
|
+
function isOldDecline(e) {
|
|
412
|
+
return e.kind === OLD_DECLINE && typeof e.key === "string" && e.verdict === undefined;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function unmigrated(file, n) {
|
|
416
|
+
return Object.assign(new Error(
|
|
417
|
+
`${file} holds ${n} decline${n === 1 ? "" : "s"} written by seisin before 0.6.0 ` +
|
|
418
|
+
`as "kind":"denied", which this version does not read.\n` +
|
|
419
|
+
` Run \`seisin requests\` once from a terminal: it rewrites them as "declined" and ` +
|
|
420
|
+
`keeps the original beside the queue as requests.jsonl.pre-0.6.0.`), { code: "EQUEUEFORMAT" });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** One chunk of the queue with every old decline renamed; anything else, byte for byte. */
|
|
424
|
+
function renameDeclines(text) {
|
|
425
|
+
let n = 0;
|
|
426
|
+
const lines = text.split("\n");
|
|
427
|
+
// The last piece has no newline after it: a line still being written, or
|
|
428
|
+
// nothing. It is copied as it is, and a later open migrates it if it must.
|
|
429
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
430
|
+
let e;
|
|
431
|
+
try { e = JSON.parse(lines[i]); } catch { continue; }
|
|
432
|
+
if (!e || typeof e !== "object" || !isOldDecline(e)) continue;
|
|
433
|
+
lines[i] = JSON.stringify({ ...e, kind: "declined" });
|
|
434
|
+
n++;
|
|
435
|
+
}
|
|
436
|
+
return { text: lines.join("\n"), n };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Bytes of `fd` from `from` to its end, as it is now. */
|
|
440
|
+
function readFrom(fd, from) {
|
|
441
|
+
const size = fstatSync(fd).size;
|
|
442
|
+
const buf = Buffer.alloc(Math.max(0, size - from));
|
|
443
|
+
let got = 0;
|
|
444
|
+
while (got < buf.length) {
|
|
445
|
+
const n = readSync(fd, buf, got, buf.length - got, from + got);
|
|
446
|
+
if (n === 0) break;
|
|
447
|
+
got += n;
|
|
448
|
+
}
|
|
449
|
+
return buf.subarray(0, got);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Rewrite the old declines of a queue as "declined", once. Returns how many.
|
|
454
|
+
*
|
|
455
|
+
* The one place the queue is not appended to, and why that is allowed is in
|
|
456
|
+
* docs/decisions.md: it renames one stored value, keeps every line, its order
|
|
457
|
+
* and its time, and copies the file as it was to `requests.jsonl.pre-0.6.0`
|
|
458
|
+
* before touching it, so the evidence is still there, word for word.
|
|
459
|
+
*
|
|
460
|
+
* Every line seisin 0.6.0 appends takes the queue's lock (write), and so does
|
|
461
|
+
* this, so none of those can land between the read and the rename. A run
|
|
462
|
+
* started by an older seisin and still going appends without it; for that one
|
|
463
|
+
* the rewrite follows the old file instead of trusting the lock: it works from
|
|
464
|
+
* one open descriptor, copies what was appended while it worked, renames, and
|
|
465
|
+
* keeps copying what still lands in the old file until it has been quiet for
|
|
466
|
+
* FOLLOW_QUIET_MS. Such an append opened the file before the rename and writes
|
|
467
|
+
* microseconds later; the wait is a hundred times that.
|
|
468
|
+
*/
|
|
469
|
+
const FOLLOW_QUIET_MS = 50;
|
|
470
|
+
|
|
471
|
+
export function migrateQueue(file) {
|
|
472
|
+
return withLock(file, () => {
|
|
473
|
+
let fd;
|
|
474
|
+
try { fd = openSync(file, "r"); } catch (e) { if (e.code === "ENOENT") return 0; throw e; }
|
|
475
|
+
try {
|
|
476
|
+
const before = readFrom(fd, 0);
|
|
477
|
+
let read = before.length;
|
|
478
|
+
const first = renameDeclines(before.toString("utf8"));
|
|
479
|
+
if (!first.n) return 0;
|
|
480
|
+
let n = first.n;
|
|
481
|
+
const mode = fstatSync(fd).mode & 0o777;
|
|
482
|
+
let backup = `${file}.pre-0.6.0`;
|
|
483
|
+
try { writeFileSync(backup, before, { flag: "wx", mode }); }
|
|
484
|
+
catch (e) {
|
|
485
|
+
if (e.code !== "EEXIST") throw e;
|
|
486
|
+
backup = `${file}.pre-0.6.0.${Date.now()}`;
|
|
487
|
+
writeFileSync(backup, before, { flag: "wx", mode });
|
|
488
|
+
}
|
|
489
|
+
const tmp = join(dirname(file), `.requests.migrate-${randomBytes(8).toString("hex")}`);
|
|
490
|
+
writeFileSync(tmp, first.text, { flag: "wx", mode });
|
|
491
|
+
try {
|
|
492
|
+
const more = readFrom(fd, read);
|
|
493
|
+
read += more.length;
|
|
494
|
+
const tail = renameDeclines(more.toString("utf8"));
|
|
495
|
+
n += tail.n;
|
|
496
|
+
appendFileSync(tmp, tail.text);
|
|
497
|
+
renameSync(tmp, file);
|
|
498
|
+
} catch (e) { try { unlinkSync(tmp); } catch {} throw e; }
|
|
499
|
+
const until = Date.now() + 2000;
|
|
500
|
+
for (let quiet = Date.now(); Date.now() - quiet < FOLLOW_QUIET_MS && Date.now() < until;) {
|
|
501
|
+
const late = readFrom(fd, read);
|
|
502
|
+
if (!late.length) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); continue; }
|
|
503
|
+
read += late.length;
|
|
504
|
+
quiet = Date.now();
|
|
505
|
+
const tail = renameDeclines(late.toString("utf8"));
|
|
506
|
+
n += tail.n;
|
|
507
|
+
appendFileSync(file, tail.text);
|
|
508
|
+
}
|
|
509
|
+
return n;
|
|
510
|
+
} finally { closeSync(fd); }
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The chain a request was asked in, kept as the deepest one seen.
|
|
516
|
+
*
|
|
517
|
+
* One request (same role, action, folder) can be asked by a run that was
|
|
518
|
+
* handed work and by one that was not. The deeper chain is the one that knows
|
|
519
|
+
* more of where the work has been, so it is the one kept: dropping it on a
|
|
520
|
+
* later plain ask would let the work be handed back to a role it came from.
|
|
521
|
+
*/
|
|
522
|
+
function keepChain(seen, e) {
|
|
523
|
+
const c = e.chain;
|
|
524
|
+
if (!c || typeof c !== "object" || !Number.isInteger(c.depth)) return;
|
|
525
|
+
if (!seen.chain || c.depth > seen.chain.depth) seen.chain = c;
|
|
526
|
+
}
|
|
527
|
+
|
|
239
528
|
/**
|
|
240
529
|
* How many runs of the role, after the last time it asked, make a request old.
|
|
241
530
|
*
|
|
@@ -328,11 +617,33 @@ export function refuseIfBarred(config, request) {
|
|
|
328
617
|
`what you mean; otherwise decline this request.`
|
|
329
618
|
: `${request.target} is protected (${guard.why}) and no role can ever be granted it. ` +
|
|
330
619
|
`Decline this request.`);
|
|
620
|
+
// Another role's private folder: the kernel denies it to every role but its
|
|
621
|
+
// owner, however wide `writes` gets, so a grant would read as done and open nothing.
|
|
622
|
+
const mine = privateOwner(config, request.target);
|
|
623
|
+
if (mine && mine.role !== request.role)
|
|
624
|
+
throw new Error(
|
|
625
|
+
`${request.target} is in ${mine.role}'s private folder (private = ["${mine.entry}"]): every other role ` +
|
|
626
|
+
`is denied it, so a grant to ${request.role} would change nothing. Decline this request, or move the ` +
|
|
627
|
+
`folder out of ${mine.role}'s private list if it should not be private.`);
|
|
331
628
|
// Only a repository the role does not write: its own `.git` is its own business.
|
|
332
629
|
if (isGitMetadata(request.target) && !(role?.writes ?? []).some((g) => covers(g, request.target)))
|
|
333
630
|
throw new Error(
|
|
334
631
|
`${request.target} is git's own bookkeeping, not a territory: granting it lets ${request.role} ` +
|
|
335
632
|
`rewrite another owner's history. Decline it; a role that needs to commit gets a worktree of its own.`);
|
|
633
|
+
/**
|
|
634
|
+
* The whole repository is never granted from a request. A request is one
|
|
635
|
+
* denied path; the grant it derives is that path's folder, and at the root
|
|
636
|
+
* that folder is everything — every other role's territory included. A role
|
|
637
|
+
* that should write the whole repo gets `writes = ["**"]` from a person
|
|
638
|
+
* editing the policy, not from approving one denial.
|
|
639
|
+
*/
|
|
640
|
+
const grant = request.grant ?? grantFor(request);
|
|
641
|
+
const g = String(grant).replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
642
|
+
if (["", ".", "*", "**", "./**"].includes(g) || /^(\*\*\/)*\*\*$/.test(g))
|
|
643
|
+
throw new Error(
|
|
644
|
+
`this request would grant ${request.role} the whole repository ("${grant}"), every other ` +
|
|
645
|
+
`role's territory included. A request cannot do that: decline it, and if ${request.role} really ` +
|
|
646
|
+
`should write everything, edit [roles.${request.role}] writes by hand.`);
|
|
336
647
|
}
|
|
337
648
|
|
|
338
649
|
/**
|
|
@@ -355,7 +666,11 @@ export function editPolicy(config, mutate, { waitMs = 10000, after = null } = {}
|
|
|
355
666
|
return withLock(policyLockBase(config), () => {
|
|
356
667
|
const before = readFileSync(config.path, "utf8");
|
|
357
668
|
const result = mutate(before);
|
|
358
|
-
if (result.changed)
|
|
669
|
+
if (result.changed) {
|
|
670
|
+
// A decision cannot settle against text the next run cannot load.
|
|
671
|
+
loadConfig(config.path, result.toml);
|
|
672
|
+
writePolicy(config, result.toml);
|
|
673
|
+
}
|
|
359
674
|
after?.(result);
|
|
360
675
|
return result;
|
|
361
676
|
}, { waitMs });
|
|
@@ -391,10 +706,12 @@ export function policyLockBase(config) {
|
|
|
391
706
|
* exclusive, which is what defeats a planted name.
|
|
392
707
|
*/
|
|
393
708
|
function writePolicy(config, text) {
|
|
709
|
+
const mode = statSync(config.path).mode & 0o777;
|
|
394
710
|
const name = `seisin.toml.tmp-${randomBytes(12).toString("hex")}`;
|
|
395
711
|
const stateTmp = join(config.root, STATE_DIR, name);
|
|
396
|
-
writeFileSync(stateTmp, text, { flag: "wx" });
|
|
712
|
+
writeFileSync(stateTmp, text, { flag: "wx", mode });
|
|
397
713
|
try {
|
|
714
|
+
chmodSync(stateTmp, mode);
|
|
398
715
|
renameSync(stateTmp, config.path);
|
|
399
716
|
return;
|
|
400
717
|
} catch (e) {
|
|
@@ -402,8 +719,8 @@ function writePolicy(config, text) {
|
|
|
402
719
|
if (e.code !== "EXDEV") throw e;
|
|
403
720
|
}
|
|
404
721
|
const near = join(dirname(config.path), `.${name}`);
|
|
405
|
-
writeFileSync(near, text, { flag: "wx" });
|
|
406
|
-
try { renameSync(near, config.path); }
|
|
722
|
+
writeFileSync(near, text, { flag: "wx", mode });
|
|
723
|
+
try { chmodSync(near, mode); renameSync(near, config.path); }
|
|
407
724
|
catch (e) { try { unlinkSync(near); } catch {} throw e; }
|
|
408
725
|
}
|
|
409
726
|
|
|
@@ -424,17 +741,17 @@ function writePolicy(config, text) {
|
|
|
424
741
|
*/
|
|
425
742
|
export function applyGrant(toml, request, note = "") {
|
|
426
743
|
const field = request.action === "read" ? "keys" : "writes";
|
|
427
|
-
const header = new RegExp(
|
|
744
|
+
const header = new RegExp(`^[ \\t]*\\[roles\\.${escapeRe(request.role)}\\][ \\t\\r]*(?:#[^\\n]*)?$`, "m");
|
|
428
745
|
const at = header.exec(toml);
|
|
429
746
|
if (!at) throw new Error(`no [roles.${request.role}] section to grant into`);
|
|
430
747
|
|
|
431
748
|
// The section runs from its header to the next table header, or to the end.
|
|
432
749
|
const from = at.index + at[0].length;
|
|
433
|
-
const next =
|
|
750
|
+
const next = /^[ \t]*\[[A-Za-z0-9_.\-]+\][ \t\r]*(?:#[^\n]*)?$/m.exec(toml.slice(from));
|
|
434
751
|
const to = next ? from + next.index : toml.length;
|
|
435
752
|
const section = toml.slice(from, to);
|
|
436
753
|
|
|
437
|
-
const open = new RegExp(
|
|
754
|
+
const open = new RegExp(`^[ \\t]*${field}[ \\t]*=[ \\t]*\\[`, "m").exec(section);
|
|
438
755
|
if (!open)
|
|
439
756
|
throw new Error(
|
|
440
757
|
`[roles.${request.role}] has no ${field} list to grant into. ` +
|
|
@@ -470,7 +787,8 @@ export function applyGrant(toml, request, note = "") {
|
|
|
470
787
|
}
|
|
471
788
|
const stamp = `# granted ${new Date().toISOString().slice(0, 10)} · asked ${request.times}×` +
|
|
472
789
|
`${note ? ` · «${cleanReason(note)}»` : ""}`;
|
|
473
|
-
const
|
|
790
|
+
const eol = toml.includes("\r\n") ? "\r\n" : "\n";
|
|
791
|
+
const edited = section.slice(0, start) + `${inner}${eol} "${request.grant}" ${stamp}${eol}]` + section.slice(list.end + 1);
|
|
474
792
|
return { toml: toml.slice(0, from) + edited + toml.slice(to), changed: true };
|
|
475
793
|
}
|
|
476
794
|
|
package/src/review.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { read, logPath } from "./log.js";
|
|
31
31
|
import { covers, standingOf } from "./owners.js";
|
|
32
|
-
import { kindsOf } from "./kinds.js";
|
|
32
|
+
import { kindsOf, expectedOf } from "./kinds.js";
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* One reading of the log against the policy.
|
|
@@ -38,16 +38,18 @@ import { kindsOf } from "./kinds.js";
|
|
|
38
38
|
* is low on purpose: the cost of mentioning it is a line, and the cost of
|
|
39
39
|
* missing it is a role that quietly cannot do its job.
|
|
40
40
|
*/
|
|
41
|
-
export function review(config, { minDenials = 3 } = {}) {
|
|
42
|
-
|
|
41
|
+
export function review(config, { minDenials = 3, since = null } = {}) {
|
|
42
|
+
// `since`: an ISO time (views.js parseSince). Without it the whole log, and
|
|
43
|
+
// on a log months long "denied repeatedly" is led by paths fixed long ago.
|
|
44
|
+
const entries = read(logPath(config.root), { limit: 0, ...(since && { since }) });
|
|
43
45
|
|
|
44
46
|
if (entries.length === 0)
|
|
45
|
-
return { window: null, entries: 0, friction: [], guarded: [], unused: [], unusedKnowable: false, unowned: [] };
|
|
47
|
+
return { window: null, ...(since && { since }), entries: 0, friction: [], expected: [], guarded: [], unused: [], unusedKnowable: false, unowned: [] };
|
|
46
48
|
|
|
47
49
|
const at = entries.map((e) => e.at).filter(Boolean).sort();
|
|
48
50
|
const window = { from: at[0], to: at[at.length - 1], entries: entries.length };
|
|
49
51
|
|
|
50
|
-
return { window, entries: entries.length, ...findings(config, entries, minDenials) };
|
|
52
|
+
return { window, ...(since && { since }), entries: entries.length, ...findings(config, entries, minDenials) };
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
function findings(config, entries, minDenials) {
|
|
@@ -74,15 +76,19 @@ function findings(config, entries, minDenials) {
|
|
|
74
76
|
const stopped = new Map(); // role + directory -> how often
|
|
75
77
|
for (const e of entries) {
|
|
76
78
|
if (e.verdict !== "denied" || !e.target) continue;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
// The caller's own probe (`seisin run --expect-denied`): denied on purpose.
|
|
80
|
+
if (e.expected === true) continue;
|
|
81
|
+
const dir = e.action === "read" || e.action === "connect" || e.kind === "system" ? e.target : dirOf(e.target);
|
|
82
|
+
const expected = expectedOf(e.target);
|
|
83
|
+
const key = `${e.role} ${e.action} ${dir}${expected ? " expected" : ""}`;
|
|
79
84
|
const seen = stopped.get(key) ?? {
|
|
80
85
|
role: e.role, action: e.action, where: dir, times: 0,
|
|
81
86
|
owners: e.owners ?? [],
|
|
87
|
+
...(expected && { expected }),
|
|
82
88
|
// Carried by the log from both writers — the hook sets it from the config
|
|
83
89
|
// and so does the parent for a kernel denial. Recomputing it from the path
|
|
84
90
|
// here would be a second opinion on a settled question.
|
|
85
|
-
kind: e.kind === "key" || e.kind === "network" ? e.kind : "file",
|
|
91
|
+
kind: e.kind === "key" || e.kind === "network" || e.kind === "system" ? e.kind : "file",
|
|
86
92
|
};
|
|
87
93
|
seen.times++;
|
|
88
94
|
stopped.set(key, seen);
|
|
@@ -92,7 +98,12 @@ function findings(config, entries, minDenials) {
|
|
|
92
98
|
.filter((f) => f.times >= minDenials)
|
|
93
99
|
.sort((a, b) => b.times - a.times);
|
|
94
100
|
|
|
95
|
-
|
|
101
|
+
// Expected (kinds.js expectedOf): Claude Code's plugin updater, refused
|
|
102
|
+
// correctly. Repeated on every start, and not a policy that is wrong — so it
|
|
103
|
+
// is said apart and never counts as friction, which is what exits 1.
|
|
104
|
+
const expected = repeated.filter((f) => f.expected);
|
|
105
|
+
// A system service is once per run (intake.js) and never grantable: not friction.
|
|
106
|
+
const friction = repeated.filter((f) => !f.expected && f.kind !== "key" && f.kind !== "network" && f.kind !== "system");
|
|
96
107
|
const guarded = repeated.filter((f) => f.kind === "key");
|
|
97
108
|
// Ports and sockets: repeated, and not a thing `grant` can answer.
|
|
98
109
|
const connects = repeated.filter((f) => f.kind === "network");
|
|
@@ -179,6 +190,7 @@ function findings(config, entries, minDenials) {
|
|
|
179
190
|
|
|
180
191
|
return {
|
|
181
192
|
friction,
|
|
193
|
+
expected,
|
|
182
194
|
guarded,
|
|
183
195
|
connects,
|
|
184
196
|
unused,
|