seisin 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/canary.js +10 -0
- 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 +74 -7
- package/src/config.js +126 -9
- package/src/help.js +6 -4
- package/src/inspect/inject.js +16 -5
- package/src/keys.js +16 -17
- 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/command.js +3 -0
- package/src/sandboxes/index.js +19 -2
- package/src/sandboxes/srt.js +3 -0
- package/src/sandboxes/tartaros.js +36 -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
|
@@ -855,7 +855,7 @@ A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bit
|
|
|
855
855
|
confined process. Nothing degrades: a provider that fails stops the run.
|
|
856
856
|
|
|
857
857
|
A key reaches the role as a variable (`key_mode = "env"`), as a file in the run's private
|
|
858
|
-
directory (`"run-file"`), or
|
|
858
|
+
directory (`"run-file"`), or not at all (on Linux, and on macOS under tartaros): with `"inject"` the agent holds a
|
|
859
859
|
placeholder and the sandbox swaps in the real value only on the way to the hosts you list for
|
|
860
860
|
it. That costs terminating TLS to those hosts, which `seisin check` names, and a host that
|
|
861
861
|
stores what it is sent can still hand the value back — use a least-scope token.
|
|
@@ -1037,8 +1037,8 @@ the same kernel or workspace, and which one you want depends on what you are pro
|
|
|
1037
1037
|
|
|
1038
1038
|
## Status
|
|
1039
1039
|
|
|
1040
|
-
**
|
|
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/canary.js
CHANGED
|
@@ -130,6 +130,16 @@ export async function runCanary({ srt, wrap = null, settingsFile, settings, env,
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
if (timedOut) failed.push(`the canary did not finish in ${timeoutMs / 1000}s`);
|
|
133
|
+
// tartaros refuses a run whose real value another process exposes (its exec
|
|
134
|
+
// record, which the agent can read; on macOS no sandbox hides it). That is not
|
|
135
|
+
// a boundary to fix in the policy, and its own advice names a flag seisin does
|
|
136
|
+
// not pass, so it is said apart, as the variable and the process.
|
|
137
|
+
const exposed = [...stderr.matchAll(/tartaros: secret exposed: the value of (\S+) in pid (\d+) \(([^)]*)\)/g)]
|
|
138
|
+
.map(([, name, pid, proc]) => ({ name, pid: Number(pid), proc }));
|
|
139
|
+
if (exposed.length) {
|
|
140
|
+
for (const p of [secret, outside, insideDir, shared]) if (p) try { rmSync(p, { force: true, recursive: p === insideDir }); } catch {}
|
|
141
|
+
return { ok: false, exposed, failed: exposed.map((e) => `${e.name}'s real value is readable in pid ${e.pid} (${e.proc})`), ms: Date.now() - t0 };
|
|
142
|
+
}
|
|
133
143
|
if (!output.includes(`ALIVE-${nonce}`)) {
|
|
134
144
|
// The runtime's own words, when it said any: a sandbox that refuses to
|
|
135
145
|
// start usually says why (measured: a path the runtime read as a broken
|
package/src/cli.js
CHANGED
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
* never know: which role a path belongs to, and therefore who to ask next.
|
|
14
14
|
*/
|
|
15
15
|
import { readFileSync } from "node:fs";
|
|
16
|
-
import { join, dirname } from "node:path";
|
|
16
|
+
import { join, dirname, resolve } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
|
|
19
19
|
import { findConfig, loadConfig } from "./config.js";
|
|
20
20
|
import { CONFIG_NAME } from "./layout.js";
|
|
21
|
+
import { setStateDir } from "./state.js";
|
|
21
22
|
import { C, out, err } from "./render.js";
|
|
22
23
|
import { COMMAND_HELP, flagsOf, renderHelp, renderLimits } from "./help.js";
|
|
23
24
|
import { nearest, checkFlags } from "./suggest.js";
|
|
@@ -36,6 +37,44 @@ import { nearest, checkFlags } from "./suggest.js";
|
|
|
36
37
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
37
38
|
const [, , command, ...argv] = process.argv;
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* `--policy <file>` and `--state-dir <dir>`, for a launcher that keeps both out
|
|
42
|
+
* of the repo it runs in (an orchestrator with a folder per run). The policy is
|
|
43
|
+
* read from the file and rooted at the working directory; the log and the
|
|
44
|
+
* request queue go to the state directory. Both are taken out of seisin's own
|
|
45
|
+
* part of the line before the command reads it (after `--`, or after `run
|
|
46
|
+
* <role> <command>`, the words are the agent's), and both reach everything a
|
|
47
|
+
* run starts through the environment (SEISIN_CONFIG, SEISIN_ROOT,
|
|
48
|
+
* SEISIN_STATE_DIR).
|
|
49
|
+
*/
|
|
50
|
+
function ownEnd() {
|
|
51
|
+
const split = argv.indexOf("--");
|
|
52
|
+
if (split !== -1) return split;
|
|
53
|
+
if (command !== "run") return argv.length;
|
|
54
|
+
let i = 1;
|
|
55
|
+
const values = new Set([...(flagsOf("run")?.values ?? []), "--policy", "--state-dir"]);
|
|
56
|
+
while (i < argv.length && argv[i].startsWith("-")) i += values.has(argv[i]) ? 2 : 1;
|
|
57
|
+
return i;
|
|
58
|
+
}
|
|
59
|
+
function takeGlobal(name) {
|
|
60
|
+
const i = argv.indexOf(name);
|
|
61
|
+
if (i === -1 || i >= ownEnd()) return null;
|
|
62
|
+
const v = argv[i + 1];
|
|
63
|
+
if (v === undefined || v.startsWith("-")) {
|
|
64
|
+
err(`${C.red}seisin:${C.off} ${name} needs a value\n`);
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
argv.splice(i, 2);
|
|
68
|
+
return v;
|
|
69
|
+
}
|
|
70
|
+
const policyFlag = takeGlobal("--policy");
|
|
71
|
+
const stateFlag = takeGlobal("--state-dir");
|
|
72
|
+
if (stateFlag) setStateDir(stateFlag);
|
|
73
|
+
const rootFlag = policyFlag ? process.cwd() : null;
|
|
74
|
+
if (policyFlag) {
|
|
75
|
+
process.env.SEISIN_ROOT = rootFlag;
|
|
76
|
+
}
|
|
77
|
+
|
|
39
78
|
const USAGE = `
|
|
40
79
|
${C.b}seisin${C.off} — give each agent its own folders and its own keys
|
|
41
80
|
|
|
@@ -61,6 +100,9 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
|
|
|
61
100
|
seisin mcp an MCP server on stdio — read-only
|
|
62
101
|
|
|
63
102
|
seisin <command> --help lists a command's flags, examples and exit codes.
|
|
103
|
+
Any command takes --policy <file> (the policy, rooted at the working directory) and
|
|
104
|
+
--state-dir <dir> (where the log and the request queue go) for a launcher that keeps
|
|
105
|
+
both outside the repo (docs/runs.md).
|
|
64
106
|
seisin help limits says what seisin cannot do, in any repo.
|
|
65
107
|
|
|
66
108
|
Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
|
|
@@ -69,6 +111,7 @@ seisin decides what to ask for, and says whose file it was when the answer is no
|
|
|
69
111
|
|
|
70
112
|
/** Loads the policy, or explains where to get one. */
|
|
71
113
|
function config() {
|
|
114
|
+
if (policyFlag) return loadConfig(resolve(policyFlag), undefined, { root: rootFlag });
|
|
72
115
|
const path = findConfig();
|
|
73
116
|
if (!path) throw new Error(`no ${CONFIG_NAME} found here or above. Run "seisin init" to write one.`);
|
|
74
117
|
return loadConfig(path);
|
|
@@ -76,6 +119,7 @@ function config() {
|
|
|
76
119
|
|
|
77
120
|
/** The policy here, or null — for a command that can answer without one. */
|
|
78
121
|
function maybeConfig() {
|
|
122
|
+
if (policyFlag) return config();
|
|
79
123
|
const path = findConfig();
|
|
80
124
|
return path ? loadConfig(path) : null;
|
|
81
125
|
}
|
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,9 +543,17 @@ export async function run(config, argv) {
|
|
|
493
543
|
});
|
|
494
544
|
if (!probe.ok) {
|
|
495
545
|
append(logPath(config.root), {
|
|
496
|
-
event: "canary", role, run:
|
|
546
|
+
event: "canary", role, run: runLabel, ...trace, agent: agent ?? null, confined: false, reasons: probe.failed,
|
|
497
547
|
...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
|
|
498
548
|
});
|
|
549
|
+
if (probe.exposed) {
|
|
550
|
+
err(`${C.red}seisin: ${role} was NOT started — another process holds a real value the agent would see:${C.off}\n` +
|
|
551
|
+
probe.failed.map((f) => ` - ${f}\n`).join("") +
|
|
552
|
+
` A process's environment and arguments are readable by every process of your user (on macOS no sandbox ` +
|
|
553
|
+
`hides them), so masking would hide nothing. Usually a shell that exported the token, and what it started. ` +
|
|
554
|
+
`Unset it there or end that process, and keep the value only where seisin's key points (docs/keys.md).\n`);
|
|
555
|
+
process.exit(CANARY_EXIT);
|
|
556
|
+
}
|
|
499
557
|
err(`${C.red}seisin: ${role} was NOT started — the sandbox failed its canary:${C.off}\n` +
|
|
500
558
|
probe.failed.map((f) => ` - ${f}\n`).join("") +
|
|
501
559
|
` The policy as generated does not hold on this machine, so no agent runs under it. ` +
|
|
@@ -504,7 +562,7 @@ export async function run(config, argv) {
|
|
|
504
562
|
process.exit(CANARY_EXIT);
|
|
505
563
|
}
|
|
506
564
|
append(logPath(config.root), {
|
|
507
|
-
event: "canary", role, run:
|
|
565
|
+
event: "canary", role, run: runLabel, ...trace, agent: agent ?? null, confined: true, reasons: [],
|
|
508
566
|
...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
|
|
509
567
|
});
|
|
510
568
|
// Said once, only when it is not the default: which sandbox holds this run
|
|
@@ -544,7 +602,12 @@ export async function run(config, argv) {
|
|
|
544
602
|
const nested = nestedSandboxWarning(cmd);
|
|
545
603
|
if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
|
|
546
604
|
|
|
547
|
-
|
|
605
|
+
// The sandbox's own record of what its egress broker refused (tartaros
|
|
606
|
+
// `--log`), in this run's private directory: read back into seisin's log when
|
|
607
|
+
// the run ends, so a denied host is a wall like any other instead of a line
|
|
608
|
+
// on stderr. Only the agent's run gets it, not the canary's.
|
|
609
|
+
const brokerLog = join(theRun.dir, "sandbox-log.jsonl");
|
|
610
|
+
const [runner, ...runnerArgs] = sandbox.wrap(bin, file, settings, boxed, { role, machRegister, log: brokerLog });
|
|
548
611
|
const child = spawn(runner, runnerArgs, {
|
|
549
612
|
stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
550
613
|
env,
|
|
@@ -624,6 +687,10 @@ export async function run(config, argv) {
|
|
|
624
687
|
// returns as soon as the stream goes quiet, so a run that was denied
|
|
625
688
|
// nothing pays a tick.
|
|
626
689
|
await denials.close({ drain: DRAIN_MS });
|
|
690
|
+
// Before the directory goes: the hosts the sandbox's broker refused.
|
|
691
|
+
for (const d of brokerDenials(brokerLog))
|
|
692
|
+
append(logPath(config.root), { role, run: runLabel, ...trace, tool: "broker", source: sandbox.name, action: "connect",
|
|
693
|
+
kind: "network", target: d.target, verdict: "denied", owners: [], reason: d.reason, times: d.times });
|
|
627
694
|
// The run made this directory, so the run removes it.
|
|
628
695
|
cleanup();
|
|
629
696
|
|
|
@@ -640,7 +707,7 @@ export async function run(config, argv) {
|
|
|
640
707
|
// the record did not. Said every time, and written down, because a denial
|
|
641
708
|
// that never arrived files no request and nothing else would show it.
|
|
642
709
|
if (dropped) {
|
|
643
|
-
append(logPath(config.root), { event: "log-dropped", role, run:
|
|
710
|
+
append(logPath(config.root), { event: "log-dropped", role, run: runLabel, ...trace, batches: dropped });
|
|
644
711
|
err(`${C.yellow}seisin: the system log dropped ${dropped} batch(es) of messages during this run: ` +
|
|
645
712
|
`kernel denials may be missing from the log and the queue. ` +
|
|
646
713
|
`\`seisin check --system-log\` says what is flooding it.${C.off}\n`);
|
package/src/config.js
CHANGED
|
@@ -17,6 +17,21 @@ import { parseKey, MODES, checkNames, BUILTIN, unknownMode } from "./keys.js";
|
|
|
17
17
|
import { readNotify } from "./notify.js";
|
|
18
18
|
import { BROWSERS } from "./browsers.js";
|
|
19
19
|
|
|
20
|
+
/** `a/b//` → `a/b`, in linear time (a `/\/+$/` regex backtracks on a long run of slashes). */
|
|
21
|
+
const trimSlashes = (p) => {
|
|
22
|
+
let end = p.length;
|
|
23
|
+
while (end > 0 && p[end - 1] === "/") end--;
|
|
24
|
+
return p.slice(0, end);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// The directory above a path's first glob character: "src/a*/b" gives "src", "a*" gives "".
|
|
28
|
+
const beforeGlob = (p) => {
|
|
29
|
+
const g = p.search(/[*?[\]{}]/);
|
|
30
|
+
if (g < 0) return p;
|
|
31
|
+
const slash = p.lastIndexOf("/", g);
|
|
32
|
+
return slash < 0 ? "" : p.slice(0, slash);
|
|
33
|
+
};
|
|
34
|
+
|
|
20
35
|
export { CONFIG_NAME } from "./layout.js";
|
|
21
36
|
|
|
22
37
|
/** Walk up from `from` until a seisin.toml shows up. Returns its path or null. */
|
|
@@ -329,7 +344,7 @@ function readReadList(v, at) {
|
|
|
329
344
|
if (/[*?[\]{}]/.test(above) || p === "**")
|
|
330
345
|
throw new Error(
|
|
331
346
|
`${at} = "${p}" cannot be enforced as written.\n` +
|
|
332
|
-
` A read grant is a path and everything under it. Write "${above
|
|
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/inspect/inject.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* price printed next to the thing it buys.
|
|
12
12
|
*/
|
|
13
13
|
import { injectKeysOf } from "../keys.js";
|
|
14
|
+
import { sandboxesOf } from "../sandboxes/index.js";
|
|
14
15
|
import { coversHost as covers } from "../srt.js";
|
|
15
16
|
|
|
16
17
|
export function injectWarnings(config, roles) {
|
|
@@ -27,14 +28,24 @@ export function injectWarnings(config, roles) {
|
|
|
27
28
|
if (inject.length === 0) continue;
|
|
28
29
|
const allowed = r.network ?? config.allowedDomains ?? [];
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
// Which sandbox masks is the sandbox's to say (sandboxes/, `masks`): on macOS
|
|
32
|
+
// only tartaros does, so a provider list without it cannot run the role here.
|
|
33
|
+
const names = sandboxesOf(config).map((s) => s.name);
|
|
34
|
+
if (process.platform === "darwin" && !names.includes("tartaros"))
|
|
31
35
|
out.push({
|
|
32
36
|
kind: "inject-platform",
|
|
33
37
|
role: r.name,
|
|
34
|
-
headline: `${r.name}: key_mode = "inject"
|
|
35
|
-
detail: `On
|
|
36
|
-
`
|
|
37
|
-
`use "env"/"run-file" here.`,
|
|
38
|
+
headline: `${r.name}: key_mode = "inject" needs tartaros on macOS — [sandbox] provider is ${names.join(", ")}`,
|
|
39
|
+
detail: `On macOS sandbox-runtime does not mask credentials yet (anthropics/sandbox-runtime#593), so ` +
|
|
40
|
+
`this role will not run here. Add tartaros: [sandbox] provider = ["tartaros", "srt"]. Or run it on ` +
|
|
41
|
+
`Linux, or use "env"/"run-file" here.`,
|
|
42
|
+
});
|
|
43
|
+
else if (process.platform !== "linux" && process.platform !== "darwin")
|
|
44
|
+
out.push({
|
|
45
|
+
kind: "inject-platform",
|
|
46
|
+
role: r.name,
|
|
47
|
+
headline: `${r.name}: key_mode = "inject" is not available on ${process.platform}`,
|
|
48
|
+
detail: `No sandbox seisin runs here masks credentials. Run this role on Linux or macOS, or use "env"/"run-file".`,
|
|
38
49
|
});
|
|
39
50
|
|
|
40
51
|
for (const c of inject) {
|
package/src/keys.js
CHANGED
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
* policy holds a reviewable reference instead of a path, and every resolution
|
|
31
31
|
* is logged without its value (`event: "key"`, see commands/run.js) — but it is not "the agent never sees it". That
|
|
32
32
|
* is `inject`, which costs terminating the role's TLS with a CA of your own and
|
|
33
|
-
*
|
|
34
|
-
* `
|
|
33
|
+
* needs a sandbox that masks credentials: srt on Linux, tartaros on Linux and
|
|
34
|
+
* macOS (see `injectGate` below, and `masks` on each sandbox).
|
|
35
35
|
*
|
|
36
36
|
* ## Why the value is never written down
|
|
37
37
|
*
|
|
@@ -375,7 +375,7 @@ export function readFileRef(entry, root = ".", { exact = false } = {}) {
|
|
|
375
375
|
export const RESERVED_ENV = new Set([
|
|
376
376
|
// The last is handoff.js's CHAIN_ENV, spelled out: handoff.js reaches this
|
|
377
377
|
// module through owners.js, and importing it back would be a cycle.
|
|
378
|
-
"SEISIN_ROLE", "SEISIN_CONFIG", SOCK_ENV, "SEISIN_HANDOFF_CHAIN",
|
|
378
|
+
"SEISIN_ROLE", "SEISIN_CONFIG", "SEISIN_ROOT", "SEISIN_STATE_DIR", SOCK_ENV, "SEISIN_HANDOFF_CHAIN",
|
|
379
379
|
...BASE, ...Object.keys(DEFAULTS),
|
|
380
380
|
]);
|
|
381
381
|
|
|
@@ -522,23 +522,22 @@ export function modeOf(config, role, entry) {
|
|
|
522
522
|
}
|
|
523
523
|
|
|
524
524
|
/**
|
|
525
|
-
* `inject` is the mode where the agent never sees the value: the
|
|
526
|
-
* the
|
|
527
|
-
* credential's declared hosts.
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
525
|
+
* `inject` is the mode where the agent never sees the value: the sandbox hands
|
|
526
|
+
* the agent a sentinel and substitutes the real value on egress to the
|
|
527
|
+
* credential's declared hosts. Which sandbox can do that is the sandbox's to
|
|
528
|
+
* say (`masks` in sandboxes/): srt on Linux, tartaros on Linux and macOS
|
|
529
|
+
* (srt's macOS path is tracked upstream, anthropics/sandbox-runtime#593). A
|
|
530
|
+
* sandbox that does not mask is never handed the role, because the real value
|
|
531
|
+
* is in the environment it is started with. Here only the platforms where no
|
|
532
|
+
* sandbox masks are refused outright.
|
|
533
533
|
*/
|
|
534
534
|
function injectGate(role) {
|
|
535
|
-
if (process.platform === "linux") return;
|
|
535
|
+
if (process.platform === "linux" || process.platform === "darwin") return;
|
|
536
536
|
throw new Error(
|
|
537
|
-
`roles.${role.name}: key_mode = "inject" is not available on ${process.platform}
|
|
538
|
-
`
|
|
539
|
-
`
|
|
540
|
-
`
|
|
541
|
-
`See https://github.com/carlostapiacl/seisin/blob/main/docs/decisions.md.`);
|
|
537
|
+
`roles.${role.name}: key_mode = "inject" is not available on ${process.platform}.\n` +
|
|
538
|
+
` No sandbox seisin runs here masks credentials: srt does on Linux, tartaros on Linux and macOS.\n` +
|
|
539
|
+
` Use "env" or "run-file" here, or run this role on Linux or macOS. ` +
|
|
540
|
+
`See https://github.com/carlostapiacl/seisin/blob/main/docs/keys.md.`);
|
|
542
541
|
}
|
|
543
542
|
|
|
544
543
|
/**
|
package/src/kinds.js
CHANGED
|
@@ -85,6 +85,10 @@ const SCRATCH_FILE = [
|
|
|
85
85
|
/^\.!\d+!/, // `.!36165!file` — a copy in flight
|
|
86
86
|
/^\.DS_Store$/,
|
|
87
87
|
/^pytest-cache-files-/,
|
|
88
|
+
// Vite and Vitest bundle a TS config into `vite.config.ts.timestamp-<ms>-<hash>.mjs`
|
|
89
|
+
// beside it, load it and delete it: a name that never repeats, found asked for
|
|
90
|
+
// 28 times in one orchestrator's queue, owned by a phase that takes no work.
|
|
91
|
+
/\.timestamp-\d+(-[0-9a-f]+)?\.[cm]?js$/,
|
|
88
92
|
/(^|[-_])logs?$/i, // `logs`, `latest_logs`: what a run leaves
|
|
89
93
|
];
|
|
90
94
|
/**
|
|
@@ -230,8 +234,22 @@ export function kindsOf(targets, opts = {}) {
|
|
|
230
234
|
export const EXPECTED = {
|
|
231
235
|
plugins:
|
|
232
236
|
"Claude Code updating its plugins from inside the sandbox: plugins run outside it and update there, so the denial is expected — nothing to grant",
|
|
237
|
+
"os-background":
|
|
238
|
+
"macOS frameworks reaching for shared state every program touches at start (preferences, notifications, a compatibility sysctl): denied, and nothing the agent does depends on it — nothing to grant",
|
|
233
239
|
};
|
|
234
240
|
|
|
241
|
+
/**
|
|
242
|
+
* The system denials macOS frameworks cause in any sandboxed program, whatever
|
|
243
|
+
* it is doing: counted in one orchestrator's runs, 280 lines of them against the
|
|
244
|
+
* handful that were the task's. Exact names, so a service the agent itself asks
|
|
245
|
+
* for still reads as a wall.
|
|
246
|
+
*/
|
|
247
|
+
const OS_BACKGROUND = new Set([
|
|
248
|
+
"apple.shm.notification_center", "apple.cfprefs.daemonv1", "com.apple.AppleDatabaseChanged",
|
|
249
|
+
"kcfpreferencesanyapplication", "kern.tcsm_enable", "kern.system_version_compat",
|
|
250
|
+
]);
|
|
251
|
+
const OS_BACKGROUND_RE = /^apple\.cfprefs\.\d+v1$/; // the per-user cfprefs segment
|
|
252
|
+
|
|
235
253
|
/**
|
|
236
254
|
* `{ kind, hint }` when the refusal of `target` is expected, else null.
|
|
237
255
|
*
|
|
@@ -240,6 +258,7 @@ export const EXPECTED = {
|
|
|
240
258
|
*/
|
|
241
259
|
export function expectedOf(target, { home = homedir(), env = process.env } = {}) {
|
|
242
260
|
const t = String(target ?? "").replace(/\\/g, "/");
|
|
261
|
+
if (OS_BACKGROUND.has(t) || OS_BACKGROUND_RE.test(t)) return { kind: "os-background", hint: EXPECTED["os-background"] };
|
|
243
262
|
const roots = [
|
|
244
263
|
env.CLAUDE_CONFIG_DIR,
|
|
245
264
|
home && `${home.replace(/\/+$/, "")}/.claude`,
|
package/src/log.js
CHANGED
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
18
18
|
import { basename, dirname, join } from "node:path";
|
|
19
|
-
import {
|
|
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/command.js
CHANGED
|
@@ -85,6 +85,9 @@ export function commandSandbox(name, command, check = null, { tagsDenials = fals
|
|
|
85
85
|
assess,
|
|
86
86
|
unmet: (settings, ctx) => assess(settings, ctx).unmet,
|
|
87
87
|
wrap: (bin, file, _settings, inner) => [bin, ...withPolicy(command.slice(1), file), ...inner],
|
|
88
|
+
// A sandbox of your own is not known to mask credentials, and it is started
|
|
89
|
+
// with the real value in its environment: an inject role is never its.
|
|
90
|
+
masks: () => `masked credentials (key_mode = "inject"): ${name} is not known to mask them, and it would start with the real value in its environment`,
|
|
88
91
|
// Declared, not detected: `tags_denials = true` says its Seatbelt denials
|
|
89
92
|
// carry sandbox-runtime's tag (docs/sandboxes.md). Without it, on macOS,
|
|
90
93
|
// none of the kernel's refusals under it reach the log.
|
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: [] }
|
|
@@ -71,10 +73,25 @@ export function weigh(config, settings, { platform = process.platform, role = nu
|
|
|
71
73
|
// `browsers`: a Mach service to register. srt's schema has no field for it
|
|
72
74
|
// (srt-mach.mjs adds it); tartaros takes it as network.allowMachRegister
|
|
73
75
|
// (adapt). A sandbox that is given it neither way cannot hold the role.
|
|
76
|
+
// `inject`: the real value is in the environment the sandbox starts with,
|
|
77
|
+
// so only one that masks credentials may be handed the role.
|
|
78
|
+
const masked = (own.credentials?.envVars ?? []).length > 0;
|
|
79
|
+
const masking = masked ? (sandbox.masks ? sandbox.masks(platform) : `masked credentials: ${sandbox.name} does not mask them`) : null;
|
|
74
80
|
const browser = register.length && !sandbox.machRegister
|
|
75
81
|
? [`a Mach service to register (${register.join(", ")}, for browsers): ${sandbox.name} is not given it, only srt and tartaros are`]
|
|
76
82
|
: [];
|
|
77
|
-
|
|
83
|
+
// `handoff_exempt`: a folder where trust-handoff is not protected. Only a
|
|
84
|
+
// sandbox that opens exactly that is handed the role; srt protects its
|
|
85
|
+
// own surfaces with no way to exempt a subtree.
|
|
86
|
+
const exemption = exempt.length && !sandbox.handoffExempt
|
|
87
|
+
? [`handoff_exempt (${config.roles[role].handoffExempt.join(", ")}): ${sandbox.name} cannot leave a folder out of its trust-handoff protection, tartaros can`]
|
|
88
|
+
: [];
|
|
89
|
+
// `requests`: methods and paths per host. srt takes them only as a library
|
|
90
|
+
// callback, so seisin cannot hand them to it.
|
|
91
|
+
const ruled = requests.length && !sandbox.requestRules
|
|
92
|
+
? [`requests (${requests.map((q) => q.host).join(", ")}): ${sandbox.name} cannot limit the methods and paths of a host from its settings, tartaros can`]
|
|
93
|
+
: [];
|
|
94
|
+
return { sandbox, bin, unmet: [...(masking ? [masking] : []), ...unmet, ...browser, ...exemption, ...ruled], warnings, missing: bin ? null : sandbox.missing() };
|
|
78
95
|
});
|
|
79
96
|
const chosen = options.find((o) => o.bin && o.unmet.length === 0) ?? null;
|
|
80
97
|
return { chosen, options };
|
package/src/sandboxes/srt.js
CHANGED
|
@@ -150,6 +150,9 @@ export const srt = {
|
|
|
150
150
|
locate: () => resolveSrt(),
|
|
151
151
|
missing: () => `sandbox runtime not found. ${installHint()}`,
|
|
152
152
|
unmet: () => [],
|
|
153
|
+
// Masked credentials: on Linux; on macOS not yet (anthropics/sandbox-runtime#593).
|
|
154
|
+
masks: (platform) => platform === "linux" ? null
|
|
155
|
+
: `masked credentials (key_mode = "inject") on ${platform}: sandbox-runtime does not mask them there yet (anthropics/sandbox-runtime#593); tartaros does`,
|
|
153
156
|
// `--` before the command, and it is not cosmetic. Without it the runtime's
|
|
154
157
|
// own argument parser reaches into what the agent was invoked with and eats
|
|
155
158
|
// anything resembling one of its flags: `claude --settings`, `claude -c`.
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* check` says so), and the canary still checks every run that it can.
|
|
18
18
|
*/
|
|
19
19
|
import { existsSync } from "node:fs";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
20
21
|
import { createRequire } from "node:module";
|
|
21
22
|
import { join, dirname, delimiter } from "node:path";
|
|
22
23
|
import { fileURLToPath } from "node:url";
|
|
@@ -58,7 +59,17 @@ export function dependencyTartaros(from = HERE) {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/** The command line for one run: the settings as they are, the role as a label. */
|
|
61
|
-
|
|
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 = {
|
|
@@ -95,8 +118,16 @@ export const tartaros = {
|
|
|
95
118
|
locate: () => resolveTartaros(),
|
|
96
119
|
missing: () => `tartaros not found: neither ${TARTAROS_PACKAGE}, which installs with seisin unless optional dependencies are left out, nor tartaros on PATH.`,
|
|
97
120
|
asksFile: true,
|
|
121
|
+
// Masked credentials: tartaros says whether it masks them, through explain
|
|
122
|
+
// (0.1.0-alpha.16 and later do; earlier ones refuse the field).
|
|
123
|
+
masks: () => null,
|
|
98
124
|
adapt: tartarosSettings,
|
|
99
125
|
machRegister: true,
|
|
126
|
+
// Takes filesystem.handoffExempt (tartaros 0.1.0-alpha.18): a folder left out of
|
|
127
|
+
// its trust-handoff protection.
|
|
128
|
+
handoffExempt: true,
|
|
129
|
+
// Takes network.requestRules (0.1.0-alpha.18): methods and paths per host.
|
|
130
|
+
requestRules: true,
|
|
100
131
|
assess: (_settings, { bin, file, platform } = {}) => tartarosAssess(bin, file, { platform }),
|
|
101
132
|
unmet: (_settings, { bin, file, platform } = {}) => tartarosUnmet(bin, file, { platform }),
|
|
102
133
|
wrap: (bin, file, _settings, inner, ctx) => tartarosArgv(bin, file, inner, ctx),
|
package/src/srt.js
CHANGED
|
@@ -535,8 +535,11 @@ export function settingsFor(config, roleName, spool = null, observe = false, { a
|
|
|
535
535
|
* Masked credentials: the real value reaches the runtime's own environment
|
|
536
536
|
* (run.js), never the sandbox, which sees `fake_value_…`. `mode: "mask"`
|
|
537
537
|
* and the per-credential `injectHosts` are all that is emitted — no
|
|
538
|
-
* `extract`, `decode`, files or
|
|
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>"
|