seisin 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
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
@@ -17,20 +17,22 @@
17
17
  * never holds the URL, and `SEISIN_NOTIFY_URL` is dropped from its
18
18
  * environment even if the role names it;
19
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
20
+ * a credential — whoever can read it can send the person a fake "grant
21
21
  * #3". So it lives in a key directory no role declares (`url_file`), or in
22
22
  * the parent's environment, and never as a plain value in seisin.toml,
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
- export const FORMATS = ["text", "json", "slack"];
30
- export const ENV_URL = "SEISIN_NOTIFY_URL";
31
+ const FORMATS = ["text", "json", "slack"];
32
+ const ENV_URL = "SEISIN_NOTIFY_URL";
31
33
 
32
34
  /** The URL to notify, or null. Read in the parent only. */
33
- export function notifyUrl(config, env = process.env) {
35
+ function notifyUrl(config, env = process.env) {
34
36
  if (env[ENV_URL]) return env[ENV_URL].trim();
35
37
  if (!config.notify?.urlFile) return null;
36
38
  try {
@@ -41,19 +43,34 @@ export 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
- `seisin (${basename(config.root)}): ${req.role} was refused ${req.action} on ${req.target}. ${owner} ` +
49
- `Approve: seisin grant ${shellId(keyOf(req))} · decline: seisin decline ${shellId(keyOf(req))}`;
50
- if (format === "slack") return { type: "application/json", body: JSON.stringify({ text }) };
60
+ `seisin (${basename(config.root)}): ${req.role} was denied ${req.action} on ${req.target}. ${owner} ` +
61
+ `Grant: seisin grant ${shellId(keyOf(req))} · decline: seisin decline ${shellId(keyOf(req))}`;
62
+ // Slack reads `<url|label>` as a link and `<!channel>` as a mention, and the
63
+ // path is chosen by the agent: `<https://evil|approve here>` in a target
64
+ // would arrive as a clickable link in the channel a person approves from.
65
+ // Its own escaping, and only these three characters, is what it asks for.
66
+ if (format === "slack")
67
+ return { type: "application/json", body: JSON.stringify({ text: text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") }) };
51
68
  if (format === "json")
52
69
  return {
53
70
  type: "application/json",
54
71
  body: JSON.stringify({
55
72
  text, repo: basename(config.root), number, id: keyOf(req), role: req.role, action: req.action,
56
- target: req.target, owners: req.owners ?? [], grant: grantFor(req),
73
+ target: req.target, owners, grant: grantFor(req), handoff: d,
57
74
  }),
58
75
  };
59
76
  return { type: "text/plain; charset=utf-8", body: text };
@@ -81,10 +98,10 @@ export function notifier(config, { env = process.env, fetchImpl = globalThis.fet
81
98
  const key = keyOf(req);
82
99
  if (known.has(key)) return false;
83
100
  known.add(key);
84
- const queue = pending(file);
101
+ const queue = queueOf(config);
85
102
  const number = queue.findIndex((p) => p.key === key) + 1;
86
103
  if (number < 1) return false;
87
- 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);
88
105
  inflight.push(
89
106
  fetchImpl(url, {
90
107
  method: "POST",
@@ -95,20 +112,37 @@ export function notifier(config, { env = process.env, fetchImpl = globalThis.fet
95
112
  signal: AbortSignal.timeout(timeoutMs),
96
113
  })
97
114
  .then((r) => { if (!r.ok) failures.push(`HTTP ${r.status}`); })
98
- .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))),
99
118
  );
100
119
  return true;
101
120
  }
102
121
 
103
122
  async function settle(capMs = timeoutMs + 500) {
104
123
  if (!inflight.length) return failures;
105
- 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
+ }
106
130
  return failures;
107
131
  }
108
132
 
109
133
  return { enabled: !!url, maybe, settle };
110
134
  }
111
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
+
112
146
  /**
113
147
  * `[notify]` from seisin.toml, refused rather than guessed when it would leak.
114
148
  * Called by the config loader.
@@ -127,20 +161,32 @@ export function readNotify(table, { path, root, keyDirs, roles, own }) {
127
161
  if (urlFile === undefined) return { urlFile: null, format };
128
162
  if (typeof urlFile !== "string" || !urlFile.trim() || urlFile.startsWith("/") || urlFile.split("/").includes(".."))
129
163
  throw new Error(`${path}: [notify] url_file must be a path inside the repo`);
130
- 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));
131
167
  if (!inKeyDir)
132
168
  throw new Error(
133
169
  `${path}: [notify] url_file "${urlFile}" is not inside a key directory, so every role can read ` +
134
170
  `it. Move it under ${keyDirs[0] ?? "a [keys] dir"}.`);
135
171
  // A file key is written bare (`netlify.txt`, meaning the first key directory)
136
172
  // or with its directory (`shared/api.txt`) — the same resolution settingsFor uses.
137
- const resolve = (raw) => (raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
173
+ const fileKey = (raw) => canonical(raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
138
174
  const holders = Object.entries(roles)
139
- .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
+ }))
140
186
  .map(([n]) => n);
141
187
  if (holders.length)
142
188
  throw new Error(
143
189
  `${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.`);
190
+ `that role could read it and send the person a fake "grant this". No role may hold it.`);
145
191
  return { urlFile, format };
146
192
  }