seisin 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +385 -129
  2. package/package.json +13 -5
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +47 -23
  6. package/src/commands/check.js +154 -3
  7. package/src/commands/explain.js +2 -5
  8. package/src/commands/hook.js +7 -3
  9. package/src/commands/init.js +57 -50
  10. package/src/commands/requests.js +31 -56
  11. package/src/commands/review.js +33 -6
  12. package/src/commands/run.js +332 -110
  13. package/src/commands/walls.js +39 -7
  14. package/src/commands/whose.js +2 -1
  15. package/src/config.js +321 -15
  16. package/src/controls.js +5 -5
  17. package/src/diagnose.js +67 -16
  18. package/src/env.js +3 -0
  19. package/src/grants.js +107 -2
  20. package/src/handoff.js +285 -10
  21. package/src/help.js +39 -19
  22. package/src/hook.js +59 -30
  23. package/src/index.d.ts +217 -0
  24. package/src/index.js +20 -12
  25. package/src/inspect/codex.js +64 -0
  26. package/src/inspect/credentials.js +48 -0
  27. package/src/inspect/enforce.js +24 -0
  28. package/src/inspect/files.js +71 -0
  29. package/src/inspect/home.js +43 -0
  30. package/src/inspect/inject.js +68 -0
  31. package/src/inspect/keys.js +74 -0
  32. package/src/inspect/layout.js +258 -0
  33. package/src/inspect/limits.js +98 -0
  34. package/src/inspect/machine.js +107 -0
  35. package/src/inspect/network.js +37 -0
  36. package/src/inspect/providers.js +71 -0
  37. package/src/inspect/read-territory.js +105 -0
  38. package/src/inspect/role-keys.js +136 -0
  39. package/src/inspect/shared.js +37 -0
  40. package/src/inspect/warnings.js +58 -0
  41. package/src/inspect.js +13 -815
  42. package/src/intake.js +131 -21
  43. package/src/keys.js +223 -67
  44. package/src/kinds.js +42 -0
  45. package/src/lines.js +26 -0
  46. package/src/log.js +7 -2
  47. package/src/mcp.js +28 -43
  48. package/src/nested.js +12 -3
  49. package/src/notify.js +53 -12
  50. package/src/owners.js +165 -3
  51. package/src/paths.js +17 -1
  52. package/src/provider-run.js +81 -0
  53. package/src/redact.js +34 -15
  54. package/src/render.js +35 -9
  55. package/src/requests.js +363 -45
  56. package/src/review.js +21 -9
  57. package/src/rundir.js +29 -8
  58. package/src/sandboxes/command.js +93 -0
  59. package/src/sandboxes/index.js +113 -0
  60. package/src/sandboxes/mach-profile.js +31 -0
  61. package/src/sandboxes/srt-mach.mjs +43 -0
  62. package/src/sandboxes/srt.js +163 -0
  63. package/src/sandboxes/tartaros.js +80 -0
  64. package/src/scan.js +7 -3
  65. package/src/serve.js +35 -17
  66. package/src/spool.js +8 -17
  67. package/src/srt.js +263 -33
  68. package/src/surface.js +129 -16
  69. package/src/territory.js +261 -0
  70. package/src/trace.js +33 -0
  71. package/src/uilink.js +15 -6
  72. package/src/views.js +40 -14
  73. package/src/violations.js +143 -7
  74. package/src/walls.js +62 -9
  75. package/ui/index.html +85 -13
  76. package/ui/favicon.svg +0 -1
package/src/mcp.js CHANGED
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * If granting were a tool here, an agent holding it could widen its own
18
18
  * territory and the record would say a human did it. See
19
- * `docs/permission-requests.md`.
19
+ * `docs/requests.md`.
20
20
  *
21
21
  * ── Why there is no SDK ──
22
22
  * The official SDK pulls 91 packages and 26 MB — express, hono, cors, jose —
@@ -29,13 +29,13 @@
29
29
  import { toRepoRelative } from "./paths.js";
30
30
  import { loadConfig, findConfig } from "./config.js";
31
31
  import { inspect } from "./inspect.js";
32
- import { explain, explainFileRead, ownersOf, readTarget } from "./owners.js";
33
- import { CREDENTIAL_HOMES, expand } from "./grants.js";
32
+ import { explain, ownersOf } from "./owners.js";
34
33
  import { settingsFor } from "./srt.js";
35
- import { pending, requestsPath, refuseIfBarred, shellId } from "./requests.js";
34
+ import { queueOf, queueData, refuseIfBarred, shellId } from "./requests.js";
36
35
  import { read, logPath } from "./log.js";
37
36
  import { causesOf, parseSince, queue as requestQueue, verdicts, wallsByRole } from "./views.js";
38
37
  import { walls, wasted } from "./walls.js";
38
+ import { lineReader } from "./lines.js";
39
39
 
