seisin 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +143 -686
  2. package/package.json +2 -2
  3. package/src/canary.js +1 -1
  4. package/src/cli.js +47 -3
  5. package/src/commands/check.js +68 -12
  6. package/src/commands/hook.js +1 -1
  7. package/src/commands/log.js +21 -14
  8. package/src/commands/requests.js +10 -15
  9. package/src/commands/review.js +2 -1
  10. package/src/commands/run.js +331 -245
  11. package/src/commands/walls.js +12 -5
  12. package/src/config-fields.js +305 -0
  13. package/src/config.js +114 -534
  14. package/src/controls.js +1 -1
  15. package/src/decide-request.js +37 -0
  16. package/src/denial-attribution.js +338 -0
  17. package/src/grants.js +1 -1
  18. package/src/help.js +6 -4
  19. package/src/index.js +1 -1
  20. package/src/inspect/enforce.js +1 -1
  21. package/src/inspect/files.js +1 -1
  22. package/src/inspect/home.js +1 -1
  23. package/src/inspect/inject.js +1 -1
  24. package/src/intake.js +3 -2
  25. package/src/keys.js +2 -2
  26. package/src/kinds.js +19 -0
  27. package/src/layout.js +1 -1
  28. package/src/log.js +3 -2
  29. package/src/mcp.js +1 -1
  30. package/src/notify-config.js +58 -0
  31. package/src/notify.js +4 -52
  32. package/src/owners.js +2 -2
  33. package/src/requests.js +9 -4
  34. package/src/rundir.js +2 -2
  35. package/src/runs.js +29 -10
  36. package/src/sandboxes/command.js +0 -2
  37. package/src/sandboxes/index.js +15 -2
  38. package/src/sandboxes/srt.js +1 -1
  39. package/src/sandboxes/tartaros.js +34 -6
  40. package/src/serve.js +11 -22
  41. package/src/spool.js +1 -1
  42. package/src/{srt.js → srt-settings.js} +5 -2
  43. package/src/state.js +25 -0
  44. package/src/suggest.js +26 -1
  45. package/src/surface.js +3 -2
  46. package/src/territory.js +20 -1
  47. package/src/toml.js +250 -0
  48. package/src/violations.js +16 -315
  49. package/src/walls.js +4 -2
  50. package/ui/index.html +35 -9
package/src/notify.js CHANGED
@@ -23,13 +23,13 @@
23
23
  * which every role can read.
24
24
  */
25
25
  import { readFileSync } from "node:fs";
26
- import { basename, join, resolve, sep } from "node:path";
26
+ import { basename, join } from "node:path";
27
27
  import { keyOf, pending, requestsPath, grantFor, shellId, queueOf, withHandoffs } from "./requests.js";
28
28
  import { describeHandoff } from "./handoff.js";
29
- import { realAncestor } from "./paths.js";
30
29
 
31
- const FORMATS = ["text", "json", "slack"];
32
- const ENV_URL = "SEISIN_NOTIFY_URL";
30
+ import { ENV_URL, FORMATS } from "./notify-config.js";
31
+
32
+ export { readNotify } from "./notify-config.js";
33
33
 
34
34
  /** The URL to notify, or null. Read in the parent only. */
