seisin 0.6.1 → 0.6.3

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 CHANGED
@@ -280,6 +280,7 @@ seisin explain frontend write src/api/x # ask one question, exit 0 or 1
280
280
  seisin review --since 7d # what the log says about the policy, this week
281
281
  seisin wire # install the PreToolUse hook in this repo
282
282
  seisin ui # a console for deciding what is waiting
283
+ seisin ui --runs <dir> # read only, over many runs a launcher archived (docs/runs.md)
283
284
  ```
284
285
 
285
286
  **Three ways in, and they say the same thing.** The **CLI** is what a person types and what
@@ -854,7 +855,7 @@ A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bit
854
855
  confined process. Nothing degrades: a provider that fails stops the run.
855
856
 
856
857
  A key reaches the role as a variable (`key_mode = "env"`), as a file in the run's private
857
- directory (`"run-file"`), or, on Linux, not at all: with `"inject"` the agent holds a
858
+ directory (`"run-file"`), or not at all (on Linux, and on macOS under tartaros): with `"inject"` the agent holds a
858
859
  placeholder and the sandbox swaps in the real value only on the way to the hosts you list for
859
860
  it. That costs terminating TLS to those hosts, which `seisin check` names, and a host that
860
861
  stores what it is sent can still hand the value back — use a least-scope token.
@@ -1036,9 +1037,9 @@ the same kernel or workspace, and which one you want depends on what you are pro
1036
1037
 
1037
1038
  ## Status
1038
1039
 
1039
- **1090 tests on macOS, 1083 passing and 7 platform skips** (0.6.1, 2026-10-26, local); in CI the
1040
- same day, on `ubuntu-latest` **1089, 1060 passing and 29 platform skips**, and on macOS 1090,
1041
- 1081 passing and 9 skips, Node 18/20/22/24, with the 26 core boundary tests skipping none. On 2026-10-12, 91 of the suite's tests
1040
+ **1103 tests on macOS, 1096 passing and 7 platform skips** (after 0.6.3, 2026-10-26, local); in CI the
1041
+ same day, on `ubuntu-latest` **1100, 1071 passing and 29 platform skips**, and on macOS 1101,
1042
+ 1092 passing and 9 skips, Node 18/20/22/24, with the 26 core boundary tests skipping none. On 2026-10-12, 91 of the suite's tests
1042
1043
  needed `@anthropic-ai/sandbox-runtime` installed — counted by running it with the runtime
1043
1044
  hidden — and run real commands through the real kernel; 26 of them are the core boundary file,
1044
1045
  `test/sandbox.test.js`, which the CI gate fails if it *skips*, because a green run that quietly
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it denies something it tells you whose file it was.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -45,7 +45,7 @@
45
45
  "@anthropic-ai/sandbox-runtime": "0.0.81"
46
46
  },
47
47
  "optionalDependencies": {
48
- "@carlostapiacl/tartaros": "0.1.0-alpha.14"
48
+ "@carlostapiacl/tartaros": "0.1.0-alpha.16"
49
49
  },
50
50
  "scripts": {
51
51
  "test": "node --test test/*.test.js",
package/src/canary.js CHANGED
@@ -130,6 +130,16 @@ export async function runCanary({ srt, wrap = null, settingsFile, settings, env,
130
130
  }
131
131
 
132
132
  if (timedOut) failed.push(`the canary did not finish in ${timeoutMs / 1000}s`);
133
+ // tartaros refuses a run whose real value another process exposes (its exec
134
+ // record, which the agent can read; on macOS no sandbox hides it). That is not
135
+ // a boundary to fix in the policy, and its own advice names a flag seisin does
136
+ // not pass, so it is said apart, as the variable and the process.
137
+ const exposed = [...stderr.matchAll(/tartaros: secret exposed: the value of (\S+) in pid (\d+) \(([^)]*)\)/g)]
138
+ .map(([, name, pid, proc]) => ({ name, pid: Number(pid), proc }));
139
+ if (exposed.length) {
140
+ for (const p of [secret, outside, insideDir, shared]) if (p) try { rmSync(p, { force: true, recursive: p === insideDir }); } catch {}
141
+ return { ok: false, exposed, failed: exposed.map((e) => `${e.name}'s real value is readable in pid ${e.pid} (${e.proc})`), ms: Date.now() - t0 };
142
+ }
133
143
  if (!output.includes(`ALIVE-${nonce}`)) {
134
144
  // The runtime's own words, when it said any: a sandbox that refuses to
135
145
  // start usually says why (measured: a path the runtime read as a broken
package/src/cli.js CHANGED
@@ -57,6 +57,7 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
57
57
  seisin init [--force] propose a ${CONFIG_NAME} for this repo
58
58
  seisin init --from-observations write ${CONFIG_NAME}.observed from observed runs
59
59
  seisin ui [--port n] [--link] open the console, live (--link reopens a running one)
60
+ seisin ui --runs <dir> [--link --json] a read-only console over a directory of runs (docs/runs.md)
60
61
  seisin mcp an MCP server on stdio — read-only
61
62
 
62
63
  seisin <command> --help lists a command's flags, examples and exit codes.
@@ -105,7 +106,7 @@ const COMMANDS = {
105
106
  },
106
107
  // --link only reads a record; it answers without a policy here too, and
107
108
  // says so when the console it finds serves another one.
108
- ui: async () => (await import("./commands/ui.js")).ui(argv.includes("--link") ? maybeConfig() : config(), argv),
109
+ ui: async () => (await import("./commands/ui.js")).ui(argv.includes("--link") || argv.includes("--runs") ? maybeConfig() : config(), argv),
109
110
  whose: async () => void (await import("./commands/whose.js")).whose(config(), argv),
110
111
  wire: async () => void (await import("./commands/wire.js")).wire(config()),
111
112
  review: async () => ((await import("./commands/review.js")).reviewCommand(config(), argv).friction.length ? 1 : 0),
@@ -496,6 +496,14 @@ export async function run(config, argv) {
496
496
  event: "canary", role, run: theRun.id.slice(0, 8), ...trace, agent: agent ?? null, confined: false, reasons: probe.failed,
497
497
  ...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
498
498
  });
