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