40
40
  /**
41
41
  * Versions this server will agree to.
@@ -80,9 +80,11 @@ const TOOLS = [
80
80
  name: "seisin_requests",
81
81
  description:
82
82
  "Requests: access agents asked for and cannot have yet, waiting on a person, with how many " +
83
- "times each was asked. A denial files one of these automatically, so this is where " +
84
- "to look after being denied rather than retrying. Read-only — granting is not " +
85
- "available here, by design.",
83
+ "times each was asked and where each goes: `handoff` is seisin's decision from the policy " +
84
+ "(route to the owning role, or a person decides; a cycle or a chain too deep stops). " +
85
+ "A denial files one of these automatically, so this is where to look after being denied " +
86
+ "rather than retrying. Read-only — granting is not available here, and nothing here " +
87
+ "starts a role, by design.",
86
88
  inputSchema: { type: "object", properties: {} },
87
89
  },
88
90
  {
@@ -105,7 +107,7 @@ const TOOLS = [
105
107
  "what was denied. What is measured against the policy as it stands is where each " +
106
108
  "path is now — `standing`: unowned (a decision nobody has made), owned, protected " +
107
109
  "(closed to every role on purpose, nothing to grant) or outside (a port, a key, or " +
108
- "outside the repository) — and, per cause, `stillRefused`, how many of the roles " +
110
+ "outside the repository) — and, per cause, `stillDenied`, how many of the roles " +
109
111
  "that hit it would hit it today. An unowned cause also carries `kind` and `hint`: " +
110
112
  "git (lock or metadata), credential, temporary, build or territory, and the move " +
111
113
  "that fits it (`standing.unowned.kinds` counts them). Only `territory` is a question " +
@@ -119,7 +121,7 @@ const TOOLS = [
119
121
  type: "object",
120
122
  properties: {
121
123
  limit: { type: "number" },
122
- since: { type: "string", description: "ISO date; only denials at or after it" },
124
+ since: { type: "string", description: "ISO date, or a duration back from now (7d, 12h); only denials at or after it" },
123
125
  },
124
126
  },
125
127
  },
@@ -165,6 +167,8 @@ const HANDLERS = {
165
167
  return {
166
168
  config: cfg.path,
167
169
  keyDirectories: cfg.keyDirs,
170
+ read: cfg.read,
171
+ isolate: cfg.isolate,
168
172
  roles: report.roles.map((r) => ({
169
173
  ...r,
170
174
  sandbox: settingsFor(cfg, r.name).filesystem,
@@ -194,10 +198,7 @@ const HANDLERS = {
194
198
  // A read of a file outside every key dir is open, as it is for the CLI.
195
199
  // The server has no working directory of its own, so the root stands in.
196
200
  if (action === "read") {
197
- const asked = readTarget(cfg, target, cfg.root);
198
- return asked.key
199
- ? explain(cfg, role, "read", asked.target)
200
- : explainFileRead(cfg, role, asked.target, CREDENTIAL_HOMES.map(expand));
201
+ return explain(cfg, role, "read", target, cfg.root);
201
202
  }
202
203
  const rel = toRepoRelative(cfg, target);
203
204
  const verdict = explain(cfg, role, action, rel);
@@ -206,13 +207,12 @@ const HANDLERS = {
206
207
 
207
208
  seisin_requests() {
208
209
  const cfg = config();
209
- const queue = requestQueue(cfg.root, read(logPath(cfg.root)), cfg.keyDirs);
210
+ const queue = requestQueue(cfg, read(logPath(cfg.root)), { readOnly: true });
210
211
  return {
211
- pending: queue.map((r, i) => ({
212
- number: i + 1, id: r.key, role: r.role, action: r.action,
213
- wants: r.grant, ownedBy: r.owners, asked: r.times, lastAsked: r.last,
214
- ...(r.stale ? { stale: `not asked again in ${r.stale.runs} runs of ${r.role} since` } : {}),
215
- })),
212
+ // The same shape as `seisin requests --json`, each with its handoff
213
+ // decision. Reading it tells an agent where its request goes; it cannot
214
+ // change where, and nothing here starts the role it names.
215
+ ...queueData(queue),
216
216
  // Said in the payload and not only in the tool description, because a
217
217
  // model reading this is deciding what to do next.
218
218
  note: queue.length
@@ -274,7 +274,8 @@ const HANDLERS = {
274
274
 
275
275
  seisin_draft_grant({ id, number } = {}) {
276
276
  const cfg = config();
277
- const queue = pending(requestsPath(cfg.root), { keyDirs: cfg.keyDirs });
277
+ // Owners as the policy says now, the same as seisin_requests shows.
278
+ const queue = queueOf(cfg, null, { readOnly: true });
278
279
  let req;
279
280
  if (typeof id === "string" && id) {
280
281
  req = queue.find((r) => r.key === id);
@@ -388,30 +389,14 @@ function handle(message, { write, version }) {
388
389
  export function serveMcp(version = "0.0.0", input = process.stdin, output = process.stdout) {
389
390
  const ctx = { version, write: (s) => output.write(s) };
390
391
  input.setEncoding("utf8");
391
- let buffer = "";
392
-
393
- input.on("data", (chunk) => {
394
- buffer += chunk;
395
- // A megabyte with no newline in it is not a JSON-RPC message, it is a
396
- // client — or something wearing one — filling this process's memory.
397
- // spool.js already caps its input; this one did not.
398
- if (buffer.length > 1_000_000) {
399
- buffer = "";
400
- return;
401
- }
402
- let nl;
403
- while ((nl = buffer.indexOf("\n")) !== -1) {
404
- const line = buffer.slice(0, nl).trim();
405
- buffer = buffer.slice(nl + 1);
406
- if (!line) continue;
407
- try {
408
- handle(JSON.parse(line), ctx);
409
- } catch {
410
- // Unparseable input has no id to answer to, so there is nobody to tell.
411
- // Staying alive is the only useful response.
412
- }
392
+ input.on("data", lineReader((line) => {
393
+ try {
394
+ handle(JSON.parse(line), ctx);
395
+ } catch {
396
+ // Unparseable input has no id to answer to, so there is nobody to tell.
397
+ // Staying alive is the only useful response.
413
398
  }
414
- });
399
+ }));
415
400
 
416
401
  return new Promise((done) => input.on("end", done));
417
402
  }
package/src/nested.js CHANGED
@@ -41,7 +41,15 @@
41
41
  const SELF_SANDBOXING = {
42
42
  codex: {
43
43
  on: true,
44
- off: ["--dangerously-bypass-approvals-and-sandbox"],
44
+ // `-s danger-full-access` first: it is the one to suggest. It turns off only
45
+ // the sandbox, and inside `seisin run` that is the whole change wanted —
46
+ // `--dangerously-bypass…` also turns off approvals, which says more than
47
+ // the situation needs. Warning about `-s danger-full-access` was a warning
48
+ // about a thing that was not happening: measured on 2026-09-30 with codex
49
+ // 0.150.1, a turn under seisin with that flag ran its commands and finished.
50
+ off: ["--sandbox=danger-full-access", "--dangerously-bypass-approvals-and-sandbox"],
51
+ // The same setting spelt as a flag and its value, two words.
52
+ offPairs: [["-s", "danger-full-access"], ["--sandbox", "danger-full-access"]],
45
53
  // The subcommand whose entire job is to confine. No flag turns that off.
46
54
  never: ["sandbox"],
47
55
  },
@@ -71,7 +79,8 @@ export function nestedSandboxWarning(cmd = []) {
71
79
  ` Run the command directly — seisin is already the boundary here.`
72
80
  );
73
81
 
74
- const turnedOff = (spec.off ?? []).some((f) => rest.some((a) => a === f || a.startsWith(f + "=")));
82
+ const turnedOff = (spec.off ?? []).some((f) => rest.some((a) => a === f || a.startsWith(f + "="))) ||
83
+ (spec.offPairs ?? []).some(([flag, value]) => rest.some((a, i) => a === flag && rest[i + 1] === value));
75
84
  if (turnedOff) return null;
76
85
  const confining = spec.on || (spec.onWith ?? []).some((f) => rest.includes(f));
77
86
  if (!confining) return null;
@@ -81,6 +90,6 @@ export function nestedSandboxWarning(cmd = []) {
81
90
  `second profile to a process that already has one.\n` +
82
91
  ` This does not fail here — it fails on the first command ${name} tries to run, which ` +
83
92
  `looks like an agent that did nothing.\n` +
84
- ` Run it with its own sandbox off: \`${name} … ${(spec.off ?? ["--no-sandbox"])[0]}\`.`
93
+ ` Run it with its own sandbox off: \`${name} … ${spec.offPairs ? spec.offPairs[0].join(" ") : (spec.off ?? ["--no-sandbox"])[0]}\`.`
85
94
  );
86
95
  }
package/src/notify.js CHANGED
@@ -23,8 +23,10 @@
23
23
  * which every role can read.
24
24
  */
