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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.6.4",
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
- "@anthropic-ai/sandbox-runtime": "0.0.81"
45
+ "@carlostapiacl/tartaros": "0.1.0-alpha.23"
46
46
  },
47
- "optionalDependencies": {
48
- "@carlostapiacl/tartaros": "0.1.0-alpha.18"
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. @anthropic-ai/sandbox-runtime asks the operating
11
- * system — Seatbelt on macOS, bubblewrap on Linux — and the kernel does not
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} — give each agent its own folders and its own keys
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 @anthropic-ai/sandbox-runtime, which asks the OS.
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
 
@@ -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
- if (json) {
38
- out(JSON.stringify(asJson(config, report), null, 2) + "\n");
39
- return report;
40
- }
41
- if (argv.includes("--verify")) report.verify = await verify(config, role, argv.includes("--parallel"));
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 ?? ["srt"]).join(" → ")})\n`);
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, [cli, "run", r.name, "--agent", "none", "--", ...r.verify], config.root)
164
- : spawnSync(process.execPath, [cli, "run", r.name, "--agent", "none", "--", ...r.verify], {
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;
@@ -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 { createReadStream, watch as watchDir } from "node:fs";
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 <= at) { at = end; return; } // truncated, or nothing new
126
- const stream = createReadStream(file, { start: at, end: end - 1, encoding: "utf8" });
127
- at = end;
128
- let buf = "";
129
- stream.on("data", (d) => (buf += d));
130
- stream.on("end", () => {
131
- for (const line of buf.split("\n")) {
132
- if (!line.trim()) continue;
133
- try { out(renderEntry(JSON.parse(line))); } catch { /* half-written line */ }
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
- try { watchDir(dirname(file), () => drain()); } catch { /* platform without events */ }
139
- return setInterval(drain, interval);
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
  }
@@ -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 { settle, applyGrant, refuseIfBarred, requestsPath, editPolicy, queueOf, queueData, shellId, asFileGrant, lastAgentOf, fileOnlyNote } from "../requests.js";
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 { loadConfig } from "../config.js";
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
- let req;
129
- const { changed } = underLock(() => editPolicy(config, (before) => {
130
- const current = loadConfig(config.path, before);
131
- req = pick(current, which, "grant");
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
- let req;
185
- underLock(() => editPolicy(config, (before) => {
186
- req = pick(config, argv[0], "decline");
187
- return { toml: before, changed: false };
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`
@@ -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 : Number(argv[i + 1]), since });
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");