seisin 0.6.3 → 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 +2 -2
- package/package.json +2 -2
- package/src/cli.js +45 -1
- package/src/commands/check.js +53 -5
- package/src/commands/hook.js +1 -1
- package/src/commands/run.js +66 -7
- package/src/config.js +126 -9
- package/src/help.js +6 -4
- package/src/keys.js +1 -1
- package/src/kinds.js +19 -0
- package/src/log.js +3 -2
- package/src/requests.js +9 -4
- package/src/runs.js +29 -10
- package/src/sandboxes/index.js +15 -2
- package/src/sandboxes/tartaros.js +33 -5
- package/src/srt.js +4 -1
- package/src/state.js +25 -0
- package/src/surface.js +3 -2
- package/src/territory.js +9 -0
- package/ui/index.html +8 -4
package/README.md
CHANGED
|
@@ -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
|
-
**
|
|
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.
|
|
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.
|
|
48
|
+
"@carlostapiacl/tartaros": "0.1.0-alpha.18"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"test": "node --test test/*.test.js",
|
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
|
}
|
package/src/commands/check.js
CHANGED
|
@@ -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
|
|
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
|
package/src/commands/hook.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/run.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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,7 +543,7 @@ export async function run(config, argv) {
|
|
|
493
543
|
});
|
|
494
544
|
if (!probe.ok) {
|
|
495
545
|
append(logPath(config.root), {
|
|
496
|
-
event: "canary", role, run:
|
|
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
|
});
|
|
499
549
|
if (probe.exposed) {
|
|
@@ -512,7 +562,7 @@ export async function run(config, argv) {
|
|
|
512
562
|
process.exit(CANARY_EXIT);
|
|
513
563
|
}
|
|
514
564
|
append(logPath(config.root), {
|
|
515
|
-
event: "canary", role, run:
|
|
565
|
+
event: "canary", role, run: runLabel, ...trace, agent: agent ?? null, confined: true, reasons: [],
|
|
516
566
|
...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
|
|
517
567
|
});
|
|
518
568
|
// Said once, only when it is not the default: which sandbox holds this run
|
|
@@ -552,7 +602,12 @@ export async function run(config, argv) {
|
|
|
552
602
|
const nested = nestedSandboxWarning(cmd);
|
|
553
603
|
if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
|
|
554
604
|
|
|
555
|
-
|
|
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 });
|
|
556
611
|
const child = spawn(runner, runnerArgs, {
|
|
557
612
|
stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
558
613
|
env,
|
|
@@ -632,6 +687,10 @@ export async function run(config, argv) {
|
|
|
632
687
|
// returns as soon as the stream goes quiet, so a run that was denied
|
|
633
688
|
// nothing pays a tick.
|
|
634
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 });
|
|
635
694
|
// The run made this directory, so the run removes it.
|
|
636
695
|
cleanup();
|
|
637
696
|
|
|
@@ -648,7 +707,7 @@ export async function run(config, argv) {
|
|
|
648
707
|
// the record did not. Said every time, and written down, because a denial
|
|
649
708
|
// that never arrived files no request and nothing else would show it.
|
|
650
709
|
if (dropped) {
|
|
651
|
-
append(logPath(config.root), { event: "log-dropped", role, run:
|
|
710
|
+
append(logPath(config.root), { event: "log-dropped", role, run: runLabel, ...trace, batches: dropped });
|
|
652
711
|
err(`${C.yellow}seisin: the system log dropped ${dropped} batch(es) of messages during this run: ` +
|
|
653
712
|
`kernel denials may be missing from the log and the queue. ` +
|
|
654
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
|
|
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
|
|
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
|
|
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(/\/\*\*$/, "")
|
|
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
|
|
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(/\/\*\*$/, "")
|
|
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
|
},
|
package/src/keys.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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,
|
|
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 {
|
|
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,
|
|
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 =
|
|
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,
|
|
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
|
|
24
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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",
|
package/src/sandboxes/index.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/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
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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>"
|