seisin 0.4.0 → 0.4.2
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 +12 -16
- package/package.json +1 -1
- package/src/commands/init.js +13 -5
- package/src/commands/log.js +9 -3
- package/src/commands/requests.js +26 -18
- package/src/commands/run.js +15 -2
- package/src/config.js +79 -7
- package/src/intake.js +4 -1
- package/src/log.js +83 -11
- package/src/mcp.js +63 -24
- package/src/notify.js +3 -3
- package/src/owners.js +140 -15
- package/src/render.js +13 -2
- package/src/requests.js +90 -13
- package/src/review.js +15 -7
- package/src/serve.js +65 -23
- package/src/surface.js +15 -1
- package/ui/index.html +30 -12
package/src/mcp.js
CHANGED
|
@@ -31,7 +31,7 @@ import { loadConfig, findConfig } from "./config.js";
|
|
|
31
31
|
import { inspect } from "./inspect.js";
|
|
32
32
|
import { explain, ownersOf } from "./owners.js";
|
|
33
33
|
import { settingsFor } from "./srt.js";
|
|
34
|
-
import { pending, requestsPath, grantFor, refuseIfBarred, markStale } from "./requests.js";
|
|
34
|
+
import { pending, requestsPath, grantFor, refuseIfBarred, markStale, shellId } from "./requests.js";
|
|
35
35
|
import { read, logPath } from "./log.js";
|
|
36
36
|
import { causesOf } from "./serve.js";
|
|
37
37
|
import { walls, wasted } from "./walls.js";
|
|
@@ -100,13 +100,22 @@ const TOOLS = [
|
|
|
100
100
|
name: "seisin_causes",
|
|
101
101
|
description:
|
|
102
102
|
"Denials grouped by what was denied — by path, and one level coarser by the name at " +
|
|
103
|
-
"the end of it —
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
|
|
103
|
+
"the end of it — over the whole log, or since an ISO date. The counts are history: " +
|
|
104
|
+
"what was refused. What is measured against the policy as it stands is where each " +
|
|
105
|
+
"path is now — `standing`: unowned (a decision nobody has made), owned, protected " +
|
|
106
|
+
"(closed to every role on purpose, nothing to grant) or outside (a port, a key, or " +
|
|
107
|
+
"outside the repository) — and, per cause, `stillRefused`, how many of the roles " +
|
|
108
|
+
"that hit it would hit it today. `seisin_activity` has the raw events; this has " +
|
|
109
|
+
"them read against the policy. Reach for it before proposing a change: a day that " +
|
|
110
|
+
"is three quarters one filename is a tooling problem, and the same volume spread " +
|
|
111
|
+
"across unrelated unowned paths is a question about who owns what. Read-only.",
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
limit: { type: "number" },
|
|
116
|
+
since: { type: "string", description: "ISO date; only refusals at or after it" },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
110
119
|
},
|
|
111
120
|
{
|
|
112
121
|
name: "seisin_walls",
|
|
@@ -127,8 +136,10 @@ const TOOLS = [
|
|
|
127
136
|
"does not grant. The approval is a human action in another channel.",
|
|
128
137
|
inputSchema: {
|
|
129
138
|
type: "object",
|
|
130
|
-
properties: {
|
|
131
|
-
|
|
139
|
+
properties: {
|
|
140
|
+
id: { type: "string", description: "the request's id from seisin_requests — preferred: it does not move when the queue does" },
|
|
141
|
+
number: { type: "number", description: "position in seisin_requests (legacy; the draft still names the id)" },
|
|
142
|
+
},
|
|
132
143
|
},
|
|
133
144
|
},
|
|
134
145
|
];
|
|
@@ -166,6 +177,8 @@ const HANDLERS = {
|
|
|
166
177
|
// not enforced is documentation.
|
|
167
178
|
if (action !== "read" && action !== "write" && action !== "mcp")
|
|
168
179
|
throw new Error(`action must be "read", "write" or "mcp", got ${JSON.stringify(action)}`);
|
|
180
|
+
if (typeof role !== "string" || !role)
|
|
181
|
+
throw new Error("role must be a non-empty string");
|
|
169
182
|
if (typeof target !== "string" || !target)
|
|
170
183
|
throw new Error("target must be a non-empty path");
|
|
171
184
|
const cfg = config();
|
|
@@ -182,14 +195,14 @@ const HANDLERS = {
|
|
|
182
195
|
const queue = markStale(pending(requestsPath(cfg.root)), read(logPath(cfg.root)));
|
|
183
196
|
return {
|
|
184
197
|
pending: queue.map((r, i) => ({
|
|
185
|
-
number: i + 1, role: r.role, action: r.action,
|
|
198
|
+
number: i + 1, id: r.key, role: r.role, action: r.action,
|
|
186
199
|
wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
|
|
187
200
|
...(r.stale ? { stale: `not asked again in ${r.stale.runs} runs of ${r.role} since` } : {}),
|
|
188
201
|
})),
|
|
189
202
|
// Said in the payload and not only in the tool description, because a
|
|
190
203
|
// model reading this is deciding what to do next.
|
|
191
204
|
note: queue.length
|
|
192
|
-
? "Approving is not available through MCP. Tell the operator to run: seisin grant <
|
|
205
|
+
? "Approving is not available through MCP. Tell the operator to run: seisin grant '<id>'"
|
|
193
206
|
: "nothing waiting",
|
|
194
207
|
};
|
|
195
208
|
},
|
|
@@ -203,13 +216,21 @@ const HANDLERS = {
|
|
|
203
216
|
* and asks it to re-derive the grouping — without the policy, which is the
|
|
204
217
|
* half that makes the grouping mean anything.
|
|
205
218
|
*/
|
|
206
|
-
seisin_causes({ limit }) {
|
|
219
|
+
seisin_causes({ limit, since }) {
|
|
207
220
|
const cfg = config();
|
|
208
|
-
|
|
221
|
+
// The whole log, as the console reads it. This read the last 4000 lines
|
|
222
|
+
// after the console had stopped doing so, and the two then disagreed about
|
|
223
|
+
// the same log — 3,999 refusals here against 6,630 there, measured — with
|
|
224
|
+
// nothing telling the agent its window was half of the person's.
|
|
225
|
+
const t = since ? Date.parse(since) : NaN;
|
|
226
|
+
const from = Number.isNaN(t) ? null : new Date(t).toISOString();
|
|
227
|
+
const c = causesOf(cfg, read(logPath(cfg.root), from ? { since: from } : {}));
|
|
209
228
|
return {
|
|
210
229
|
total: c.total,
|
|
211
230
|
distinct: c.distinct,
|
|
231
|
+
...(from && { since: from }),
|
|
212
232
|
unowned: c.unowned,
|
|
233
|
+
standing: c.standing,
|
|
213
234
|
families: c.families,
|
|
214
235
|
causes: typeof limit === "number" ? c.causes.slice(0, limit) : c.causes,
|
|
215
236
|
};
|
|
@@ -237,12 +258,23 @@ const HANDLERS = {
|
|
|
237
258
|
return { entries: read(logPath(cfg.root), { role, verdict, limit: n }) };
|
|
238
259
|
},
|
|
239
260
|
|
|
240
|
-
seisin_draft_grant({ number }) {
|
|
241
|
-
if (!Number.isInteger(Number(number)) || Number(number) < 1)
|
|
242
|
-
throw new Error("number must be a positive integer from seisin_requests");
|
|
261
|
+
seisin_draft_grant({ id, number } = {}) {
|
|
243
262
|
const cfg = config();
|
|
244
|
-
const
|
|
245
|
-
|
|
263
|
+
const queue = pending(requestsPath(cfg.root));
|
|
264
|
+
let req;
|
|
265
|
+
if (typeof id === "string" && id) {
|
|
266
|
+
req = queue.find((r) => r.key === id);
|
|
267
|
+
if (!req) throw new Error(`request ${id} is not pending`);
|
|
268
|
+
} else {
|
|
269
|
+
if (!Number.isInteger(Number(number)) || Number(number) < 1)
|
|
270
|
+
throw new Error("id (preferred) or number from seisin_requests is required");
|
|
271
|
+
req = queue[Number(number) - 1];
|
|
272
|
+
if (!req) throw new Error(`no pending request #${number}`);
|
|
273
|
+
}
|
|
274
|
+
// The command is written against the id, whatever was asked with: a
|
|
275
|
+
// number is a position in a queue agents keep writing to, and the person
|
|
276
|
+
// runs this later, against the queue as it is then.
|
|
277
|
+
const ref = shellId(req.key);
|
|
246
278
|
|
|
247
279
|
// The same refusal `seisin grant` and the console apply, asked here first.
|
|
248
280
|
// Without it this drafted, as approvable, a request that `never_writes`
|
|
@@ -255,7 +287,7 @@ const HANDLERS = {
|
|
|
255
287
|
ownedBy: req.owners,
|
|
256
288
|
applyWith: null,
|
|
257
289
|
note: "Not approvable as it stands: never_writes wins over writes. Decline it with " +
|
|
258
|
-
`\`seisin decline ${
|
|
290
|
+
`\`seisin decline ${ref}\`, or remove the never_writes entry if the subtraction is wrong.`,
|
|
259
291
|
};
|
|
260
292
|
}
|
|
261
293
|
const field = req.action === "read" ? "keys" : "writes";
|
|
@@ -263,7 +295,8 @@ const HANDLERS = {
|
|
|
263
295
|
wouldAdd: { role: req.role, field, value: req.grant },
|
|
264
296
|
preview: `[roles.${req.role}]\n${field} = [ …, "${req.grant}" ] # asked ${req.times}×`,
|
|
265
297
|
ownedBy: req.owners,
|
|
266
|
-
|
|
298
|
+
id: req.key,
|
|
299
|
+
applyWith: `seisin grant ${ref} --reason "<why>"`,
|
|
267
300
|
note: "Not applied. seisin's MCP server writes nothing — a person runs the command above.",
|
|
268
301
|
};
|
|
269
302
|
},
|
|
@@ -284,7 +317,10 @@ function handle(message, { write, version }) {
|
|
|
284
317
|
const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
|
|
285
318
|
const fail = (id, code, msg) => send({ jsonrpc: "2.0", id, error: { code, message: msg } });
|
|
286
319
|
|
|
287
|
-
const { id, method
|
|
320
|
+
const { id, method } = message;
|
|
321
|
+
// `?? {}` so an explicit null params (not just missing) does not crash the
|
|
322
|
+
// dispatcher and leave the request unanswered.
|
|
323
|
+
const params = message.params ?? {};
|
|
288
324
|
|
|
289
325
|
// A notification has no id and takes no answer. Replying to one is a protocol
|
|
290
326
|
// error that some clients tolerate and others hang on.
|
|
@@ -310,8 +346,11 @@ function handle(message, { write, version }) {
|
|
|
310
346
|
case "tools/list":
|
|
311
347
|
return reply(id, { tools: TOOLS });
|
|
312
348
|
case "tools/call": {
|
|
313
|
-
|
|
314
|
-
|
|
349
|
+
// hasOwn, not a bare lookup: `params.name` of "constructor" or
|
|
350
|
+
// "hasOwnProperty" would otherwise resolve to a function on Object's
|
|
351
|
+
// prototype and be invoked as a tool.
|
|
352
|
+
const fn = Object.hasOwn(HANDLERS, params.name) ? HANDLERS[params.name] : null;
|
|
353
|
+
if (typeof fn !== "function") return fail(id, -32602, `unknown tool ${JSON.stringify(params.name)}`);
|
|
315
354
|
try {
|
|
316
355
|
const result = fn(params.arguments ?? {});
|
|
317
356
|
// Content, not a bare object: every client renders `content`, and only
|
package/src/notify.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { readFileSync } from "node:fs";
|
|
26
26
|
import { basename, join } from "node:path";
|
|
27
|
-
import { keyOf, pending, requestsPath, grantFor } from "./requests.js";
|
|
27
|
+
import { keyOf, pending, requestsPath, grantFor, shellId } from "./requests.js";
|
|
28
28
|
|
|
29
29
|
export const FORMATS = ["text", "json", "slack"];
|
|
30
30
|
export const ENV_URL = "SEISIN_NOTIFY_URL";
|
|
@@ -46,13 +46,13 @@ export function message(config, req, number, format = "text") {
|
|
|
46
46
|
const owner = req.owners?.length ? `It belongs to ${req.owners.join(", ")}.` : "Nobody owns it.";
|
|
47
47
|
const text =
|
|
48
48
|
`seisin (${basename(config.root)}): ${req.role} was refused ${req.action} on ${req.target}. ${owner} ` +
|
|
49
|
-
`Approve: seisin grant ${
|
|
49
|
+
`Approve: seisin grant ${shellId(keyOf(req))} · decline: seisin decline ${shellId(keyOf(req))}`;
|
|
50
50
|
if (format === "slack") return { type: "application/json", body: JSON.stringify({ text }) };
|
|
51
51
|
if (format === "json")
|
|
52
52
|
return {
|
|
53
53
|
type: "application/json",
|
|
54
54
|
body: JSON.stringify({
|
|
55
|
-
text, repo: basename(config.root), number, role: req.role, action: req.action,
|
|
55
|
+
text, repo: basename(config.root), number, id: keyOf(req), role: req.role, action: req.action,
|
|
56
56
|
target: req.target, owners: req.owners ?? [], grant: grantFor(req),
|
|
57
57
|
}),
|
|
58
58
|
};
|
package/src/owners.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* agents have to read each other's code to do anything useful. Keys are the
|
|
11
11
|
* exception, and they are handled separately below.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
15
|
import { join } from "node:path";
|
|
15
16
|
import { protectedBy } from "./surface.js";
|
|
16
17
|
|
|
@@ -45,7 +46,7 @@ export function covers(glob, path) {
|
|
|
45
46
|
// The path itself, or anything beneath it. The `/` is load-bearing: without
|
|
46
47
|
// it `src/api` would also cover `src/apifoo.ts`, which the kernel does not.
|
|
47
48
|
if (m.prefix !== null) return p === m.prefix || p.startsWith(m.prefix + "/");
|
|
48
|
-
return m.
|
|
49
|
+
return matchGlob(m.glob, p);
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/**
|
|
@@ -68,7 +69,7 @@ function matcherOf(glob) {
|
|
|
68
69
|
if (m) return m;
|
|
69
70
|
let g = normalize(glob);
|
|
70
71
|
if (glob.endsWith("/")) g += "/**";
|
|
71
|
-
m = WILD.test(g) ? { prefix: null,
|
|
72
|
+
m = WILD.test(g) ? { prefix: null, glob: g } : { prefix: g, glob: null };
|
|
72
73
|
if (GLOBS.size >= MAX_CACHED) GLOBS.clear();
|
|
73
74
|
GLOBS.set(glob, m);
|
|
74
75
|
return m;
|
|
@@ -126,18 +127,66 @@ export function normalize(s) {
|
|
|
126
127
|
* must not: covering one file too many costs an argument, covering one too few
|
|
127
128
|
* is the hole. When the two readings disagree, this one takes the wider set.
|
|
128
129
|
*/
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Does `glob` cover `path`? A linear matcher, not a regular expression.
|
|
132
|
+
*
|
|
133
|
+
* The regex form (`**` → `.*`, `**\/` → `(?:.*\/)?`) put several unbounded `.*`
|
|
134
|
+
* next to literals, and on a long path that does not match, the engine
|
|
135
|
+
* backtracks catastrophically: measured on a crafted glob, 9 `**` took ~26 s
|
|
136
|
+
* and 11 hung for over a minute. Since a request's target — which an agent
|
|
137
|
+
* chooses — becomes a glob here (`grantFor`), that is a denial-of-service on the
|
|
138
|
+
* hook and on `explain`. This matcher is memoised on (glob index, path index),
|
|
139
|
+
* so its cost is bounded by their product and no input backtracks.
|
|
140
|
+
*
|
|
141
|
+
* Same wildcards as before: `?` one non-`/`, `*` a run of non-`/`, `**` any run
|
|
142
|
+
* including `/`, `**\/` zero or more whole directories, a trailing `/**` the
|
|
143
|
+
* subtree. Characters are compared literally; nothing is treated as regex.
|
|
144
|
+
*/
|
|
145
|
+
function matchGlob(glob, path) {
|
|
146
|
+
const G = glob.length, P = path.length;
|
|
147
|
+
// memo[gi * (P+1) + pi]: 0 unknown, 1 true, 2 false. One flat array, reused.
|
|
148
|
+
const memo = new Uint8Array((G + 1) * (P + 1));
|
|
149
|
+
const at = (gi, pi) => {
|
|
150
|
+
for (;;) {
|
|
151
|
+
const key = gi * (P + 1) + pi;
|
|
152
|
+
const seen = memo[key];
|
|
153
|
+
if (seen) return seen === 1;
|
|
154
|
+
let res;
|
|
155
|
+
if (gi === G) { res = pi === P; }
|
|
156
|
+
else if (glob[gi] === "*" && glob[gi + 1] === "*") {
|
|
157
|
+
if (glob[gi + 2] === "/") {
|
|
158
|
+
// `**/` = zero or more whole directories: empty, or any chars ending
|
|
159
|
+
// at a `/` then the rest.
|
|
160
|
+
if (at(gi + 3, pi)) res = true;
|
|
161
|
+
else if (pi < P) {
|
|
162
|
+
if (path[pi] === "/" && at(gi + 3, pi + 1)) res = true;
|
|
163
|
+
else { memo[key] = 2; pi++; continue; } // consume one char, stay on `**/`
|
|
164
|
+
} else res = false;
|
|
165
|
+
} else {
|
|
166
|
+
// `**` = any run including `/` (also covers `**` at the very end,
|
|
167
|
+
// where `at(gi+2, pi)` requires pi to reach the end).
|
|
168
|
+
if (at(gi + 2, pi)) res = true;
|
|
169
|
+
else if (pi < P) { memo[key] = 2; pi++; continue; } // consume one char, stay on `**`
|
|
170
|
+
else res = false;
|
|
171
|
+
}
|
|
172
|
+
} else if (glob[gi] === "/" && glob[gi + 1] === "*" && glob[gi + 2] === "*" && gi + 3 === G) {
|
|
173
|
+
// trailing `/**`: the directory itself, or anything beneath it.
|
|
174
|
+
res = pi === P || path[pi] === "/";
|
|
175
|
+
} else if (glob[gi] === "*") {
|
|
176
|
+
// `*` = a run of non-`/`.
|
|
177
|
+
if (at(gi + 1, pi)) res = true;
|
|
178
|
+
else if (pi < P && path[pi] !== "/") { memo[key] = 2; pi++; continue; }
|
|
179
|
+
else res = false;
|
|
180
|
+
} else if (glob[gi] === "?") {
|
|
181
|
+
res = pi < P && path[pi] !== "/" && at(gi + 1, pi + 1);
|
|
182
|
+
} else {
|
|
183
|
+
res = pi < P && glob[gi] === path[pi] && at(gi + 1, pi + 1);
|
|
184
|
+
}
|
|
185
|
+
memo[key] = res ? 1 : 2;
|
|
186
|
+
return res;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
return at(0, 0);
|
|
141
190
|
}
|
|
142
191
|
|
|
143
192
|
/** Is this path inside a `.git` directory (git's metadata, not a working file)? */
|
|
@@ -166,6 +215,82 @@ export function ownersOf(config, path) {
|
|
|
166
215
|
.map((r) => r.name);
|
|
167
216
|
}
|
|
168
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Where a refused target stands against the policy as it is now.
|
|
220
|
+
*
|
|
221
|
+
* One of four, and only the last one is a decision somebody has to make:
|
|
222
|
+
*
|
|
223
|
+
* outside not a place in the map at all — a port, a key directory, or a
|
|
224
|
+
* path outside the repository, which no `writes` reaches
|
|
225
|
+
* protected closed to every role on purpose (surface.js); the boundary
|
|
226
|
+
* working, with nothing to grant
|
|
227
|
+
* owned some role's territory today
|
|
228
|
+
* unowned in the repository, ownable, and nobody claims it
|
|
229
|
+
*
|
|
230
|
+
* Computed from the policy, never from the `owners` a log line carries. That
|
|
231
|
+
* value is what was true the day of the refusal; every grant made since makes
|
|
232
|
+
* it more wrong, so a screen reading it gets worse the more seisin is used the
|
|
233
|
+
* way it is meant to be. Measured on one deployment: the console said 1,178 of
|
|
234
|
+
* 1,486 refused paths had no owner; against the policy of that day it was 316.
|
|
235
|
+
* The rest had been claimed since, were protected, or were not paths at all.
|
|
236
|
+
*/
|
|
237
|
+
export function standing(config, entry) {
|
|
238
|
+
const target = entry.target;
|
|
239
|
+
if (entry.kind === "network")
|
|
240
|
+
return { kind: "outside", owners: [], why: "a port or a socket, which no role owns" };
|
|
241
|
+
if (entry.kind === "key")
|
|
242
|
+
return { kind: "outside", owners: [], why: "a key directory, closed on purpose" };
|
|
243
|
+
const guard = protectedBy(config, target);
|
|
244
|
+
if (guard) return { kind: "protected", owners: [], why: guard.why };
|
|
245
|
+
if (target.startsWith("/")) {
|
|
246
|
+
const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
|
|
247
|
+
if (!config.root || !target.startsWith(root))
|
|
248
|
+
return { kind: "outside", owners: [], why: "outside the repository, where no role's territory reaches" };
|
|
249
|
+
}
|
|
250
|
+
const owners = ownersOf(config, target);
|
|
251
|
+
return { kind: owners.length ? "owned" : "unowned", owners };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* `standing`, remembered while the policy stays the same.
|
|
256
|
+
*
|
|
257
|
+
* The console asks for its whole state every two seconds, and a real log has
|
|
258
|
+
* well over a thousand distinct refused paths: classifying all of them costs
|
|
259
|
+
* most of a second each time (measured: ~400 ms in protectedBy, ~335 ms in
|
|
260
|
+
* ownersOf), on a call that already took about that long. What a path's
|
|
261
|
+
* standing depends on is the policy file, so that is the key. It also depends,
|
|
262
|
+
* a little, on the disk — on Linux a control file is protected only once it
|
|
263
|
+
* exists — so the memory is dropped after a minute as well, which bounds how
|
|
264
|
+
* stale an answer can be without paying for it on every poll.
|
|
265
|
+
*
|
|
266
|
+
* A config that was not read from a file (tests, embedders) gets a memory for
|
|
267
|
+
* this call only.
|
|
268
|
+
*/
|
|
269
|
+
const STANDING_TTL = 60_000;
|
|
270
|
+
let standingMemo = { key: null, at: 0, map: new Map() };
|
|
271
|
+
|
|
272
|
+
export function standingOf(config, { now = Date.now() } = {}) {
|
|
273
|
+
let map;
|
|
274
|
+
let key = null;
|
|
275
|
+
try {
|
|
276
|
+
if (config.path) key = `${config.path}\u0000${createHash("sha256").update(readFileSync(config.path)).digest("hex")}`;
|
|
277
|
+
} catch {
|
|
278
|
+
key = null;
|
|
279
|
+
}
|
|
280
|
+
if (key === null) map = new Map();
|
|
281
|
+
else {
|
|
282
|
+
if (standingMemo.key !== key || now - standingMemo.at > STANDING_TTL)
|
|
283
|
+
standingMemo = { key, at: now, map: new Map() };
|
|
284
|
+
map = standingMemo.map;
|
|
285
|
+
}
|
|
286
|
+
return (entry) => {
|
|
287
|
+
const k = `${entry.kind ?? ""}\u0000${entry.target}`;
|
|
288
|
+
let s = map.get(k);
|
|
289
|
+
if (!s) map.set(k, (s = standing(config, entry)));
|
|
290
|
+
return s;
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
169
294
|
/**
|
|
170
295
|
* The `never_writes` entries the kernel will actually be given for this role.
|
|
171
296
|
*
|
package/src/render.js
CHANGED
|
@@ -25,6 +25,17 @@ export const C =
|
|
|
25
25
|
}
|
|
26
26
|
: { dim: "", b: "", off: "", red: "", green: "", yellow: "", blue: "" };
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A value chosen by an agent (a target, a grant, an id) made safe to print.
|
|
30
|
+
*
|
|
31
|
+
* `target` and the id built from it are strings the agent picked, and they were
|
|
32
|
+
* written to the terminal raw. Terminal escape sequences (ESC, CR, other C0
|
|
33
|
+
* controls) let such a string repaint the line a person reads before approving.
|
|
34
|
+
* On disk they are already JSON-escaped; this is for the screen. Stripped, not
|
|
35
|
+
* rendered, so what shows is the literal path with the control bytes removed.
|
|
36
|
+
*/
|
|
37
|
+
export const safe = (s) => String(s ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
|
38
|
+
|
|
28
39
|
export const out = (s) => process.stdout.write(s);
|
|
29
40
|
export const err = (s) => process.stderr.write(s);
|
|
30
41
|
|
|
@@ -125,7 +136,7 @@ export function renderReport(report) {
|
|
|
125
136
|
/** The `explain` answer, as text. Takes what `owners.explain()` returns. */
|
|
126
137
|
export function renderVerdict(role, action, target, verdict) {
|
|
127
138
|
const head = verdict.allowed ? `${C.green}allowed${C.off}` : `${C.yellow}denied${C.off}`;
|
|
128
|
-
return `\n ${head} ${C.b}${role}${C.off} ${action} ${target}\n ${C.dim}${verdict.reason}${C.off}\n\n`;
|
|
139
|
+
return `\n ${head} ${C.b}${role}${C.off} ${action} ${safe(target)}\n ${C.dim}${safe(verdict.reason)}${C.off}\n\n`;
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
/* ── the log ──────────────────────────────────────────────────────────── */
|
|
@@ -149,7 +160,7 @@ export function renderEntry(e) {
|
|
|
149
160
|
// about a run, a reader needs to know which line is which without going to
|
|
150
161
|
// the file — so the one that carries more weight is the one that is marked.
|
|
151
162
|
const from = e.source === "kernel" ? ` ${C.dim}[kernel]${C.off}` : "";
|
|
152
|
-
return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${e.action} ${e.target}${owners}${from}\n`;
|
|
163
|
+
return ` ${C.dim}${(e.at ?? "").slice(11, 19)}${C.off} ${mark} ${C.b}${e.role}${C.off} ${e.action} ${safe(e.target)}${owners}${from}\n`;
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
/* ── the scan ─────────────────────────────────────────────────────────── */
|
package/src/requests.js
CHANGED
|
@@ -27,10 +27,11 @@
|
|
|
27
27
|
* deleted — it is followed by a line saying what happened to it.
|
|
28
28
|
*/
|
|
29
29
|
import { neverWrites, isGitMetadata, covers } from "./owners.js";
|
|
30
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
30
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
31
31
|
import { dirname, join } from "node:path";
|
|
32
32
|
import { STATE_DIR, tomlString } from "./layout.js";
|
|
33
33
|
import { send } from "./spool.js";
|
|
34
|
+
import { withLock } from "./log.js";
|
|
34
35
|
|
|
35
36
|
export const REQUESTS_NAME = "requests.jsonl";
|
|
36
37
|
|
|
@@ -57,6 +58,17 @@ export function keyOf({ role, action, target }) {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** The glob a grant would add, derived from what was asked. */
|
|
61
|
+
/**
|
|
62
|
+
* A request's id as one shell word, for the commands seisin suggests.
|
|
63
|
+
*
|
|
64
|
+
* The id carries a path an agent chose, so it is single-quoted whole and any
|
|
65
|
+
* quote inside it is closed and escaped: pasting the suggestion runs `seisin
|
|
66
|
+
* grant` on that request and nothing else.
|
|
67
|
+
*/
|
|
68
|
+
export function shellId(key) {
|
|
69
|
+
return `'${String(key).replace(/'/g, `'\\''`)}'`;
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
export function grantFor({ action, target }) {
|
|
61
73
|
const t = typeof target === "string" ? target : "";
|
|
62
74
|
// A key keeps its directory when it has one. Stripping it turned a request
|
|
@@ -283,6 +295,32 @@ export function refuseIfBarred(config, request) {
|
|
|
283
295
|
`rewrite another owner's history. Decline it; a role that needs to commit gets a worktree of its own.`);
|
|
284
296
|
}
|
|
285
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Apply one policy edit under a lock, and write it atomically.
|
|
300
|
+
*
|
|
301
|
+
* `grant` and the console both did read → applyGrant → write with no lock, so
|
|
302
|
+
* two approvals racing (a person at the CLI and one on the console, or two
|
|
303
|
+
* terminals) lost one edit: the queue marked both granted while `seisin.toml`
|
|
304
|
+
* kept only the last writer's change (measured: 39 of 40 concurrent pairs). The
|
|
305
|
+
* lock serialises them — it already survives a writer that died holding it
|
|
306
|
+
* (see log.js) — and the write goes to a temp file then renames over the config,
|
|
307
|
+
* so a crash mid-write cannot leave a half-written, partly-privileged policy.
|
|
308
|
+
*
|
|
309
|
+
* `mutate(toml)` returns `{ toml, changed }`. Nothing is written when unchanged.
|
|
310
|
+
*/
|
|
311
|
+
export function editPolicy(config, mutate, { waitMs = 10000 } = {}) {
|
|
312
|
+
return withLock(config.path, () => {
|
|
313
|
+
const before = readFileSync(config.path, "utf8");
|
|
314
|
+
const result = mutate(before);
|
|
315
|
+
if (result.changed) {
|
|
316
|
+
const tmp = `${config.path}.tmp-${process.pid}`;
|
|
317
|
+
writeFileSync(tmp, result.toml);
|
|
318
|
+
renameSync(tmp, config.path);
|
|
319
|
+
}
|
|
320
|
+
return result;
|
|
321
|
+
}, { waitMs });
|
|
322
|
+
}
|
|
323
|
+
|
|
286
324
|
export function applyGrant(toml, request, note = "") {
|
|
287
325
|
const field = request.action === "read" ? "keys" : "writes";
|
|
288
326
|
const header = new RegExp(`^\\[roles\\.${escapeRe(request.role)}\\]\\s*$`, "m");
|
|
@@ -295,9 +333,8 @@ export function applyGrant(toml, request, note = "") {
|
|
|
295
333
|
const to = next ? from + next.index : toml.length;
|
|
296
334
|
const section = toml.slice(from, to);
|
|
297
335
|
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
if (!m)
|
|
336
|
+
const open = new RegExp(`^${field}\\s*=\\s*\\[`, "m").exec(section);
|
|
337
|
+
if (!open)
|
|
301
338
|
throw new Error(
|
|
302
339
|
`[roles.${request.role}] has no ${field} list to grant into. ` +
|
|
303
340
|
`Add \`${field} = []\` to that section first — seisin will not write it into another role's block.`
|
|
@@ -309,20 +346,60 @@ export function applyGrant(toml, request, note = "") {
|
|
|
309
346
|
// loads, after they approved something, is its own kind of broken.
|
|
310
347
|
tomlString(request.grant); // refuses what the format cannot hold
|
|
311
348
|
|
|
312
|
-
const
|
|
313
|
-
|
|
349
|
+
const start = open.index + open[0].length;
|
|
350
|
+
const list = scanList(section, start);
|
|
351
|
+
if (list.items.includes(request.grant)) return { toml, changed: false };
|
|
314
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Appended, never rebuilt.
|
|
355
|
+
*
|
|
356
|
+
* The list used to be re-written from every quoted string between its
|
|
357
|
+
* brackets — comments included. `"src/web/**", # was "src/**"` came back as
|
|
358
|
+
* two grants, so approving anything also granted what a comment mentioned,
|
|
359
|
+
* and the reason an approver typed (quoted in the provenance comment) became
|
|
360
|
+
* a path on the next approval. Every earlier provenance comment was lost on
|
|
361
|
+
* the way. Now the items are the strings outside comments, what is there is
|
|
362
|
+
* left as it is, and the new entry goes last. The reason is set off with
|
|
363
|
+
* «», never a double quote, so no older reader can take it for a value.
|
|
364
|
+
*/
|
|
365
|
+
let inner = section.slice(start, list.end).replace(/\s+$/, "");
|
|
366
|
+
if (list.lastItemEnd !== null && !list.commaAfterLast) {
|
|
367
|
+
const at = list.lastItemEnd - start;
|
|
368
|
+
inner = inner.slice(0, at) + "," + inner.slice(at);
|
|
369
|
+
}
|
|
315
370
|
const stamp = `# granted ${new Date().toISOString().slice(0, 10)} · asked ${request.times}×` +
|
|
316
|
-
`${note ? ` ·
|
|
317
|
-
const
|
|
318
|
-
.map((v) => ` "${v}"${v === request.grant ? `, ${stamp}` : ","}`)
|
|
319
|
-
.join("\n")
|
|
320
|
-
.replace(/,(\s*#[^\n]*)?$/, "$1");
|
|
321
|
-
|
|
322
|
-
const edited = section.replace(line, `$1[\n${body}\n]`);
|
|
371
|
+
`${note ? ` · «${cleanReason(note)}»` : ""}`;
|
|
372
|
+
const edited = section.slice(0, start) + `${inner}\n "${request.grant}" ${stamp}\n]` + section.slice(list.end + 1);
|
|
323
373
|
return { toml: toml.slice(0, from) + edited + toml.slice(to), changed: true };
|
|
324
374
|
}
|
|
325
375
|
|
|
376
|
+
/**
|
|
377
|
+
* The strings of a TOML array that starts at `start` (just past its `[`),
|
|
378
|
+
* skipping comments, and where it closes. The subset has no escapes, so a
|
|
379
|
+
* string runs to the next double quote.
|
|
380
|
+
*/
|
|
381
|
+
function scanList(text, start) {
|
|
382
|
+
const items = [];
|
|
383
|
+
let lastItemEnd = null;
|
|
384
|
+
let commaAfterLast = false;
|
|
385
|
+
for (let i = start; i < text.length; i++) {
|
|
386
|
+
const c = text[i];
|
|
387
|
+
if (c === "#") { const nl = text.indexOf("\n", i); if (nl === -1) break; i = nl; continue; }
|
|
388
|
+
if (c === '"') {
|
|
389
|
+
const close = text.indexOf('"', i + 1);
|
|
390
|
+
if (close === -1) break;
|
|
391
|
+
items.push(text.slice(i + 1, close));
|
|
392
|
+
lastItemEnd = close + 1;
|
|
393
|
+
commaAfterLast = false;
|
|
394
|
+
i = close;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (c === ",") { commaAfterLast = true; continue; }
|
|
398
|
+
if (c === "]") return { items, end: i, lastItemEnd, commaAfterLast };
|
|
399
|
+
}
|
|
400
|
+
throw new Error(`the ${text.slice(0, 40).trim()}… list never closes; fix the config by hand before granting`);
|
|
401
|
+
}
|
|
402
|
+
|
|
326
403
|
/**
|
|
327
404
|
* The approver's own words, made safe to put in a config file.
|
|
328
405
|
*
|
package/src/review.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* means nothing without "…in how long".
|
|
29
29
|
*/
|
|
30
30
|
import { read, logPath } from "./log.js";
|
|
31
|
-
import { covers,
|
|
31
|
+
import { covers, standingOf } from "./owners.js";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* One reading of the log against the policy.
|
|
@@ -122,7 +122,10 @@ function findings(config, entries, minDenials) {
|
|
|
122
122
|
* than full — a consumer that has never heard of `unusedKnowable` then reports
|
|
123
123
|
* nothing instead of everything, which is the direction this has to fail in.
|
|
124
124
|
*/
|
|
125
|
-
|
|
125
|
+
// A disputed line is the parent saying the verdict did not match the policy
|
|
126
|
+
// — often a forged line. It is not evidence of legitimate use, so it neither
|
|
127
|
+
// makes the window knowable nor marks a territory used.
|
|
128
|
+
const knowable = entries.some((e) => e.verdict === "allowed" && !e.disputed);
|
|
126
129
|
|
|
127
130
|
// Only writes. A key that is declared and not read is ordinary — most roles
|
|
128
131
|
// hold a credential for the one turn a month that needs it — but a folder a
|
|
@@ -130,7 +133,7 @@ function findings(config, entries, minDenials) {
|
|
|
130
133
|
// miss, and the only evidence anyone will ever have for removing it.
|
|
131
134
|
const used = new Set();
|
|
132
135
|
for (const e of entries) {
|
|
133
|
-
if (e.verdict !== "allowed" || e.action !== "write" || !e.target) continue;
|
|
136
|
+
if (e.verdict !== "allowed" || e.disputed || e.action !== "write" || !e.target) continue;
|
|
134
137
|
for (const role of Object.values(config.roles))
|
|
135
138
|
for (const glob of role.writes)
|
|
136
139
|
if (role.name === e.role && covers(glob, e.target)) used.add(`${role.name} ${glob}`);
|
|
@@ -144,12 +147,17 @@ function findings(config, entries, minDenials) {
|
|
|
144
147
|
|
|
145
148
|
/* ── 3. what nobody owns ────────────────────────────────────────────── */
|
|
146
149
|
|
|
150
|
+
// The same classification the console and `seisin_causes` use, so the word
|
|
151
|
+
// means one thing everywhere. A key directory is closed on purpose, a port is
|
|
152
|
+
// not a path, a protected file is closed to every role and a path outside
|
|
153
|
+
// the repository is outside every territory: none of them is a hole in the
|
|
154
|
+
// map. This used to skip keys and ports and count the rest, protected ones
|
|
155
|
+
// included, while the console counted all four — one word, two numbers.
|
|
147
156
|
const unowned = new Map();
|
|
157
|
+
const standOf = standingOf(config);
|
|
148
158
|
for (const e of entries) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (!e.target || e.kind === "key" || e.kind === "network") continue;
|
|
152
|
-
if (ownersOf(config, e.target).length) continue;
|
|
159
|
+
if (!e.target) continue;
|
|
160
|
+
if (standOf(e).kind !== "unowned") continue;
|
|
153
161
|
const dir = dirOf(e.target);
|
|
154
162
|
unowned.set(dir, (unowned.get(dir) ?? 0) + 1);
|
|
155
163
|
}
|