35
35
  function notifyUrl(config, env = process.env) {
@@ -142,51 +142,3 @@ function notificationFailure(error) {
142
142
  if (code === "ECONNREFUSED") return "connection unavailable";
143
143
  return "request failed";
144
144
  }
145
-
146
- /**
147
- * `[notify]` from seisin.toml, refused rather than guessed when it would leak.
148
- * Called by the config loader.
149
- */
150
- export function readNotify(table, { path, root, keyDirs, roles, own }) {
151
- if (table === undefined) return null;
152
- if (own(table, "url") !== undefined)
153
- throw new Error(
154
- `${path}: [notify] url is readable by every role — seisin.toml is not a secret. ` +
155
- `Put the URL in a file inside a key directory no role declares and name it with url_file, ` +
156
- `or set ${ENV_URL} in the environment that starts seisin.`);
157
- const urlFile = own(table, "url_file");
158
- const format = own(table, "format") ?? "text";
159
- if (!FORMATS.includes(format))
160
- throw new Error(`${path}: [notify] format = "${format}" is not one of ${FORMATS.join(", ")}`);
161
- if (urlFile === undefined) return { urlFile: null, format };
162
- if (typeof urlFile !== "string" || !urlFile.trim() || urlFile.startsWith("/") || urlFile.split("/").includes(".."))
163
- throw new Error(`${path}: [notify] url_file must be a path inside the repo`);
164
- const canonical = (p) => realAncestor(resolve(root, p));
165
- const file = canonical(urlFile);
166
- const inKeyDir = keyDirs.some((d) => file.startsWith(canonical(d) + sep));
167
- if (!inKeyDir)
168
- throw new Error(
169
- `${path}: [notify] url_file "${urlFile}" is not inside a key directory, so every role can read ` +
170
- `it. Move it under ${keyDirs[0] ?? "a [keys] dir"}.`);
171
- // A file key is written bare (`netlify.txt`, meaning the first key directory)
172
- // or with its directory (`shared/api.txt`) — the same resolution settingsFor uses.
173
- const fileKey = (raw) => canonical(raw.includes("/") ? raw : join(keyDirs[0] ?? "", raw));
174
- const holders = Object.entries(roles)
175
- .filter(([, r]) => r.keyEntries?.some((k) => {
176
- if (k.kind === "file") {
177
- const held = fileKey(k.raw);
178
- return file === held || file.startsWith(held + sep);
179
- }
180
- if (k.kind !== "ref" || k.scheme !== "file") return false;
181
- // Match the builtin resolver's last-fragment rule. A reference delivers
182
- // the contents even though it opens no filesystem read for the role.
183
- const hash = k.ref.lastIndexOf("#");
184
- return canonical(hash === -1 ? k.ref : k.ref.slice(0, hash)) === file;
185
- }))
186
- .map(([n]) => n);
187
- if (holders.length)
188
- throw new Error(
189
- `${path}: [notify] url_file "${urlFile}" is declared as a key by ${holders.join(", ")} — ` +
190
- `that role could read it and send the person a fake "grant this". No role may hold it.`);
191
- return { urlFile, format };
192
- }
package/src/owners.js CHANGED
@@ -23,7 +23,7 @@ import { keptFor, shutByTerritory } from "./territory.js";
23
23
  * Does this glob cover this path? Supports `**`, `*`, `?` and a trailing `/`.
24
24
  *
25
25
  * A wildcard-free pattern is a **prefix**, because that is what the kernel is
26
- * given. `toWritePath` in srt.js hands `src/api` to `allowWrite` unchanged, and
26
+ * given. `toWritePath` in srt-settings.js hands `src/api` to `allowWrite` unchanged, and
27
27
  * a sandbox grants a path and everything under it — so `src/api`, `src/api/`
28
28
  * and `src/api/**` are one grant to the OS, and reading the first of them as
29
29
  * "that path only" made seisin describe a boundary that was not there.
@@ -246,7 +246,7 @@ export function ownersOf(config, path) {
246
246
  path = path.slice(root.length);
247
247
  }
248
248
  // A private folder has one owner, whatever else covers it: every other role
249
- // is given a denyWrite on it (srt.js), and denyWrite wins over any territory.
249
+ // is given a denyWrite on it (srt-settings.js), and denyWrite wins over any territory.
250
250
  const mine = privateOwner(config, path);
251
251
  return Object.values(config.roles)
252
252
  .filter((r) => r.writes.some((g) => covers(g, path)) && !neverWrites(r, path, config))
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/rundir.js CHANGED
@@ -31,10 +31,10 @@ export const RUNS_NAME = "snr";
31
31
  /**
32
32
  * Where a run's temp directory lives when the role may write no other: beside
33
33
  * the runs root, not inside it. The runs root is denied to writes as a whole
34
- * (srt.js), and a deny wins over any grant beneath it.
34
+ * (srt-settings.js), and a deny wins over any grant beneath it.
35
35
  */
36
36
  export const TMP_NAME = "snt";
37
- /** The settings file inside a run's directory. srt.js denies it to the run by this name. */
37
+ /** The settings file inside a run's directory. srt-settings.js denies it to the run by this name. */
38
38
  export const SETTINGS_NAME = "settings.json";
39
39
 
40
40
  /**
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",
@@ -72,8 +72,6 @@ export function assessCheck(argv, file, label) {
72
72
  return { unmet: [`${label} says it cannot hold this policy: ${why}`], warnings };
73
73
  }
74
74
 
75
- /** Only the answer: `[]` for yes, the reason for no. */
76
- export const askCheck = (argv, file, label) => assessCheck(argv, file, label).unmet;
77
75
 
78
76
  export function commandSandbox(name, command, check = null, { tagsDenials = false } = {}) {
79
77
  const assess = (_settings, { file } = {}) => (check ? assessCheck(check, file, `its check (${check[0]})`) : { unmet: [], warnings: [] });
@@ -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: [] }
@@ -78,7 +80,18 @@ export function weigh(config, settings, { platform = process.platform, role = nu
78
80
  const browser = register.length && !sandbox.machRegister
79
81
  ? [`a Mach service to register (${register.join(", ")}, for browsers): ${sandbox.name} is not given it, only srt and tartaros are`]
80
82
  : [];
81
- return { sandbox, bin, unmet: [...(masking ? [masking] : []), ...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() };
82
95
  });
83
96
  const chosen = options.find((o) => o.bin && o.unmet.length === 0) ?? null;
84
97
  return { chosen, options };
@@ -2,7 +2,7 @@
2
2
  * `srt`: @anthropic-ai/sandbox-runtime, the sandbox seisin was built on.
3
3
  *
4
4
  * The default, and the reference every other provider is measured against:
5
- * the settings seisin generates (srt.js, one level up) ARE this runtime's
5
+ * the settings seisin generates (srt-settings.js, one level up) ARE this runtime's
6
6
  * settings file, so it enforces all of them by construction and `unmet` is
7
7
  * always empty. What a run hands it is the file and the command, nothing else.
8
8
  */
@@ -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";
@@ -27,7 +28,7 @@ export const TARTAROS_PACKAGE = "@carlostapiacl/tartaros";
27
28
 
28
29
  /**
29
30
  * The tartaros seisin depends on first, one on PATH second, and nothing third:
30
- * the order srt is found in (srt.js), for the same reason. A tartaros built
31
+ * the order srt is found in (resolveSrt in sandboxes/srt.js), for the same reason. A tartaros built
31
32
  * once and left on PATH goes on answering after the pinned one has its fixes.
32
33
  *
33
34
  * The pinned one is an optional dependency: a package per platform with the
@@ -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 = {
@@ -100,6 +123,11 @@ export const tartaros = {
100
123
  masks: () => null,
101
124
  adapt: tartarosSettings,
102
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,
103
131
  assess: (_settings, { bin, file, platform } = {}) => tartarosAssess(bin, file, { platform }),
104
132
  unmet: (_settings, { bin, file, platform } = {}) => tartarosUnmet(bin, file, { platform }),
105
133
  wrap: (bin, file, _settings, inner, ctx) => tartarosArgv(bin, file, inner, ctx),
package/src/serve.js CHANGED
@@ -16,10 +16,11 @@ import { readFileSync, existsSync, statSync } from "node:fs";
16
16
  import { join, dirname } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { loadConfig } from "./config.js";
19
+ import { decideRequest } from "./decide-request.js";
19
20
  import { read, logPath, logSegments } from "./log.js";
20
- import { settingsFor } from "./srt.js";
21
+ import { settingsFor } from "./srt-settings.js";
21
22
  import { describeHandoff } from "./handoff.js";
22
- import { pending, requestsPath, settle, applyGrant, refuseIfBarred, editPolicy, cleanReason, asFileGrant, lastAgentOf, fileOnlyNote } from "./requests.js";
23
+ import { pending, requestsPath, editPolicy, settle, cleanReason, lastAgentOf, fileOnlyNote } from "./requests.js";
23
24
  import { causesOf, describeWalls, parseSince, queue, verdicts, wallsByRole } from "./views.js";
24
25
  import { planEdit, hashOf } from "./controls.js";
25
26
  import { inspect } from "./inspect.js";
@@ -263,29 +264,17 @@ function decide(configPath, { key, decision, reason, scope = "folder" }) {
263
264
  const cfg = loadConfig(configPath);
264
265
  const file = requestsPath(cfg.root);
265
266
 
266
- /**
267
- * Looked up, applied and settled under one lock — the policy's, the one
268
- * every grant takes (editPolicy). The lookup used to happen before it and
269
- * the settle after it, so the console and `seisin grant` could both find the
270
- * same request pending and both act on it. A decline takes the lock as well,
271
- * though it writes no policy, for the same reason.
272
- *
273
- * The config is edited as text, so comments and order survive, and it is
274
- * written before the queue is settled: if the write throws, the request is
275
- * still open rather than marked done against a file that never changed.
276
- */
277
267
  let req = null;
278
268
  let result = null;
279
269
  try {
280
- result = editPolicy(cfg, (before) => {
281
- const current = loadConfig(configPath, before);
282
- req = pending(file, { keyDirs: current.keyDirs }).find((r) => r.key === key);
283
- if (!req) throw bad(`request ${key} is no longer pending — reload to see the queue as it is now`);
284
- if (decision !== "granted") return { toml: before, changed: false };
285
- if (scope === "file") req = asFileGrant(req);
286
- refuseIfBarred(current, req);
287
- return applyGrant(before, req, reason);
288
- }, { after: () => settle(file, req.key, decision, reason, { only: req.only }) });
270
+ ({ request: req, ...result } = decideRequest(cfg, {
271
+ decision, reason, only: scope === "file",
272
+ select: (current) => {
273
+ const request = pending(file, { keyDirs: current.keyDirs }).find((r) => r.key === key);
274
+ if (!request) throw bad(`request ${key} is no longer pending — reload to see the queue as it is now`);
275
+ return request;
276
+ },
277
+ }));
289
278
  } catch (e) {
290
279
  if (e.code === "ELOCKED") throw Object.assign(new Error("another grant is in progress — try again in a moment"), { status: 503 });
291
280
  throw e;
package/src/spool.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * request queue writable by the process they are a record of: an agent could
8
8
  * truncate `log.jsonl`, rewrite a decision, or invent a request that had never
9
9
  * been refused. The files said "append-only" in their own comments and nothing
10
- * enforced it. An external review named this and was right.
10
+ * enforced it. A review named this and was right.
11
11
  *
12
12
  * So the file moves out of reach and a socket takes its place:
13
13
  *
@@ -33,7 +33,7 @@ import {
33
33
  CREDENTIAL_HOMES, toWritePath, writePathsOf, otherAgentsCredentials,
34
34
  } from "./grants.js";
35
35
 
36
- // Re-exported: these were srt.js's exports before grants.js existed, and
36
+ // Re-exported: these were srt-settings.js's exports before grants.js existed, and
37
37
  // callers (the CLI, the tests, anyone embedding seisin) import them from here.
38
38
  export {
39
39
  expand, homeFits, roleHome, roleHomeRoot, CREDENTIAL_HOMES, RUNTIME_WRITES,
@@ -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/suggest.js CHANGED
@@ -41,12 +41,37 @@ export function unknownRole(config, role) {
41
41
  * `--verbsoe` is a question answered as if it had not been asked.
42
42
  * `values` are the flags that take the next word as their value.
43
43
  */
44
+ /**
45
+ * A count a person types (`--min 3`): a whole number from 1. Anything else is
46
+ * refused; `Number("banana")` made every comparison false and the answer
47
+ * "no walls" looked like a real one.
48
+ */
49
+ export function positiveInt(flag, value) {
50
+ if (!/^[1-9][0-9]*$/.test(String(value ?? ""))) {
51
+ const e = new Error(`${flag} takes a whole number from 1, not "${value ?? ""}"`);
52
+ e.usage = true;
53
+ throw e;
54
+ }
55
+ return Number(value);
56
+ }
57
+
44
58
  export function checkFlags(command, argv, known, values = []) {
45
59
  const all = [...known, ...values];
46
60
  for (let i = 0; i < argv.length; i++) {
47
61
  const a = argv[i];
48
62
  if (a === "--") break;
49
- if (values.includes(a)) { i++; continue; }
63
+ if (values.includes(a)) {
64
+ // A flag that takes a value and has none is refused: `log --role` read
65
+ // as no filter at all, and answered for every role.
66
+ const v = argv[i + 1];
67
+ if (v === undefined || v === "--" || (v.startsWith("-") && v !== "-")) {
68
+ const e = new Error(`${a} needs a value\n seisin ${command} --help lists its flags`);
69
+ e.usage = true;
70
+ throw e;
71
+ }
72
+ i++;
73
+ continue;
74
+ }
50
75
  if (!a.startsWith("-") || a === "-") continue;
51
76
  if (all.includes(a)) continue;
52
77
  const near = nearest(a, all);
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
@@ -50,7 +50,7 @@
50
50
  * nothing else in a data root is.
51
51
  */
52
52
  import { readdirSync, lstatSync } from "node:fs";
53
- import { homedir, tmpdir } from "node:os";
53
+ import { homedir, tmpdir, userInfo } from "node:os";
54
54
  import { realOrSelf, writePathsOf, expand } from "./grants.js";
55
55
  import { realAncestor } from "./paths.js";
56
56
 
@@ -59,6 +59,24 @@ 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
+ * The system reads it from the account's home, not from `$HOME`: an
68
+ * orchestrator that gives each run its own HOME still sees the real file read
69
+ * (measured), so these resolve against the account, see `accountHome`.
70
+ */
71
+ export const PLATFORM_FILES = { darwin: ["~/.CFUserTextEncoding"], linux: [] };
72
+
73
+ /** The home directory of the account, from the user database, whatever `$HOME` says. */
74
+ export function accountHome() {
75
+ try { return userInfo().homedir || homedir(); } catch { return homedir(); }
76
+ }
77
+
78
+ const expandAccount = (p) => (p.startsWith("~/") ? `${accountHome()}/${p.slice(2)}` : expand(p));
79
+
62
80
  /**
63
81
  * Where data lives, per platform. Whole roots: what is under them belongs to
64
82
  * somebody. `/opt/homebrew/var` and `/usr/local/var` hold the databases a
@@ -225,6 +243,7 @@ export function keptFor(config, role, { observe = false, agent, program } = {})
225
243
  // starts and says "Not logged in" (measured, 2026-10-09). A run of no
226
244
  // agent, or of another, does not get it.
227
245
  ...(agent ? (AGENT_FILES[agent] ?? []).map(expand) : []),
246
+ ...(PLATFORM_FILES[process.platform] ?? []).map(expandAccount),
228
247
  ...writePathsOf(config, role, { observe, agent }),
229
248
  ...(role.reads ?? []).map((p) => readPath(config.root, p)),
230
249
  ...(role.toolchain ?? []).map((p) => readPath(config.root, p)),