seisin 0.6.2 → 0.6.4

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
@@ -855,7 +855,7 @@ A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bit
855
855
  confined process. Nothing degrades: a provider that fails stops the run.
856
856
 
857
857
  A key reaches the role as a variable (`key_mode = "env"`), as a file in the run's private
858
- 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
859
859
  placeholder and the sandbox swaps in the real value only on the way to the hosts you list for
860
860
  it. That costs terminating TLS to those hosts, which `seisin check` names, and a host that
861
861
  stores what it is sent can still hand the value back — use a least-scope token.
@@ -1037,8 +1037,8 @@ the same kernel or workspace, and which one you want depends on what you are pro
1037
1037
 
1038
1038
  ## Status
1039
1039
 
1040
- **1101 tests on macOS, 1094 passing and 7 platform skips** (0.6.2, 2026-10-26, local); in CI the
1041
- same day, on `ubuntu-latest` **1100, 1071 passing and 29 platform skips**, and on macOS 1101,
1040
+ **1120 tests on macOS, 1113 passing and 7 platform skips** (with the 0.6.4 changes, 2026-10-26, local); for 0.6.3, in CI
1041
+ the same day, on `ubuntu-latest` **1100, 1071 passing and 29 platform skips**, and on macOS 1101,
1042
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
1043
1043
  needed `@anthropic-ai/sandbox-runtime` installed — counted by running it with the runtime
1044
1044
  hidden — and run real commands through the real kernel; 26 of them are the core boundary file,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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.15"
48
+ "@carlostapiacl/tartaros": "0.1.0-alpha.18"
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
@@ -13,11 +13,12 @@
13
13
  * never know: which role a path belongs to, and therefore who to ask next.
14
14
  */
15
15
  import { readFileSync } from "node:fs";
16
- import { join, dirname } from "node:path";
16
+ import { join, dirname, resolve } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
 
19
19
  import { findConfig, loadConfig } from "./config.js";
20
20
  import { CONFIG_NAME } from "./layout.js";
21
+ import { setStateDir } from "./state.js";
21
22
  import { C, out, err } from "./render.js";
22
23
  import { COMMAND_HELP, flagsOf, renderHelp, renderLimits } from "./help.js";
23
24
  import { nearest, checkFlags } from "./suggest.js";
@@ -36,6 +37,44 @@ import { nearest, checkFlags } from "./suggest.js";
36
37
  const HERE = dirname(fileURLToPath(import.meta.url));
37
38
  const [, , command, ...argv] = process.argv;
38
39
 
