seisin 0.6.4 → 0.7.0
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 +158 -694
- package/package.json +4 -4
- package/src/canary.js +1 -1
- package/src/cli.js +5 -5
- package/src/commands/check.js +21 -13
- 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 +275 -248
- package/src/commands/walls.js +12 -5
- package/src/config-fields.js +305 -0
- package/src/config.js +14 -551
- 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/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 +6 -4
- package/src/intake.js +3 -2
- package/src/keys.js +6 -4
- package/src/layout.js +1 -1
- 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/rundir.js +2 -2
- package/src/sandboxes/command.js +0 -2
- package/src/sandboxes/index.js +26 -9
- package/src/sandboxes/srt.js +27 -7
- package/src/sandboxes/tartaros.js +8 -6
- package/src/serve.js +11 -22
- package/src/spool.js +1 -1
- package/src/{srt.js → srt-settings.js} +1 -1
- package/src/suggest.js +26 -1
- package/src/territory.js +12 -2
- package/src/toml.js +250 -0
- package/src/violations.js +16 -315
- package/src/walls.js +4 -2
- package/ui/index.html +27 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seisin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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",
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
"node": ">=18"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@
|
|
45
|
+
"@carlostapiacl/tartaros": "0.1.0-alpha.23"
|
|
46
46
|
},
|
|
47
|
-
"
|
|
48
|
-
"@
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@anthropic-ai/sandbox-runtime": "0.0.81"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"test": "node --test test/*.test.js",
|
package/src/canary.js
CHANGED
|
@@ -75,7 +75,7 @@ export async function runCanary({ srt, wrap = null, settingsFile, settings, env,
|
|
|
75
75
|
// exists when the sandbox starts is one of them.
|
|
76
76
|
const insideDir = writable ? join(writable, `.seisin-canary-${nonce}`) : null;
|
|
77
77
|
const inside = insideDir ? join(insideDir, "in") : null;
|
|
78
|
-
// The runtime's shared temp directory (srt.js, SHARED_TMP): granted by the
|
|
78
|
+
// The runtime's shared temp directory (srt-settings.js, SHARED_TMP): granted by the
|
|
79
79
|
// runtime to every sandbox, taken back by seisin. Probed where it exists;
|
|
80
80
|
// where it does not, there is nothing beside which to write.
|
|
81
81
|
const shared = isDir(SHARED_TMP_DIR) ? join(SHARED_TMP_DIR, `.seisin-canary-${nonce}`) : null;
|
package/src/cli.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* `commands/` and every decision lives in a module beside it, so that a change
|
|
8
8
|
* to how something is printed cannot change what is allowed.
|
|
9
9
|
*
|
|
10
|
-
* Enforcement is not ours.
|
|
11
|
-
* system — Seatbelt on macOS,
|
|
12
|
-
* care how a command was spelled. What seisin adds is the part a kernel can
|
|
10
|
+
* Enforcement is not ours. The sandbox (tartaros by default, or
|
|
11
|
+
* sandbox-runtime) asks the operating system — Seatbelt on macOS, Landlock or
|
|
12
|
+
* bubblewrap on Linux — and the kernel does not care how a command was spelled. What seisin adds is the part a kernel can
|
|
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";
|
|
@@ -76,7 +76,7 @@ if (policyFlag) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
const USAGE = `
|
|
79
|
-
${C.b}seisin${C.off}
|
|
79
|
+
${C.b}seisin${C.off}: give each agent its own folders and its own keys
|
|
80
80
|
|
|
81
81
|
seisin run <role> -- <command...> run a command as that role
|
|
82
82
|
seisin run <role> --observe -- <cmd> open the repo and watch — the network stays shut
|
|
@@ -105,7 +105,7 @@ Any command takes --policy <file> (the policy, rooted at the working directory)
|
|
|
105
105
|
both outside the repo (docs/runs.md).
|
|
106
106
|
seisin help limits says what seisin cannot do, in any repo.
|
|
107
107
|
|
|
108
|
-
Enforcement comes from
|
|
108
|
+
Enforcement comes from the sandbox (tartaros by default, or sandbox-runtime), which asks the OS.
|
|
109
109
|
seisin decides what to ask for, and says whose file it was when the answer is no.
|
|
110
110
|
`;
|
|
111
111
|
|
package/src/commands/check.js
CHANGED
|
@@ -10,8 +10,8 @@ import { stateDir } from "../state.js";
|
|
|
10
10
|
const say = out;
|
|
11
11
|
import { measureSystemLog } from "../violations.js";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
-
import { settingsFor } from "../srt.js";
|
|
14
|
-
import { weigh } from "../sandboxes/index.js";
|
|
13
|
+
import { settingsFor } from "../srt-settings.js";
|
|
14
|
+
import { weigh, DEFAULT_PROVIDERS } from "../sandboxes/index.js";
|
|
15
15
|
import { runsRoot } from "../rundir.js";
|
|
16
16
|
import { channelName } from "../spool.js";
|
|
17
17
|
|
|
@@ -34,12 +34,11 @@ export async function check(config, argv = []) {
|
|
|
34
34
|
const report = inspect(config, role, config.path);
|
|
35
35
|
if (!json) out(renderReport(report, { verbose: argv.includes("--verbose") }));
|
|
36
36
|
if (config.sandbox?.provider || json) report.sandboxes = sandboxes(config, role, { quiet: json });
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
if (
|
|
42
|
-
if (argv.includes("--system-log")) report.systemLog = await systemLog();
|
|
37
|
+
// What a flag asked for runs in --json too, quietly, and lands in the JSON:
|
|
38
|
+
// skipping it answered "ok" about a check that never ran.
|
|
39
|
+
if (argv.includes("--verify")) report.verify = await verify(config, role, argv.includes("--parallel"), { quiet: json });
|
|
40
|
+
if (argv.includes("--system-log")) report.systemLog = await systemLog({ quiet: json });
|
|
41
|
+
if (json) out(JSON.stringify(asJson(config, report), null, 2) + "\n");
|
|
43
42
|
return report;
|
|
44
43
|
}
|
|
45
44
|
|
|
@@ -51,7 +50,8 @@ export async function check(config, argv = []) {
|
|
|
51
50
|
* it before, with who is writing the most — the only lead to a fix. A flag,
|
|
52
51
|
* because it takes seconds and `check` otherwise answers at once.
|
|
53
52
|
*/
|
|
54
|
-
async function systemLog() {
|
|
53
|
+
async function systemLog({ quiet = false } = {}) {
|
|
54
|
+
const out = quiet ? () => {} : say;
|
|
55
55
|
const m = await measureSystemLog({ seconds: SYSTEM_LOG_SECONDS });
|
|
56
56
|
if (!m.available) {
|
|
57
57
|
out(`\n${C.dim}system log: not measured — ${m.reason}${C.off}\n`);
|
|
@@ -81,7 +81,7 @@ export function sandboxes(config, only = null, { platform = process.platform, qu
|
|
|
81
81
|
const out = quiet ? () => {} : say;
|
|
82
82
|
const sock = join(runsRoot(), "check", channelName(platform));
|
|
83
83
|
const rows = [];
|
|
84
|
-
out(`\n${C.b}sandboxes${C.off} (${(config.sandbox?.provider ??
|
|
84
|
+
out(`\n${C.b}sandboxes${C.off} (${(config.sandbox?.provider ?? DEFAULT_PROVIDERS).join(" → ")})\n`);
|
|
85
85
|
for (const r of Object.values(config.roles)) {
|
|
86
86
|
if (only && r.name !== only) continue;
|
|
87
87
|
let settings;
|
|
@@ -126,6 +126,8 @@ function asJson(config, report) {
|
|
|
126
126
|
unmet: (s.options ?? []).filter((o) => o.name !== s.chosen).map((o) => ({ sandbox: o.name, missing: o.missing, unmet: o.unmet })),
|
|
127
127
|
})),
|
|
128
128
|
warnings: report.warnings.map((w) => ({ kind: w.kind, role: w.role ?? null, headline: w.headline ?? w.message ?? null })),
|
|
129
|
+
...(report.verify ? { verify: report.verify.map((v) => ({ role: v.role, ok: v.ok, status: v.status ?? null })) } : {}),
|
|
130
|
+
...(report.systemLog ? { systemLog: { available: report.systemLog.available, dropped: report.systemLog.dropped ?? 0, reason: report.systemLog.reason ?? null } } : {}),
|
|
129
131
|
ok: checkStatus(report) === 0,
|
|
130
132
|
};
|
|
131
133
|
}
|
|
@@ -153,15 +155,21 @@ export function checkStatus(report) {
|
|
|
153
155
|
* The one thing `check` executes, which is why it is a flag: without it
|
|
154
156
|
* `check` still runs nothing.
|
|
155
157
|
*/
|
|
156
|
-
async function verify(config, only, parallel = false) {
|
|
158
|
+
async function verify(config, only, parallel = false, { quiet = false } = {}) {
|
|
159
|
+
const out = quiet ? () => {} : say;
|
|
157
160
|
const cli = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
158
161
|
const roles = Object.values(config.roles).filter((r) => (only ? r.name === only : true) && r.verify);
|
|
159
162
|
const results = [];
|
|
160
163
|
if (!roles.length) out(`\n${C.yellow}verify: no role ${only ? `"${only}" ` : ""}declares a verify command${C.off}\n`);
|
|
164
|
+
// The policy check read, passed on by name: one given with --policy (a JSON
|
|
165
|
+
// file outside the repo) is not where the child would look for seisin.toml.
|
|
166
|
+
// The state directory already travels as SEISIN_STATE_DIR.
|
|
167
|
+
const policy = config.path ? ["--policy", config.path] : [];
|
|
161
168
|
const run = async (r) => {
|
|
169
|
+
const argv = [cli, "run", ...policy, r.name, "--agent", "none", "--", ...r.verify];
|
|
162
170
|
const res = parallel
|
|
163
|
-
? await runParallel(process.execPath,
|
|
164
|
-
: spawnSync(process.execPath,
|
|
171
|
+
? await runParallel(process.execPath, argv, config.root)
|
|
172
|
+
: spawnSync(process.execPath, argv, {
|
|
165
173
|
cwd: config.root, encoding: "utf8", timeout: 300_000, env: process.env,
|
|
166
174
|
});
|
|
167
175
|
const ok = res.status === 0;
|
package/src/commands/log.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* Reading is the easy half. Writing goes through spool.js when the writer is
|
|
9
9
|
* inside a sandbox, so that the record is not editable by what it records.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { closeSync, openSync, readSync, watch as watchDir } from "node:fs";
|
|
12
|
+
import { StringDecoder } from "node:string_decoder";
|
|
13
|
+
import { lineReader } from "../lines.js";
|
|
12
14
|
import { dirname, relative } from "node:path";
|
|
13
15
|
import { read, logPath, size, verifyLog, LOG_NAME } from "../log.js";
|
|
14
16
|
import { renderEntry, C, out } from "../render.js";
|
|
@@ -120,21 +122,26 @@ export function watch(config, { interval = 500 } = {}) {
|
|
|
120
122
|
let at = size(file);
|
|
121
123
|
out(`\n ${C.dim}watching ${relative(process.cwd(), file)} — ctrl-c to stop${C.off}\n\n`);
|
|
122
124
|
|
|
125
|
+
// One reader across reads: a line that arrives in two pieces is shown once
|
|
126
|
+
// it is whole, instead of being dropped as half-written. Read synchronously,
|
|
127
|
+
// so a filesystem event and the interval never interleave two reads.
|
|
128
|
+
const show = (line) => { try { out(renderEntry(JSON.parse(line))); } catch { /* not a log line */ } };
|
|
129
|
+
let reader = lineReader(show), text = new StringDecoder("utf8");
|
|
123
130
|
const drain = () => {
|
|
124
131
|
const end = size(file);
|
|
125
|
-
if (end
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
});
|
|
132
|
+
if (end < at) { reader = lineReader(show); text = new StringDecoder("utf8"); } // truncated or rotated: start over
|
|
133
|
+
if (end <= at) { at = end; return; }
|
|
134
|
+
const fd = openSync(file, "r");
|
|
135
|
+
try {
|
|
136
|
+
const chunk = Buffer.alloc(end - at);
|
|
137
|
+
readSync(fd, chunk, 0, chunk.length, at);
|
|
138
|
+
at = end;
|
|
139
|
+
reader(text.write(chunk));
|
|
140
|
+
} finally { closeSync(fd); }
|
|
136
141
|
};
|
|
137
142
|
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
let dir = null;
|
|
144
|
+
try { dir = watchDir(dirname(file), () => drain()); } catch { /* platform without events */ }
|
|
145
|
+
const timer = setInterval(drain, interval);
|
|
146
|
+
return { stop: () => { clearInterval(timer); dir?.close(); } };
|
|
140
147
|
}
|
package/src/commands/requests.js
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* person acting in a channel the agent does not have. That is the invariant the
|
|
7
7
|
* whole feature rests on — see docs/requests.md.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { queueOf, queueData, shellId, lastAgentOf, fileOnlyNote } from "../requests.js";
|
|
10
10
|
import { describeHandoff } from "../handoff.js";
|
|
11
11
|
import { keyHolders } from "../owners.js";
|
|
12
|
-
import {
|
|
12
|
+
import { decideRequest } from "../decide-request.js";
|
|
13
13
|
import { read, logPath } from "../log.js";
|
|
14
14
|
import { C, out, safe } from "../render.js";
|
|
15
15
|
|
|
@@ -125,14 +125,10 @@ export function grant(config, argv = []) {
|
|
|
125
125
|
// Looked up, applied and settled under one lock — the one the console's
|
|
126
126
|
// decide takes too. Picking before the lock let this and the console both
|
|
127
127
|
// find the same request open and act on it twice.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (only) req = asFileGrant(req);
|
|
133
|
-
refuseIfBarred(current, req);
|
|
134
|
-
return applyGrant(before, req, reason);
|
|
135
|
-
}, { after: () => settle(requestsPath(config.root), req.key, "granted", reason, { only: req.only }) }));
|
|
128
|
+
const { request: req, changed } = underLock(() => decideRequest(config, {
|
|
129
|
+
decision: "granted", reason, only,
|
|
130
|
+
select: (current) => pick(current, which, "grant"),
|
|
131
|
+
}));
|
|
136
132
|
// Said once, beside the grant it is about: a literal file is useless to an
|
|
137
133
|
// agent that saves through a temporary beside it.
|
|
138
134
|
const caveat = only ? fileOnlyNote(req.role, lastAgentOf(read(logPath(config.root)), req.role)) : "";
|
|
@@ -181,11 +177,10 @@ export function decline(config, argv = []) {
|
|
|
181
177
|
|
|
182
178
|
// The policy is not edited, but the lock is still the policy's: it is the one
|
|
183
179
|
// every decision takes, so a decline cannot settle what a grant is applying.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}, { after: () => settle(requestsPath(config.root), req.key, "declined", reason) }));
|
|
180
|
+
const { request: req } = underLock(() => decideRequest(config, {
|
|
181
|
+
decision: "declined", reason, reload: false, includeOnly: false,
|
|
182
|
+
select: (current) => pick(current, argv[0], "decline"),
|
|
183
|
+
}));
|
|
189
184
|
out(
|
|
190
185
|
`\n ${C.yellow}declined${C.off} ${req.role} ✕ ${req.grant}\n` +
|
|
191
186
|
` ${C.dim}${reason || "no reason recorded"}${C.off}\n\n`
|
package/src/commands/review.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* would mean a correct policy cannot go green.
|
|
16
16
|
*/
|
|
17
17
|
import { review } from "../review.js";
|
|
18
|
+
import { positiveInt } from "../suggest.js";
|
|
18
19
|
import { parseSince } from "../views.js";
|
|
19
20
|
import { C, out, safe } from "../render.js";
|
|
20
21
|
import { wired } from "./wire.js";
|
|
@@ -30,7 +31,7 @@ export function reviewCommand(config, argv = []) {
|
|
|
30
31
|
const since = s === -1 ? null : parseSince(argv[s + 1]);
|
|
31
32
|
if (s !== -1 && !since)
|
|
32
33
|
throw new Error(`--since takes a date (2026-10-01) or a duration back from now (7d, 12h, 2w), not "${safe(argv[s + 1] ?? "")}"`);
|
|
33
|
-
const r = review(config, { minDenials: i === -1 ? 3 :
|
|
34
|
+
const r = review(config, { minDenials: i === -1 ? 3 : positiveInt("--min", argv[i + 1]), since });
|
|
34
35
|
// Ten rows per section unless --all: on a real 32-role log the full report
|
|
35
36
|
// was 1,557 lines, which nobody reads.
|
|
36
37
|
const all = argv.includes("--all");
|