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/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
- appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
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, or key filename for a read" },
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
- "Writes nothing: the approval is a human action in another channel.",
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 "write", got ${JSON.stringify(action)}`);
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
- const verdict = explain(cfg, role, action, target);
150
- return { ...verdict, alsoOwnedBy: action === "write" ? ownersOf(cfg, target) : undefined };
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
+ }