40
+ /**
41
+ * `--policy <file>` and `--state-dir <dir>`, for a launcher that keeps both out
42
+ * of the repo it runs in (an orchestrator with a folder per run). The policy is
43
+ * read from the file and rooted at the working directory; the log and the
44
+ * request queue go to the state directory. Both are taken out of seisin's own
45
+ * part of the line before the command reads it (after `--`, or after `run
46
+ * <role> <command>`, the words are the agent's), and both reach everything a
47
+ * run starts through the environment (SEISIN_CONFIG, SEISIN_ROOT,
48
+ * SEISIN_STATE_DIR).
49
+ */
50
+ function ownEnd() {
51
+ const split = argv.indexOf("--");
52
+ if (split !== -1) return split;
53
+ if (command !== "run") return argv.length;
54
+ let i = 1;
55
+ const values = new Set([...(flagsOf("run")?.values ?? []), "--policy", "--state-dir"]);
56
+ while (i < argv.length && argv[i].startsWith("-")) i += values.has(argv[i]) ? 2 : 1;
57
+ return i;
58
+ }
59
+ function takeGlobal(name) {
60
+ const i = argv.indexOf(name);
61
+ if (i === -1 || i >= ownEnd()) return null;
62
+ const v = argv[i + 1];
63
+ if (v === undefined || v.startsWith("-")) {
64
+ err(`${C.red}seisin:${C.off} ${name} needs a value\n`);
65
+ process.exit(2);
66
+ }
67
+ argv.splice(i, 2);
68
+ return v;
69
+ }
70
+ const policyFlag = takeGlobal("--policy");
71
+ const stateFlag = takeGlobal("--state-dir");
72
+ if (stateFlag) setStateDir(stateFlag);
73
+ const rootFlag = policyFlag ? process.cwd() : null;
74
+ if (policyFlag) {
75
+ process.env.SEISIN_ROOT = rootFlag;
76
+ }
77
+
39
78
  const USAGE = `
40
79
  ${C.b}seisin${C.off} — give each agent its own folders and its own keys
41
80
 
@@ -61,6 +100,9 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
61
100
  seisin mcp an MCP server on stdio — read-only
62
101
 
63
102
  seisin <command> --help lists a command's flags, examples and exit codes.
103
+ Any command takes --policy <file> (the policy, rooted at the working directory) and
104
+ --state-dir <dir> (where the log and the request queue go) for a launcher that keeps
105
+ both outside the repo (docs/runs.md).
64
106
  seisin help limits says what seisin cannot do, in any repo.
65
107
 
66
108
  Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
@@ -69,6 +111,7 @@ seisin decides what to ask for, and says whose file it was when the answer is no
69
111
 
70
112
  /** Loads the policy, or explains where to get one. */
71
113
  function config() {
114
+ if (policyFlag) return loadConfig(resolve(policyFlag), undefined, { root: rootFlag });
72
115
  const path = findConfig();
73
116
  if (!path) throw new Error(`no ${CONFIG_NAME} found here or above. Run "seisin init" to write one.`);
74
117
  return loadConfig(path);
@@ -76,6 +119,7 @@ function config() {
76
119
 
77
120
  /** The policy here, or null — for a command that can answer without one. */
78
121
  function maybeConfig() {
122
+ if (policyFlag) return config();
79
123
  const path = findConfig();
80
124
  return path ? loadConfig(path) : null;
81
125
  }
@@ -3,6 +3,11 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { inspect } from "../inspect.js";
5
5
  import { renderReport, out, C } from "../render.js";
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { stateDir } from "../state.js";
9
+
10
+ const say = out;
6
11
  import { measureSystemLog } from "../violations.js";
7
12
  import { join } from "node:path";
8
13
  import { settingsFor } from "../srt.js";
@@ -10,13 +15,29 @@ import { weigh } from "../sandboxes/index.js";
10
15
  import { runsRoot } from "../rundir.js";
11
16
  import { channelName } from "../spool.js";
12
17
 
18
+ /**
19
+ * What this seisin can be asked to do, for a launcher deciding which flags to
20
+ * pass: one list, instead of a version compared against minimums or a search
21
+ * through `--help`. A name is added when the feature ships and never removed
22
+ * while it works.
23
+ */
24
+ export const CAPABILITIES = [
25
+ "policy-flag", "state-dir", "run-id", "expect-denied", "agent-flag", "handoff", "handoff-false",
26
+ "browsers", "local-ports", "key-mode-inject", "check-json", "ui-runs",
27
+ ];
28
+
13
29
  export async function check(config, argv = []) {
14
30
  if (argv.includes("--parallel") && !argv.includes("--verify"))
15
31
  throw new Error("--parallel is experimental and requires --verify");
32
+ const json = argv.includes("--json");
16
33
  const role = argv.find((a) => !a.startsWith("-")) ?? null;
17
34
  const report = inspect(config, role, config.path);
18
- out(renderReport(report, { verbose: argv.includes("--verbose") }));
19
- if (config.sandbox?.provider) report.sandboxes = sandboxes(config, role);
35
+ if (!json) out(renderReport(report, { verbose: argv.includes("--verbose") }));
36
+ if (config.sandbox?.provider || json) report.sandboxes = sandboxes(config, role, { quiet: json });
37
+ if (json) {
38
+ out(JSON.stringify(asJson(config, report), null, 2) + "\n");
39
+ return report;
40
+ }
20
41
  if (argv.includes("--verify")) report.verify = await verify(config, role, argv.includes("--parallel"));
21
42
  if (argv.includes("--system-log")) report.systemLog = await systemLog();
22
43
  return report;
@@ -56,16 +77,17 @@ const SYSTEM_LOG_SECONDS = 3;
56
77
  * too. Printed only when `[sandbox] provider` is set: without it every role
57
78
  * runs under srt, as it always has, and there is nothing to choose.
58
79
  */
59
- export function sandboxes(config, only = null, { platform = process.platform } = {}) {
80
+ export function sandboxes(config, only = null, { platform = process.platform, quiet = false } = {}) {
81
+ const out = quiet ? () => {} : say;
60
82
  const sock = join(runsRoot(), "check", channelName(platform));
61
83
  const rows = [];
62
- out(`\n${C.b}sandboxes${C.off} (${config.sandbox.provider.join(" → ")})\n`);
84
+ out(`\n${C.b}sandboxes${C.off} (${(config.sandbox?.provider ?? ["srt"]).join(" → ")})\n`);
63
85
  for (const r of Object.values(config.roles)) {
64
86
  if (only && r.name !== only) continue;
65
87
  let settings;
66
88
  try { settings = settingsFor(config, r.name, sock); } catch (e) { rows.push({ role: r.name, chosen: null, error: e.message }); continue; }
67
89
  const { chosen, options } = weigh(config, settings, { platform, role: r.name });
68
- rows.push({ role: r.name, chosen: chosen?.sandbox.name ?? null,
90
+ rows.push({ role: r.name, chosen: chosen?.sandbox.name ?? null, bin: chosen?.bin ?? null,
69
91
  options: options.map((o) => ({ name: o.sandbox.name, missing: o.missing, unmet: o.unmet, warnings: o.warnings })) });
70
92
  out(chosen ? ` ${C.green}✓${C.off} ${r.name}: ${chosen.sandbox.name}\n` : ` ${C.red}✗${C.off} ${r.name}: no sandbox can hold it\n`);
71
93
  // Held, but not the way the role may expect: the sandbox's own words.
@@ -82,6 +104,32 @@ export function sandboxes(config, only = null, { platform = process.platform } =
82
104
  return rows;
83
105
  }
84
106
 
107
+ /** `--version`'s first line from a sandbox binary, or null: what a launcher would otherwise parse itself. */
108
+ function versionOf(bin) {
109
+ if (!bin || bin === "(not looked up)") return null;
110
+ const r = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 5000 });
111
+ return r.status === 0 ? (r.stdout || "").split("\n")[0].trim() || null : null;
112
+ }
113
+
114
+ /** `check --json`: the same answer as the text, for a program. */
115
+ function asJson(config, report) {
116
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8"));
117
+ return {
118
+ seisin: pkg.version,
119
+ platform: process.platform,
120
+ capabilities: CAPABILITIES,
121
+ policy: config.path,
122
+ root: config.root,
123
+ stateDir: stateDir(config.root),
124
+ roles: (report.sandboxes ?? []).map((s) => ({
125
+ role: s.role, sandbox: s.chosen, bin: s.bin ?? null, version: versionOf(s.bin), error: s.error ?? null,
126
+ unmet: (s.options ?? []).filter((o) => o.name !== s.chosen).map((o) => ({ sandbox: o.name, missing: o.missing, unmet: o.unmet })),
127
+ })),
128
+ warnings: report.warnings.map((w) => ({ kind: w.kind, role: w.role ?? null, headline: w.headline ?? w.message ?? null })),
129
+ ok: checkStatus(report) === 0,
130
+ };
131
+ }
132
+
85
133
  /**
86
134
  * The exit status: 1 when a role cannot be enforced as written, or when what a
87
135
  * flag asked for failed. `--system-log` is asked for like `--verify` and fails
@@ -30,7 +30,7 @@ export async function hook(stdin = process.stdin, env = process.env) {
30
30
 
31
31
  let config;
32
32
  try {
33
- config = loadConfig(env.SEISIN_CONFIG ?? findConfig());
33
+ config = loadConfig(env.SEISIN_CONFIG ?? findConfig(), undefined, { root: env.SEISIN_ROOT || null });
34
34
  } catch {
35
35
  return null;
36
36
  }
@@ -73,6 +73,46 @@ export function agentFor(mine, cmd) {
73
73
  return given;
74
74
  }
75
75
 
76
+ /**
77
+ * The egress refusals in a sandbox's own JSON-lines log (tartaros `--log`:
78
+ * `{axis: "network", outcome: "denied", target: "host:port", action}`), one per
79
+ * target with how many times: a client retrying a refused host is one wall, not
80
+ * fifty lines. Missing or unreadable: none, the run's own record still stands.
81
+ */
82
+ export function brokerDenials(file) {
83
+ let text;
84
+ try { text = readFileSync(file, "utf8"); } catch { return []; }
85
+ const by = new Map();
86
+ for (const line of text.split("\n")) {
87
+ let e;
88
+ try { e = JSON.parse(line); } catch { continue; }
89
+ if (e?.axis !== "network" || e.outcome !== "denied" || typeof e.target !== "string") continue;
90
+ // A request refused on a terminated connection says `host:port (why)`: the
91
+ // host is the wall, the why (method and path) is its reason.
92
+ const m = /^(\S+) \((.*)\)$/.exec(e.target);
93
+ const target = (m ? m[1] : e.target).replace(/\/$/, "").slice(0, 300);
94
+ const reason = m ? m[2].slice(0, 300) : String(e.action ?? "denied");
95
+ const d = by.get(`${target}\u0000${reason}`) ?? { target, reason, times: 0 };
96
+ d.times++;
97
+ by.set(`${target}\u0000${reason}`, d);
98
+ }
99
+ return [...by.values()];
100
+ }
101
+
102
+ /**
103
+ * `--run-id <id>`: the launcher's own id for this run (an orchestrator's ULID),
104
+ * used instead of a random one wherever the run is named. Letters, digits, `-`
105
+ * and `_`, 64 at most: it becomes part of a directory name and of log lines.
106
+ */
107
+ export function runIdOf(mine) {
108
+ const at = mine.lastIndexOf("--run-id");
109
+ if (at === -1) return null;
110
+ const id = mine[at + 1];
111
+ if (!id || !/^[A-Za-z0-9_-]{1,64}$/.test(id))
112
+ throw new Error(`--run-id ${id ?? ""}: letters, digits, "-" and "_" only, 64 at most`);
113
+ return id;
114
+ }
115
+
76
116
  export async function run(config, argv) {
77
117
  /**
78
118
  * Ours before the `--`, theirs after it.
@@ -146,7 +186,11 @@ export async function run(config, argv) {
146
186
  const notify = notifier(config);
147
187
  // Everything that belongs to this run alone — socket, settings, run-file keys
148
188
  // — in one private directory, with one id. See rundir.js.
149
- const theRun = openRun();
189
+ const runId = runIdOf(mine);
190
+ const theRun = openRun(runId ? { id: runId } : undefined);
191
+ // How the log names this run: the launcher's id when it gave one, so its
192
+ // records and seisin's join on the same key; else the first 8 of seisin's.
193
+ const runLabel = runId ?? theRun.id.slice(0, 8);
150
194
  /**
151
195
  * From here on, every way out removes what this run made.
152
196
  *
@@ -214,7 +258,9 @@ export async function run(config, argv) {
214
258
  const box = chooseSandbox(config, generated, { role });
215
259
  if (box.error) throw box.error;
216
260
  const { sandbox, bin } = box;
217
- const settings = settingsOf(sandbox, generated, { machRegister });
261
+ const handoffExempt = (config.roles[role].handoffExempt ?? []).map((e) => join(config.root, e));
262
+ const requests = config.roles[role].requests ?? [];
263
+ const settings = settingsOf(sandbox, generated, { machRegister, handoffExempt, requests });
218
264
  // Per run, never per role: `.seisin/<role>.json` was one file for every run
219
265
  // of that role, and two at once wrote each other's socket path into it.
220
266
  const file = theRun.writeSettings(settings);
@@ -245,6 +291,10 @@ export async function run(config, argv) {
245
291
  const { env, dropped } = buildEnv(process.env, config.roles[role]);
246
292
  env.SEISIN_ROLE = role;
247
293
  env.SEISIN_CONFIG = config.path;
294
+ // A policy read with --policy is rooted where the run started, not beside the
295
+ // file; the hook must agree, or it would judge paths against the wrong repo.
296
+ if (config.root !== dirname(config.path)) env.SEISIN_ROOT = config.root;
297
+ else delete env.SEISIN_ROOT;
248
298
  env[SOCK_ENV] = sockPath;
249
299
  // The handoff chain, for the hook's words only (handoff.js, CHAIN_ENV): set
250
300
  // from the decision checked above, and removed otherwise, so a value the
@@ -289,7 +339,7 @@ export async function run(config, argv) {
289
339
  // The variable's name and the provider, never the reference: a reference
290
340
  // can carry what it points at (a provider that echoes it, a path that
291
341
  // names the secret), and the log is read by more people than the policy.
292
- event: "key", role, run: theRun.id.slice(0, 8), ...trace, key: entry.name, provider: entry.scheme, mode, ok,
342
+ event: "key", role, run: runLabel, ...trace, key: entry.name, provider: entry.scheme, mode, ok,
293
343
  }),
294
344
  });
295
345
  const runFileKeys = theRun.keys;
@@ -493,9 +543,17 @@ export async function run(config, argv) {
493
543
  });
494
544
  if (!probe.ok) {
495
545
  append(logPath(config.root), {
496
- event: "canary", role, run: theRun.id.slice(0, 8), ...trace, agent: agent ?? null, confined: false, reasons: probe.failed,
546
+ event: "canary", role, run: runLabel, ...trace, agent: agent ?? null, confined: false, reasons: probe.failed,
497
547
  ...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
498
548
  });
549
+ if (probe.exposed) {
550
+ err(`${C.red}seisin: ${role} was NOT started — another process holds a real value the agent would see:${C.off}\n` +
551
+ probe.failed.map((f) => ` - ${f}\n`).join("") +
552
+ ` A process's environment and arguments are readable by every process of your user (on macOS no sandbox ` +
553
+ `hides them), so masking would hide nothing. Usually a shell that exported the token, and what it started. ` +
554
+ `Unset it there or end that process, and keep the value only where seisin's key points (docs/keys.md).\n`);
555
+ process.exit(CANARY_EXIT);
556
+ }
499
557
  err(`${C.red}seisin: ${role} was NOT started — the sandbox failed its canary:${C.off}\n` +
500
558
  probe.failed.map((f) => ` - ${f}\n`).join("") +
501
559
  ` The policy as generated does not hold on this machine, so no agent runs under it. ` +
@@ -504,7 +562,7 @@ export async function run(config, argv) {
504
562
  process.exit(CANARY_EXIT);
505
563
  }
506
564
  append(logPath(config.root), {
507
- event: "canary", role, run: theRun.id.slice(0, 8), ...trace, agent: agent ?? null, confined: true, reasons: [],
565
+ event: "canary", role, run: runLabel, ...trace, agent: agent ?? null, confined: true, reasons: [],
508
566
  ...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
509
567
  });
510
568
  // Said once, only when it is not the default: which sandbox holds this run
@@ -544,7 +602,12 @@ export async function run(config, argv) {
544
602
  const nested = nestedSandboxWarning(cmd);
545
603
  if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
546
604
 
547
- const [runner, ...runnerArgs] = wrap(boxed);
605
+ // The sandbox's own record of what its egress broker refused (tartaros
606
+ // `--log`), in this run's private directory: read back into seisin's log when
607
+ // the run ends, so a denied host is a wall like any other instead of a line
608
+ // on stderr. Only the agent's run gets it, not the canary's.
609
+ const brokerLog = join(theRun.dir, "sandbox-log.jsonl");
610
+ const [runner, ...runnerArgs] = sandbox.wrap(bin, file, settings, boxed, { role, machRegister, log: brokerLog });
548
611
  const child = spawn(runner, runnerArgs, {
549
612
  stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
550
613
  env,
@@ -624,6 +687,10 @@ export async function run(config, argv) {
624
687
  // returns as soon as the stream goes quiet, so a run that was denied
625
688
  // nothing pays a tick.
626
689
  await denials.close({ drain: DRAIN_MS });
690
+ // Before the directory goes: the hosts the sandbox's broker refused.
691
+ for (const d of brokerDenials(brokerLog))
692
+ append(logPath(config.root), { role, run: runLabel, ...trace, tool: "broker", source: sandbox.name, action: "connect",
693
+ kind: "network", target: d.target, verdict: "denied", owners: [], reason: d.reason, times: d.times });
627
694
  // The run made this directory, so the run removes it.
628
695
  cleanup();
629
696
 
@@ -640,7 +707,7 @@ export async function run(config, argv) {
640
707
  // the record did not. Said every time, and written down, because a denial
641
708
  // that never arrived files no request and nothing else would show it.
642
709
  if (dropped) {
643
- append(logPath(config.root), { event: "log-dropped", role, run: theRun.id.slice(0, 8), ...trace, batches: dropped });
710
+ append(logPath(config.root), { event: "log-dropped", role, run: runLabel, ...trace, batches: dropped });
644
711
  err(`${C.yellow}seisin: the system log dropped ${dropped} batch(es) of messages during this run: ` +
645
712
  `kernel denials may be missing from the log and the queue. ` +
646
713
  `\`seisin check --system-log\` says what is flooding it.${C.off}\n`);
package/src/config.js CHANGED
@@ -17,6 +17,21 @@ import { parseKey, MODES, checkNames, BUILTIN, unknownMode } from "./keys.js";
17
17
  import { readNotify } from "./notify.js";
18
18
  import { BROWSERS } from "./browsers.js";
19
19
 
20
+ /** `a/b//` → `a/b`, in linear time (a `/\/+$/` regex backtracks on a long run of slashes). */
21
+ const trimSlashes = (p) => {
22
+ let end = p.length;
23
+ while (end > 0 && p[end - 1] === "/") end--;
24
+ return p.slice(0, end);
25
+ };
26
+
27
+ // The directory above a path's first glob character: "src/a*/b" gives "src", "a*" gives "".
28
+ const beforeGlob = (p) => {
29
+ const g = p.search(/[*?[\]{}]/);
30
+ if (g < 0) return p;
31
+ const slash = p.lastIndexOf("/", g);
32
+ return slash < 0 ? "" : p.slice(0, slash);
33
+ };
34
+
20
35
  export { CONFIG_NAME } from "./layout.js";
21
36
 
22
37
  /** Walk up from `from` until a seisin.toml shows up. Returns its path or null. */
@@ -329,7 +344,7 @@ function readReadList(v, at) {
329
344
  if (/[*?[\]{}]/.test(above) || p === "**")
330
345
  throw new Error(
331
346
  `${at} = "${p}" cannot be enforced as written.\n` +
332
- ` A read grant is a path and everything under it. Write "${above.replace(/\/?[^/]*[*?[\]{}].*$/, "") || "."}/**" ` +
347
+ ` A read grant is a path and everything under it. Write "${beforeGlob(above) || "."}/**" ` +
333
348
  `for the subtree, or name the paths.`);
334
349
  }
335
350
  return list;
@@ -365,10 +380,10 @@ export function policyId(config) {
365
380
  * settings file rather than falling back to a permissive default, and seisin
366
381
  * matches that: a permission tool that guesses is worse than no permission tool.
367
382
  */
368
- export function loadConfig(path, text = readFileSync(path, "utf8")) {
383
+ export function loadConfig(path, text = readFileSync(path, "utf8"), { root = null } = {}) {
369
384
  // `text` lets a caller validate an edit before writing it — the console
370
385
  // previews a policy that does not exist on disk yet, against the real root.
371
- const parsed = parseToml(text);
386
+ const parsed = String(path).endsWith(".json") ? parseJsonPolicy(text, path) : parseToml(text);
372
387
  checkShape(parsed, SHAPE, "", path, LINES.get(parsed));
373
388
  // Read only what the file actually declared. The parser refuses the names
374
389
  // that make inheritance possible, and this is the second half of the same
@@ -436,7 +451,7 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
436
451
  // them as prefixes: with the slash kept, `scan` walked into the protected
437
452
  // directory and a bare key resolved to `.secrets//x`, which matched nothing.
438
453
  const keyDirs = (keyDir === undefined ? [] : asArray(keyDir, "keys.dir"))
439
- .map((d) => d.replace(/\/+$/, "") || d);
454
+ .map((d) => trimSlashes(d) || d);
440
455
  const keyProviders = readProviders(own(own(parsed, "keys"), "providers"), path);
441
456
  // `undefined` means "use the defaults"; an explicit empty array means "none".
442
457
  // The difference matters: one is a user who has not thought about it, the
@@ -445,7 +460,10 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
445
460
  const runtimeWrites = own(runtime, "writes");
446
461
 
447
462
  const out = {
448
- root: dirname(path), path, keyDirs, keyProviders,
463
+ // The root is the policy's folder, unless the policy was read from elsewhere
464
+ // (`--policy`): then it is the folder the command runs in, and the policy
465
+ // stays outside the repo it governs.
466
+ root: root ? resolve(root) : dirname(path), path, keyDirs, keyProviders,
449
467
  // Through asArray like every other list: `allow = "github.com"` was kept as
450
468
  // a string, and a string iterated is its characters.
451
469
  allowedDomains: asArray(own(own(parsed, "network"), "allow"), "network.allow"),
@@ -537,6 +555,37 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
537
555
  const toolchain = readReadList(own(r, "toolchain"), `${path}: roles.${name}.toolchain`);
538
556
  const priv = readPrivate(own(r, "private"), `${path}: roles.${name}.private`);
539
557
  const verify = own(r, "verify") === undefined ? null : asArray(own(r, "verify"), `roles.${name}.verify`);
558
+ /**
559
+ * `handoff_exempt`: folders where the trust-handoff set is not protected, for a
560
+ * setup phase that fills a dependency tree whose packages ship their own
561
+ * `.gitattributes`, `.github/` or `.git/` (`composer install`, measured: 326
562
+ * denials). Relative to the repo, no `..`, no globs. Only a sandbox that can
563
+ * open exactly that (tartaros) is handed the role.
564
+ */
565
+ const handoffExempt = own(r, "handoff_exempt") === undefined ? [] : asArray(own(r, "handoff_exempt"), `roles.${name}.handoff_exempt`);
566
+ /**
567
+ * `requests`: the only requests the role may send to an allowed host,
568
+ * `"METHODS host[/path]"` (METHODS: `GET,POST` or `*`). An allowed host is
569
+ * otherwise all of it: a token for api.github.com pushes to any repository
570
+ * it reaches. The host must be in `network`. Only tartaros enforces it
571
+ * (from 0.1.0-alpha.18): it terminates that host and refuses anything else.
572
+ */
573
+ const requests = own(r, "requests") === undefined ? [] : asArray(own(r, "requests"), `roles.${name}.requests`).map((spec) => {
574
+ const m = /^(\*|[A-Z]+(?:,[A-Z]+)*)\s+([A-Za-z0-9.-]+)(\/\S*)?$/.exec(String(spec).trim());
575
+ if (!m || (m[3] ?? "").split("/").some((seg) => seg === "." || seg === ".."))
576
+ throw new Error(`${path}: roles.${name}.requests "${spec}": "METHODS host[/path]", e.g. "GET api.github.com" or "POST,PATCH api.github.com/repos/acme/"`);
577
+ return { host: m[2], methods: m[1] === "*" ? [] : m[1].split(","), paths: m[3] ? [m[3]] : [] };
578
+ });
579
+ // A rule narrows a host the role may reach; on a host it may not, it says nothing.
580
+ const reach = own(r, "network") === undefined ? [] : asArray(own(r, "network"), `roles.${name}.network`);
581
+ for (const q of requests) {
582
+ const covered = reach.some((h) => h === q.host || (h.startsWith("*.") && q.host.endsWith(h.slice(1))));
583
+ if (!covered)
584
+ throw new Error(`${path}: roles.${name}.requests: ${q.host} is not in roles.${name}.network, so the role cannot reach it at all`);
585
+ }
586
+ for (const e of handoffExempt)
587
+ if (e.startsWith("/") || e.startsWith("~") || e.split("/").includes("..") || /[*?[\]]/.test(e) || !e.trim())
588
+ throw new Error(`${path}: roles.${name}.handoff_exempt "${e}": a folder in the repo, relative, with no ".." and no globs`);
540
589
  if (verify && verify.length === 0)
541
590
  throw new Error(`${path}: roles.${name}.verify is empty. It is the command that proves the role can check ` +
542
591
  `its own work — ["python", "-m", "pytest", "--version"] — or leave it out.`);
@@ -644,6 +693,10 @@ export function loadConfig(path, text = readFileSync(path, "utf8")) {
644
693
  * another owner or to a person instead of starting it.
645
694
  */
646
695
  handoff: handoff !== false,
696
+ /** Folders, relative to the root, where trust-handoff is not protected (tartaros only). */
697
+ handoffExempt,
698
+ /** The only requests to an allowed host: `{ host, methods, paths }` (tartaros only). */
699
+ requests,
647
700
  /**
648
701
  * What this role reads beyond its repo, under `read = "territory"`.
649
702
  *
@@ -879,6 +932,70 @@ function checkShape(table, shape, prefix, path, lines) {
879
932
  }
880
933
  }
881
934
 
935
+ /**
936
+ * A policy written as JSON (`--policy run.json`), for a launcher that generates
937
+ * one: JSON can hold a quote in a value, which the TOML subset cannot, and a
938
+ * program writing the policy should not have to avoid them.
939
+ *
940
+ * The same data and the same strictness as the TOML reader: tables, strings,
941
+ * whole numbers, booleans, and arrays of strings or whole numbers, nothing
942
+ * else; the forbidden names refused; every table with a null prototype; and a
943
+ * key written twice in one object refused, because JSON.parse keeps the last
944
+ * one, and a policy that is restrictive at the top and cancelled further down
945
+ * is exactly what the TOML reader refuses too. A JSON policy is not edited by
946
+ * `grant`: it is generated, so it is changed where it is generated.
947
+ */
948
+ export function parseJsonPolicy(text, path = "policy.json") {
949
+ let raw;
950
+ try { raw = JSON.parse(text); } catch (e) { throw new Error(`${path}: not JSON — ${e.message}`); }
951
+ // Duplicate keys, which JSON.parse folds away: one pass over the text, which
952
+ // JSON.parse has just said is well formed.
953
+ const stack = [];
954
+ for (let i = 0; i < text.length; i++) {
955
+ const c = text[i];
956
+ if (c === "{") stack.push({ obj: true, keys: new Set(), key: true });
957
+ else if (c === "[") stack.push({ obj: false });
958
+ else if (c === "}" || c === "]") stack.pop();
959
+ else if (c === "," && stack.at(-1)?.obj) stack.at(-1).key = true;
960
+ else if (c === '"') {
961
+ let j = i + 1;
962
+ while (text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
963
+ const top = stack.at(-1);
964
+ if (top?.obj && top.key) {
965
+ const k = JSON.parse(text.slice(i, j + 1));
966
+ if (top.keys.has(k)) throw new Error(`${path}: "${k}" is written twice in one object; the last would silently win.`);
967
+ top.keys.add(k);
968
+ top.key = false;
969
+ }
970
+ i = j;
971
+ }
972
+ }
973
+ const walk = (v, name) => {
974
+ if (typeof v === "string" || typeof v === "boolean") return v;
975
+ if (typeof v === "number") {
976
+ if (!Number.isInteger(v) || v < 0) throw new Error(`${path}: ${name} = ${v} — only whole numbers`);
977
+ return v;
978
+ }
979
+ if (Array.isArray(v)) {
980
+ return v.map((x, i) => {
981
+ if (typeof x === "string" || (typeof x === "number" && Number.isInteger(x) && x >= 0)) return x;
982
+ throw new Error(`${path}: ${name}[${i}] — array items are strings or whole numbers`);
983
+ });
984
+ }
985
+ if (isTable(v)) {
986
+ const t = dict();
987
+ for (const [k, x] of Object.entries(v)) {
988
+ if (FORBIDDEN.has(k)) throw new Error(`${path}: "${k}" is not a name a table or key may have`);
989
+ t[k] = walk(x, name ? `${name}.${k}` : k);
990
+ }
991
+ return t;
992
+ }
993
+ throw new Error(`${path}: ${name} = ${JSON.stringify(v)} — a value is a string, a whole number, a boolean, an array or a table`);
994
+ };
995
+ if (!isTable(raw)) throw new Error(`${path}: the policy is an object`);
996
+ return walk(raw, "");
997
+ }
998
+
882
999
  /** The candidate within edit distance 3 of `word`, or null. Shared with `check`. */
883
1000
  export function closest(word, candidates) {
884
1001
  let best = null, bestD = 4;
@@ -910,7 +1027,7 @@ function readBoolean(value, where) {
910
1027
  }
911
1028
 
912
1029
  /** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
913
- export const ROLE_KEYS = ["writes", "private", "keys", "key_mode", "inject_hosts", "env", "network", "never_writes", "local_binding", "local_ports", "browsers", "mcp", "trustd", "control_files", "reads", "toolchain", "verify", "handoff"];
1030
+ export const ROLE_KEYS = ["writes", "private", "keys", "key_mode", "inject_hosts", "env", "network", "never_writes", "local_binding", "local_ports", "browsers", "mcp", "trustd", "control_files", "reads", "toolchain", "verify", "handoff", "handoff_exempt", "requests"];
914
1031
 
915
1032
  /**
916
1033
  * `control_files`: which families of control files a role may edit. Refused at
@@ -1081,7 +1198,7 @@ function readNeverWrites(value, where) {
1081
1198
  function readPrivate(value, where) {
1082
1199
  const list = value === undefined ? [] : asArray(value, where);
1083
1200
  for (const p of list) {
1084
- const dir = p.replace(/\/\*\*$/, "").replace(/\/+$/, "").replace(/^\.\//, "");
1201
+ const dir = trimSlashes(p.replace(/\/\*\*$/, "")).replace(/^\.\//, "");
1085
1202
  if (p.startsWith("/"))
1086
1203
  throw new Error(`${where}: "${p}" is absolute. A private folder is relative to the repo root, like writes.`);
1087
1204
  if (dir.split("/").includes(".."))
@@ -1091,13 +1208,13 @@ function readPrivate(value, where) {
1091
1208
  `"notes/**" — and every other role is denied it.`);
1092
1209
  if (/[*?[\]{}]/.test(dir))
1093
1210
  throw new Error(`${where}: "${p}" is a pattern. A private folder is one path and everything under it: ` +
1094
- `write "${dir.replace(/\/?[^/]*[*?[\]{}].*$/, "") || "dir"}/**".`);
1211
+ `write "${beforeGlob(dir) || "dir"}/**".`);
1095
1212
  }
1096
1213
  return list;
1097
1214
  }
1098
1215
 
1099
1216
  /** `private` with its `/**` and `./` taken off: the folder itself. */
1100
- export const privateDir = (p) => p.replace(/\/\*\*$/, "").replace(/\/+$/, "").replace(/^\.\//, "");
1217
+ export const privateDir = (p) => trimSlashes(p.replace(/\/\*\*$/, "")).replace(/^\.\//, "");
1101
1218
 
1102
1219
  /**
1103
1220
  * One owner per private folder. Two roles naming the same folder, or one
package/src/help.js CHANGED
@@ -12,14 +12,15 @@ import { LIMITS } from "./inspect/limits.js";
12
12
 
13
13
  export const COMMAND_HELP = {
14
14
  run: {
15
- usage: ["seisin run <role> [--observe] [--debug-env] [--agent <name>] [--handoff <id>] [--expect-denied <path>]... -- <command...>"],
16
- values: ["--agent", "--handoff", "--expect-denied"],
15
+ usage: ["seisin run <role> [--observe] [--debug-env] [--agent <name>] [--handoff <id>] [--expect-denied <path>]... [--run-id <id>] -- <command...>"],
16
+ values: ["--agent", "--handoff", "--expect-denied", "--run-id"],
17
17
  flags: [
18
18
  ["--observe", "open the repo for this run and record what the agent writes; keys and network stay shut"],
19
19
  ["--debug-env", "print the environment variables the role does not get"],
20
20
  ["--agent <name>", "claude, codex or none: which agent the command runs, when seisin cannot tell from it (a wrapper script). Its home is the run's scratch; the other agents' sign-in is closed"],
21
21
  ["--handoff <id>", "this run takes a pending request handed to this role, and starts only if the policy routes that request here. Its own requests join the handoff chain"],
22
22
  ["--expect-denied <path>", "a path the caller will probe and expects to be denied (its own canary). That exact path is logged as expected and files no request; repeatable. Any other path is asked as usual"],
23
+ ["--run-id <id>", "the launcher's own id for this run, used in the log instead of a random one, so an orchestrator's records and seisin's join on it"],
23
24
  ],
24
25
  notes: ["Everything after -- is the command's own, flags included: `-- claude --settings x.json` goes to claude."],
25
26
  examples: ['seisin run frontend -- claude', 'seisin run backend --observe -- claude -p "…"'],
@@ -28,11 +29,12 @@ export const COMMAND_HELP = {
28
29
  ["128+n", "killed by signal n (130 for ctrl-c)"], ["2", "seisin could not start it (usage, policy, runtime)"]],
29
30
  },
30
31
  check: {
31
- usage: ["seisin check [role] [--verbose] [--verify] [--parallel] [--system-log]"],
32
+ usage: ["seisin check [role] [--verbose] [--verify] [--parallel] [--system-log] [--json]"],
32
33
  flags: [["--verbose", "also print the protected paths and each warning's explanation (the limits of every repo: seisin help limits)"],
33
34
  ["--verify", "run each role's verify command inside its sandbox (the one thing check executes)"],
34
35
  ["--parallel", "experimental: with --verify, run role checks concurrently; shared resources may race"],
35
- ["--system-log", "macOS: measure for 3 s whether the system log is dropping messages, and who writes the most; exits 1 when it is"]],
36
+ ["--system-log", "macOS: measure for 3 s whether the system log is dropping messages, and who writes the most; exits 1 when it is"],
37
+ ["--json", "the same answer for a program: seisin's version and capabilities, the sandbox each role gets (path and version), the warnings"]],
36
38
  examples: ["seisin check", "seisin check frontend --verbose", "seisin check backend --verify", "seisin check --verify --parallel"],
37
39
  exits: [["0", "the policy can be enforced as written (warnings included)"], ["1", "a role cannot be enforced as written, or its verify failed"], ["2", "usage or policy error"]],
38
40
  },
@@ -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
  *
@@ -375,7 +375,7 @@ export function readFileRef(entry, root = ".", { exact = false } = {}) {
375
375
  export const RESERVED_ENV = new Set([
376
376
  // The last is handoff.js's CHAIN_ENV, spelled out: handoff.js reaches this
377
377
  // module through owners.js, and importing it back would be a cycle.
378
- "SEISIN_ROLE", "SEISIN_CONFIG", SOCK_ENV, "SEISIN_HANDOFF_CHAIN",
378
+ "SEISIN_ROLE", "SEISIN_CONFIG", "SEISIN_ROOT", "SEISIN_STATE_DIR", SOCK_ENV, "SEISIN_HANDOFF_CHAIN",
379
379
  ...BASE, ...Object.keys(DEFAULTS),
380
380
  ]);
381
381
 
@@ -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/kinds.js CHANGED
@@ -85,6 +85,10 @@ const SCRATCH_FILE = [
85
85
  /^\.!\d+!/, // `.!36165!file` — a copy in flight
86
86
  /^\.DS_Store$/,
87
87
  /^pytest-cache-files-/,
88
+ // Vite and Vitest bundle a TS config into `vite.config.ts.timestamp-<ms>-<hash>.mjs`
89
+ // beside it, load it and delete it: a name that never repeats, found asked for
90
+ // 28 times in one orchestrator's queue, owned by a phase that takes no work.
91
+ /\.timestamp-\d+(-[0-9a-f]+)?\.[cm]?js$/,
88
92
  /(^|[-_])logs?$/i, // `logs`, `latest_logs`: what a run leaves
89
93
  ];
90
94
  /**
@@ -230,8 +234,22 @@ export function kindsOf(targets, opts = {}) {
230
234
  export const EXPECTED = {
231
235
  plugins:
232
236
  "Claude Code updating its plugins from inside the sandbox: plugins run outside it and update there, so the denial is expected — nothing to grant",
237
+ "os-background":
238
+ "macOS frameworks reaching for shared state every program touches at start (preferences, notifications, a compatibility sysctl): denied, and nothing the agent does depends on it — nothing to grant",
233
239
  };
234
240
 
241
+ /**
242
+ * The system denials macOS frameworks cause in any sandboxed program, whatever
243
+ * it is doing: counted in one orchestrator's runs, 280 lines of them against the
244
+ * handful that were the task's. Exact names, so a service the agent itself asks
245
+ * for still reads as a wall.
246
+ */
247
+ const OS_BACKGROUND = new Set([
248
+ "apple.shm.notification_center", "apple.cfprefs.daemonv1", "com.apple.AppleDatabaseChanged",
249
+ "kcfpreferencesanyapplication", "kern.tcsm_enable", "kern.system_version_compat",
250
+ ]);
251
+ const OS_BACKGROUND_RE = /^apple\.cfprefs\.\d+v1$/; // the per-user cfprefs segment
252
+
235
253
  /**
236
254
  * `{ kind, hint }` when the refusal of `target` is expected, else null.
237
255
  *
@@ -240,6 +258,7 @@ export const EXPECTED = {
240
258
  */
241
259
  export function expectedOf(target, { home = homedir(), env = process.env } = {}) {
242
260
  const t = String(target ?? "").replace(/\\/g, "/");
261
+ if (OS_BACKGROUND.has(t) || OS_BACKGROUND_RE.test(t)) return { kind: "os-background", hint: EXPECTED["os-background"] };
243
262
  const roots = [
244
263
  env.CLAUDE_CONFIG_DIR,
245
264
  home && `${home.replace(/\/+$/, "")}/.claude`,
package/src/log.js CHANGED
@@ -16,13 +16,14 @@
16
16
  import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
17
17
  import { createHash } from "node:crypto";
18
18
  import { basename, dirname, join } from "node:path";
19
- import { STATE_DIR, LOG_NAME } from "./layout.js";
19
+ import { LOG_NAME } from "./layout.js";
20
+ import { stateDir } from "./state.js";
20
21
  import { send } from "./spool.js";
21
22
 
22
23
  export { STATE_DIR, LOG_NAME } from "./layout.js";
23
24
 
24
25
  export function logPath(root) {
25
- return join(root, STATE_DIR, LOG_NAME);
26
+ return join(stateDir(root), LOG_NAME);
26
27
  }
27
28
 
28
29
  /**
package/src/requests.js CHANGED
@@ -33,7 +33,8 @@ import { protectedBy } from "./surface.js";
33
33
  import { appendFileSync, chmodSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
34
34
  import { randomBytes } from "node:crypto";
35
35
  import { dirname, join } from "node:path";
36
- import { STATE_DIR, tomlString } from "./layout.js";
36
+ import { tomlString } from "./layout.js";
37
+ import { stateDir } from "./state.js";
37
38
  import { send } from "./spool.js";
38
39
  import { withLock, readEntries } from "./log.js";
39
40
  import { loadConfig, policyId } from "./config.js";
@@ -41,7 +42,7 @@ import { loadConfig, policyId } from "./config.js";
41
42
  const REQUESTS_NAME = "requests.jsonl";
42
43
 
43
44
  export function requestsPath(root) {
44
- return join(root, STATE_DIR, REQUESTS_NAME);
45
+ return join(stateDir(root), REQUESTS_NAME);
45
46
  }
46
47
 
47
48
  /**
@@ -663,6 +664,10 @@ export function refuseIfBarred(config, request) {
663
664
  * one step to anyone else taking this lock.
664
665
  */
665
666
  export function editPolicy(config, mutate, { waitMs = 10000, after = null } = {}) {
667
+ // A JSON policy (`--policy x.json`) is generated by whoever launches the run;
668
+ // an edit here would be overwritten there, so it is made there.
669
+ if (String(config.path).endsWith(".json"))
670
+ throw new Error(`${config.path} is a generated JSON policy: change it where it is generated, not with grant`);
666
671
  return withLock(policyLockBase(config), () => {
667
672
  const before = readFileSync(config.path, "utf8");
668
673
  const result = mutate(before);
@@ -686,7 +691,7 @@ export function editPolicy(config, mutate, { waitMs = 10000, after = null } = {}
686
691
  * nothing confined can hold this one. withLock adds the `.lock`.
687
692
  */
688
693
  export function policyLockBase(config) {
689
- const dir = join(config.root, STATE_DIR);
694
+ const dir = stateDir(config.root);
690
695
  mkdirSync(dir, { recursive: true });
691
696
  return join(dir, "policy");
692
697
  }
@@ -708,7 +713,7 @@ export function policyLockBase(config) {
708
713
  function writePolicy(config, text) {
709
714
  const mode = statSync(config.path).mode & 0o777;
710
715
  const name = `seisin.toml.tmp-${randomBytes(12).toString("hex")}`;
711
- const stateTmp = join(config.root, STATE_DIR, name);
716
+ const stateTmp = join(stateDir(config.root), name);
712
717
  writeFileSync(stateTmp, text, { flag: "wx", mode });
713
718
  try {
714
719
  chmodSync(stateTmp, mode);
package/src/runs.js CHANGED
@@ -17,11 +17,21 @@
17
17
  * seconds over hundreds of runs re-reads a directory listing and some stats,
18
18
  * not every log.
19
19
  */
20
- import { readdirSync, readFileSync, statSync } from "node:fs";
20
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
21
21
  import { dirname, join } from "node:path";
22
22
  import { loadConfig } from "./config.js";
23
- import { read, logPath } from "./log.js";
24
- import { requestsPath } from "./requests.js";
23
+ import { read } from "./log.js";
24
+ import { STATE_DIR, LOG_NAME } from "./layout.js";
25
+ import { expectedOf } from "./kinds.js";
26
+
27
+ /**
28
+ * An archived run's own files, always at `<run>/.seisin/`: a run folder is read
29
+ * as it was written, whatever `--state-dir` this process was given. A launcher
30
+ * that runs with `--policy <run>/seisin.toml --state-dir <run>/.seisin` writes
31
+ * each run straight into this shape, and has nothing to copy at the end.
32
+ */
33
+ const logPath = (root) => join(root, STATE_DIR, LOG_NAME);
34
+ const requestsPath = (root) => join(root, STATE_DIR, "requests.jsonl");
25
35
 
26
36
  /** The name a run's folder may have: what becomes `run=` in a link. */
27
37
  const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
@@ -51,9 +61,11 @@ export function runFolders(dir) {
51
61
  return names.filter((d) => d.isDirectory() && isRunId(d.name)).map((d) => d.name).sort();
52
62
  }
53
63
 
54
- /** Where a run's policy is. */
64
+ /** Where a run's policy is: `seisin.toml`, or `seisin.json` for a launcher that generates JSON. */
55
65
  export function runPolicy(dir, id) {
56
- return join(dir, id, "seisin.toml");
66
+ const toml = join(dir, id, "seisin.toml");
67
+ const json = join(dir, id, "seisin.json");
68
+ return existsSync(toml) || !existsSync(json) ? toml : json;
57
69
  }
58
70
 
59
71
  /**
@@ -66,7 +78,7 @@ function signatureOf(dir, id) {
66
78
  const root = join(dir, id);
67
79
  const log = logPath(root);
68
80
  return [
69
- statSig(join(root, "seisin.toml")),
81
+ statSig(runPolicy(dir, id)),
70
82
  statSig(join(root, "run.json")),
71
83
  statSig(requestsPath(root)),
72
84
  statSig(log),
@@ -114,8 +126,8 @@ function summarize(dir, id) {
114
126
  let cfg;
115
127
  try { cfg = loadConfig(runPolicy(dir, id)); }
116
128
  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 };
129
+ return { ...base, outcome: "unread", error: e.code === "ENOENT" ? "no seisin.toml (or seisin.json) in this run's folder" : e.message,
130
+ roles: [], denied: 0, byKind: {}, expected: 0, retried: 0, causes: [], hosts: [], entries: 0, canary: null, dropped: 0 };
119
131
  }
120
132
  const all = read(logPath(cfg.root));
121
133
  /**
@@ -131,7 +143,9 @@ function summarize(dir, id) {
131
143
  const groups = new Map();
132
144
  for (const e of all) {
133
145
  if (e.verdict !== "denied") continue;
134
- if (e.expected === true) { expected++; continue; }
146
+ // macOS framework housekeeping counts as expected even in a log written
147
+ // before seisin said so (kinds.js expectedOf).
148
+ if (e.expected === true || (e.kind === "system" && expectedOf(e.target))) { expected++; continue; }
135
149
  const k = e.kind ?? "file";
136
150
  byKind[k] = (byKind[k] ?? 0) + 1;
137
151
  if (k !== "system") denied++;
@@ -144,6 +158,9 @@ function summarize(dir, id) {
144
158
  const causes = [...groups.values()].sort((a, b) => b.times - a.times);
145
159
  const retried = causes.reduce((n, g) => n + (g.about === "system" ? 0 : g.times - 1), 0);
146
160
  const hosts = causes.filter((g) => g.about === "network").slice(0, 10).map(({ target, times }) => ({ target, times }));
161
+ // Batches of kernel messages the system log threw away during the run: its
162
+ // walls may be missing some. The boundary held; the record did not.
163
+ const dropped = all.filter((e) => e.event === "log-dropped").reduce((n, e) => n + (Number(e.batches) || 0), 0);
147
164
  const canaries = all.filter((e) => e.event === "canary");
148
165
  const canary = canaries.length ? canaries[canaries.length - 1] : null;
149
166
  const outcome = canaries.some((e) => e.confined === false) ? "refused" : denied ? "denied" : "clean";
@@ -164,6 +181,7 @@ function summarize(dir, id) {
164
181
  // Kept whole for the sum across runs, cut to RUN_CAUSES when sent.
165
182
  causes,
166
183
  canary: canary ? { confined: canary.confined !== false, reasons: canary.reasons ?? [] } : null,
184
+ dropped,
167
185
  };
168
186
  }
169
187
 
@@ -267,13 +285,14 @@ export function runsView(dir, filters = {}, { scanned = scanRuns(dir) } = {}) {
267
285
  function viewOf(dir, filters, every) {
268
286
  const shown = every.filter((r) => matches(r, filters))
269
287
  .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: {} };
288
+ const totals = { runs: shown.length, denied: 0, retried: 0, expected: 0, byKind: {}, outcome: {}, incomplete: 0 };
271
289
  for (const r of shown) {
272
290
  totals.denied += r.denied;
273
291
  totals.retried += r.retried;
274
292
  totals.expected += r.expected;
275
293
  for (const [k, n] of Object.entries(r.byKind)) totals.byKind[k] = (totals.byKind[k] ?? 0) + n;
276
294
  totals.outcome[r.outcome] = (totals.outcome[r.outcome] ?? 0) + 1;
295
+ if (r.dropped) totals.incomplete++;
277
296
  }
278
297
  return {
279
298
  mode: "runs",
@@ -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.
@@ -49,6 +49,8 @@ export function sandboxesOf(config) {
49
49
  export function weigh(config, settings, { platform = process.platform, role = null, locate = true, file = null } = {}) {
50
50
  const list = sandboxesOf(config);
51
51
  const register = role ? machOf(config.roles?.[role]).register : [];
52
+ const exempt = role ? (config.roles?.[role]?.handoffExempt ?? []).map((e) => join(config.root, e)) : [];
53
+ const requests = role ? (config.roles?.[role]?.requests ?? []) : [];
52
54
  let scratch = null;
53
55
  let copies = 0;
54
56
  // The file a sandbox is asked about is the one it would be handed: the run's
@@ -63,7 +65,7 @@ export function weigh(config, settings, { platform = process.platform, role = nu
63
65
  try {
64
66
  const options = list.map((sandbox) => {
65
67
  const bin = locate ? sandbox.locate() : "(not looked up)";
66
- const own = settingsOf(sandbox, settings, { machRegister: register });
68
+ const own = settingsOf(sandbox, settings, { machRegister: register, handoffExempt: exempt, requests });
67
69
  const asked = sandbox.asksFile && locate && bin;
68
70
  const ctx = { platform, role, bin, file: asked ? fileFor(own) : null };
69
71
  const { unmet, warnings } = sandbox.asksFile && !asked ? { unmet: [], warnings: [] }
@@ -71,10 +73,25 @@ export function weigh(config, settings, { platform = process.platform, role = nu
71
73
  // `browsers`: a Mach service to register. srt's schema has no field for it
72
74
  // (srt-mach.mjs adds it); tartaros takes it as network.allowMachRegister
73
75
  // (adapt). A sandbox that is given it neither way cannot hold the role.
76
+ // `inject`: the real value is in the environment the sandbox starts with,
77
+ // so only one that masks credentials may be handed the role.
78
+ const masked = (own.credentials?.envVars ?? []).length > 0;
79
+ const masking = masked ? (sandbox.masks ? sandbox.masks(platform) : `masked credentials: ${sandbox.name} does not mask them`) : null;
74
80
  const browser = register.length && !sandbox.machRegister
75
81
  ? [`a Mach service to register (${register.join(", ")}, for browsers): ${sandbox.name} is not given it, only srt and tartaros are`]
76
82
  : [];
77
- return { sandbox, bin, unmet: [...unmet, ...browser], warnings, missing: bin ? null : sandbox.missing() };
83
+ // `handoff_exempt`: a folder where trust-handoff is not protected. Only a
84
+ // sandbox that opens exactly that is handed the role; srt protects its
85
+ // own surfaces with no way to exempt a subtree.
86
+ const exemption = exempt.length && !sandbox.handoffExempt
87
+ ? [`handoff_exempt (${config.roles[role].handoffExempt.join(", ")}): ${sandbox.name} cannot leave a folder out of its trust-handoff protection, tartaros can`]
88
+ : [];
89
+ // `requests`: methods and paths per host. srt takes them only as a library
90
+ // callback, so seisin cannot hand them to it.
91
+ const ruled = requests.length && !sandbox.requestRules
92
+ ? [`requests (${requests.map((q) => q.host).join(", ")}): ${sandbox.name} cannot limit the methods and paths of a host from its settings, tartaros can`]
93
+ : [];
94
+ return { sandbox, bin, unmet: [...(masking ? [masking] : []), ...unmet, ...browser, ...exemption, ...ruled], warnings, missing: bin ? null : sandbox.missing() };
78
95
  });
79
96
  const chosen = options.find((o) => o.bin && o.unmet.length === 0) ?? null;
80
97
  return { chosen, options };
@@ -150,6 +150,9 @@ export const srt = {
150
150
  locate: () => resolveSrt(),
151
151
  missing: () => `sandbox runtime not found. ${installHint()}`,
152
152
  unmet: () => [],
153
+ // Masked credentials: on Linux; on macOS not yet (anthropics/sandbox-runtime#593).
154
+ masks: (platform) => platform === "linux" ? null
155
+ : `masked credentials (key_mode = "inject") on ${platform}: sandbox-runtime does not mask them there yet (anthropics/sandbox-runtime#593); tartaros does`,
153
156
  // `--` before the command, and it is not cosmetic. Without it the runtime's
154
157
  // own argument parser reaches into what the agent was invoked with and eats
155
158
  // anything resembling one of its flags: `claude --settings`, `claude -c`.
@@ -17,6 +17,7 @@
17
17
  * check` says so), and the canary still checks every run that it can.
18
18
  */
19
19
  import { existsSync } from "node:fs";
20
+ import { spawnSync } from "node:child_process";
20
21
  import { createRequire } from "node:module";
21
22
  import { join, dirname, delimiter } from "node:path";
22
23
  import { fileURLToPath } from "node:url";
@@ -58,7 +59,17 @@ export function dependencyTartaros(from = HERE) {
58
59
  }
59
60
 
60
61
  /** The command line for one run: the settings as they are, the role as a label. */
61
- export function tartarosArgv(bin, file, inner, { role = null, machRegister = [] } = {}) {
62
+ /** Whether this tartaros takes `--egress-log` (0.1.0-alpha.18): asked once per binary. */
63
+ const egressLog = new Map();
64
+ export function supportsEgressLog(bin) {
65
+ if (!egressLog.has(bin)) {
66
+ const r = spawnSync(bin, ["--help"], { encoding: "utf8", timeout: 5000 });
67
+ egressLog.set(bin, `${r.stdout ?? ""}${r.stderr ?? ""}`.includes("--egress-log"));
68
+ }
69
+ return egressLog.get(bin);
70
+ }
71
+
72
+ export function tartarosArgv(bin, file, inner, { role = null, machRegister = [], log = null } = {}) {
62
73
  // A browser role gets tartaros's browser grants — the shared memory, IOKit
63
74
  // classes and sysctls a headless Chromium dies without (f7edae1) — and a
64
75
  // run-private TMPDIR, the only place it may open a unix socket (bcb0c69):
@@ -68,7 +79,13 @@ export function tartarosArgv(bin, file, inner, { role = null, machRegister = []
68
79
  // the role gets — built from the role's own list (env.js), with the hook's
69
80
  // socket, the role's name and its env-mode keys. tartaros's own scrub
70
81
  // (7f9925f) would drop those, and the hook would go silent.
71
- return [bin, "run", ...(role ? ["--role", role] : []), "--inherit-env", ...browser, "--settings", file, "--", ...inner];
82
+ // --egress-log: tartaros's record of its egress broker's decisions, read back
83
+ // into seisin's log at the end of the run (commands/run.js, brokerDenials).
84
+ // Not --log, which also starts the effect collectors (more than a second a
85
+ // run, measured). A tartaros older than the flag gets nothing: an unknown
86
+ // option would refuse the run.
87
+ const record = log && supportsEgressLog(bin) ? ["--egress-log", log] : [];
88
+ return [bin, "run", ...(role ? ["--role", role] : []), "--inherit-env", ...browser, ...record, "--settings", file, "--", ...inner];
72
89
  }
73
90
 
74
91
  /** What tartaros says of this file: what it cannot hold, and its warnings. Only asked of a binary that was found. */
@@ -85,9 +102,15 @@ export const tartarosUnmet = (bin, file, ctx) => tartarosAssess(bin, file, ctx).
85
102
  * (tartaros 99b0117). Only when there is one: an unchanged role is handed the
86
103
  * same file as before, and srt, whose schema is strict, never sees the field.
87
104
  */
88
- export function tartarosSettings(settings, { machRegister = [] } = {}) {
89
- if (!machRegister.length) return settings;
90
- return { ...settings, network: { ...settings.network, allowMachRegister: machRegister.map((p) => `${p}*`) } };
105
+ export function tartarosSettings(settings, { machRegister = [], handoffExempt = [], requests = [] } = {}) {
106
+ if (!machRegister.length && !handoffExempt.length && !requests.length) return settings;
107
+ let out = settings;
108
+ if (machRegister.length) out = { ...out, network: { ...out.network, allowMachRegister: machRegister.map((p) => `${p}*`) } };
109
+ // tartaros's own field (its --handoff-exempt), absolute: srt never sees it.
110
+ if (handoffExempt.length) out = { ...out, filesystem: { ...out.filesystem, handoffExempt } };
111
+ // tartaros's own field (its --allow-request): the only requests an allowed host gets.
112
+ if (requests.length) out = { ...out, network: { ...out.network, requestRules: requests } };
113
+ return out;
91
114
  }
92
115
 
93
116
  export const tartaros = {
@@ -95,8 +118,16 @@ export const tartaros = {
95
118
  locate: () => resolveTartaros(),
96
119
  missing: () => `tartaros not found: neither ${TARTAROS_PACKAGE}, which installs with seisin unless optional dependencies are left out, nor tartaros on PATH.`,
97
120
  asksFile: true,
121
+ // Masked credentials: tartaros says whether it masks them, through explain
122
+ // (0.1.0-alpha.16 and later do; earlier ones refuse the field).
123
+ masks: () => null,
98
124
  adapt: tartarosSettings,
99
125
  machRegister: true,
126
+ // Takes filesystem.handoffExempt (tartaros 0.1.0-alpha.18): a folder left out of
127
+ // its trust-handoff protection.
128
+ handoffExempt: true,
129
+ // Takes network.requestRules (0.1.0-alpha.18): methods and paths per host.
130
+ requestRules: true,
100
131
  assess: (_settings, { bin, file, platform } = {}) => tartarosAssess(bin, file, { platform }),
101
132
  unmet: (_settings, { bin, file, platform } = {}) => tartarosUnmet(bin, file, { platform }),
102
133
  wrap: (bin, file, _settings, inner, ctx) => tartarosArgv(bin, file, inner, ctx),
package/src/srt.js CHANGED
@@ -535,8 +535,11 @@ export function settingsFor(config, roleName, spool = null, observe = false, { a
535
535
  * Masked credentials: the real value reaches the runtime's own environment
536
536
  * (run.js), never the sandbox, which sees `fake_value_…`. `mode: "mask"`
537
537
  * and the per-credential `injectHosts` are all that is emitted — no
538
- * `extract`, `decode`, files or SigV4, and never `allowPlaintextInject`,
538
+ * `extract`, `decode`, files or `awsPairs`, and never `allowPlaintextInject`,
539
539
  * which over HTTPS carries the sentinel and so is deny with extra steps.
540
+ * An AWS pair under its usual names (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
541
+ * is paired by the sandbox itself and re-signed (srt; tartaros from
542
+ * 0.1.0-alpha.18).
540
543
  */
541
544
  ...(credentials ? { credentials } : {}),
542
545
  };
package/src/state.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Where this process keeps seisin's own files: the log, the request queue and
3
+ * their lock.
4
+ *
5
+ * `<root>/.seisin/` unless a launcher keeps them elsewhere with `--state-dir`
6
+ * (an orchestrator that archives each run in a folder of its own, and should
7
+ * not have to copy a directory out of a worktree it is about to delete). The
8
+ * choice travels to everything a run starts as SEISIN_STATE_DIR, so the hook
9
+ * inside the sandbox and the parent agree on one place.
10
+ */
11
+ import { join, resolve } from "node:path";
12
+ import { STATE_DIR } from "./layout.js";
13
+
14
+ let override = process.env.SEISIN_STATE_DIR ? resolve(process.env.SEISIN_STATE_DIR) : null;
15
+
16
+ /** The state directory for a policy rooted at `root`. */
17
+ export function stateDir(root) {
18
+ return override ?? join(root, STATE_DIR);
19
+ }
20
+
21
+ /** `--state-dir`: from now on, and for everything this process starts. */
22
+ export function setStateDir(dir) {
23
+ override = resolve(dir);
24
+ process.env.SEISIN_STATE_DIR = override;
25
+ }
package/src/surface.js CHANGED
@@ -32,7 +32,8 @@ import { join, isAbsolute, delimiter, dirname, resolve, basename, sep } from "no
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { homedir } from "node:os";
34
34
  import { writePathsOf, realOrSelf } from "./grants.js";
35
- import { STATE_DIR, CONFIG_NAME } from "./layout.js";
35
+ import { CONFIG_NAME } from "./layout.js";
36
+ import { stateDir } from "./state.js";
36
37
 
37
38
  /** seisin's own package: the code the next `seisin run` executes. */
38
39
  const SEISIN_HOME = realOrSelf(join(dirname(fileURLToPath(import.meta.url)), ".."));
@@ -270,7 +271,7 @@ function computeParentInputs(config, env) {
270
271
  // for a person to diff and move onto the policy; a role that could write it
271
272
  // could plant a policy the operator then promotes in good faith.
272
273
  { path: policy + ".observed", why: "the policy", always: true },
273
- { path: abs(STATE_DIR), why: "seisin's log and request queue", always: true },
274
+ { path: stateDir(config.root), why: "seisin's log and request queue", always: true },
274
275
  ...(config.keyDirs ?? []).map((d) => ({ path: abs(d), why: "a key directory", always: true })),
275
276
  ];
276
277
  for (const [scheme, p] of Object.entries(config.keyProviders ?? {})) {
package/src/territory.js CHANGED
@@ -59,6 +59,14 @@ export const READ_MODES = ["all", "territory"];
59
59
  /** Files an agent reads at start that live outside its home directory. */
60
60
  export const AGENT_FILES = { claude: ["~/.claude.json"], codex: [] };
61
61
 
62
+ /**
63
+ * Files in HOME every process on the platform reads at start, whatever it is,
64
+ * and that hold nothing about anybody. On macOS CoreFoundation reads
65
+ * `~/.CFUserTextEncoding` (a text-encoding code, `0x1F5:0x0:0x0`); shut, it was
66
+ * 278 of 409 denials in one orchestrator's runs, every one of them noise.
67
+ */
68
+ export const PLATFORM_FILES = { darwin: ["~/.CFUserTextEncoding"], linux: [] };
69
+
62
70
  /**
63
71
  * Where data lives, per platform. Whole roots: what is under them belongs to
64
72
  * somebody. `/opt/homebrew/var` and `/usr/local/var` hold the databases a
@@ -225,6 +233,7 @@ export function keptFor(config, role, { observe = false, agent, program } = {})
225
233
  // starts and says "Not logged in" (measured, 2026-10-09). A run of no
226
234
  // agent, or of another, does not get it.
227
235
  ...(agent ? (AGENT_FILES[agent] ?? []).map(expand) : []),
236
+ ...(PLATFORM_FILES[process.platform] ?? []).map(expand),
228
237
  ...writePathsOf(config, role, { observe, agent }),
229
238
  ...(role.reads ?? []).map((p) => readPath(config.root, p)),
230
239
  ...(role.toolchain ?? []).map((p) => readPath(config.root, p)),
package/ui/index.html CHANGED
@@ -2403,15 +2403,18 @@ table.runs tr.sys td{color:var(--dim)}
2403
2403
  if (w.text) bits.push('started <span title="' + escA(w.title) + '">' + esc(w.text) + "</span>");
2404
2404
  return bits.join(" · ");
2405
2405
  }
2406
- function outcomeTag(o){
2406
+ function outcomeTag(o, dropped){
2407
2407
  var says = { clean: "clean", denied: "denied", refused: "canary failed", unread: "policy unreadable" };
2408
- return '<span class="oc ' + escA(o) + '">' + esc(says[o] || o) + "</span>";
2408
+ // A run whose system log dropped kernel messages: its walls may be missing some.
2409
+ var lost = dropped ? ' <span class="oc refused" title="the system log dropped ' + escA(String(dropped)) +
2410
+ ' batch(es) of kernel messages during this run: denials may be missing from its record">record incomplete</span>' : "";
2411
+ return '<span class="oc ' + escA(o) + '">' + esc(says[o] || o) + "</span>" + lost;
2409
2412
  }
2410
2413
 
2411
2414
  function drawRunBanner(){
2412
2415
  var b = document.getElementById("run-banner");
2413
2416
  var r = RUNINFO || { id: RUN };
2414
- b.innerHTML = "<b>Run " + esc(r.run_id || r.id) + "</b> " + outcomeTag(r.outcome || "") +
2417
+ b.innerHTML = "<b>Run " + esc(r.run_id || r.id) + "</b> " + outcomeTag(r.outcome || "", r.dropped) +
2415
2418
  ' · <a href="./">all runs</a>' +
2416
2419
  '<div class="meta">' + (runMeta(r) || "no run.json: only what its policy and log say") +
2417
2420
  " · read only: nothing is granted from here</div>";
@@ -2442,6 +2445,7 @@ table.runs tr.sys td{color:var(--dim)}
2442
2445
  document.getElementById("rn-kpis").innerHTML =
2443
2446
  card("b", "runs", t.runs, (t.outcome.denied || 0) + " denied something · " + (t.outcome.clean || 0) + " clean") +
2444
2447
  card(t.outcome.refused ? "a" : "g", "canary failed", t.outcome.refused || 0, "runs that never started") +
2448
+ (t.incomplete ? card("a", "record incomplete", t.incomplete, "runs whose system log dropped kernel messages") : "") +
2445
2449
  card(t.denied ? "a" : "g", "denied", t.denied, (k.file || 0).toLocaleString() + " files · " + (k.network || 0).toLocaleString() + " network") +
2446
2450
  card(t.retried ? "a" : "g", "spent retrying", t.retried, "calls after the first denial of the same thing");
2447
2451
 
@@ -2463,7 +2467,7 @@ table.runs tr.sys td{color:var(--dim)}
2463
2467
  var w = when(r.started_at || "");
2464
2468
  return "<tr><td><a href=\"./?run=" + encodeURIComponent(r.id) + "#requests\"><code>" + esc(r.run_id || r.id) + "</code></a></td>" +
2465
2469
  '<td title="' + escA(w.title || "") + '">' + esc(w.text || "") + "</td><td>" + esc(r.team || "") + "</td><td>" + esc(r.role || "") +
2466
- "</td><td>" + esc(r.work_item || "") + "</td><td>" + esc(r.status || "") + "</td><td>" + outcomeTag(r.outcome) +
2470
+ "</td><td>" + esc(r.work_item || "") + "</td><td>" + esc(r.status || "") + "</td><td>" + outcomeTag(r.outcome, r.dropped) +
2467
2471
  "</td><td class=num>" + r.denied + "</td><td class=num>" + r.retried + "</td><td>" +
2468
2472
  (r.hosts || []).slice(0, 3).map(function(h){ return "<code>" + esc(h.target) + "</code>"; }).join(" ") + "</td></tr>";
2469
2473
  }).join("") + "</tbody>"