seisin 0.6.1 → 0.6.3
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 +5 -4
- package/package.json +2 -2
- package/src/canary.js +10 -0
- package/src/cli.js +2 -1
- package/src/commands/run.js +8 -0
- package/src/commands/ui.js +46 -10
- package/src/help.js +6 -4
- package/src/inspect/inject.js +16 -5
- package/src/keys.js +15 -16
- package/src/runs.js +298 -0
- package/src/sandboxes/command.js +3 -0
- package/src/sandboxes/index.js +5 -1
- package/src/sandboxes/srt.js +3 -0
- package/src/sandboxes/tartaros.js +3 -0
- package/src/serve.js +72 -11
- package/src/uilink.js +10 -3
- package/ui/index.html +289 -18
package/README.md
CHANGED
|
@@ -280,6 +280,7 @@ seisin explain frontend write src/api/x # ask one question, exit 0 or 1
|
|
|
280
280
|
seisin review --since 7d # what the log says about the policy, this week
|
|
281
281
|
seisin wire # install the PreToolUse hook in this repo
|
|
282
282
|
seisin ui # a console for deciding what is waiting
|
|
283
|
+
seisin ui --runs <dir> # read only, over many runs a launcher archived (docs/runs.md)
|
|
283
284
|
```
|
|
284
285
|
|
|
285
286
|
**Three ways in, and they say the same thing.** The **CLI** is what a person types and what
|
|
@@ -854,7 +855,7 @@ A **provider** is a command with a `{ref}` placeholder, so adding 1Password, Bit
|
|
|
854
855
|
confined process. Nothing degrades: a provider that fails stops the run.
|
|
855
856
|
|
|
856
857
|
A key reaches the role as a variable (`key_mode = "env"`), as a file in the run's private
|
|
857
|
-
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
|
|
858
859
|
placeholder and the sandbox swaps in the real value only on the way to the hosts you list for
|
|
859
860
|
it. That costs terminating TLS to those hosts, which `seisin check` names, and a host that
|
|
860
861
|
stores what it is sent can still hand the value back — use a least-scope token.
|
|
@@ -1036,9 +1037,9 @@ the same kernel or workspace, and which one you want depends on what you are pro
|
|
|
1036
1037
|
|
|
1037
1038
|
## Status
|
|
1038
1039
|
|
|
1039
|
-
**
|
|
1040
|
-
same day, on `ubuntu-latest` **
|
|
1041
|
-
|
|
1040
|
+
**1103 tests on macOS, 1096 passing and 7 platform skips** (after 0.6.3, 2026-10-26, local); in CI the
|
|
1041
|
+
same day, on `ubuntu-latest` **1100, 1071 passing and 29 platform skips**, and on macOS 1101,
|
|
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
|
|
1042
1043
|
needed `@anthropic-ai/sandbox-runtime` installed — counted by running it with the runtime
|
|
1043
1044
|
hidden — and run real commands through the real kernel; 26 of them are the core boundary file,
|
|
1044
1045
|
`test/sandbox.test.js`, which the CI gate fails if it *skips*, because a green run that quietly
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seisin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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.16"
|
|
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
|
@@ -57,6 +57,7 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
|
|
|
57
57
|
seisin init [--force] propose a ${CONFIG_NAME} for this repo
|
|
58
58
|
seisin init --from-observations write ${CONFIG_NAME}.observed from observed runs
|
|
59
59
|
seisin ui [--port n] [--link] open the console, live (--link reopens a running one)
|
|
60
|
+
seisin ui --runs <dir> [--link --json] a read-only console over a directory of runs (docs/runs.md)
|
|
60
61
|
seisin mcp an MCP server on stdio — read-only
|
|
61
62
|
|
|
62
63
|
seisin <command> --help lists a command's flags, examples and exit codes.
|
|
@@ -105,7 +106,7 @@ const COMMANDS = {
|
|
|
105
106
|
},
|
|
106
107
|
// --link only reads a record; it answers without a policy here too, and
|
|
107
108
|
// says so when the console it finds serves another one.
|
|
108
|
-
ui: async () => (await import("./commands/ui.js")).ui(argv.includes("--link") ? maybeConfig() : config(), argv),
|
|
109
|
+
ui: async () => (await import("./commands/ui.js")).ui(argv.includes("--link") || argv.includes("--runs") ? maybeConfig() : config(), argv),
|
|
109
110
|
whose: async () => void (await import("./commands/whose.js")).whose(config(), argv),
|
|
110
111
|
wire: async () => void (await import("./commands/wire.js")).wire(config()),
|
|
111
112
|
review: async () => ((await import("./commands/review.js")).reviewCommand(config(), argv).friction.length ? 1 : 0),
|
package/src/commands/run.js
CHANGED
|
@@ -496,6 +496,14 @@ export async function run(config, argv) {
|
|
|
496
496
|
event: "canary", role, run: theRun.id.slice(0, 8), ...trace, agent: agent ?? null, confined: false, reasons: probe.failed,
|
|
497
497
|
...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
|
|
498
498
|
});
|
|
499
|
+
if (probe.exposed) {
|
|
500
|
+
err(`${C.red}seisin: ${role} was NOT started — another process holds a real value the agent would see:${C.off}\n` +
|
|
501
|
+
probe.failed.map((f) => ` - ${f}\n`).join("") +
|
|
502
|
+
` A process's environment and arguments are readable by every process of your user (on macOS no sandbox ` +
|
|
503
|
+
`hides them), so masking would hide nothing. Usually a shell that exported the token, and what it started. ` +
|
|
504
|
+
`Unset it there or end that process, and keep the value only where seisin's key points (docs/keys.md).\n`);
|
|
505
|
+
process.exit(CANARY_EXIT);
|
|
506
|
+
}
|
|
499
507
|
err(`${C.red}seisin: ${role} was NOT started — the sandbox failed its canary:${C.off}\n` +
|
|
500
508
|
probe.failed.map((f) => ` - ${f}\n`).join("") +
|
|
501
509
|
` The policy as generated does not hold on this machine, so no agent runs under it. ` +
|
package/src/commands/ui.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* failing. See uilink.js.
|
|
15
15
|
*/
|
|
16
16
|
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { statSync } from "node:fs";
|
|
18
|
+
import { resolve } from "node:path";
|
|
17
19
|
import { serve } from "../serve.js";
|
|
18
20
|
import { writeUiLink, readUiLink, clearUiLink } from "../uilink.js";
|
|
19
21
|
import { C, out } from "../render.js";
|
|
@@ -23,28 +25,58 @@ function open(url) {
|
|
|
23
25
|
spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* `--runs <dir>`: the console over a directory of runs, read only (runs.js,
|
|
30
|
+
* docs/runs.md), on 4179 unless told otherwise, so it does not take the port a
|
|
31
|
+
* repository's own console would want.
|
|
32
|
+
*/
|
|
33
|
+
function runsDirOf(argv) {
|
|
34
|
+
const i = argv.indexOf("--runs");
|
|
35
|
+
if (i === -1) return null;
|
|
36
|
+
const dir = argv[i + 1];
|
|
37
|
+
if (!dir || dir.startsWith("--")) throw new Error("--runs needs a directory: seisin ui --runs <dir>");
|
|
38
|
+
let st = null;
|
|
39
|
+
try { st = statSync(dir); } catch {}
|
|
40
|
+
if (!st?.isDirectory()) throw new Error(`--runs ${dir}: not a directory`);
|
|
41
|
+
return resolve(dir);
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
export async function ui(config, argv = []) {
|
|
45
|
+
const runsDir = runsDirOf(argv);
|
|
46
|
+
const mode = runsDir ? "runs" : "policy";
|
|
27
47
|
const i = argv.indexOf("--port");
|
|
28
|
-
const port = i === -1 ? 4178 : Number(argv[i + 1]);
|
|
48
|
+
const port = i === -1 ? (runsDir ? 4179 : 4178) : Number(argv[i + 1]);
|
|
49
|
+
const json = argv.includes("--json");
|
|
29
50
|
|
|
30
51
|
// `seisin ui --link`: print the live link a running console left, and open
|
|
31
52
|
// it, without starting anything. For when the terminal that had it is gone.
|
|
53
|
+
// With --json, the record for a program to read — `{url, port, mode}` — and
|
|
54
|
+
// nothing opened: the launcher's own panel links to it.
|
|
32
55
|
if (argv.includes("--link")) {
|
|
33
56
|
const found = readUiLink(port);
|
|
57
|
+
if (json) {
|
|
58
|
+
if (found && found.alive === false) clearUiLink(port);
|
|
59
|
+
const live = found && found.alive !== false;
|
|
60
|
+
out(JSON.stringify(live
|
|
61
|
+
? { running: true, url: found.url, port, mode: found.mode, ...(found.dir && { dir: found.dir }), ...(found.policy && { policy: found.policy }) }
|
|
62
|
+
: { running: false, port }) + "\n");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
34
65
|
if (found && found.alive !== false) {
|
|
35
66
|
out(`\n ${C.b}${found.url}${C.off}\n ${C.dim}the link a seisin ui on ${port} is serving. open it here, do not paste it anywhere.${C.off}\n` +
|
|
36
|
-
servesWhich(found.policy, config?.path) + "\n");
|
|
67
|
+
servesWhich(found.policy, runsDir ? null : config?.path, found) + "\n");
|
|
37
68
|
open(found.url);
|
|
38
69
|
return;
|
|
39
70
|
}
|
|
40
71
|
if (found && found.alive === false) clearUiLink(port);
|
|
41
|
-
|
|
72
|
+
const start = runsDir ? `seisin ui --runs ${runsDir}${port === 4179 ? "" : ` --port ${port}`}` : `seisin ui${port === 4178 ? "" : ` --port ${port}`}`;
|
|
73
|
+
out(`\n ${C.dim}no seisin ui running on ${port} — start one with: ${start}${C.off}\n\n`);
|
|
42
74
|
return;
|
|
43
75
|
}
|
|
44
76
|
|
|
45
77
|
let server;
|
|
46
78
|
try {
|
|
47
|
-
server = await serve(config.path, port);
|
|
79
|
+
server = await serve(runsDir ? null : config.path, port, { runsDir });
|
|
48
80
|
} catch (e) {
|
|
49
81
|
// A console is already on this port: hand back its live link rather than
|
|
50
82
|
// erroring, which is the case that sends people digging in the terminal.
|
|
@@ -54,7 +86,7 @@ export async function ui(config, argv = []) {
|
|
|
54
86
|
out(
|
|
55
87
|
`\n ${C.b}${found.url}${C.off}\n` +
|
|
56
88
|
` ${C.dim}a seisin ui is already running on ${port}; reopening its link. ctrl-c that terminal to stop it.${C.off}\n` +
|
|
57
|
-
servesWhich(found.policy, config.path) + "\n"
|
|
89
|
+
servesWhich(found.policy, runsDir ? null : config.path, found) + "\n"
|
|
58
90
|
);
|
|
59
91
|
open(found.url);
|
|
60
92
|
return;
|
|
@@ -70,19 +102,22 @@ export async function ui(config, argv = []) {
|
|
|
70
102
|
const bound = server.address().port;
|
|
71
103
|
const url = `http://127.0.0.1:${bound}/#t=${server.seisinToken}`;
|
|
72
104
|
// Recorded before we print, so `--link` and a second `seisin ui` can find it.
|
|
73
|
-
writeUiLink(bound, url, undefined, config.path);
|
|
105
|
+
writeUiLink(bound, url, undefined, runsDir ? null : config.path, { mode, dir: runsDir });
|
|
74
106
|
|
|
75
107
|
// local_binding opens every port (macOS); local_ports opens the ones it
|
|
76
108
|
// names, on every platform — so only a role that names THIS port counts.
|
|
77
|
-
const reach = Object.values(config
|
|
109
|
+
const reach = Object.values(config?.roles ?? {})
|
|
78
110
|
.filter((r) => (r.localBinding && process.platform === "darwin") || (r.localPorts ?? []).includes(bound))
|
|
79
111
|
.map((r) => r.name);
|
|
80
112
|
|
|
81
113
|
out(
|
|
82
114
|
`\n ${C.b}${url}${C.off}\n` +
|
|
83
|
-
|
|
115
|
+
(runsDir
|
|
116
|
+
? ` ${C.dim}reading the runs in ${runsDir}, live and read only: nothing is granted from here. ctrl-c to stop.${C.off}\n` +
|
|
117
|
+
` ${C.dim}one run: add &run=<run_id> to the link.${C.off}\n`
|
|
118
|
+
: ` ${C.dim}reading ${config.path} and the log, live. ctrl-c to stop.${C.off}\n`) +
|
|
84
119
|
` ${C.dim}the link carries this run's token: open it here, do not paste it anywhere.${C.off}\n` +
|
|
85
|
-
` ${C.dim}lost it? this terminal has it above, or run: seisin ui --link${bound === 4178 ? "" : ` --port ${bound}`}${C.off}\n` +
|
|
120
|
+
` ${C.dim}lost it? this terminal has it above, or run: seisin ui${runsDir ? ` --runs ${runsDir}` : ""} --link${bound === (runsDir ? 4179 : 4178) ? "" : ` --port ${bound}`}${C.off}\n` +
|
|
86
121
|
(reach.length
|
|
87
122
|
? ` ${C.yellow}${reach.join(", ")} can reach this port on localhost: they can load this page, ` +
|
|
88
123
|
`but not the token, which only travels in the link above.${C.off}\n`
|
|
@@ -101,7 +136,8 @@ export async function ui(config, argv = []) {
|
|
|
101
136
|
* Which policy a running console serves, and a warning when it is not the one
|
|
102
137
|
* this directory would use. `here` is null when there is no policy here.
|
|
103
138
|
*/
|
|
104
|
-
export function servesWhich(served, here) {
|
|
139
|
+
export function servesWhich(served, here, found = null) {
|
|
140
|
+
if (found?.mode === "runs") return ` ${C.dim}serving the runs in ${found.dir} (read only)${C.off}\n`;
|
|
105
141
|
if (!served) return ` ${C.dim}(started by an older seisin: which seisin.toml it serves is not recorded)${C.off}\n`;
|
|
106
142
|
if (here && served !== here)
|
|
107
143
|
return ` ${C.yellow}it serves ${served} — not ${here}, the policy for this directory.${C.off}\n` +
|
package/src/help.js
CHANGED
|
@@ -123,10 +123,12 @@ export const COMMAND_HELP = {
|
|
|
123
123
|
exits: [["0", "written"], ["2", "a policy is already there, or nothing was observed"]],
|
|
124
124
|
},
|
|
125
125
|
ui: {
|
|
126
|
-
usage: ["seisin ui [--port <n>] [--link]"],
|
|
127
|
-
values: ["--port"],
|
|
128
|
-
flags: [["--port <n>", "port on 127.0.0.1 (default 4178)"], ["--link", "print the link of a console already running, start nothing"]
|
|
129
|
-
|
|
126
|
+
usage: ["seisin ui [--port <n>] [--link]", "seisin ui --runs <dir> [--port <n>] [--link [--json]]"],
|
|
127
|
+
values: ["--port", "--runs"],
|
|
128
|
+
flags: [["--port <n>", "port on 127.0.0.1 (default 4178; 4179 with --runs)"], ["--link", "print the link of a console already running, start nothing"],
|
|
129
|
+
["--runs <dir>", "a read-only console over a directory of runs, one folder each (docs/runs.md)"],
|
|
130
|
+
["--json", "with --link: {running, url, port, mode} for a program to read, nothing opened"]],
|
|
131
|
+
examples: ["seisin ui", "seisin ui --link", "seisin ui --runs ~/.wapentake/seisin/runs", "seisin ui --runs ~/.wapentake/seisin/runs --link --json"],
|
|
130
132
|
exits: [["0", "stopped"], ["2", "could not start"]],
|
|
131
133
|
},
|
|
132
134
|
mcp: {
|
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
|
*
|
|
@@ -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/runs.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `seisin ui --runs <dir>`: the console over many runs, each with its own policy.
|
|
3
|
+
*
|
|
4
|
+
* A launcher that lends each run a territory of its own (wapentake does) has no
|
|
5
|
+
* single seisin.toml to open: every run gets one written for it at the root of
|
|
6
|
+
* a worktree, runs there, and the worktree is gone a day or two later. What is
|
|
7
|
+
* worth keeping is copied out — the policy, the `.seisin/` beside it and a
|
|
8
|
+
* `run.json` the launcher writes — one folder per run, under one directory.
|
|
9
|
+
* This reads that directory. The contract is docs/runs.md.
|
|
10
|
+
*
|
|
11
|
+
* Read only, on purpose. The territory is the launcher's to lend: a grant
|
|
12
|
+
* written into an archived run's seisin.toml changes nothing anywhere, so the
|
|
13
|
+
* console in this mode offers none and the server refuses every write.
|
|
14
|
+
*
|
|
15
|
+
* A run that is archived does not change, so what is worked out from it is
|
|
16
|
+
* kept for as long as its files' signature stays the same — the poll every two
|
|
17
|
+
* seconds over hundreds of runs re-reads a directory listing and some stats,
|
|
18
|
+
* not every log.
|
|
19
|
+
*/
|
|
20
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { loadConfig } from "./config.js";
|
|
23
|
+
import { read, logPath } from "./log.js";
|
|
24
|
+
import { requestsPath } from "./requests.js";
|
|
25
|
+
|
|
26
|
+
/** The name a run's folder may have: what becomes `run=` in a link. */
|
|
27
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
28
|
+
|
|
29
|
+
/** The fields of run.json the console reads; anything else in it is ignored. */
|
|
30
|
+
const META = ["run_id", "team", "work_item", "role", "repo", "started_at", "ended_at", "status", "launcher"];
|
|
31
|
+
|
|
32
|
+
/** How many causes the global view is sent, summed over the runs shown. */
|
|
33
|
+
const CAUSES_SHOWN = 50;
|
|
34
|
+
|
|
35
|
+
/** How many of a run's own causes travel with it in the list. */
|
|
36
|
+
const RUN_CAUSES = 5;
|
|
37
|
+
|
|
38
|
+
export function isRunId(id) {
|
|
39
|
+
return typeof id === "string" && RUN_ID.test(id) && id !== "." && id !== "..";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function statSig(p) {
|
|
43
|
+
try { const s = statSync(p); return `${s.ino}:${s.size}:${s.mtimeMs}`; } catch { return "-"; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The run folders under `dir`, by name; a file or a name that is not a run id is skipped. */
|
|
47
|
+
export function runFolders(dir) {
|
|
48
|
+
let names;
|
|
49
|
+
try { names = readdirSync(dir, { withFileTypes: true }); }
|
|
50
|
+
catch (e) { throw Object.assign(new Error(`cannot read the runs directory ${dir}: ${e.message}`), { status: 500 }); }
|
|
51
|
+
return names.filter((d) => d.isDirectory() && isRunId(d.name)).map((d) => d.name).sort();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Where a run's policy is. */
|
|
55
|
+
export function runPolicy(dir, id) {
|
|
56
|
+
return join(dir, id, "seisin.toml");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What a run's summary is made from, as stats only: the policy, run.json, the
|
|
61
|
+
* queue, the current log and the `.seisin/` directory itself, whose mtime
|
|
62
|
+
* moves when a segment is rotated in or out. No directory is listed: six
|
|
63
|
+
* hundred `readdir`s were most of a warm poll (~0.7 s).
|
|
64
|
+
*/
|
|
65
|
+
function signatureOf(dir, id) {
|
|
66
|
+
const root = join(dir, id);
|
|
67
|
+
const log = logPath(root);
|
|
68
|
+
return [
|
|
69
|
+
statSig(join(root, "seisin.toml")),
|
|
70
|
+
statSig(join(root, "run.json")),
|
|
71
|
+
statSig(requestsPath(root)),
|
|
72
|
+
statSig(log),
|
|
73
|
+
statSig(dirname(log)),
|
|
74
|
+
].join("|");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** run.json, the launcher's word on the run — or null when it is missing or not JSON. */
|
|
78
|
+
function metaOf(dir, id) {
|
|
79
|
+
let raw;
|
|
80
|
+
try { raw = JSON.parse(readFileSync(join(dir, id, "run.json"), "utf8")); } catch { return null; }
|
|
81
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
82
|
+
const out = {};
|
|
83
|
+
for (const k of META) if (raw[k] !== undefined && raw[k] !== null) out[k] = typeof raw[k] === "string" ? raw[k] : String(raw[k]);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What one run comes to: who it was, what it was denied, what the denials
|
|
89
|
+
* cost in retries (every denial after the first of the same thing, system
|
|
90
|
+
* denials aside), and whether it was let start at all.
|
|
91
|
+
*
|
|
92
|
+
* `outcome` is seisin's word, beside the launcher's `status`:
|
|
93
|
+
* refused — its canary failed, so nothing ran;
|
|
94
|
+
* denied — it ran and was denied a file or a host;
|
|
95
|
+
* clean — it ran and was denied neither (system denials, the OS's own
|
|
96
|
+
* housekeeping, and the canary's expected probes do not count);
|
|
97
|
+
* unread — its policy could not be read.
|
|
98
|
+
*/
|
|
99
|
+
function summarize(dir, id) {
|
|
100
|
+
const meta = metaOf(dir, id);
|
|
101
|
+
const base = {
|
|
102
|
+
id,
|
|
103
|
+
run_id: meta?.run_id ?? id,
|
|
104
|
+
team: meta?.team ?? null,
|
|
105
|
+
work_item: meta?.work_item ?? null,
|
|
106
|
+
role: meta?.role ?? null,
|
|
107
|
+
repo: meta?.repo ?? null,
|
|
108
|
+
started_at: meta?.started_at ?? null,
|
|
109
|
+
ended_at: meta?.ended_at ?? null,
|
|
110
|
+
status: meta?.status ?? null,
|
|
111
|
+
launcher: meta?.launcher ?? null,
|
|
112
|
+
meta: meta !== null,
|
|
113
|
+
};
|
|
114
|
+
let cfg;
|
|
115
|
+
try { cfg = loadConfig(runPolicy(dir, id)); }
|
|
116
|
+
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 };
|
|
119
|
+
}
|
|
120
|
+
const all = read(logPath(cfg.root));
|
|
121
|
+
/**
|
|
122
|
+
* Grouped here, by what was denied, rather than through causesOf and walls:
|
|
123
|
+
* those ask the policy and the disk about every path (~130 ms a run on the
|
|
124
|
+
* pilot's logs, a minute and more over six hundred), to say whether a
|
|
125
|
+
* denial would still be one today. An archived run's policy does not change,
|
|
126
|
+
* so every repeat of a denial is a retry against a standing wall, and the
|
|
127
|
+
* per-run view, which is one run, still gets the full answer from state().
|
|
128
|
+
*/
|
|
129
|
+
const byKind = {};
|
|
130
|
+
let denied = 0, expected = 0;
|
|
131
|
+
const groups = new Map();
|
|
132
|
+
for (const e of all) {
|
|
133
|
+
if (e.verdict !== "denied") continue;
|
|
134
|
+
if (e.expected === true) { expected++; continue; }
|
|
135
|
+
const k = e.kind ?? "file";
|
|
136
|
+
byKind[k] = (byKind[k] ?? 0) + 1;
|
|
137
|
+
if (k !== "system") denied++;
|
|
138
|
+
if (!e.action || !e.target) continue;
|
|
139
|
+
const key = `${e.action}\u0000${e.target}`;
|
|
140
|
+
const g = groups.get(key) ?? { about: k, action: e.action, target: e.target, times: 0, owners: e.owners ?? [] };
|
|
141
|
+
g.times++;
|
|
142
|
+
groups.set(key, g);
|
|
143
|
+
}
|
|
144
|
+
const causes = [...groups.values()].sort((a, b) => b.times - a.times);
|
|
145
|
+
const retried = causes.reduce((n, g) => n + (g.about === "system" ? 0 : g.times - 1), 0);
|
|
146
|
+
const hosts = causes.filter((g) => g.about === "network").slice(0, 10).map(({ target, times }) => ({ target, times }));
|
|
147
|
+
const canaries = all.filter((e) => e.event === "canary");
|
|
148
|
+
const canary = canaries.length ? canaries[canaries.length - 1] : null;
|
|
149
|
+
const outcome = canaries.some((e) => e.confined === false) ? "refused" : denied ? "denied" : "clean";
|
|
150
|
+
return {
|
|
151
|
+
...base,
|
|
152
|
+
// A run.json that did not say when it started: the log's first line did.
|
|
153
|
+
started_at: base.started_at ?? all.find((e) => e.at)?.at ?? null,
|
|
154
|
+
ended_at: base.ended_at ?? [...all].reverse().find((e) => e.at)?.at ?? null,
|
|
155
|
+
outcome,
|
|
156
|
+
roles: Object.keys(cfg.roles),
|
|
157
|
+
sandbox: canary?.sandbox ?? null,
|
|
158
|
+
entries: all.length,
|
|
159
|
+
denied,
|
|
160
|
+
expected,
|
|
161
|
+
byKind,
|
|
162
|
+
retried,
|
|
163
|
+
hosts,
|
|
164
|
+
// Kept whole for the sum across runs, cut to RUN_CAUSES when sent.
|
|
165
|
+
causes,
|
|
166
|
+
canary: canary ? { confined: canary.confined !== false, reasons: canary.reasons ?? [] } : null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Summaries kept by run id, with the signature they were made from. */
|
|
171
|
+
const cache = new Map();
|
|
172
|
+
|
|
173
|
+
/** Every run under `dir`, summarized, re-read only where a file moved. */
|
|
174
|
+
export function scanRuns(dir) {
|
|
175
|
+
const ids = runFolders(dir);
|
|
176
|
+
const seen = new Set(ids);
|
|
177
|
+
for (const k of cache.keys()) if (k.startsWith(`${dir}\u0000`) && !seen.has(k.slice(dir.length + 1))) cache.delete(k);
|
|
178
|
+
const sigs = [];
|
|
179
|
+
const runs = ids.map((id) => {
|
|
180
|
+
const sig = signatureOf(dir, id);
|
|
181
|
+
sigs.push(`${id}=${sig}`);
|
|
182
|
+
const k = `${dir}\u0000${id}`;
|
|
183
|
+
const hit = cache.get(k);
|
|
184
|
+
if (hit && hit.sig === sig) return hit.summary;
|
|
185
|
+
const summary = summarize(dir, id);
|
|
186
|
+
cache.set(k, { sig, summary });
|
|
187
|
+
return summary;
|
|
188
|
+
});
|
|
189
|
+
return { runs, sig: sigs.join("\n") };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The filters the global view takes, from a query string. Unknown keys are ignored. */
|
|
193
|
+
export const FILTERS = ["team", "role", "work_item", "status", "outcome"];
|
|
194
|
+
|
|
195
|
+
export function filtersOf(query) {
|
|
196
|
+
const q = new URLSearchParams(query ?? "");
|
|
197
|
+
const out = {};
|
|
198
|
+
for (const f of FILTERS) { const v = q.get(f); if (v) out[f] = v; }
|
|
199
|
+
const text = q.get("q");
|
|
200
|
+
if (text) out.q = text.toLowerCase();
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function matches(run, filters) {
|
|
205
|
+
for (const f of FILTERS) if (filters[f] !== undefined && (run[f] ?? "") !== filters[f]) return false;
|
|
206
|
+
if (filters.q) {
|
|
207
|
+
const hay = [run.id, run.run_id, run.work_item, run.repo, run.team, run.role].filter(Boolean).join(" ").toLowerCase();
|
|
208
|
+
if (!hay.includes(filters.q)) return false;
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** How many runs have each value of each filter — over every run, so a filter never hides its siblings. */
|
|
214
|
+
function facetsOf(runs) {
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const f of FILTERS) {
|
|
217
|
+
const m = new Map();
|
|
218
|
+
for (const r of runs) { const v = r[f]; if (v) m.set(v, (m.get(v) ?? 0) + 1); }
|
|
219
|
+
out[f] = [...m].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]))).map(([value, runs]) => ({ value, runs }));
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The same refusal across runs is one cause: summed by what was refused, with
|
|
226
|
+
* how many runs hit it and which teams and roles they were.
|
|
227
|
+
*/
|
|
228
|
+
function causesAcross(runs) {
|
|
229
|
+
const by = new Map();
|
|
230
|
+
for (const r of runs) {
|
|
231
|
+
for (const c of r.causes) {
|
|
232
|
+
const k = `${c.action}\u0000${c.target}`;
|
|
233
|
+
const g = by.get(k) ?? { action: c.action, target: c.target, about: c.about, owners: c.owners, times: 0, runs: 0, teams: new Set(), roles: new Set(), last: null };
|
|
234
|
+
g.times += c.times;
|
|
235
|
+
g.runs++;
|
|
236
|
+
if (r.team) g.teams.add(r.team);
|
|
237
|
+
if (r.role) g.roles.add(r.role);
|
|
238
|
+
if (!g.last || (r.started_at && r.started_at > g.last.at)) g.last = { run: r.id, at: r.started_at };
|
|
239
|
+
by.set(k, g);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return [...by.values()]
|
|
243
|
+
// The OS's own housekeeping last: it is in every run and nobody grants it.
|
|
244
|
+
.sort((a, b) => (a.about === "system") - (b.about === "system") || b.runs - a.runs || b.times - a.times)
|
|
245
|
+
.slice(0, CAUSES_SHOWN)
|
|
246
|
+
.map((g) => ({ ...g, teams: [...g.teams], roles: [...g.roles], last: g.last?.run ?? null }));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
250
|
+
|
|
251
|
+
/** The global view: the runs that pass the filters, newest first, and what they add up to. */
|
|
252
|
+
const views = new Map();
|
|
253
|
+
|
|
254
|
+
export function runsView(dir, filters = {}, { scanned = scanRuns(dir) } = {}) {
|
|
255
|
+
// The same runs and the same filters are the same answer: the poll asks
|
|
256
|
+
// every two seconds, and summing the causes of six hundred runs was most
|
|
257
|
+
// of what it cost once the runs themselves were remembered.
|
|
258
|
+
const key = `${dir}\u0000${JSON.stringify(filters)}\u0000${scanned.sig}`;
|
|
259
|
+
const hit = views.get(key);
|
|
260
|
+
if (hit) return hit;
|
|
261
|
+
const view = viewOf(dir, filters, scanned.runs);
|
|
262
|
+
views.set(key, view);
|
|
263
|
+
while (views.size > 8) views.delete(views.keys().next().value);
|
|
264
|
+
return view;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function viewOf(dir, filters, every) {
|
|
268
|
+
const shown = every.filter((r) => matches(r, filters))
|
|
269
|
+
.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: {} };
|
|
271
|
+
for (const r of shown) {
|
|
272
|
+
totals.denied += r.denied;
|
|
273
|
+
totals.retried += r.retried;
|
|
274
|
+
totals.expected += r.expected;
|
|
275
|
+
for (const [k, n] of Object.entries(r.byKind)) totals.byKind[k] = (totals.byKind[k] ?? 0) + n;
|
|
276
|
+
totals.outcome[r.outcome] = (totals.outcome[r.outcome] ?? 0) + 1;
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
mode: "runs",
|
|
280
|
+
dir,
|
|
281
|
+
filters,
|
|
282
|
+
total: every.length,
|
|
283
|
+
facets: facetsOf(every),
|
|
284
|
+
totals,
|
|
285
|
+
causes: causesAcross(shown),
|
|
286
|
+
runs: shown.map((r) => ({ ...r, causes: r.causes.slice(0, RUN_CAUSES) })),
|
|
287
|
+
readOnly: true,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** One run's summary, by id; throws 404 for a name that is not a run here. */
|
|
292
|
+
export function runOf(dir, id) {
|
|
293
|
+
if (!isRunId(id)) throw Object.assign(new Error(`not a run id: ${JSON.stringify(id)}`), { status: 400 });
|
|
294
|
+
if (!runFolders(dir).includes(id)) throw Object.assign(new Error(`no run ${id} in ${dir}`), { status: 404 });
|
|
295
|
+
const { runs } = scanRuns(dir);
|
|
296
|
+
const r = runs.find((x) => x.id === id);
|
|
297
|
+
return { ...r, causes: r.causes.slice(0, RUN_CAUSES) };
|
|
298
|
+
}
|
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
|
@@ -71,10 +71,14 @@ export function weigh(config, settings, { platform = process.platform, role = nu
|
|
|
71
71
|
// `browsers`: a Mach service to register. srt's schema has no field for it
|
|
72
72
|
// (srt-mach.mjs adds it); tartaros takes it as network.allowMachRegister
|
|
73
73
|
// (adapt). A sandbox that is given it neither way cannot hold the role.
|
|
74
|
+
// `inject`: the real value is in the environment the sandbox starts with,
|
|
75
|
+
// so only one that masks credentials may be handed the role.
|
|
76
|
+
const masked = (own.credentials?.envVars ?? []).length > 0;
|
|
77
|
+
const masking = masked ? (sandbox.masks ? sandbox.masks(platform) : `masked credentials: ${sandbox.name} does not mask them`) : null;
|
|
74
78
|
const browser = register.length && !sandbox.machRegister
|
|
75
79
|
? [`a Mach service to register (${register.join(", ")}, for browsers): ${sandbox.name} is not given it, only srt and tartaros are`]
|
|
76
80
|
: [];
|
|
77
|
-
return { sandbox, bin, unmet: [...unmet, ...browser], warnings, missing: bin ? null : sandbox.missing() };
|
|
81
|
+
return { sandbox, bin, unmet: [...(masking ? [masking] : []), ...unmet, ...browser], warnings, missing: bin ? null : sandbox.missing() };
|
|
78
82
|
});
|
|
79
83
|
const chosen = options.find((o) => o.bin && o.unmet.length === 0) ?? null;
|
|
80
84
|
return { chosen, options };
|