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/requests.js
CHANGED
|
@@ -1,39 +1,44 @@
|
|
|
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
|
-
* 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
|
-
* a
|
|
13
|
+
* a path before reaching for it is reasonable, and nothing required a
|
|
13
14
|
* 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
|
|
15
|
+
* event that never occurred, in front of a person about to grant something.
|
|
16
|
+
* `seisin run` drops any request the policy does not actually deny; what is
|
|
16
17
|
* left is "you cannot have this, and you asked", which holds whether the agent
|
|
17
18
|
* tried or simply asked.
|
|
18
19
|
*
|
|
19
20
|
* A denial already contains everything a request needs — the hook knows the
|
|
20
|
-
* role, the path and the owner — so the
|
|
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
|
-
* rewritten is not evidence. A request that is granted or
|
|
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 } from "./owners.js";
|
|
30
|
-
import {
|
|
30
|
+
import { neverWrites, isGitMetadata, covers, privateOwner } from "./owners.js";
|
|
31
|
+
import { decideHandoff, chainOf, isKeyRequest } from "./handoff.js";
|
|
32
|
+
import { protectedBy } from "./surface.js";
|
|
33
|
+
import { appendFileSync, chmodSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
|
|
34
|
+
import { randomBytes } from "node:crypto";
|
|
31
35
|
import { dirname, join } from "node:path";
|
|
32
36
|
import { STATE_DIR, tomlString } from "./layout.js";
|
|
33
37
|
import { send } from "./spool.js";
|
|
34
|
-
import { withLock } from "./log.js";
|
|
38
|
+
import { withLock, readEntries } from "./log.js";
|
|
39
|
+
import { loadConfig, policyId } from "./config.js";
|
|
35
40
|
|
|
36
|
-
|
|
41
|
+
const REQUESTS_NAME = "requests.jsonl";
|
|
37
42
|
|
|
38
43
|
export function requestsPath(root) {
|
|
39
44
|
return join(root, STATE_DIR, REQUESTS_NAME);
|
|
@@ -57,7 +62,6 @@ export function keyOf({ role, action, target }) {
|
|
|
57
62
|
return `${role}:${action}:${dir}`;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
/** The glob a grant would add, derived from what was asked. */
|
|
61
65
|
/**
|
|
62
66
|
* A request's id as one shell word, for the commands seisin suggests.
|
|
63
67
|
*
|
|
@@ -69,13 +73,23 @@ export function shellId(key) {
|
|
|
69
73
|
return `'${String(key).replace(/'/g, `'\\''`)}'`;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
|
|
76
|
+
/** The glob a grant would add, derived from what was asked. */
|
|
77
|
+
export function grantFor({ action, target }, keyDir = null) {
|
|
73
78
|
const t = typeof target === "string" ? target : "";
|
|
74
79
|
// A key keeps its directory when it has one. Stripping it turned a request
|
|
75
80
|
// for `shared/api.txt` into a grant of `api.txt`, which settingsFor then
|
|
76
|
-
// resolves against the FIRST key directory — so the person
|
|
81
|
+
// resolves against the FIRST key directory — so the person grants one file
|
|
77
82
|
// and another one of the same name is what gets read.
|
|
78
|
-
|
|
83
|
+
//
|
|
84
|
+
// Except the first key directory itself, which is exactly the one a bare
|
|
85
|
+
// name resolves against: `.secrets/stripe.txt` is written `stripe.txt`, the
|
|
86
|
+
// way every hand-written key is. Granting the long form put two spellings of
|
|
87
|
+
// one key in the same file.
|
|
88
|
+
if (action === "read") {
|
|
89
|
+
const first = keyDir ? String(keyDir).replace(/^\.\/+/, "").replace(/\/+$/, "") : null;
|
|
90
|
+
if (first && t.startsWith(first + "/") && !t.slice(first.length + 1).includes("/")) return t.slice(first.length + 1);
|
|
91
|
+
return t.includes("/") ? t : t.replace(/^.*\//, "");
|
|
92
|
+
}
|
|
79
93
|
const dir = t.split("/").slice(0, -1).join("/");
|
|
80
94
|
return dir ? `${dir}/**` : t;
|
|
81
95
|
}
|
|
@@ -84,7 +98,11 @@ function write(file, entry) {
|
|
|
84
98
|
if (send("requests", entry)) return true;
|
|
85
99
|
try {
|
|
86
100
|
mkdirSync(dirname(file), { recursive: true });
|
|
87
|
-
|
|
101
|
+
// The time is this process's, named after the spread so no entry sets it.
|
|
102
|
+
const now = new Date().toISOString();
|
|
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"));
|
|
88
106
|
return true;
|
|
89
107
|
} catch {
|
|
90
108
|
// Same rule as the log: never take the agent down over bookkeeping.
|
|
@@ -92,15 +110,150 @@ function write(file, entry) {
|
|
|
92
110
|
}
|
|
93
111
|
}
|
|
94
112
|
|
|
95
|
-
/**
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Who each request goes to, decided now (handoff.js), and whose it is.
|
|
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.
|
|
129
|
+
*/
|
|
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: {} };
|
|
134
|
+
for (const r of queue) {
|
|
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]);
|
|
150
|
+
}
|
|
151
|
+
for (const r of queue) if (stoppedIn.has(r.key)) r.chainStopped = stoppedIn.get(r.key);
|
|
152
|
+
return queue;
|
|
153
|
+
}
|
|
154
|
+
|
|
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
|
+
};
|
|
99
186
|
}
|
|
100
187
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
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}.`;
|
|
104
257
|
}
|
|
105
258
|
|
|
106
259
|
/**
|
|
@@ -115,6 +268,10 @@ export function settle(file, key, decision, reason = "") {
|
|
|
115
268
|
*
|
|
116
269
|
* It exists so that a refusal to run cannot be invisible. Admission may
|
|
117
270
|
* postpone work; it may not make work disappear from the queue a person reads.
|
|
271
|
+
*
|
|
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`.
|
|
118
275
|
*/
|
|
119
276
|
export function recordHandoff(file, key, decision = {}) {
|
|
120
277
|
const { type, role, limit, max, reason, depth, chainId, revision } = decision;
|
|
@@ -135,30 +292,79 @@ export function recordHandoff(file, key, decision = {}) {
|
|
|
135
292
|
* it was asked and what was decided.
|
|
136
293
|
*
|
|
137
294
|
* Reduced from the whole file rather than kept as state, so the file stays the
|
|
138
|
-
* only thing that has to be correct.
|
|
295
|
+
* only thing that has to be correct. The reduction is remembered for as long as
|
|
296
|
+
* the file is unchanged (same inode, size and mtime): the console asks every
|
|
297
|
+
* two seconds, and re-parsing a 2 MB queue each time to find it had not moved
|
|
298
|
+
* was most of the cost of asking. Each call gets its own copies, so a caller
|
|
299
|
+
* marking entries (markStale) cannot mark the next caller's.
|
|
139
300
|
*/
|
|
140
|
-
|
|
141
|
-
if (!existsSync(file)) return [];
|
|
142
|
-
const byKey = new Map();
|
|
301
|
+
let reduced = null; // { file, ino, size, mtimeMs, all }
|
|
143
302
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 } = {}) {
|
|
308
|
+
let st;
|
|
309
|
+
try { st = statSync(file); } catch { return []; }
|
|
310
|
+
let all;
|
|
311
|
+
if (reduced && reduced.file === file && reduced.ino === st.ino && reduced.size === st.size && reduced.mtimeMs === st.mtimeMs) {
|
|
312
|
+
all = reduced.all;
|
|
313
|
+
} else {
|
|
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);
|
|
151
322
|
}
|
|
323
|
+
if (entries.some(isOldDecline)) throw unmigrated(file, entries.filter(isOldDecline).length);
|
|
324
|
+
all = reduce(entries);
|
|
325
|
+
reduced = { file, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, all };
|
|
326
|
+
}
|
|
327
|
+
// The grant is re-derived with the policy's first key directory when the
|
|
328
|
+
// caller has one, so a key is proposed in the form hand-written keys use.
|
|
329
|
+
const first = keyDirs?.[0] ?? null;
|
|
330
|
+
const copies = all.map((r) => (first && r.action === "read" ? { ...r, grant: grantFor(r, first) } : { ...r }));
|
|
331
|
+
return includeSettled ? copies : copies.filter((r) => r.state === "pending");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function reduce(entries) {
|
|
335
|
+
const byKey = new Map();
|
|
336
|
+
|
|
337
|
+
for (const e of entries) {
|
|
152
338
|
if (!e.key) continue;
|
|
153
339
|
|
|
154
340
|
if (e.kind === "asked") {
|
|
155
341
|
const seen = byKey.get(e.key);
|
|
156
|
-
|
|
157
|
-
|
|
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 = {
|
|
158
362
|
key: e.key, role: e.role, action: e.action, target: e.target,
|
|
159
363
|
owners: e.owners ?? [], grant: grantFor(e),
|
|
160
364
|
times: 1, first: e.at, last: e.at, state: "pending", reason: "",
|
|
161
|
-
}
|
|
365
|
+
};
|
|
366
|
+
keepChain(fresh, e);
|
|
367
|
+
byKey.set(e.key, fresh);
|
|
162
368
|
continue;
|
|
163
369
|
}
|
|
164
370
|
const seen = byKey.get(e.key);
|
|
@@ -169,10 +375,12 @@ export function pending(file, { includeSettled = false } = {}) {
|
|
|
169
375
|
*
|
|
170
376
|
* Without this branch the catch-all below would set state = "handoff" and a
|
|
171
377
|
* throttled attempt would drop out of the pending queue — the exact silent
|
|
172
|
-
* disappearance the attempt is recorded to prevent.
|
|
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.
|
|
173
381
|
*/
|
|
174
382
|
if (e.kind === "handoff") {
|
|
175
|
-
seen.
|
|
383
|
+
seen.attempt = {
|
|
176
384
|
outcome: e.outcome, role: e.role, limit: e.limit, max: e.max,
|
|
177
385
|
reason: e.reason, depth: e.depth, chainId: e.chainId, at: e.at,
|
|
178
386
|
};
|
|
@@ -180,16 +388,147 @@ export function pending(file, { includeSettled = false } = {}) {
|
|
|
180
388
|
}
|
|
181
389
|
|
|
182
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
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return [...byKey.values()];
|
|
399
|
+
}
|
|
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++;
|
|
183
435
|
}
|
|
436
|
+
return { text: lines.join("\n"), n };
|
|
437
|
+
}
|
|
184
438
|
|
|
185
|
-
|
|
186
|
-
|
|
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;
|
|
187
526
|
}
|
|
188
527
|
|
|
189
528
|
/**
|
|
190
529
|
* How many runs of the role, after the last time it asked, make a request old.
|
|
191
530
|
*
|
|
192
|
-
* A request is recorded again on every
|
|
531
|
+
* A request is recorded again on every denial, so one the role still needs
|
|
193
532
|
* keeps a fresh `last`. One whose need went away — the work moved somewhere the
|
|
194
533
|
* role may write, or the task ended — stops being asked while the role keeps
|
|
195
534
|
* running. Three runs is enough to say "not since" and few enough that a queue
|
|
@@ -233,7 +572,7 @@ export function runsAfter(entries, since, gapMs = RUN_GAP_MS) {
|
|
|
233
572
|
* reordering would shift the numbers `grant <n>` is typed against while
|
|
234
573
|
* nobody decided anything. The mark says what is true — the role ran N times
|
|
235
574
|
* since and did not ask again — and leaves the rest to whoever reads it.
|
|
236
|
-
* `entries` are log lines (`read(logPath(root))`)
|
|
575
|
+
* `entries` are log lines (`read(logPath(root))`).
|
|
237
576
|
*/
|
|
238
577
|
export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_MS } = {}) {
|
|
239
578
|
const byRole = new Map(); // role -> its log lines
|
|
@@ -248,35 +587,13 @@ export function markStale(queue, entries, { runs = STALE_RUNS, gapMs = RUN_GAP_M
|
|
|
248
587
|
return queue;
|
|
249
588
|
}
|
|
250
589
|
|
|
251
|
-
/**
|
|
252
|
-
* Adds a granted glob to a role in the config text, with its provenance.
|
|
253
|
-
*
|
|
254
|
-
* Edits the text rather than re-emitting the file, because a config someone
|
|
255
|
-
* wrote has comments and an order that mean something, and a tool that
|
|
256
|
-
* reformats it on every grant is a tool people stop letting near it.
|
|
257
|
-
*/
|
|
258
|
-
/**
|
|
259
|
-
* Adds a granted glob to a role in the config text, with its provenance.
|
|
260
|
-
*
|
|
261
|
-
* Edits the text rather than re-emitting the file, because a config someone
|
|
262
|
-
* wrote has comments and an order that mean something, and a tool that
|
|
263
|
-
* reformats it on every grant is a tool people stop letting near it.
|
|
264
|
-
*
|
|
265
|
-
* The edit is bounded to the role's own section, and that is not tidiness. The
|
|
266
|
-
* first version searched from the role header to the next `writes =` anywhere
|
|
267
|
-
* in the file, so approving for a role that had no `writes` line wrote the
|
|
268
|
-
* grant into the NEXT role — with a comment saying who it was for. A human
|
|
269
|
-
* approved one thing and the file recorded another. In a permission tool that
|
|
270
|
-
* is the worst possible bug, and it is not exotic: a role with only `keys`
|
|
271
|
-
* declared is an ordinary config.
|
|
272
|
-
*/
|
|
273
590
|
/**
|
|
274
591
|
* Refuses a grant that `never_writes` would cancel, before anything is written.
|
|
275
592
|
*
|
|
276
593
|
* A request filed before the subtraction existed can still be in the queue.
|
|
277
|
-
*
|
|
594
|
+
* Granting it would add the path to `writes` and change nothing — denyWrite
|
|
278
595
|
* wins — so the person would believe they granted something the kernel still
|
|
279
|
-
*
|
|
596
|
+
* denies. The two settings contradict each other, and which one should win is the
|
|
280
597
|
* decision; seisin will not make it by writing one of them silently.
|
|
281
598
|
*/
|
|
282
599
|
export function refuseIfBarred(config, request) {
|
|
@@ -288,11 +605,45 @@ export function refuseIfBarred(config, request) {
|
|
|
288
605
|
`${request.target} is under never_writes of ${request.role} ("${hit}"), which wins over ` +
|
|
289
606
|
`writes. Granting it would change nothing. If the subtraction is wrong, remove that ` +
|
|
290
607
|
`entry from [roles.${request.role}]; otherwise decline this request.`);
|
|
608
|
+
// A protected file stays denied whatever `writes` says: the kernel is given
|
|
609
|
+
// it as a deny, and explain answers the same. Granting `web/.vscode/**`
|
|
610
|
+
// used to add the line and change nothing — a grant that reads as done.
|
|
611
|
+
const guard = protectedBy(config, request.target, { role: request.role });
|
|
612
|
+
if (guard)
|
|
613
|
+
throw new Error(guard.family
|
|
614
|
+
? `${request.target} is protected (${guard.why}). A grant cannot open it: ${request.role} ` +
|
|
615
|
+
`writes it only if the policy hands it the family, control_files = ["${guard.family}"] ` +
|
|
616
|
+
`under [roles.${request.role}], inside its own territory. Edit the policy if that is ` +
|
|
617
|
+
`what you mean; otherwise decline this request.`
|
|
618
|
+
: `${request.target} is protected (${guard.why}) and no role can ever be granted it. ` +
|
|
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.`);
|
|
291
628
|
// Only a repository the role does not write: its own `.git` is its own business.
|
|
292
629
|
if (isGitMetadata(request.target) && !(role?.writes ?? []).some((g) => covers(g, request.target)))
|
|
293
630
|
throw new Error(
|
|
294
631
|
`${request.target} is git's own bookkeeping, not a territory: granting it lets ${request.role} ` +
|
|
295
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.`);
|
|
296
647
|
}
|
|
297
648
|
|
|
298
649
|
/**
|
|
@@ -307,43 +658,110 @@ export function refuseIfBarred(config, request) {
|
|
|
307
658
|
* so a crash mid-write cannot leave a half-written, partly-privileged policy.
|
|
308
659
|
*
|
|
309
660
|
* `mutate(toml)` returns `{ toml, changed }`. Nothing is written when unchanged.
|
|
661
|
+
* `after(result)` runs once the file is written, still under the lock — for
|
|
662
|
+
* the queue line that records the decision, so a decision and its edit are
|
|
663
|
+
* one step to anyone else taking this lock.
|
|
310
664
|
*/
|
|
311
|
-
export function editPolicy(config, mutate, { waitMs = 10000 } = {}) {
|
|
312
|
-
return withLock(config
|
|
665
|
+
export function editPolicy(config, mutate, { waitMs = 10000, after = null } = {}) {
|
|
666
|
+
return withLock(policyLockBase(config), () => {
|
|
313
667
|
const before = readFileSync(config.path, "utf8");
|
|
314
668
|
const result = mutate(before);
|
|
315
669
|
if (result.changed) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
670
|
+
// A decision cannot settle against text the next run cannot load.
|
|
671
|
+
loadConfig(config.path, result.toml);
|
|
672
|
+
writePolicy(config, result.toml);
|
|
319
673
|
}
|
|
674
|
+
after?.(result);
|
|
320
675
|
return result;
|
|
321
676
|
}, { waitMs });
|
|
322
677
|
}
|
|
323
678
|
|
|
679
|
+
/**
|
|
680
|
+
* Where the policy's lock lives: inside `.seisin/`, never beside the policy.
|
|
681
|
+
*
|
|
682
|
+
* It was `seisin.toml.lock`, next to the file. Any role that writes the repo
|
|
683
|
+
* root — every `--observe` run, any `writes = ["**"]` — could create that name
|
|
684
|
+
* holding a live pid and hold up every grant and every decline for as long as
|
|
685
|
+
* it liked. `.seisin/` is denied to every role (surface.js, parentInputs), so
|
|
686
|
+
* nothing confined can hold this one. withLock adds the `.lock`.
|
|
687
|
+
*/
|
|
688
|
+
export function policyLockBase(config) {
|
|
689
|
+
const dir = join(config.root, STATE_DIR);
|
|
690
|
+
mkdirSync(dir, { recursive: true });
|
|
691
|
+
return join(dir, "policy");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Write the policy through a temp file that nobody could have prepared.
|
|
696
|
+
*
|
|
697
|
+
* The temp was `seisin.toml.tmp-<pid>` beside the policy: a predictable name,
|
|
698
|
+
* in a directory roles can write, opened by writeFileSync — which follows a
|
|
699
|
+
* symlink. A role could plant that name as a link into its own territory, and
|
|
700
|
+
* the next grant made the policy itself a symlink the role could rewrite.
|
|
701
|
+
* Now the temp sits in `.seisin/`, has a random name, and is opened with `wx`
|
|
702
|
+
* (O_CREAT|O_EXCL), which refuses anything already there, a link included.
|
|
703
|
+
*
|
|
704
|
+
* A `.seisin/` on another filesystem (a symlink a person made) cannot be
|
|
705
|
+
* renamed across; then the temp goes beside the policy, still random and still
|
|
706
|
+
* exclusive, which is what defeats a planted name.
|
|
707
|
+
*/
|
|
708
|
+
function writePolicy(config, text) {
|
|
709
|
+
const mode = statSync(config.path).mode & 0o777;
|
|
710
|
+
const name = `seisin.toml.tmp-${randomBytes(12).toString("hex")}`;
|
|
711
|
+
const stateTmp = join(config.root, STATE_DIR, name);
|
|
712
|
+
writeFileSync(stateTmp, text, { flag: "wx", mode });
|
|
713
|
+
try {
|
|
714
|
+
chmodSync(stateTmp, mode);
|
|
715
|
+
renameSync(stateTmp, config.path);
|
|
716
|
+
return;
|
|
717
|
+
} catch (e) {
|
|
718
|
+
try { unlinkSync(stateTmp); } catch {}
|
|
719
|
+
if (e.code !== "EXDEV") throw e;
|
|
720
|
+
}
|
|
721
|
+
const near = join(dirname(config.path), `.${name}`);
|
|
722
|
+
writeFileSync(near, text, { flag: "wx", mode });
|
|
723
|
+
try { chmodSync(near, mode); renameSync(near, config.path); }
|
|
724
|
+
catch (e) { try { unlinkSync(near); } catch {} throw e; }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Adds a granted glob to a role in the config text, with its provenance.
|
|
729
|
+
*
|
|
730
|
+
* Edits the text rather than re-emitting the file, because a config someone
|
|
731
|
+
* wrote has comments and an order that mean something, and a tool that
|
|
732
|
+
* reformats it on every grant is a tool people stop letting near it.
|
|
733
|
+
*
|
|
734
|
+
* The edit is bounded to the role's own section, and that is not tidiness. The
|
|
735
|
+
* first version searched from the role header to the next `writes =` anywhere
|
|
736
|
+
* in the file, so granting for a role that had no `writes` line wrote the
|
|
737
|
+
* grant into the NEXT role — with a comment saying who it was for. A person
|
|
738
|
+
* granted one thing and the file recorded another. In a permission layer that
|
|
739
|
+
* is the worst possible bug, and it is not exotic: a role with only `keys`
|
|
740
|
+
* declared is an ordinary config.
|
|
741
|
+
*/
|
|
324
742
|
export function applyGrant(toml, request, note = "") {
|
|
325
743
|
const field = request.action === "read" ? "keys" : "writes";
|
|
326
|
-
const header = new RegExp(
|
|
744
|
+
const header = new RegExp(`^[ \\t]*\\[roles\\.${escapeRe(request.role)}\\][ \\t\\r]*(?:#[^\\n]*)?$`, "m");
|
|
327
745
|
const at = header.exec(toml);
|
|
328
746
|
if (!at) throw new Error(`no [roles.${request.role}] section to grant into`);
|
|
329
747
|
|
|
330
748
|
// The section runs from its header to the next table header, or to the end.
|
|
331
749
|
const from = at.index + at[0].length;
|
|
332
|
-
const next =
|
|
750
|
+
const next = /^[ \t]*\[[A-Za-z0-9_.\-]+\][ \t\r]*(?:#[^\n]*)?$/m.exec(toml.slice(from));
|
|
333
751
|
const to = next ? from + next.index : toml.length;
|
|
334
752
|
const section = toml.slice(from, to);
|
|
335
753
|
|
|
336
|
-
const open = new RegExp(
|
|
754
|
+
const open = new RegExp(`^[ \\t]*${field}[ \\t]*=[ \\t]*\\[`, "m").exec(section);
|
|
337
755
|
if (!open)
|
|
338
756
|
throw new Error(
|
|
339
757
|
`[roles.${request.role}] has no ${field} list to grant into. ` +
|
|
340
|
-
`Add \`${field} = []\` to that section first — seisin will not write it into another role's
|
|
758
|
+
`Add \`${field} = []\` to that section first — seisin will not write it into another role's table.`
|
|
341
759
|
);
|
|
342
760
|
|
|
343
761
|
// The subset has no escapes, so a value carrying a quote or a newline cannot
|
|
344
762
|
// be written down at all. The strict parser would reject the result rather
|
|
345
763
|
// than widen anything — but leaving someone with a config that no longer
|
|
346
|
-
// loads, after they
|
|
764
|
+
// loads, after they granted something, is its own kind of broken.
|
|
347
765
|
tomlString(request.grant); // refuses what the format cannot hold
|
|
348
766
|
|
|
349
767
|
const start = open.index + open[0].length;
|
|
@@ -355,9 +773,9 @@ export function applyGrant(toml, request, note = "") {
|
|
|
355
773
|
*
|
|
356
774
|
* The list used to be re-written from every quoted string between its
|
|
357
775
|
* brackets — comments included. `"src/web/**", # was "src/**"` came back as
|
|
358
|
-
* two grants, so
|
|
359
|
-
* and the reason
|
|
360
|
-
* a path on the next
|
|
776
|
+
* two grants, so granting anything also granted what a comment mentioned,
|
|
777
|
+
* and the reason a person typed (quoted in the provenance comment) became
|
|
778
|
+
* a path on the next grant. Every earlier provenance comment was lost on
|
|
361
779
|
* the way. Now the items are the strings outside comments, what is there is
|
|
362
780
|
* left as it is, and the new entry goes last. The reason is set off with
|
|
363
781
|
* «», never a double quote, so no older reader can take it for a value.
|
|
@@ -369,7 +787,8 @@ export function applyGrant(toml, request, note = "") {
|
|
|
369
787
|
}
|
|
370
788
|
const stamp = `# granted ${new Date().toISOString().slice(0, 10)} · asked ${request.times}×` +
|
|
371
789
|
`${note ? ` · «${cleanReason(note)}»` : ""}`;
|
|
372
|
-
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);
|
|
373
792
|
return { toml: toml.slice(0, from) + edited + toml.slice(to), changed: true };
|
|
374
793
|
}
|
|
375
794
|
|
|
@@ -401,14 +820,14 @@ function scanList(text, start) {
|
|
|
401
820
|
}
|
|
402
821
|
|
|
403
822
|
/**
|
|
404
|
-
* The
|
|
823
|
+
* The reason a person typed, made safe to put in a config file.
|
|
405
824
|
*
|
|
406
825
|
* A reason is free text typed by a person, and a person can be talked into
|
|
407
826
|
* typing something — "paste this as the reason" is a plausible thing for an
|
|
408
827
|
* agent to suggest. Newlines in a comment would end the comment, and what
|
|
409
828
|
* follows is parsed as TOML. The strict parser rejects the result rather than
|
|
410
829
|
* widening anything, so the worst case is a config that no longer loads, but a
|
|
411
|
-
*
|
|
830
|
+
* policy that can be broken by a sentence is still broken.
|
|
412
831
|
*/
|
|
413
832
|
export function cleanReason(s) {
|
|
414
833
|
return String(s ?? "")
|