499
+ if (probe.exposed) {
500
+ err(`${C.red}seisin: ${role} was NOT started — another process holds a real value the agent would see:${C.off}\n` +
501
+ probe.failed.map((f) => ` - ${f}\n`).join("") +
502
+ ` A process's environment and arguments are readable by every process of your user (on macOS no sandbox ` +
503
+ `hides them), so masking would hide nothing. Usually a shell that exported the token, and what it started. ` +
504
+ `Unset it there or end that process, and keep the value only where seisin's key points (docs/keys.md).\n`);
505
+ process.exit(CANARY_EXIT);
506
+ }
499
507
  err(`${C.red}seisin: ${role} was NOT started — the sandbox failed its canary:${C.off}\n` +
500
508
  probe.failed.map((f) => ` - ${f}\n`).join("") +
501
509
  ` The policy as generated does not hold on this machine, so no agent runs under it. ` +
@@ -14,6 +14,8 @@
14
14
  * failing. See uilink.js.
15
15
  */
16
16
  import { spawnSync } from "node:child_process";
17
+ import { statSync } from "node:fs";
18
+ import { resolve } from "node:path";
17
19
  import { serve } from "../serve.js";
18
20
  import { writeUiLink, readUiLink, clearUiLink } from "../uilink.js";
19
21
  import { C, out } from "../render.js";
@@ -23,28 +25,58 @@ function open(url) {
23
25
  spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
24
26
  }
25
27
 
28
+ /**
29
+ * `--runs <dir>`: the console over a directory of runs, read only (runs.js,
30
+ * docs/runs.md), on 4179 unless told otherwise, so it does not take the port a
31
+ * repository's own console would want.
32
+ */
33
+ function runsDirOf(argv) {
34
+ const i = argv.indexOf("--runs");
35
+ if (i === -1) return null;
36
+ const dir = argv[i + 1];
37
+ if (!dir || dir.startsWith("--")) throw new Error("--runs needs a directory: seisin ui --runs <dir>");
38
+ let st = null;
39
+ try { st = statSync(dir); } catch {}
40
+ if (!st?.isDirectory()) throw new Error(`--runs ${dir}: not a directory`);
41
+ return resolve(dir);
42
+ }
43
+
26
44
  export async function ui(config, argv = []) {
45
+ const runsDir = runsDirOf(argv);
46
+ const mode = runsDir ? "runs" : "policy";
27
47
  const i = argv.indexOf("--port");
28
- const port = i === -1 ? 4178 : Number(argv[i + 1]);
48
+ const port = i === -1 ? (runsDir ? 4179 : 4178) : Number(argv[i + 1]);
49
+ const json = argv.includes("--json");
29
50
 
30
51
  // `seisin ui --link`: print the live link a running console left, and open
31
52
  // it, without starting anything. For when the terminal that had it is gone.
53
+ // With --json, the record for a program to read — `{url, port, mode}` — and
54
+ // nothing opened: the launcher's own panel links to it.
32
55
  if (argv.includes("--link")) {
33
56
  const found = readUiLink(port);
57
+ if (json) {
58
+ if (found && found.alive === false) clearUiLink(port);
59
+ const live = found && found.alive !== false;
60
+ out(JSON.stringify(live
61
+ ? { running: true, url: found.url, port, mode: found.mode, ...(found.dir && { dir: found.dir }), ...(found.policy && { policy: found.policy }) }
62
+ : { running: false, port }) + "\n");
63
+ return;
64
+ }
34
65
  if (found && found.alive !== false) {
35
66
  out(`\n ${C.b}${found.url}${C.off}\n ${C.dim}the link a seisin ui on ${port} is serving. open it here, do not paste it anywhere.${C.off}\n` +
36
- servesWhich(found.policy, config?.path) + "\n");
67
+ servesWhich(found.policy, runsDir ? null : config?.path, found) + "\n");
37
68
  open(found.url);
38
69
  return;
39
70
  }
40
71
  if (found && found.alive === false) clearUiLink(port);
41
- out(`\n ${C.dim}no seisin ui running on ${port} — start one with: seisin ui${port === 4178 ? "" : ` --port ${port}`}${C.off}\n\n`);
72
+ const start = runsDir ? `seisin ui --runs ${runsDir}${port === 4179 ? "" : ` --port ${port}`}` : `seisin ui${port === 4178 ? "" : ` --port ${port}`}`;
73
+ out(`\n ${C.dim}no seisin ui running on ${port} — start one with: ${start}${C.off}\n\n`);
42
74
  return;
43
75
  }
44
76
 
45
77
  let server;
46
78
  try {
47
- server = await serve(config.path, port);
79
+ server = await serve(runsDir ? null : config.path, port, { runsDir });
48
80
  } catch (e) {
49
81
  // A console is already on this port: hand back its live link rather than
50
82
  // erroring, which is the case that sends people digging in the terminal.
@@ -54,7 +86,7 @@ export async function ui(config, argv = []) {
54
86
  out(
55
87
  `\n ${C.b}${found.url}${C.off}\n` +
56
88
  ` ${C.dim}a seisin ui is already running on ${port}; reopening its link. ctrl-c that terminal to stop it.${C.off}\n` +
57
- servesWhich(found.policy, config.path) + "\n"
89
+ servesWhich(found.policy, runsDir ? null : config.path, found) + "\n"
58
90
  );
59
91
  open(found.url);
60
92
  return;
@@ -70,19 +102,22 @@ export async function ui(config, argv = []) {
70
102
  const bound = server.address().port;
71
103
  const url = `http://127.0.0.1:${bound}/#t=${server.seisinToken}`;
72
104
  // Recorded before we print, so `--link` and a second `seisin ui` can find it.
73
- writeUiLink(bound, url, undefined, config.path);
105
+ writeUiLink(bound, url, undefined, runsDir ? null : config.path, { mode, dir: runsDir });
74
106
 
75
107
  // local_binding opens every port (macOS); local_ports opens the ones it
76
108
  // names, on every platform — so only a role that names THIS port counts.
77
- const reach = Object.values(config.roles)
109
+ const reach = Object.values(config?.roles ?? {})
78
110
  .filter((r) => (r.localBinding && process.platform === "darwin") || (r.localPorts ?? []).includes(bound))
79
111
  .map((r) => r.name);
80
112
 
81
113
  out(
82
114
  `\n ${C.b}${url}${C.off}\n` +
83
- ` ${C.dim}reading ${config.path} and the log, live. ctrl-c to stop.${C.off}\n` +
115
+ (runsDir
116
+ ? ` ${C.dim}reading the runs in ${runsDir}, live and read only: nothing is granted from here. ctrl-c to stop.${C.off}\n` +
117
+ ` ${C.dim}one run: add &run=<run_id> to the link.${C.off}\n`
118
+ : ` ${C.dim}reading ${config.path} and the log, live. ctrl-c to stop.${C.off}\n`) +
84
119
  ` ${C.dim}the link carries this run's token: open it here, do not paste it anywhere.${C.off}\n` +
85
- ` ${C.dim}lost it? this terminal has it above, or run: seisin ui --link${bound === 4178 ? "" : ` --port ${bound}`}${C.off}\n` +
120
+ ` ${C.dim}lost it? this terminal has it above, or run: seisin ui${runsDir ? ` --runs ${runsDir}` : ""} --link${bound === (runsDir ? 4179 : 4178) ? "" : ` --port ${bound}`}${C.off}\n` +
86
121
  (reach.length
87
122
  ? ` ${C.yellow}${reach.join(", ")} can reach this port on localhost: they can load this page, ` +
88
123
  `but not the token, which only travels in the link above.${C.off}\n`
@@ -101,7 +136,8 @@ export async function ui(config, argv = []) {
101
136
  * Which policy a running console serves, and a warning when it is not the one
102
137
  * this directory would use. `here` is null when there is no policy here.
103
138
  */
104
- export function servesWhich(served, here) {
139
+ export function servesWhich(served, here, found = null) {
140
+ if (found?.mode === "runs") return ` ${C.dim}serving the runs in ${found.dir} (read only)${C.off}\n`;
105
141
  if (!served) return ` ${C.dim}(started by an older seisin: which seisin.toml it serves is not recorded)${C.off}\n`;
106
142
  if (here && served !== here)
107
143
  return ` ${C.yellow}it serves ${served} — not ${here}, the policy for this directory.${C.off}\n` +
package/src/help.js CHANGED
@@ -123,10 +123,12 @@ export const COMMAND_HELP = {
123
123
  exits: [["0", "written"], ["2", "a policy is already there, or nothing was observed"]],
124
124
  },
125
125
  ui: {
126
- usage: ["seisin ui [--port <n>] [--link]"],
127
- values: ["--port"],
128
- flags: [["--port <n>", "port on 127.0.0.1 (default 4178)"], ["--link", "print the link of a console already running, start nothing"]],
129
- examples: ["seisin ui", "seisin ui --link"],
126
+ usage: ["seisin ui [--port <n>] [--link]", "seisin ui --runs <dir> [--port <n>] [--link [--json]]"],
127
+ values: ["--port", "--runs"],
128
+ flags: [["--port <n>", "port on 127.0.0.1 (default 4178; 4179 with --runs)"], ["--link", "print the link of a console already running, start nothing"],
129
+ ["--runs <dir>", "a read-only console over a directory of runs, one folder each (docs/runs.md)"],
130
+ ["--json", "with --link: {running, url, port, mode} for a program to read, nothing opened"]],
131
+ examples: ["seisin ui", "seisin ui --link", "seisin ui --runs ~/.wapentake/seisin/runs", "seisin ui --runs ~/.wapentake/seisin/runs --link --json"],
130
132
  exits: [["0", "stopped"], ["2", "could not start"]],
131
133
  },
132
134
  mcp: {
@@ -11,6 +11,7 @@
11
11
  * price printed next to the thing it buys.
12
12
  */
13
13
  import { injectKeysOf } from "../keys.js";
14
+ import { sandboxesOf } from "../sandboxes/index.js";
14
15
  import { coversHost as covers } from "../srt.js";
15
16
 
16
17
  export function injectWarnings(config, roles) {
@@ -27,14 +28,24 @@ export function injectWarnings(config, roles) {
27
28
  if (inject.length === 0) continue;
28
29
  const allowed = r.network ?? config.allowedDomains ?? [];
29
30
 
30
- if (process.platform !== "linux")
31
+ // Which sandbox masks is the sandbox's to say (sandboxes/, `masks`): on macOS
32
+ // only tartaros does, so a provider list without it cannot run the role here.
33
+ const names = sandboxesOf(config).map((s) => s.name);
34
+ if (process.platform === "darwin" && !names.includes("tartaros"))
31
35
  out.push({
32
36
  kind: "inject-platform",
33
37
  role: r.name,
34
- headline: `${r.name}: key_mode = "inject" is not available on ${process.platform} — it is Linux-only for now`,
35
- detail: `On this machine the role will not run. Masked injection on macOS is tracked upstream ` +
36
- `(anthropics/sandbox-runtime#593) and enables once that lands. Run this role on Linux, or ` +
37
- `use "env"/"run-file" here.`,
38
+ headline: `${r.name}: key_mode = "inject" needs tartaros on macOS — [sandbox] provider is ${names.join(", ")}`,
39
+ detail: `On macOS sandbox-runtime does not mask credentials yet (anthropics/sandbox-runtime#593), so ` +
40
+ `this role will not run here. Add tartaros: [sandbox] provider = ["tartaros", "srt"]. Or run it on ` +
41
+ `Linux, or use "env"/"run-file" here.`,
42
+ });
43
+ else if (process.platform !== "linux" && process.platform !== "darwin")
44
+ out.push({
45
+ kind: "inject-platform",
46
+ role: r.name,
47
+ headline: `${r.name}: key_mode = "inject" is not available on ${process.platform}`,
48
+ detail: `No sandbox seisin runs here masks credentials. Run this role on Linux or macOS, or use "env"/"run-file".`,
38
49
  });
39
50
 
40
51
  for (const c of inject) {
package/src/keys.js CHANGED
@@ -30,8 +30,8 @@
30
30
  * policy holds a reviewable reference instead of a path, and every resolution
31
31
  * is logged without its value (`event: "key"`, see commands/run.js) — but it is not "the agent never sees it". That
32
32
  * is `inject`, which costs terminating the role's TLS with a CA of your own and
33
- * is Linux-only for now (see `injectGate` and `injectKeysOf` below); off Linux
34
- * `modeOf` holds it, with the reason in the message.
33
+ * needs a sandbox that masks credentials: srt on Linux, tartaros on Linux and
34
+ * macOS (see `injectGate` below, and `masks` on each sandbox).
35
35
  *
36
36
  * ## Why the value is never written down
37
37
  *
@@ -522,23 +522,22 @@ export function modeOf(config, role, entry) {
522
522
  }
523
523
 
524
524
  /**
525
- * `inject` is the mode where the agent never sees the value: the runtime hands
526
- * the sandbox a sentinel and substitutes the real value on egress to the
527
- * credential's declared hosts. It is Linux-only in this version — masked
528
- * delivery is validated on the Linux backend; the macOS path is not enabled
529
- * yet and is tracked upstream, where the fit for it is a credential computed
530
- * per run rather than read from the environment
531
- * (anthropics/sandbox-runtime#593). Until that lands, macOS `inject` is held
532
- * rather than offered, with the alternatives in the message.
525
+ * `inject` is the mode where the agent never sees the value: the sandbox hands
526
+ * the agent a sentinel and substitutes the real value on egress to the
527
+ * credential's declared hosts. Which sandbox can do that is the sandbox's to
528
+ * say (`masks` in sandboxes/): srt on Linux, tartaros on Linux and macOS
529
+ * (srt's macOS path is tracked upstream, anthropics/sandbox-runtime#593). A
530
+ * sandbox that does not mask is never handed the role, because the real value
531
+ * is in the environment it is started with. Here only the platforms where no
532
+ * sandbox masks are refused outright.
533
533
  */
534
534
  function injectGate(role) {
535
- if (process.platform === "linux") return;
535
+ if (process.platform === "linux" || process.platform === "darwin") return;
536
536
  throw new Error(
537
- `roles.${role.name}: key_mode = "inject" is not available on ${process.platform} yet.\n` +
538
- ` It is Linux-only for now; masked injection on macOS is tracked upstream ` +
539
- `(anthropics/sandbox-runtime#593) and enables once that lands.\n` +
540
- ` Use "env" or "run-file" here, or run this role on Linux. ` +
541
- `See https://github.com/carlostapiacl/seisin/blob/main/docs/decisions.md.`);
537
+ `roles.${role.name}: key_mode = "inject" is not available on ${process.platform}.\n` +
538
+ ` No sandbox seisin runs here masks credentials: srt does on Linux, tartaros on Linux and macOS.\n` +
539
+ ` Use "env" or "run-file" here, or run this role on Linux or macOS. ` +
540
+ `See https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md.`);
542
541
  }
543
542
 
544
543
  /**
package/src/runs.js ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * `seisin ui --runs <dir>`: the console over many runs, each with its own policy.
3
+ *
4
+ * A launcher that lends each run a territory of its own (wapentake does) has no
5
+ * single seisin.toml to open: every run gets one written for it at the root of
6
+ * a worktree, runs there, and the worktree is gone a day or two later. What is
7
+ * worth keeping is copied out — the policy, the `.seisin/` beside it and a
8
+ * `run.json` the launcher writes — one folder per run, under one directory.
9
+ * This reads that directory. The contract is docs/runs.md.
10
+ *
11
+ * Read only, on purpose. The territory is the launcher's to lend: a grant
12
+ * written into an archived run's seisin.toml changes nothing anywhere, so the
13
+ * console in this mode offers none and the server refuses every write.
14
+ *
15
+ * A run that is archived does not change, so what is worked out from it is
16
+ * kept for as long as its files' signature stays the same — the poll every two
17
+ * seconds over hundreds of runs re-reads a directory listing and some stats,
18
+ * not every log.
19
+ */
20
+ import { readdirSync, readFileSync, statSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { loadConfig } from "./config.js";
23
+ import { read, logPath } from "./log.js";
24
+ import { requestsPath } from "./requests.js";
25
+
26
+ /** The name a run's folder may have: what becomes `run=` in a link. */
27
+ const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
28
+
29
+ /** The fields of run.json the console reads; anything else in it is ignored. */
30
+ const META = ["run_id", "team", "work_item", "role", "repo", "started_at", "ended_at", "status", "launcher"];
31
+
32
+ /** How many causes the global view is sent, summed over the runs shown. */
33
+ const CAUSES_SHOWN = 50;
34
+
35
+ /** How many of a run's own causes travel with it in the list. */
36
+ const RUN_CAUSES = 5;
37
+
38
+ export function isRunId(id) {
39
+ return typeof id === "string" && RUN_ID.test(id) && id !== "." && id !== "..";
40
+ }
41
+
42
+ function statSig(p) {
43
+ try { const s = statSync(p); return `${s.ino}:${s.size}:${s.mtimeMs}`; } catch { return "-"; }
44
+ }
45
+
46
+ /** The run folders under `dir`, by name; a file or a name that is not a run id is skipped. */
47
+ export function runFolders(dir) {
48
+ let names;
49
+ try { names = readdirSync(dir, { withFileTypes: true }); }
50
+ catch (e) { throw Object.assign(new Error(`cannot read the runs directory ${dir}: ${e.message}`), { status: 500 }); }
51
+ return names.filter((d) => d.isDirectory() && isRunId(d.name)).map((d) => d.name).sort();
52
+ }
53
+
54
+ /** Where a run's policy is. */
55
+ export function runPolicy(dir, id) {
56
+ return join(dir, id, "seisin.toml");
57
+ }
58
+
59
+ /**
60
+ * What a run's summary is made from, as stats only: the policy, run.json, the
61
+ * queue, the current log and the `.seisin/` directory itself, whose mtime
62
+ * moves when a segment is rotated in or out. No directory is listed: six
63
+ * hundred `readdir`s were most of a warm poll (~0.7 s).
64
+ */
65
+ function signatureOf(dir, id) {
66
+ const root = join(dir, id);
67
+ const log = logPath(root);
68
+ return [
69
+ statSig(join(root, "seisin.toml")),
70
+ statSig(join(root, "run.json")),
71
+ statSig(requestsPath(root)),
72
+ statSig(log),
73
+ statSig(dirname(log)),
74
+ ].join("|");
75
+ }
76
+
77
+ /** run.json, the launcher's word on the run — or null when it is missing or not JSON. */
78
+ function metaOf(dir, id) {
79
+ let raw;
80
+ try { raw = JSON.parse(readFileSync(join(dir, id, "run.json"), "utf8")); } catch { return null; }
81
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
82
+ const out = {};
83
+ for (const k of META) if (raw[k] !== undefined && raw[k] !== null) out[k] = typeof raw[k] === "string" ? raw[k] : String(raw[k]);
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * What one run comes to: who it was, what it was denied, what the denials
89
+ * cost in retries (every denial after the first of the same thing, system
90
+ * denials aside), and whether it was let start at all.
91
+ *
92
+ * `outcome` is seisin's word, beside the launcher's `status`:
93
+ * refused — its canary failed, so nothing ran;
94
+ * denied — it ran and was denied a file or a host;
95
+ * clean — it ran and was denied neither (system denials, the OS's own
96
+ * housekeeping, and the canary's expected probes do not count);
97
+ * unread — its policy could not be read.
98
+ */
99
+ function summarize(dir, id) {
100
+ const meta = metaOf(dir, id);
101
+ const base = {
102
+ id,
103
+ run_id: meta?.run_id ?? id,
104
+ team: meta?.team ?? null,
105
+ work_item: meta?.work_item ?? null,
106
+ role: meta?.role ?? null,
107
+ repo: meta?.repo ?? null,
108
+ started_at: meta?.started_at ?? null,
109
+ ended_at: meta?.ended_at ?? null,
110
+ status: meta?.status ?? null,
111
+ launcher: meta?.launcher ?? null,
112
+ meta: meta !== null,
113
+ };
114
+ let cfg;
115
+ try { cfg = loadConfig(runPolicy(dir, id)); }
116
+ catch (e) {
117
+ return { ...base, outcome: "unread", error: e.code === "ENOENT" ? "no seisin.toml in this run's folder" : e.message,
118
+ roles: [], denied: 0, byKind: {}, expected: 0, retried: 0, causes: [], hosts: [], entries: 0, canary: null };
119
+ }
120
+ const all = read(logPath(cfg.root));
121
+ /**
122
+ * Grouped here, by what was denied, rather than through causesOf and walls:
123
+ * those ask the policy and the disk about every path (~130 ms a run on the
124
+ * pilot's logs, a minute and more over six hundred), to say whether a
125
+ * denial would still be one today. An archived run's policy does not change,
126
+ * so every repeat of a denial is a retry against a standing wall, and the
127
+ * per-run view, which is one run, still gets the full answer from state().
128
+ */
129
+ const byKind = {};
130
+ let denied = 0, expected = 0;
131
+ const groups = new Map();
132
+ for (const e of all) {
133
+ if (e.verdict !== "denied") continue;
134
+ if (e.expected === true) { expected++; continue; }
135
+ const k = e.kind ?? "file";
136
+ byKind[k] = (byKind[k] ?? 0) + 1;
137
+ if (k !== "system") denied++;
138
+ if (!e.action || !e.target) continue;
139
+ const key = `${e.action}\u0000${e.target}`;
140
+ const g = groups.get(key) ?? { about: k, action: e.action, target: e.target, times: 0, owners: e.owners ?? [] };
141
+ g.times++;
142
+ groups.set(key, g);
143
+ }
144
+ const causes = [...groups.values()].sort((a, b) => b.times - a.times);
145
+ const retried = causes.reduce((n, g) => n + (g.about === "system" ? 0 : g.times - 1), 0);
146
+ const hosts = causes.filter((g) => g.about === "network").slice(0, 10).map(({ target, times }) => ({ target, times }));
147
+ const canaries = all.filter((e) => e.event === "canary");
148
+ const canary = canaries.length ? canaries[canaries.length - 1] : null;
149
+ const outcome = canaries.some((e) => e.confined === false) ? "refused" : denied ? "denied" : "clean";
150
+ return {
151
+ ...base,
152
+ // A run.json that did not say when it started: the log's first line did.
153
+ started_at: base.started_at ?? all.find((e) => e.at)?.at ?? null,
154
+ ended_at: base.ended_at ?? [...all].reverse().find((e) => e.at)?.at ?? null,
155
+ outcome,
156
+ roles: Object.keys(cfg.roles),
157
+ sandbox: canary?.sandbox ?? null,
158
+ entries: all.length,
159
+ denied,
160
+ expected,
161
+ byKind,
162
+ retried,
163
+ hosts,
164
+ // Kept whole for the sum across runs, cut to RUN_CAUSES when sent.
165
+ causes,
166
+ canary: canary ? { confined: canary.confined !== false, reasons: canary.reasons ?? [] } : null,
167
+ };
168
+ }
169
+
170
+ /** Summaries kept by run id, with the signature they were made from. */
171
+ const cache = new Map();
172
+
173
+ /** Every run under `dir`, summarized, re-read only where a file moved. */
174
+ export function scanRuns(dir) {
175
+ const ids = runFolders(dir);
176
+ const seen = new Set(ids);
177
+ for (const k of cache.keys()) if (k.startsWith(`${dir}\u0000`) && !seen.has(k.slice(dir.length + 1))) cache.delete(k);
178
+ const sigs = [];
179
+ const runs = ids.map((id) => {
180
+ const sig = signatureOf(dir, id);
181
+ sigs.push(`${id}=${sig}`);
182
+ const k = `${dir}\u0000${id}`;
183
+ const hit = cache.get(k);
184
+ if (hit && hit.sig === sig) return hit.summary;
185
+ const summary = summarize(dir, id);
186
+ cache.set(k, { sig, summary });
187
+ return summary;
188
+ });
189
+ return { runs, sig: sigs.join("\n") };
190
+ }
191
+
192
+ /** The filters the global view takes, from a query string. Unknown keys are ignored. */
193
+ export const FILTERS = ["team", "role", "work_item", "status", "outcome"];
194
+
195
+ export function filtersOf(query) {
196
+ const q = new URLSearchParams(query ?? "");
197
+ const out = {};
198
+ for (const f of FILTERS) { const v = q.get(f); if (v) out[f] = v; }
199
+ const text = q.get("q");
200
+ if (text) out.q = text.toLowerCase();
201
+ return out;
202
+ }
203
+
204
+ function matches(run, filters) {
205
+ for (const f of FILTERS) if (filters[f] !== undefined && (run[f] ?? "") !== filters[f]) return false;
206
+ if (filters.q) {
207
+ const hay = [run.id, run.run_id, run.work_item, run.repo, run.team, run.role].filter(Boolean).join(" ").toLowerCase();
208
+ if (!hay.includes(filters.q)) return false;
209
+ }
210
+ return true;
211
+ }
212
+
213
+ /** How many runs have each value of each filter — over every run, so a filter never hides its siblings. */
214
+ function facetsOf(runs) {
215
+ const out = {};
216
+ for (const f of FILTERS) {
217
+ const m = new Map();
218
+ for (const r of runs) { const v = r[f]; if (v) m.set(v, (m.get(v) ?? 0) + 1); }
219
+ out[f] = [...m].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]))).map(([value, runs]) => ({ value, runs }));
220
+ }
221
+ return out;
222
+ }
223
+
224
+ /**
225
+ * The same refusal across runs is one cause: summed by what was refused, with
226
+ * how many runs hit it and which teams and roles they were.
227
+ */
228
+ function causesAcross(runs) {
229
+ const by = new Map();
230
+ for (const r of runs) {
231
+ for (const c of r.causes) {
232
+ const k = `${c.action}\u0000${c.target}`;
233
+ const g = by.get(k) ?? { action: c.action, target: c.target, about: c.about, owners: c.owners, times: 0, runs: 0, teams: new Set(), roles: new Set(), last: null };
234
+ g.times += c.times;
235
+ g.runs++;
236
+ if (r.team) g.teams.add(r.team);
237
+ if (r.role) g.roles.add(r.role);
238
+ if (!g.last || (r.started_at && r.started_at > g.last.at)) g.last = { run: r.id, at: r.started_at };
239
+ by.set(k, g);
240
+ }
241
+ }
242
+ return [...by.values()]
243
+ // The OS's own housekeeping last: it is in every run and nobody grants it.
244
+ .sort((a, b) => (a.about === "system") - (b.about === "system") || b.runs - a.runs || b.times - a.times)
245
+ .slice(0, CAUSES_SHOWN)
246
+ .map((g) => ({ ...g, teams: [...g.teams], roles: [...g.roles], last: g.last?.run ?? null }));
247
+ }
248
+
249
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
250
+
251
+ /** The global view: the runs that pass the filters, newest first, and what they add up to. */
252
+ const views = new Map();
253
+
254
+ export function runsView(dir, filters = {}, { scanned = scanRuns(dir) } = {}) {
255
+ // The same runs and the same filters are the same answer: the poll asks
256
+ // every two seconds, and summing the causes of six hundred runs was most
257
+ // of what it cost once the runs themselves were remembered.
258
+ const key = `${dir}\u0000${JSON.stringify(filters)}\u0000${scanned.sig}`;
259
+ const hit = views.get(key);
260
+ if (hit) return hit;
261
+ const view = viewOf(dir, filters, scanned.runs);
262
+ views.set(key, view);
263
+ while (views.size > 8) views.delete(views.keys().next().value);
264
+ return view;
265
+ }
266
+
267
+ function viewOf(dir, filters, every) {
268
+ const shown = every.filter((r) => matches(r, filters))
269
+ .sort((a, b) => cmp(b.started_at ?? "", a.started_at ?? "") || cmp(b.id, a.id));
270
+ const totals = { runs: shown.length, denied: 0, retried: 0, expected: 0, byKind: {}, outcome: {} };
271
+ for (const r of shown) {
272
+ totals.denied += r.denied;
273
+ totals.retried += r.retried;
274
+ totals.expected += r.expected;
275
+ for (const [k, n] of Object.entries(r.byKind)) totals.byKind[k] = (totals.byKind[k] ?? 0) + n;
276
+ totals.outcome[r.outcome] = (totals.outcome[r.outcome] ?? 0) + 1;
277
+ }
278
+ return {
279
+ mode: "runs",
280
+ dir,
281
+ filters,
282
+ total: every.length,
283
+ facets: facetsOf(every),
284
+ totals,
285
+ causes: causesAcross(shown),
286
+ runs: shown.map((r) => ({ ...r, causes: r.causes.slice(0, RUN_CAUSES) })),
287
+ readOnly: true,
288
+ };
289
+ }
290
+
291
+ /** One run's summary, by id; throws 404 for a name that is not a run here. */
292
+ export function runOf(dir, id) {
293
+ if (!isRunId(id)) throw Object.assign(new Error(`not a run id: ${JSON.stringify(id)}`), { status: 400 });
294
+ if (!runFolders(dir).includes(id)) throw Object.assign(new Error(`no run ${id} in ${dir}`), { status: 404 });
295
+ const { runs } = scanRuns(dir);
296
+ const r = runs.find((x) => x.id === id);
297
+ return { ...r, causes: r.causes.slice(0, RUN_CAUSES) };
298
+ }
@@ -85,6 +85,9 @@ export function commandSandbox(name, command, check = null, { tagsDenials = fals
85
85
  assess,
86
86
  unmet: (settings, ctx) => assess(settings, ctx).unmet,
87
87
  wrap: (bin, file, _settings, inner) => [bin, ...withPolicy(command.slice(1), file), ...inner],
88
+ // A sandbox of your own is not known to mask credentials, and it is started
89
+ // with the real value in its environment: an inject role is never its.
90
+ masks: () => `masked credentials (key_mode = "inject"): ${name} is not known to mask them, and it would start with the real value in its environment`,
88
91
  // Declared, not detected: `tags_denials = true` says its Seatbelt denials
89
92
  // carry sandbox-runtime's tag (docs/sandboxes.md). Without it, on macOS,
90
93
  // none of the kernel's refusals under it reach the log.
@@ -71,10 +71,14 @@ export function weigh(config, settings, { platform = process.platform, role = nu
71
71
  // `browsers`: a Mach service to register. srt's schema has no field for it
72
72
  // (srt-mach.mjs adds it); tartaros takes it as network.allowMachRegister
73
73
  // (adapt). A sandbox that is given it neither way cannot hold the role.
74
+ // `inject`: the real value is in the environment the sandbox starts with,
75
+ // so only one that masks credentials may be handed the role.
76
+ const masked = (own.credentials?.envVars ?? []).length > 0;
77
+ const masking = masked ? (sandbox.masks ? sandbox.masks(platform) : `masked credentials: ${sandbox.name} does not mask them`) : null;
74
78
  const browser = register.length && !sandbox.machRegister
75
79
  ? [`a Mach service to register (${register.join(", ")}, for browsers): ${sandbox.name} is not given it, only srt and tartaros are`]
76
80
  : [];
77
- return { sandbox, bin, unmet: [...unmet, ...browser], warnings, missing: bin ? null : sandbox.missing() };
81
+ return { sandbox, bin, unmet: [...(masking ? [masking] : []), ...unmet, ...browser], warnings, missing: bin ? null : sandbox.missing() };
78
82
  });
79
83
  const chosen = options.find((o) => o.bin && o.unmet.length === 0) ?? null;
80
84
  return { chosen, options };