seisin 0.1.1 → 0.3.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 +287 -339
- package/package.json +7 -6
- package/src/cli.js +17 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/init.js +1 -1
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +8 -3
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +180 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +30 -0
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +310 -5
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +45 -5
- package/src/index.js +12 -0
- package/src/inspect.js +332 -9
- package/src/intake.js +215 -0
- package/src/keys.js +585 -0
- package/src/log.js +96 -2
- package/src/mcp.js +87 -11
- package/src/nested.js +86 -0
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +63 -1
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +263 -19
- package/src/spool.js +98 -20
- package/src/srt.js +148 -203
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +184 -0
- package/ui/favicon.svg +1 -1
- package/ui/index.html +585 -56
package/src/log.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* tries the parent's socket first for exactly that reason; writing the file
|
|
14
14
|
* directly is what happens when there is no parent, outside the box.
|
|
15
15
|
*/
|
|
16
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
16
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
17
18
|
import { dirname, join } from "node:path";
|
|
18
19
|
import { STATE_DIR, LOG_NAME } from "./layout.js";
|
|
19
20
|
import { send } from "./spool.js";
|
|
@@ -37,13 +38,106 @@ export function append(file, entry) {
|
|
|
37
38
|
if (send("log", entry)) return true;
|
|
38
39
|
try {
|
|
39
40
|
mkdirSync(dirname(file), { recursive: true });
|
|
40
|
-
|
|
41
|
+
withLock(file, () => {
|
|
42
|
+
// `prev` goes last and is computed from the previous line exactly as it
|
|
43
|
+
// sits on disk, so verifying needs nothing but the file.
|
|
44
|
+
const line = JSON.stringify({ at: new Date().toISOString(), ...entry, prev: hashOf(lastLine(file)) });
|
|
45
|
+
appendFileSync(file, line + "\n");
|
|
46
|
+
});
|
|
41
47
|
return true;
|
|
42
48
|
} catch {
|
|
43
49
|
return false;
|
|
44
50
|
}
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The log is chained: every line carries `prev`, the hash of the line before it.
|
|
55
|
+
*
|
|
56
|
+
* The log is the record of who touched what and who was refused what, and it
|
|
57
|
+
* could be edited without a trace. A chain does not stop anyone with write
|
|
58
|
+
* access from rewriting it — nothing inside a file can — but it makes an edit,
|
|
59
|
+
* a deletion or a reordering show, and `seisin log verify` says where. Taken
|
|
60
|
+
* from nono's audit trail; the Merkle root and the signature it adds on top
|
|
61
|
+
* are left for when someone asks for them.
|
|
62
|
+
*
|
|
63
|
+
* The first chained line of a file points at GENESIS. A file started by
|
|
64
|
+
* rotation should point at the last line of the one before it; there is no
|
|
65
|
+
* rotation yet, and when there is, that is what it has to do.
|
|
66
|
+
*/
|
|
67
|
+
export const GENESIS = "0".repeat(32);
|
|
68
|
+
|
|
69
|
+
export function hashOf(line) {
|
|
70
|
+
return line == null ? GENESIS : createHash("sha256").update(line).digest("hex").slice(0, 32);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The last complete line of the file, or null when there is none. */
|
|
74
|
+
function lastLine(file) {
|
|
75
|
+
if (!existsSync(file)) return null;
|
|
76
|
+
const { size } = statSync(file);
|
|
77
|
+
if (size === 0) return null;
|
|
78
|
+
const from = Math.max(0, size - 64 * 1024);
|
|
79
|
+
const fd = openSync(file, "r");
|
|
80
|
+
try {
|
|
81
|
+
const buf = Buffer.alloc(size - from);
|
|
82
|
+
readSync(fd, buf, 0, buf.length, from);
|
|
83
|
+
const lines = buf.toString("utf8").split("\n").filter((l) => l.trim());
|
|
84
|
+
return lines.length ? lines[lines.length - 1] : null;
|
|
85
|
+
} finally {
|
|
86
|
+
closeSync(fd);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One writer at a time. Several roles of a round run at once and write this
|
|
92
|
+
* same file; two of them reading the same last line would fork the chain.
|
|
93
|
+
* A lock left by a killed process is taken over after a few seconds.
|
|
94
|
+
*/
|
|
95
|
+
function withLock(file, fn) {
|
|
96
|
+
const lock = file + ".lock";
|
|
97
|
+
const deadline = Date.now() + 2000;
|
|
98
|
+
for (;;) {
|
|
99
|
+
try {
|
|
100
|
+
closeSync(openSync(lock, "wx"));
|
|
101
|
+
break;
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (e.code !== "EEXIST") throw e;
|
|
104
|
+
try { if (Date.now() - statSync(lock).mtimeMs > 5000) { unlinkSync(lock); continue; } } catch {}
|
|
105
|
+
if (Date.now() > deadline) break; // never block the run over bookkeeping
|
|
106
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
try { return fn(); } finally { try { unlinkSync(lock); } catch {} }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Walk the file and say where the chain breaks.
|
|
114
|
+
*
|
|
115
|
+
* Lines written before the chain existed have no `prev`; they are counted as an
|
|
116
|
+
* unchained prefix, not reported as tampering. After the first chained line,
|
|
117
|
+
* a line without `prev` is itself a break.
|
|
118
|
+
*/
|
|
119
|
+
export function verifyChain(file) {
|
|
120
|
+
const out = { lines: 0, unchained: 0, chained: 0, breaks: [] };
|
|
121
|
+
if (!existsSync(file)) return out;
|
|
122
|
+
let previous = null;
|
|
123
|
+
let started = false;
|
|
124
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
125
|
+
if (!line.trim()) continue;
|
|
126
|
+
out.lines++;
|
|
127
|
+
let e = null;
|
|
128
|
+
try { e = JSON.parse(line); } catch {}
|
|
129
|
+
const prev = e?.prev;
|
|
130
|
+
if (!started && prev === undefined) { out.unchained++; previous = line; continue; }
|
|
131
|
+
started = true;
|
|
132
|
+
const expected = out.chained === 0 && out.unchained === 0 ? GENESIS : hashOf(previous);
|
|
133
|
+
const ok = prev === expected || (out.chained === 0 && prev === hashOf(previous));
|
|
134
|
+
if (!ok) out.breaks.push({ line: out.lines, expected, found: prev ?? null });
|
|
135
|
+
out.chained++;
|
|
136
|
+
previous = line;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
47
141
|
/** Reads entries, newest last. A malformed line is skipped, never fatal. */
|
|
48
142
|
export function read(file, { role, verdict, since, limit = 0 } = {}) {
|
|
49
143
|
if (!existsSync(file)) return [];
|
package/src/mcp.js
CHANGED
|
@@ -26,12 +26,15 @@
|
|
|
26
26
|
* The cost of this choice is written down under PROTOCOLS below: we track the
|
|
27
27
|
* spec by hand, and the spec moves.
|
|
28
28
|
*/
|
|
29
|
+
import { toRepoRelative } from "./paths.js";
|
|
29
30
|
import { loadConfig, findConfig } from "./config.js";
|
|
30
31
|
import { inspect } from "./inspect.js";
|
|
31
32
|
import { explain, ownersOf } from "./owners.js";
|
|
32
33
|
import { settingsFor } from "./srt.js";
|
|
33
|
-
import { pending, requestsPath, grantFor } from "./requests.js";
|
|
34
|
+
import { pending, requestsPath, grantFor, refuseIfBarred, markStale } from "./requests.js";
|
|
34
35
|
import { read, logPath } from "./log.js";
|
|
36
|
+
import { causesOf } from "./serve.js";
|
|
37
|
+
import { walls, wasted } from "./walls.js";
|
|
35
38
|
|
|
36
39
|
/**
|
|
37
40
|
* Versions this server will agree to.
|
|
@@ -60,14 +63,14 @@ const TOOLS = [
|
|
|
60
63
|
{
|
|
61
64
|
name: "seisin_explain",
|
|
62
65
|
description:
|
|
63
|
-
"Whether a role may read or write a path, and whose it is if not
|
|
64
|
-
"Use this before suggesting an agent touch a file. Read-only.",
|
|
66
|
+
"Whether a role may read or write a path, and whose it is if not; or, with action mcp, " +
|
|
67
|
+
"whether it may load an MCP server. Use this before suggesting an agent touch a file. Read-only.",
|
|
65
68
|
inputSchema: {
|
|
66
69
|
type: "object",
|
|
67
70
|
properties: {
|
|
68
71
|
role: { type: "string" },
|
|
69
|
-
action: { type: "string", enum: ["read", "write"] },
|
|
70
|
-
target: { type: "string", description: "path
|
|
72
|
+
action: { type: "string", enum: ["read", "write", "mcp"] },
|
|
73
|
+
target: { type: "string", description: "path; key filename for a read; server name for mcp" },
|
|
71
74
|
},
|
|
72
75
|
required: ["role", "action", "target"],
|
|
73
76
|
},
|
|
@@ -93,13 +96,35 @@ const TOOLS = [
|
|
|
93
96
|
},
|
|
94
97
|
},
|
|
95
98
|
},
|
|
99
|
+
{
|
|
100
|
+
name: "seisin_causes",
|
|
101
|
+
description:
|
|
102
|
+
"Denials grouped by what was denied — by path, and one level coarser by the name at " +
|
|
103
|
+
"the end of it — with how many are on paths no role owns. This is the question " +
|
|
104
|
+
"`seisin_activity` cannot answer: the raw log has the events, this has them counted " +
|
|
105
|
+
"against the policy as it stands, so a cause that has been granted since stops " +
|
|
106
|
+
"counting. Reach for it before proposing a change: a day that is three quarters one " +
|
|
107
|
+
"filename is a tooling problem, and the same volume spread across unrelated paths is " +
|
|
108
|
+
"a question about who owns what. Read-only.",
|
|
109
|
+
inputSchema: { type: "object", properties: { limit: { type: "number" } } },
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "seisin_walls",
|
|
113
|
+
description:
|
|
114
|
+
"What a role keeps being denied AND would still be denied today, with the calls it " +
|
|
115
|
+
"spent retrying. Recomputed against the policy rather than read out of the log, so " +
|
|
116
|
+
"something granted since is not listed. Use it to find out whether an agent is stuck " +
|
|
117
|
+
"rather than slow. Omit `role` for every role that has one. Read-only.",
|
|
118
|
+
inputSchema: { type: "object", properties: { role: { type: "string" } } },
|
|
119
|
+
},
|
|
96
120
|
{
|
|
97
121
|
name: "seisin_draft_grant",
|
|
98
122
|
description:
|
|
99
123
|
"Draft the change a pending request would make, as text, plus the command a person " +
|
|
100
124
|
"runs to apply it. Reach for this after a refusal: the denial is already in " +
|
|
101
125
|
"seisin_requests, and this turns it into a concrete proposal someone can approve. " +
|
|
102
|
-
"
|
|
126
|
+
"Read-only, and it is the tool where that matters most: the name says grant and it " +
|
|
127
|
+
"does not grant. The approval is a human action in another channel.",
|
|
103
128
|
inputSchema: {
|
|
104
129
|
type: "object",
|
|
105
130
|
properties: { number: { type: "number", description: "position in seisin_requests" } },
|
|
@@ -139,24 +164,27 @@ const HANDLERS = {
|
|
|
139
164
|
// that was not "read" fell into the write branch — including a typo, which
|
|
140
165
|
// would answer the wrong question confidently. A declared schema that is
|
|
141
166
|
// not enforced is documentation.
|
|
142
|
-
if (action !== "read" && action !== "write")
|
|
143
|
-
throw new Error(`action must be "read" or "
|
|
167
|
+
if (action !== "read" && action !== "write" && action !== "mcp")
|
|
168
|
+
throw new Error(`action must be "read", "write" or "mcp", got ${JSON.stringify(action)}`);
|
|
144
169
|
if (typeof target !== "string" || !target)
|
|
145
170
|
throw new Error("target must be a non-empty path");
|
|
146
171
|
const cfg = config();
|
|
147
172
|
if (!cfg.roles[role])
|
|
148
173
|
return { error: `unknown role "${role}"`, known: Object.keys(cfg.roles) };
|
|
149
|
-
|
|
150
|
-
|
|
174
|
+
if (action === "mcp") return explain(cfg, role, "mcp", target);
|
|
175
|
+
const rel = toRepoRelative(cfg, target);
|
|
176
|
+
const verdict = explain(cfg, role, action, rel);
|
|
177
|
+
return { ...verdict, alsoOwnedBy: action === "write" ? ownersOf(cfg, rel) : undefined };
|
|
151
178
|
},
|
|
152
179
|
|
|
153
180
|
seisin_requests() {
|
|
154
181
|
const cfg = config();
|
|
155
|
-
const queue = pending(requestsPath(cfg.root));
|
|
182
|
+
const queue = markStale(pending(requestsPath(cfg.root)), read(logPath(cfg.root)));
|
|
156
183
|
return {
|
|
157
184
|
pending: queue.map((r, i) => ({
|
|
158
185
|
number: i + 1, role: r.role, action: r.action,
|
|
159
186
|
wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
|
|
187
|
+
...(r.stale ? { stale: `not asked again in ${r.stale.runs} runs of ${r.role} since` } : {}),
|
|
160
188
|
})),
|
|
161
189
|
// Said in the payload and not only in the tool description, because a
|
|
162
190
|
// model reading this is deciding what to do next.
|
|
@@ -166,6 +194,40 @@ const HANDLERS = {
|
|
|
166
194
|
};
|
|
167
195
|
},
|
|
168
196
|
|
|
197
|
+
/**
|
|
198
|
+
* The same arithmetic the console's front page does.
|
|
199
|
+
*
|
|
200
|
+
* It lives here as well as there because the two readers are different and
|
|
201
|
+
* neither can do the other's work: a person opens the console, an agent
|
|
202
|
+
* calls this. Giving the agent only `seisin_activity` hands it the raw log
|
|
203
|
+
* and asks it to re-derive the grouping — without the policy, which is the
|
|
204
|
+
* half that makes the grouping mean anything.
|
|
205
|
+
*/
|
|
206
|
+
seisin_causes({ limit }) {
|
|
207
|
+
const cfg = config();
|
|
208
|
+
const c = causesOf(cfg, read(logPath(cfg.root), { limit: 4000 }));
|
|
209
|
+
return {
|
|
210
|
+
total: c.total,
|
|
211
|
+
distinct: c.distinct,
|
|
212
|
+
unowned: c.unowned,
|
|
213
|
+
families: c.families,
|
|
214
|
+
causes: typeof limit === "number" ? c.causes.slice(0, limit) : c.causes,
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
seisin_walls({ role }) {
|
|
219
|
+
const cfg = config();
|
|
220
|
+
const file = logPath(cfg.root);
|
|
221
|
+
const names = role ? [role] : Object.keys(cfg.roles);
|
|
222
|
+
if (role && !cfg.roles[role]) throw new Error(`unknown role "${role}"`);
|
|
223
|
+
const out = {};
|
|
224
|
+
for (const r of names) {
|
|
225
|
+
const w = walls(cfg, r, { file });
|
|
226
|
+
if (w.length) out[r] = { walls: w, spentRetrying: wasted(w) };
|
|
227
|
+
}
|
|
228
|
+
return { roles: out, spentRetrying: Object.values(out).reduce((n, x) => n + x.spentRetrying, 0) };
|
|
229
|
+
},
|
|
230
|
+
|
|
169
231
|
seisin_activity({ role, verdict, limit }) {
|
|
170
232
|
if (verdict !== undefined && !["allowed", "denied", "observed"].includes(verdict))
|
|
171
233
|
throw new Error(`verdict must be allowed, denied or observed, got ${JSON.stringify(verdict)}`);
|
|
@@ -182,6 +244,20 @@ const HANDLERS = {
|
|
|
182
244
|
const req = pending(requestsPath(cfg.root))[Number(number) - 1];
|
|
183
245
|
if (!req) throw new Error(`no pending request #${number}`);
|
|
184
246
|
|
|
247
|
+
// The same refusal `seisin grant` and the console apply, asked here first.
|
|
248
|
+
// Without it this drafted, as approvable, a request that `never_writes`
|
|
249
|
+
// cancels — and `grant` then refused what the draft had offered.
|
|
250
|
+
try {
|
|
251
|
+
refuseIfBarred(cfg, req);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
return {
|
|
254
|
+
barred: e.message,
|
|
255
|
+
ownedBy: req.owners,
|
|
256
|
+
applyWith: null,
|
|
257
|
+
note: "Not approvable as it stands: never_writes wins over writes. Decline it with " +
|
|
258
|
+
`\`seisin decline ${number}\`, or remove the never_writes entry if the subtraction is wrong.`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
185
261
|
const field = req.action === "read" ? "keys" : "writes";
|
|
186
262
|
return {
|
|
187
263
|
wouldAdd: { role: req.role, field, value: req.grant },
|
package/src/nested.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agents that sandbox themselves, and why seisin has to say so first.
|
|
3
|
+
*
|
|
4
|
+
* `codex` confines every command it runs with its own `sandbox-exec` profile.
|
|
5
|
+
* macOS will not apply a second Seatbelt profile to a process that already has
|
|
6
|
+
* one, so inside `seisin run` it fails with:
|
|
7
|
+
*
|
|
8
|
+
* sandbox-exec: sandbox_apply: Operation not permitted
|
|
9
|
+
*
|
|
10
|
+
* Measured, twice: the README has it from the original run and the lab
|
|
11
|
+
* reproduced it — exit 71, and the inner command never produced a byte.
|
|
12
|
+
*
|
|
13
|
+
* Two things make it a special case in a tool that otherwise has none.
|
|
14
|
+
*
|
|
15
|
+
* **It does not fail at the start.** The agent launches, reads, thinks, and
|
|
16
|
+
* dies on the first command it tries to confine. So the operator sees a turn
|
|
17
|
+
* that did nothing, which reads exactly like an agent with nothing to do.
|
|
18
|
+
*
|
|
19
|
+
* **And the error explains nothing.** It names neither seisin, nor the agent,
|
|
20
|
+
* nor the fix. For a tool whose whole claim is that a refusal tells you what
|
|
21
|
+
* happened, shipping the one message that says nothing is the worst available
|
|
22
|
+
* outcome — so this is checked before the spawn and said in words.
|
|
23
|
+
*
|
|
24
|
+
* It is a closed list, not a detector. Guessing which binaries sandbox
|
|
25
|
+
* themselves from their name would be wrong in both directions, and being
|
|
26
|
+
* wrong here means printing a warning about a thing that is not happening.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What each agent does by default, and what turns it off.
|
|
31
|
+
*
|
|
32
|
+
* `on: true` means the agent confines by default, so the warning is the
|
|
33
|
+
* normal case and silence needs a flag. codex is that. gemini is the other
|
|
34
|
+
* way round: off unless asked. Getting this backwards means warning about a
|
|
35
|
+
* thing that is not happening, which trains people to skip the line.
|
|
36
|
+
*
|
|
37
|
+
* `opencode` is absent. It has been run under seisin and the
|
|
38
|
+
* README describes its behaviour, but nothing here has measured whether it
|
|
39
|
+
* confines its own commands, and a closed list holds only what was checked.
|
|
40
|
+
*/
|
|
41
|
+
const SELF_SANDBOXING = {
|
|
42
|
+
codex: {
|
|
43
|
+
on: true,
|
|
44
|
+
off: ["--dangerously-bypass-approvals-and-sandbox"],
|
|
45
|
+
// The subcommand whose entire job is to confine. No flag turns that off.
|
|
46
|
+
never: ["sandbox"],
|
|
47
|
+
},
|
|
48
|
+
gemini: { on: false, onWith: ["-s", "--sandbox"], off: ["--sandbox=false"] },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The basename of a command, without its path. */
|
|
52
|
+
const bare = (c) => String(c ?? "").split("/").pop();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The warning for this command line, or null.
|
|
56
|
+
*
|
|
57
|
+
* Takes the argv `run` is about to spawn, and looks only at the first token
|
|
58
|
+
* and its flags. Anything cleverer would be guessing at a shell it cannot
|
|
59
|
+
* parse, and a wrong warning is worse than no warning.
|
|
60
|
+
*/
|
|
61
|
+
export function nestedSandboxWarning(cmd = []) {
|
|
62
|
+
const name = bare(cmd[0]);
|
|
63
|
+
const spec = Object.prototype.hasOwnProperty.call(SELF_SANDBOXING, name) ? SELF_SANDBOXING[name] : null;
|
|
64
|
+
if (!spec) return null;
|
|
65
|
+
|
|
66
|
+
const rest = cmd.slice(1);
|
|
67
|
+
if ((spec.never ?? []).includes(rest[0]))
|
|
68
|
+
return (
|
|
69
|
+
`\`${name} ${rest[0]}\` exists to run a command inside a sandbox, so it cannot run inside ` +
|
|
70
|
+
`one. The OS will not apply a second profile to a process that already has one.\n` +
|
|
71
|
+
` Run the command directly — seisin is already the boundary here.`
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const turnedOff = (spec.off ?? []).some((f) => rest.some((a) => a === f || a.startsWith(f + "=")));
|
|
75
|
+
if (turnedOff) return null;
|
|
76
|
+
const confining = spec.on || (spec.onWith ?? []).some((f) => rest.includes(f));
|
|
77
|
+
if (!confining) return null;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
`${name} confines the commands it runs with its own sandbox, and the OS will not apply a ` +
|
|
81
|
+
`second profile to a process that already has one.\n` +
|
|
82
|
+
` This does not fail here — it fails on the first command ${name} tries to run, which ` +
|
|
83
|
+
`looks like an agent that did nothing.\n` +
|
|
84
|
+
` Run it with its own sandbox off: \`${name} … ${(spec.off ?? ["--no-sandbox"])[0]}\`.`
|
|
85
|
+
);
|
|
86
|
+
}
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tell a person when a request is filed.
|
|
3
|
+
*
|
|
4
|
+
* The queue is where a refusal becomes a handoff, and a queue only works if
|
|
5
|
+
* somebody looks at it. It was asynchronous with nothing to say it had moved:
|
|
6
|
+
* the handoff existed and the person did not know. This sends one message per
|
|
7
|
+
* new request — the role, the path, whose it is, and the command that answers
|
|
8
|
+
* it — to wherever the operator reads things (ntfy, Slack, a webhook).
|
|
9
|
+
*
|
|
10
|
+
* Approving is still a person at a terminal. This only notifies; it does not
|
|
11
|
+
* accept an answer, so it changes nothing about who decides. (Accepting one —
|
|
12
|
+
* nono's `approval-webhook-v1` — would, and is a separate decision.)
|
|
13
|
+
*
|
|
14
|
+
* Two rules, both from the review that found a role approving its own request
|
|
15
|
+
* through the console:
|
|
16
|
+
* - the POST leaves from the parent, never from the role: the role's process
|
|
17
|
+
* never holds the URL, and `SEISIN_NOTIFY_URL` is dropped from its
|
|
18
|
+
* environment even if the role names it;
|
|
19
|
+
* - the URL is not readable from any territory. A ntfy topic or a webhook is
|
|
20
|
+
* a credential — whoever can read it can send the person a fake "approve
|
|
21
|
+
* #3". So it lives in a key directory no role declares (`url_file`), or in
|
|
22
|
+
* the parent's environment, and never as a plain value in seisin.toml,
|
|
23
|
+
* which every role can read.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { basename, join } from "node:path";
|
|
27
|
+
import { keyOf, pending, requestsPath, grantFor } from "./requests.js";
|
|
28
|
+
|
|
29
|
+
export const FORMATS = ["text", "json", "slack"];
|
|
30
|
+
export const ENV_URL = "SEISIN_NOTIFY_URL";
|
|
31
|
+
|
|
32
|
+
/** The URL to notify, or null. Read in the parent only. */
|
|
33
|
+
export function notifyUrl(config, env = process.env) {
|
|
34
|
+
if (env[ENV_URL]) return env[ENV_URL].trim();
|
|
35
|
+
if (!config.notify?.urlFile) return null;
|
|
36
|
+
try {
|
|
37
|
+
const url = readFileSync(join(config.root, config.notify.urlFile), "utf8").trim();
|
|
38
|
+
return url || null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The body for one request, in the chosen format. */
|
|
45
|
+
export function message(config, req, number, format = "text") {
|
|
46
|
+
const owner = req.owners?.length ? `It belongs to ${req.owners.join(", ")}.` : "Nobody owns it.";
|
|
47
|
+
const text =
|
|
48
|
+
`seisin (${basename(config.root)}): ${req.role} was refused ${req.action} on ${req.target}. ${owner} ` +
|
|
49
|
+
`Approve: seisin grant ${number} · decline: seisin decline ${number}`;
|
|
50
|
+
if (format === "slack") return { type: "application/json", body: JSON.stringify({ text }) };
|
|
51
|
+
if (format === "json")
|
|
52
|
+
return {
|
|
53
|
+
type: "application/json",
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
text, repo: basename(config.root), number, role: req.role, action: req.action,
|
|
56
|
+
target: req.target, owners: req.owners ?? [], grant: grantFor(req),
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
return { type: "text/plain; charset=utf-8", body: text };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One per run. `maybe(req)` after a request is recorded; `settle()` before the
|
|
64
|
+
* run exits, so a message still in flight is not cut off.
|
|
65
|
+
*
|
|
66
|
+
* Only the first time a request appears — the queue already counts repeats,
|
|
67
|
+
* and a person pinged on every retry stops reading the pings.
|
|
68
|
+
*/
|
|
69
|
+
export function notifier(config, { env = process.env, fetchImpl = globalThis.fetch, timeoutMs = 3000 } = {}) {
|
|
70
|
+
const url = notifyUrl(config, env);
|
|
71
|
+
const format = env.SEISIN_NOTIFY_FORMAT && FORMATS.includes(env.SEISIN_NOTIFY_FORMAT)
|
|
72
|
+
? env.SEISIN_NOTIFY_FORMAT
|
|
73
|
+
: config.notify?.format ?? "text";
|
|
74
|
+
const file = requestsPath(config.root);
|
|
75
|
+
const known = url ? new Set(pending(file).map((p) => p.key)) : new Set();
|
|
76
|
+
const inflight = [];
|
|
77
|
+
const failures = [];
|
|
78
|
+
|
|
79
|
+
function maybe(req) {
|
|
80
|
+
if (!url) return false;
|
|
81
|
+
const key = keyOf(req);
|
|
82
|
+
if (known.has(key)) return false;
|
|
83
|
+
known.add(key);
|
|
84
|
+
const queue = pending(file);
|
|
85
|
+
const number = queue.findIndex((p) => p.key === key) + 1;
|
|
86
|
+
if (number < 1) return false;
|
|
87
|
+
const { type, body } = message(config, { ...req, owners: req.owners ?? queue[number - 1].owners }, number, format);
|
|
88
|
+
inflight.push(
|
|
89
|
+
fetchImpl(url, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "content-type": type },
|
|
92
|
+
body,
|
|
93
|
+
// A redirect would send the request to a place nobody configured.
|
|
94
|
+
redirect: "error",
|
|
95
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
96
|
+
})
|
|
97
|
+
.then((r) => { if (!r.ok) failures.push(`HTTP ${r.status}`); })
|
|
98
|
+
.catch((e) => failures.push(e.name === "TimeoutError" ? "timed out" : e.message)),
|
|
99
|
+
);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function settle(capMs = timeoutMs + 500) {
|
|
104
|
+
if (!inflight.length) return failures;
|
|
105
|
+
await Promise.race([Promise.allSettled(inflight), new Promise((ok) => setTimeout(ok, capMs))]);
|
|
106
|
+
return failures;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { enabled: !!url, maybe, settle };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `[notify]` from seisin.toml, refused rather than guessed when it would leak.
|
|
114
|
+
* Called by the config loader.
|
|
115
|
+
*/
|
|
116
|
+
export function readNotify(table, { path, root, keyDirs, roles, own }) {
|
|
117
|
+
if (table === undefined) return null;
|
|
118
|
+
if (own(table, "url") !== undefined)
|
|
119
|
+
throw new Error(
|
|
120
|
+
`${path}: [notify] url is readable by every role — seisin.toml is not a secret. ` +
|
|
121
|
+
`Put the URL in a file inside a key directory no role declares and name it with url_file, ` +
|
|
122
|
+
`or set ${ENV_URL} in the environment that starts seisin.`);
|
|
123
|
+
const urlFile = own(table, "url_file");
|
|
124
|
+
const format = own(table, "format") ?? "text";
|
|
125
|
+
if (!FORMATS.includes(format))
|
|
126
|
+
throw new Error(`${path}: [notify] format = "${format}" is not one of ${FORMATS.join(", ")}`);
|
|
127
|
+
if (urlFile === undefined) return { urlFile: null, format };
|
|
128
|
+
if (typeof urlFile !== "string" || !urlFile.trim() || urlFile.startsWith("/") || urlFile.split("/").includes(".."))
|
|
129
|
+
throw new Error(`${path}: [notify] url_file must be a path inside the repo`);
|
|
130
|
+
const inKeyDir = keyDirs.some((d) => urlFile.startsWith(d.replace(/\/+$/, "") + "/"));
|
|
131
|
+
if (!inKeyDir)
|
|
132
|
+
throw new Error(
|
|
133
|
+
`${path}: [notify] url_file "${urlFile}" is not inside a key directory, so every role can read ` +
|
|
134
|
+
`it. Move it under ${keyDirs[0] ?? "a [keys] dir"}.`);
|
|
135
|
+
// A file key is written bare (`netlify.txt`, meaning the first key directory)
|
|
136
|
+
// or with its directory (`shared/api.txt`) — the same resolution settingsFor uses.
|
|
137
|
+
const resolve = (raw) => (raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
|
|
138
|
+
const holders = Object.entries(roles)
|
|
139
|
+
.filter(([, r]) => r.keyEntries?.some((k) => k.kind === "file" && resolve(k.raw) === urlFile))
|
|
140
|
+
.map(([n]) => n);
|
|
141
|
+
if (holders.length)
|
|
142
|
+
throw new Error(
|
|
143
|
+
`${path}: [notify] url_file "${urlFile}" is declared as a key by ${holders.join(", ")} — ` +
|
|
144
|
+
`that role could read it and send the person a fake "approve". No role may hold it.`);
|
|
145
|
+
return { urlFile, format };
|
|
146
|
+
}
|