25
25
  import { readFileSync } from "node:fs";
26
- import { basename, join } from "node:path";
27
- import { keyOf, pending, requestsPath, grantFor, shellId } from "./requests.js";
26
+ import { basename, join, resolve, sep } from "node:path";
27
+ import { keyOf, pending, requestsPath, grantFor, shellId, queueOf, withHandoffs } from "./requests.js";
28
+ import { describeHandoff } from "./handoff.js";
29
+ import { realAncestor } from "./paths.js";
28
30
 
29
31
  const FORMATS = ["text", "json", "slack"];
30
32
  const ENV_URL = "SEISIN_NOTIFY_URL";
@@ -41,9 +43,19 @@ function notifyUrl(config, env = process.env) {
41
43
  }
42
44
  }
43
45
 
44
- /** The body for one request, in the chosen format. */
46
+ /**
47
+ * The body for one request, in the chosen format.
48
+ *
49
+ * Whose it is and where it goes are the queue's decision (handoff.js), the
50
+ * same one `seisin requests` prints; a request handed in without one is
51
+ * decided here by the same function, never described from its stored owners.
52
+ */
45
53
  export function message(config, req, number, format = "text") {
46
- const owner = req.owners?.length ? `It belongs to ${req.owners.join(", ")}.` : "Nobody owns it.";
54
+ const d = req.handoff ?? withHandoffs(config, [{ ...req, key: keyOf(req) }])[0].handoff;
55
+ const owners = d.owners ?? [];
56
+ const where = describeHandoff(d, req.attempt);
57
+ const owner = (owners.length ? `It belongs to ${owners.join(", ")}.` : "Nobody owns it.") +
58
+ (where ? ` ${where[0].toUpperCase()}${where.slice(1)}.` : "");
47
59
  const text =
48
60
  `seisin (${basename(config.root)}): ${req.role} was denied ${req.action} on ${req.target}. ${owner} ` +
49
61
  `Grant: seisin grant ${shellId(keyOf(req))} · decline: seisin decline ${shellId(keyOf(req))}`;
@@ -58,7 +70,7 @@ export function message(config, req, number, format = "text") {
58
70
  type: "application/json",
59
71
  body: JSON.stringify({
60
72
  text, repo: basename(config.root), number, id: keyOf(req), role: req.role, action: req.action,
61
- target: req.target, owners: req.owners ?? [], grant: grantFor(req),
73
+ target: req.target, owners, grant: grantFor(req), handoff: d,
62
74
  }),
63
75
  };
64
76
  return { type: "text/plain; charset=utf-8", body: text };
@@ -86,10 +98,10 @@ export function notifier(config, { env = process.env, fetchImpl = globalThis.fet
86
98
  const key = keyOf(req);
87
99
  if (known.has(key)) return false;
88
100
  known.add(key);
89
- const queue = pending(file);
101
+ const queue = queueOf(config);
90
102
  const number = queue.findIndex((p) => p.key === key) + 1;
91
103
  if (number < 1) return false;
92
- const { type, body } = message(config, { ...req, owners: req.owners ?? queue[number - 1].owners }, number, format);
104
+ const { type, body } = message(config, queue[number - 1], number, format);
93
105
  inflight.push(
94
106
  fetchImpl(url, {
95
107
  method: "POST",
@@ -100,20 +112,37 @@ export function notifier(config, { env = process.env, fetchImpl = globalThis.fet
100
112
  signal: AbortSignal.timeout(timeoutMs),
101
113
  })
102
114
  .then((r) => { if (!r.ok) failures.push(`HTTP ${r.status}`); })
103
- .catch((e) => failures.push(e.name === "TimeoutError" ? "timed out" : e.message)),
115
+ // Native fetch errors can embed the whole URL, including a webhook
116
+ // credential. Only report known categories, never the raw message.
117
+ .catch((e) => failures.push(notificationFailure(e))),
104
118
  );
105
119
  return true;
106
120
  }
107
121
 
108
122
  async function settle(capMs = timeoutMs + 500) {
109
123
  if (!inflight.length) return failures;
110
- await Promise.race([Promise.allSettled(inflight), new Promise((ok) => setTimeout(ok, capMs))]);
124
+ let timer;
125
+ try {
126
+ await Promise.race([Promise.allSettled(inflight), new Promise((ok) => { timer = setTimeout(ok, capMs); })]);
127
+ } finally {
128
+ clearTimeout(timer);
129
+ }
111
130
  return failures;
112
131
  }
113
132
 
114
133
  return { enabled: !!url, maybe, settle };
115
134
  }
116
135
 
136
+ function notificationFailure(error) {
137
+ if (error?.name === "TimeoutError") return "timed out";
138
+ if (error?.name === "AbortError") return "aborted";
139
+ const code = error?.cause?.code ?? error?.code;
140
+ if (code === "ERR_INVALID_URL") return "invalid URL";
141
+ if (code === "ENOTFOUND") return "host not found";
142
+ if (code === "ECONNREFUSED") return "connection unavailable";
143
+ return "request failed";
144
+ }
145
+
117
146
  /**
118
147
  * `[notify]` from seisin.toml, refused rather than guessed when it would leak.
119
148
  * Called by the config loader.
@@ -132,16 +161,28 @@ export function readNotify(table, { path, root, keyDirs, roles, own }) {
132
161
  if (urlFile === undefined) return { urlFile: null, format };
133
162
  if (typeof urlFile !== "string" || !urlFile.trim() || urlFile.startsWith("/") || urlFile.split("/").includes(".."))
134
163
  throw new Error(`${path}: [notify] url_file must be a path inside the repo`);
135
- const inKeyDir = keyDirs.some((d) => urlFile.startsWith(d.replace(/\/+$/, "") + "/"));
164
+ const canonical = (p) => realAncestor(resolve(root, p));
165
+ const file = canonical(urlFile);
166
+ const inKeyDir = keyDirs.some((d) => file.startsWith(canonical(d) + sep));
136
167
  if (!inKeyDir)
137
168
  throw new Error(
138
169
  `${path}: [notify] url_file "${urlFile}" is not inside a key directory, so every role can read ` +
139
170
  `it. Move it under ${keyDirs[0] ?? "a [keys] dir"}.`);
140
171
  // A file key is written bare (`netlify.txt`, meaning the first key directory)
141
172
  // or with its directory (`shared/api.txt`) — the same resolution settingsFor uses.
142
- const resolve = (raw) => (raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
173
+ const fileKey = (raw) => canonical(raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
143
174
  const holders = Object.entries(roles)
144
- .filter(([, r]) => r.keyEntries?.some((k) => k.kind === "file" && resolve(k.raw) === urlFile))
175
+ .filter(([, r]) => r.keyEntries?.some((k) => {
176
+ if (k.kind === "file") {
177
+ const held = fileKey(k.raw);
178
+ return file === held || file.startsWith(held + sep);
179
+ }
180
+ if (k.kind !== "ref" || k.scheme !== "file") return false;
181
+ // Match the builtin resolver's last-fragment rule. A reference delivers
182
+ // the contents even though it opens no filesystem read for the role.
183
+ const hash = k.ref.lastIndexOf("#");
184
+ return canonical(hash === -1 ? k.ref : k.ref.slice(0, hash)) === file;
185
+ }))
145
186
  .map(([n]) => n);
146
187
  if (holders.length)
147
188
  throw new Error(
package/src/owners.js CHANGED
@@ -14,8 +14,10 @@ import { createHash } from "node:crypto";
14
14
  import { existsSync, readFileSync, readdirSync } from "node:fs";
15
15
  import { isAbsolute, join } from "node:path";
16
16
  import { protectedBy } from "./surface.js";
17
- import { entriesOf } from "./keys.js";
17
+ import { entriesOf, parseKey, BUILTIN } from "./keys.js";
18
18
  import { WILD, fromCwd, toRepoRelative } from "./paths.js";
19
+ import { AGENTS, AGENT_CREDENTIALS, CREDENTIAL_HOMES, expand } from "./grants.js";
20
+ import { keptFor, shutByTerritory } from "./territory.js";
19
21
 
20
22
  /**
21
23
  * Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
@@ -243,11 +245,55 @@ export function ownersOf(config, path) {
243
245
  if (!config.root || !path.startsWith(root)) return [];
244
246
  path = path.slice(root.length);
245
247
  }
248
+ // A private folder has one owner, whatever else covers it: every other role
249
+ // is given a denyWrite on it (srt.js), and denyWrite wins over any territory.
250
+ const mine = privateOwner(config, path);
246
251
  return Object.values(config.roles)
247
252
  .filter((r) => r.writes.some((g) => covers(g, path)) && !neverWrites(r, path, config))
253
+ .filter((r) => !mine || r.name === mine.role)
248
254
  .map((r) => r.name);
249
255
  }
250
256
 
257
+ /**
258
+ * The private folder that holds `path` — `{ role, entry }` — or null.
259
+ *
260
+ * `path` is repo-relative, or absolute inside the repo. One answer for the
261
+ * settings, `explain`, the hook and `check`, so the denies the kernel is given
262
+ * and the sentence about them cannot drift apart.
263
+ */
264
+ export function privateOwner(config, path) {
265
+ let p = String(path ?? "");
266
+ if (p.startsWith("/")) {
267
+ const root = config.root?.endsWith("/") ? config.root : `${config.root}/`;
268
+ if (!config.root || !p.startsWith(root)) return null;
269
+ p = p.slice(root.length);
270
+ }
271
+ for (const r of Object.values(config.roles ?? {}))
272
+ for (const entry of r.private ?? [])
273
+ if (covers(entry.replace(/\/\*\*$/, "").replace(/\/+$/, ""), p)) return { role: r.name, entry };
274
+ return null;
275
+ }
276
+
277
+ /** The answer for `role` touching another role's private folder, or null. */
278
+ function privateAnswer(config, role, target, read = false) {
279
+ const hit = privateOwner(config, target);
280
+ if (!hit) return null;
281
+ // The owner's write goes on through the ordinary questions (never_writes
282
+ // still subtracts inside it); its read is answered here, because "every
283
+ // role reads it" is exactly what is no longer true.
284
+ if (hit.role === role)
285
+ return read
286
+ ? { allowed: true, owners: [role], privateTo: role,
287
+ reason: `${target} is in ${role}'s private folder (private = ["${hit.entry}"]): ${role} reads it, and no other role does` }
288
+ : null;
289
+ return {
290
+ allowed: false, owners: [hit.role], privateTo: hit.role,
291
+ reason: `${target} is in ${hit.role}'s private folder (private = ["${hit.entry}"]): only ${hit.role} ` +
292
+ `reads or writes it, and every other role is denied both, in any read mode. ` +
293
+ `There is nothing to grant — ask ${hit.role} for what you need from it`,
294
+ };
295
+ }
296
+
251
297
  /**
252
298
  * Where a refused target stands against the policy as it is now.
253
299
  *
@@ -271,6 +317,8 @@ export function standing(config, entry) {
271
317
  const target = entry.target;
272
318
  if (entry.kind === "network")
273
319
  return { kind: "outside", owners: [], why: "a port or a socket, which no role owns" };
320
+ if (entry.kind === "system")
321
+ return { kind: "outside", owners: [], why: "a system service, which no role owns" };
274
322
  if (entry.kind === "key")
275
323
  return { kind: "outside", owners: [], why: "a key directory, closed on purpose" };
276
324
  const guard = protectedBy(config, target);
@@ -401,10 +449,30 @@ export function keyName(config, target) {
401
449
  * inside a key directory is a key question; anything else is answered as the
402
450
  * open read it is, with how to make it a key.
403
451
  */
452
+ /**
453
+ * An agent's sign-in file: closed to every run but a run of that agent, which
454
+ * is a fact about the command and not the role (AGENT_CREDENTIALS, grants.js).
455
+ * explain has no command, so it answers for the ordinary case and names the
456
+ * exception. Nothing to grant either way, and the sentence says so: the advice
457
+ * it replaced was "add it to a role's keys", which would hand a ChatGPT token
458
+ * to a role by policy.
459
+ */
460
+ function signInAnswer(role, target) {
461
+ if (!String(target ?? "").startsWith("/")) return null;
462
+ const agent = AGENTS.find((a) => AGENT_CREDENTIALS[a].map(expand).includes(target));
463
+ if (!agent) return null;
464
+ return {
465
+ allowed: false, owners: [], signIn: agent,
466
+ reason: `${target} is ${agent}'s sign-in: only a run whose command is ${agent} reads it ` +
467
+ `(seisin run ${role} -- ${agent} …), and the commands that run inside it. ` +
468
+ `Any other command run as ${role} is denied it, and there is nothing to grant`,
469
+ };
470
+ }
471
+
404
472
  export function explainFileRead(config, role, rel, credentialHomes = []) {
405
473
  // Only a key directory makes a key: settingsFor refuses a key declared
406
474
  // anywhere else, so nothing outside one is denied by being a key.
407
- if (inKeyDir(config, rel)) return explain(config, role, "read", rel);
475
+ if (inKeyDir(config, rel)) return explainPolicy(config, role, "read", rel);
408
476
  const level = config.isolate === true ? "home" : config.isolate;
409
477
  if ((level === "credentials" || level === "home") && rel.startsWith("/")) {
410
478
  const hit = credentialHomes.find((h) => rel === h || rel.startsWith(h + "/"));
@@ -414,6 +482,17 @@ export function explainFileRead(config, role, rel, credentialHomes = []) {
414
482
  reason: `${rel} is under ${hit}, which isolate = "${level}" closes to every role`,
415
483
  };
416
484
  }
485
+ const signIn = signInAnswer(role, rel);
486
+ if (signIn) return signIn;
487
+ const shut = privateAnswer(config, role, rel, true);
488
+ if (shut) return shut;
489
+ if (config.read === "territory" && rel.startsWith("/") &&
490
+ shutByTerritory(rel, keptFor(config, config.roles[role])))
491
+ return {
492
+ allowed: false, owners: [], readTerritory: true,
493
+ reason: `${rel} is outside what ${role} keeps readable under read = "territory". ` +
494
+ `Declare it in reads, or declare the tool that needs it in toolchain`,
495
+ };
417
496
  const dirs = (config.keyDirs ?? []).map(dirSpelling);
418
497
  return {
419
498
  allowed: true, owners: [], open: true,
@@ -433,6 +512,10 @@ export function explainFileRead(config, role, rel, credentialHomes = []) {
433
512
  * where you stand, and inside a key directory it is a key question again.
434
513
  */
435
514
  export function readTarget(config, target, cwd = process.cwd()) {
515
+ // A reference is a key by its spelling, never a path: resolving
516
+ // `keychain://x` from where you stand printed `keychain:/x` and answered
517
+ // a question about a file nobody asked about.
518
+ if (refOf(target)) return { key: true, target };
436
519
  if (!target.includes("/") && !isAbsolute(target)) {
437
520
  const declared = keyHolders(config, target).length > 0;
438
521
  const bare = (s) => s.replace(/\.[^.]+$/, "");
@@ -487,6 +570,64 @@ export function keyHolders(config, key) {
487
570
  .map((r) => r.name);
488
571
  }
489
572
 
573
+ /** `scheme://…`, optionally `NAME=scheme://…`, read as a key entry — or null. */
574
+ function refOf(target) {
575
+ try {
576
+ const e = parseKey(target);
577
+ return e.kind === "ref" ? e : null;
578
+ } catch {
579
+ return null;
580
+ }
581
+ }
582
+
583
+ /**
584
+ * A reference key is a question about the policy, not about the disk.
585
+ *
586
+ * Two facts answer it: whether the role lists the reference in its `keys`,
587
+ * and which provider turns it into a value. The role never reads where the
588
+ * secret lives — the parent resolves it before the sandbox starts and hands
589
+ * over the value — so there is no path to normalise and no key directory to
590
+ * be inside of. Matched on scheme and reference; the variable name in front is
591
+ * how it is delivered, not which secret it is.
592
+ */
593
+ function explainRef(config, role, ref) {
594
+ const spelled = `${ref.scheme}://${ref.ref}`;
595
+ const declaring = (r) => {
596
+ try {
597
+ return entriesOf(r).find((e) => e.kind === "ref" && e.scheme === ref.scheme && e.ref === ref.ref);
598
+ } catch {
599
+ return undefined;
600
+ }
601
+ };
602
+ const holders = Object.values(config.roles).filter(declaring).map((r) => r.name);
603
+ const provider = BUILTIN.has(ref.scheme)
604
+ ? `seisin's built-in ${ref.scheme}:// provider`
605
+ : Object.hasOwn(config.keyProviders ?? {}, ref.scheme) ? `[keys.providers.${ref.scheme}]` : null;
606
+ if (!provider)
607
+ return {
608
+ allowed: false, owners: holders, key: "ref",
609
+ reason: `${spelled} uses the "${ref.scheme}" scheme and no [keys.providers.${ref.scheme}] is declared, ` +
610
+ `so nothing resolves it — declare the provider before a role can hold this key`,
611
+ };
612
+ const mine = config.roles[role] && declaring(config.roles[role]);
613
+ if (mine)
614
+ return {
615
+ allowed: true, owners: holders, key: "ref", provider,
616
+ reason: `${role} declares ${mine.raw}: the parent resolves it through ${provider} and hands ` +
617
+ `${role} the value as ${mine.name} — the role never reads where it is kept`,
618
+ };
619
+ if (!holders.length)
620
+ return {
621
+ allowed: false, owners: [], key: "ref", provider,
622
+ reason: `no role declares ${spelled} (${provider} would resolve it) — ` +
623
+ `add it to a role's keys: [roles.${role}] keys = ["${ref.raw}"]`,
624
+ };
625
+ return {
626
+ allowed: false, owners: holders, key: "ref", provider,
627
+ reason: `${spelled} belongs to ${holders.join(", ")}, through ${provider}`,
628
+ };
629
+ }
630
+
490
631
  /** The keys of `role` that grant a read: the path ones. A malformed entry grants nothing. */
491
632
  function fileKeys(role) {
492
633
  let entries;
@@ -568,10 +709,14 @@ function explainMcp(config, role, name) {
568
709
  * not a permission problem, it is a hole in the map. Saying so is more useful
569
710
  * than denying quietly, and it is the only way the hole ever gets fixed.
570
711
  */
571
- export function explain(config, role, action, target) {
712
+ function explainPolicy(config, role, action, target) {
572
713
  if (action === "connect") return explainConnect(config, role, target);
573
714
  if (action === "mcp") return explainMcp(config, role, target);
574
715
  if (action === "read") {
716
+ const ref = refOf(target);
717
+ if (ref) return explainRef(config, role, ref);
718
+ const signIn = signInAnswer(role, target);
719
+ if (signIn) return signIn;
575
720
  const holders = keyHolders(config, target);
576
721
  if (holders.includes(role)) return { allowed: true, owners: holders, reason: `${role} declares ${target}` };
577
722
  if (holders.length === 0) {
@@ -617,6 +762,8 @@ export function explain(config, role, action, target) {
617
762
  * approves it, and the subtraction is undone from the other side without
618
763
  * anyone deciding to undo it.
619
764
  */
765
+ const shut = privateAnswer(config, role, target);
766
+ if (shut) return shut;
620
767
  const r = config.roles[role];
621
768
  const barred = r && r.writes.some((g) => covers(g, target)) ? neverWrites(r, target, config) : null;
622
769
  if (barred)
@@ -648,3 +795,18 @@ export function explain(config, role, action, target) {
648
795
  return { allowed: false, owners: [], reason: `${target} has no owner — no role can write it until one claims it` };
649
796
  return { allowed: false, owners, reason: `${target} belongs to ${owners.join(", ")}` };
650
797
  }
798
+
799
+ /**
800
+ * One public answer for CLI, MCP and library callers.
801
+ *
802
+ * Reads first decide whether the target is a declared key or a file, then ask
803
+ * the same territory/isolation question. Callers may supply their cwd; servers
804
+ * use the policy root, while the CLI uses the directory it is standing in.
805
+ */
806
+ export function explain(config, role, action, target, cwd = config.root) {
807
+ if (action !== "read") return explainPolicy(config, role, action, target);
808
+ const asked = readTarget(config, target, cwd);
809
+ return asked.key
810
+ ? explainPolicy(config, role, "read", asked.target)
811
+ : explainFileRead(config, role, asked.target, CREDENTIAL_HOMES.map(expand));
812
+ }
package/src/paths.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * project refuses. Found by the read-only review of 2026-09-22.
13
13
  */
14
14
  import { realpathSync } from "node:fs";
15
- import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
15
+ import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
16
16
 
17
17
  /**
18
18
  * `p` with its deepest existing ancestor resolved through symlinks and the rest
@@ -57,6 +57,22 @@ export function toRepoRelative(config, target) {
57
57
  return target;
58
58
  }
59
59
 
60
+ /**
61
+ * One spelling for a path, so two of them can be compared with `===`.
62
+ *
63
+ * Repo-relative and normalized inside the repo (`./a/`, `a/../a` and the
64
+ * absolute path through either symlinked spelling of the root are all `a`);
65
+ * absolute and resolved through its existing ancestors outside it. Used where
66
+ * a path a caller gave must match the path a denial names exactly — never as
67
+ * a prefix and never as a glob.
68
+ */
69
+ export function samePath(config, target) {
70
+ if (typeof target !== "string" || !target.trim()) return null;
71
+ const rel = toRepoRelative(config, isAbsolute(target) ? normalize(target) : target);
72
+ if (isAbsolute(rel)) return realAncestor(normalize(rel)).replace(/(.)\/+$/, "$1");
73
+ return normalize(rel).replace(/\/+$/, "") || ".";
74
+ }
75
+
60
76
  /**
61
77
  * A path typed at a prompt, made absolute the way a shell user means it:
62
78
  * against the directory they are standing in. Relative arguments used to be
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Runs one key provider and reports what it printed, as JSON on stdout.
3
+ *
4
+ * A separate process because `spawnSync` cannot do two things a provider
5
+ * needs, and `resolveRef` is synchronous by contract (keys.js):
6
+ *
7
+ * - **Take the answer when the provider exits.** `spawnSync` returns when
8
+ * the child has exited AND every copy of its stdout is closed. A provider
9
+ * that printed the value, exited 0 and left a background process holding
10
+ * the pipe made seisin wait the whole timeout and then say the provider
11
+ * "did not answer". Here the answer is what was read by the time the
12
+ * provider exited, plus a short drain for bytes still in the pipe.
13
+ * - **Stop it for good.** A timeout is SIGKILL, not SIGTERM: a provider that
14
+ * ignored SIGTERM held the run forever. And it goes to every descendant
15
+ * too, so a child the provider was waiting on dies with it instead of
16
+ * living on. Found by walking `ps`, not by a process group of its own: a
17
+ * new session would take the provider's terminal away, and a pinentry
18
+ * needs it. A provider that exits on its own keeps whatever it left
19
+ * running: an agent it starts (gpg-agent, an op daemon) is its business.
20
+ *
21
+ * stderr is read and counted, never kept: a provider may print the value
22
+ * there, and nothing downstream shows it.
23
+ *
24
+ * argv: <timeoutMs> <maxBytes> <drainMs> <command> [args…]
25
+ */
26
+ import { spawn, execFileSync } from "node:child_process";
27
+
28
+ const [timeoutMs, maxBytes, drainMs] = process.argv.slice(2, 5).map(Number);
29
+ const argv = process.argv.slice(5);
30
+
31
+ const chunks = [];
32
+ let size = 0, errBytes = 0, timedOut = false, tooBig = false, done = false;
33
+
34
+ function finish(result) {
35
+ if (done) return;
36
+ done = true;
37
+ clearTimeout(timer);
38
+ const out = tooBig ? "" : Buffer.concat(chunks).toString("base64");
39
+ process.stdout.write(JSON.stringify({ ...result, out, errBytes, timedOut, tooBig }), () => process.exit(0));
40
+ }
41
+
42
+ /** The provider and every process below it. */
43
+ function tree(root) {
44
+ const kids = new Map();
45
+ try {
46
+ for (const line of execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" }).split("\n")) {
47
+ const [pid, ppid] = line.trim().split(/\s+/).map(Number);
48
+ if (pid && ppid) kids.set(ppid, [...(kids.get(ppid) ?? []), pid]);
49
+ }
50
+ } catch {}
51
+ const all = [root];
52
+ for (let i = 0; i < all.length; i++) all.push(...(kids.get(all[i]) ?? []));
53
+ return all;
54
+ }
55
+
56
+ const child = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"] });
57
+ const kill = () => {
58
+ if (!child.pid) return;
59
+ // Stopped first, so nothing forks between the listing and the kill.
60
+ const pids = tree(child.pid);
61
+ for (const pid of pids) try { process.kill(pid, "SIGSTOP"); } catch {}
62
+ for (const pid of tree(child.pid)) if (!pids.includes(pid)) pids.push(pid);
63
+ for (const pid of pids) try { process.kill(pid, "SIGKILL"); } catch {}
64
+ };
65
+ const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
66
+
67
+ child.stdout.on("data", (c) => {
68
+ size += c.length;
69
+ if (size > maxBytes) { tooBig = true; chunks.length = 0; kill(); return; }
70
+ if (!tooBig) chunks.push(c);
71
+ });
72
+ child.stderr.on("data", (c) => { errBytes += c.length; });
73
+ child.on("error", (e) => finish({ error: e.code ?? e.message }));
74
+ child.on("exit", (status, signal) => {
75
+ // What the provider wrote before exiting may still be in the pipe. Wait for
76
+ // the end of it, but not for a descendant that kept the pipe open.
77
+ const drained = () => finish({ status, signal });
78
+ if (child.stdout.readableEnded) return drained();
79
+ child.stdout.once("end", drained);
80
+ setTimeout(drained, drainMs).unref();
81
+ });