seisin 0.4.2 → 0.5.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 +142 -54
- package/package.json +3 -3
- package/src/cli.js +145 -46
- package/src/commands/check.js +3 -2
- package/src/commands/explain.js +34 -13
- package/src/commands/hook.js +14 -4
- package/src/commands/init.js +126 -38
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +109 -32
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +159 -37
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +25 -6
- package/src/commands/whose.js +7 -6
- package/src/commands/wire.js +156 -17
- package/src/config.js +187 -25
- package/src/controls.js +244 -0
- package/src/diagnose.js +35 -33
- package/src/env.js +11 -8
- package/src/grants.js +15 -7
- package/src/help.js +159 -0
- package/src/hook.js +49 -19
- package/src/index.js +4 -4
- package/src/inspect.js +137 -71
- package/src/intake.js +20 -15
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +236 -51
- package/src/mcp.js +42 -28
- package/src/notify.js +13 -8
- package/src/owners.js +198 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +68 -10
- package/src/requests.js +173 -72
- package/src/review.js +30 -17
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +377 -229
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +191 -44
- package/src/uilink.js +10 -4
- package/src/views.js +289 -0
- package/src/violations.js +104 -16
- package/src/walls.js +19 -42
- package/src/worktree.js +3 -11
- package/ui/index.html +1356 -537
package/src/spool.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ── Why this exists ──
|
|
5
5
|
* `.seisin/` used to be in every role's allowWrite, because the hook runs
|
|
6
|
-
* inside the
|
|
6
|
+
* inside the sandbox and has to record what it decided. That made the log and the
|
|
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
|
|
@@ -27,34 +27,14 @@
|
|
|
27
27
|
* else did.
|
|
28
28
|
*/
|
|
29
29
|
import { createServer, createConnection, Socket } from "node:net";
|
|
30
|
-
import { unlinkSync, existsSync,
|
|
30
|
+
import { unlinkSync, existsSync, openSync, writeSync, closeSync, constants } from "node:fs";
|
|
31
31
|
import { execFileSync } from "node:child_process";
|
|
32
|
-
import { tmpdir } from "node:os";
|
|
33
|
-
import { join, dirname } from "node:path";
|
|
34
32
|
|
|
35
33
|
/** The env var a confined hook looks for. Absent means "write the file". */
|
|
36
34
|
export const SOCK_ENV = "SEISIN_SPOOL";
|
|
37
35
|
|
|
38
36
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* Not under the repo: the point is a path the role's territory does not cover.
|
|
42
|
-
* Kept short because a unix socket path is capped near 104 bytes on macOS and
|
|
43
|
-
* the failure when it is too long is an unhelpful EINVAL.
|
|
44
|
-
*/
|
|
45
|
-
export function spoolPath() {
|
|
46
|
-
// A random directory, 0700, rather than a name anyone can guess from the pid.
|
|
47
|
-
// The socket itself is already denyWrite so the agent cannot unlink it, but
|
|
48
|
-
// any other process running as this user could otherwise connect and add
|
|
49
|
-
// lines. That does not let them erase anything — forgery is the part that
|
|
50
|
-
// stays open — but a predictable path invites it for free.
|
|
51
|
-
const dir = mkdtempSync(join(tmpdir(), "seisin-"));
|
|
52
|
-
chmodSync(dir, 0o700);
|
|
53
|
-
return join(dir, "spool.sock");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Listens for lines from inside the box and hands each to `sink`.
|
|
37
|
+
* Listens for lines from inside the sandbox and hands each to `sink`.
|
|
58
38
|
*
|
|
59
39
|
* One line is one JSON object with a `to` field naming which file it belongs
|
|
60
40
|
* in. The parent decides what that means; the sender does not get to pick a
|
|
@@ -63,7 +43,7 @@ export function spoolPath() {
|
|
|
63
43
|
/**
|
|
64
44
|
* Which channel this platform can use: a unix socket, or a FIFO.
|
|
65
45
|
*
|
|
66
|
-
* On Linux the runtime blocks creating ANY unix socket inside the
|
|
46
|
+
* On Linux the runtime blocks creating ANY unix socket inside the sandbox with a
|
|
67
47
|
* seccomp filter — it cannot filter by path, so `allowUnixSockets` is ignored
|
|
68
48
|
* there (its README says so). The socket this file was built on therefore
|
|
69
49
|
* never carried a line on Linux: measured in Docker on 2026-09-23, with the
|
|
@@ -96,7 +76,7 @@ function lineReader(sink, onTooLong) {
|
|
|
96
76
|
const msg = JSON.parse(line);
|
|
97
77
|
if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
|
|
98
78
|
} catch {
|
|
99
|
-
// Garbage from inside the
|
|
79
|
+
// Garbage from inside the sandbox is not the parent's emergency.
|
|
100
80
|
}
|
|
101
81
|
}
|
|
102
82
|
};
|
|
@@ -125,7 +105,7 @@ function fifoSpool(sink, path) {
|
|
|
125
105
|
});
|
|
126
106
|
}
|
|
127
107
|
|
|
128
|
-
export function spool(sink, path
|
|
108
|
+
export function spool(sink, path) {
|
|
129
109
|
if (path.endsWith(FIFO_SUFFIX)) return fifoSpool(sink, path);
|
|
130
110
|
if (existsSync(path)) unlinkSync(path);
|
|
131
111
|
|
|
@@ -143,7 +123,7 @@ export function spool(sink, path = spoolPath()) {
|
|
|
143
123
|
* too long for a unix socket, a directory that is not there — left an awaited
|
|
144
124
|
* promise pending forever. What the user saw was Node's "Detected unsettled
|
|
145
125
|
* top-level await" and rc=13: no error, no path, nothing naming the spool.
|
|
146
|
-
* Measured while running seisin inside seisin, where the outer
|
|
126
|
+
* Measured while running seisin inside seisin, where the outer sandbox denies
|
|
147
127
|
* the inner bind; but nothing about it is particular to that case, which is
|
|
148
128
|
* the reason it is fixed here rather than beside the nesting check.
|
|
149
129
|
*
|
|
@@ -161,11 +141,11 @@ export function spool(sink, path = spoolPath()) {
|
|
|
161
141
|
close() {
|
|
162
142
|
server.close();
|
|
163
143
|
// Only the socket. The first version removed `dirname(path)` too,
|
|
164
|
-
// reasoning that
|
|
144
|
+
// reasoning that the spool had just made that directory — but a
|
|
165
145
|
// caller passing its own path makes dirname the system temp directory,
|
|
166
146
|
// and closing the spool deleted it. That is a caller's whole scratch
|
|
167
147
|
// space gone, for a cleanup. Whoever created the directory removes it:
|
|
168
|
-
// see `run`,
|
|
148
|
+
// see `run`, whose run directory (rundir.js) holds the socket.
|
|
169
149
|
try { unlinkSync(path); } catch {}
|
|
170
150
|
},
|
|
171
151
|
});
|
package/src/srt.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* back to its defaults. Emitting the whole object removes that class of
|
|
14
14
|
* error, which is most of what this file is for.
|
|
15
15
|
*/
|
|
16
|
+
import { unknownRole } from "./suggest.js";
|
|
17
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
16
18
|
import { join, dirname } from "node:path";
|
|
17
19
|
import { STATE_DIR, CONFIG_NAME } from "./layout.js";
|
|
18
20
|
import { entriesOf } from "./keys.js";
|
|
@@ -79,7 +81,7 @@ export const NO_PROXY_WITHOUT_LOOPBACK = "169.254.0.0/16,10.0.0.0/8,172.16.0.0/1
|
|
|
79
81
|
*
|
|
80
82
|
* An `env` in front and not a variable in the environment seisin passes: the
|
|
81
83
|
* runtime sets NO_PROXY itself, after seisin's environment, so the only place
|
|
82
|
-
* that wins is inside the
|
|
84
|
+
* that wins is inside the sandbox. Clients that ignore HTTP_PROXY — a MySQL driver,
|
|
83
85
|
* Chromium, which exempts loopback on its own — still get refused. They need
|
|
84
86
|
* to be pointed at the proxy, or the port needs `local_binding`, which opens
|
|
85
87
|
* every port.
|
|
@@ -91,12 +93,12 @@ export function loopbackVia(role, cmd) {
|
|
|
91
93
|
|
|
92
94
|
export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
93
95
|
const role = config.roles[roleName];
|
|
94
|
-
if (!role) throw
|
|
96
|
+
if (!role) throw unknownRole(config, roleName);
|
|
95
97
|
|
|
96
98
|
const abs = (p) => (p.startsWith("/") ? p : join(config.root, p));
|
|
97
99
|
// "credentials" closes the places credentials live; "home" does that and
|
|
98
100
|
// gives the role a home of its own. Only the second one needs a new HOME,
|
|
99
|
-
// and only the second one signs every CLI in the
|
|
101
|
+
// and only the second one signs every CLI in the sandbox out. See config.js.
|
|
100
102
|
//
|
|
101
103
|
// `true` is normalised here as well as in the config reader, because a caller
|
|
102
104
|
// embedding the library builds this object itself and `isolate: true` has
|
|
@@ -104,6 +106,30 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
104
106
|
const level = config.isolate === true ? "home" : config.isolate;
|
|
105
107
|
const shielded = level === "credentials" || level === "home";
|
|
106
108
|
const isolated = level === "home";
|
|
109
|
+
/**
|
|
110
|
+
* `sandbox-runtime` silently makes these writable as conveniences even when
|
|
111
|
+
* they are absent from `allowWrite`. That gives every role a shared host
|
|
112
|
+
* directory outside its territory. A denyRead makes the runtime drop its
|
|
113
|
+
* implicit grant; denyWrite also wins over Seisin's explicit `~/.claude`
|
|
114
|
+
* runtime scratch grant for the nested debug directory.
|
|
115
|
+
*
|
|
116
|
+
* On macOS both are named whether they exist or not: the profile is a set of
|
|
117
|
+
* patterns, and a missing directory costs nothing. On Linux only the ones
|
|
118
|
+
* that exist are. bubblewrap denies a missing path by placing something on
|
|
119
|
+
* the host for the length of the run, in the user's home, where other tools
|
|
120
|
+
* see it — and a run that is killed leaves it behind. So a missing one is
|
|
121
|
+
* either not needed or made first:
|
|
122
|
+
* - its parent is not one a role may write (`~/.npm`): nothing can create
|
|
123
|
+
* it inside the sandbox, and there is nothing to deny;
|
|
124
|
+
* - its parent is (`~/.claude`, the runtime scratch every role gets): a role
|
|
125
|
+
* could create it, so it is created here, outside the sandbox, and denied like
|
|
126
|
+
* any directory that exists. It is the directory Claude Code itself logs
|
|
127
|
+
* to; making it empty changes nothing for anyone.
|
|
128
|
+
*/
|
|
129
|
+
const scratch = writePathsOf(config, config.roles[roleName] ?? { name: roleName, writes: [] });
|
|
130
|
+
const runtimeConvenience = ["~/.npm/_logs", "~/.claude/debug"]
|
|
131
|
+
.map(expand)
|
|
132
|
+
.filter((p) => process.platform !== "linux" || existsSync(p) || madeFirst(p, scratch));
|
|
107
133
|
|
|
108
134
|
/**
|
|
109
135
|
* Refuse before the runtime does, because the runtime's refusal says nothing.
|
|
@@ -127,6 +153,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
127
153
|
const home = isolated ? roleHome(config, roleName) : null;
|
|
128
154
|
const denyRead = [];
|
|
129
155
|
const allowRead = [];
|
|
156
|
+
denyRead.push(...runtimeConvenience);
|
|
130
157
|
|
|
131
158
|
// Every declared directory is denied, then each key the role names is
|
|
132
159
|
// re-allowed. A key written without a directory resolves against the first
|
|
@@ -301,6 +328,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
301
328
|
* relying on someone else's implementation detail staying put.
|
|
302
329
|
*/
|
|
303
330
|
denyWrite: [
|
|
331
|
+
...runtimeConvenience,
|
|
304
332
|
/**
|
|
305
333
|
* The role's own subtractions, `never_writes`.
|
|
306
334
|
*
|
|
@@ -318,7 +346,7 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
318
346
|
.flatMap((p) => { const r = realAncestor(p); return r === p ? [p] : [p, r]; }),
|
|
319
347
|
/**
|
|
320
348
|
* Everything the parent reads or executes, and every file that tells a
|
|
321
|
-
* program outside the
|
|
349
|
+
* program outside the sandbox what to run — the policy, `.seisin/`, the key
|
|
322
350
|
* directories, the key providers, `file://` targets, programs on PATH
|
|
323
351
|
* installed where a role writes, and each project's hooks and settings.
|
|
324
352
|
* One list with a reason per entry, built in surface.js, which says why
|
|
@@ -343,27 +371,16 @@ export function settingsFor(config, roleName, spool = null, observe = false) {
|
|
|
343
371
|
* verify through the Security framework — Go before 1.27 (gh, kubectl,
|
|
344
372
|
* terraform) and every Dart/Flutter. The runtime ships it as
|
|
345
373
|
* `enableWeakerNetworkIsolation` and warns it is an exfiltration path:
|
|
346
|
-
* trustd fetches, outside the
|
|
374
|
+
* trustd fetches, outside the sandbox, the URLs a certificate carries. Per role
|
|
347
375
|
* and off by default, so the answer is written next to the role that needs it.
|
|
348
376
|
*/
|
|
349
377
|
...(role.trustd === true ? { enableWeakerNetworkIsolation: true } : {}),
|
|
350
378
|
};
|
|
351
379
|
}
|
|
352
380
|
|
|
353
|
-
/**
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
* be" would mean writing a rule about a machine rather than about a repo.
|
|
359
|
-
*/
|
|
360
|
-
export function providerPaths(config) {
|
|
361
|
-
const out = [];
|
|
362
|
-
for (const p of Object.values(config.keyProviders ?? {})) {
|
|
363
|
-
const cmd = p.command?.[0];
|
|
364
|
-
if (!cmd || !cmd.includes("/")) continue;
|
|
365
|
-
out.push(cmd.startsWith("/") ? cmd : join(config.root ?? ".", cmd));
|
|
366
|
-
}
|
|
367
|
-
return [...new Set(out)];
|
|
381
|
+
/** Creates `p` when its parent exists and a role may write there; says whether it now exists. */
|
|
382
|
+
function madeFirst(p, writable) {
|
|
383
|
+
const parent = dirname(p);
|
|
384
|
+
if (!existsSync(parent) || !writable.some((w) => parent === w || parent.startsWith(w + "/"))) return false;
|
|
385
|
+
try { mkdirSync(p, { recursive: true }); return true; } catch { return false; }
|
|
368
386
|
}
|
|
369
|
-
|
package/src/suggest.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Did you mean" — for a command, a role, a flag.
|
|
3
|
+
*
|
|
4
|
+
* One place, so every command answers a typo the same way: the name that was
|
|
5
|
+
* not found, what exists, and the nearest one when it is near enough to be a
|
|
6
|
+
* typo rather than a different word.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Edit distance, small strings only. */
|
|
10
|
+
function distance(a, b) {
|
|
11
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
12
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
13
|
+
for (let i = 1; i <= a.length; i++)
|
|
14
|
+
for (let j = 1; j <= b.length; j++)
|
|
15
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
16
|
+
return d[a.length][b.length];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The nearest of `known` to `word`, or null when nothing is close. */
|
|
20
|
+
export function nearest(word, known) {
|
|
21
|
+
let best = null, score = Infinity;
|
|
22
|
+
for (const k of known) {
|
|
23
|
+
const n = distance(String(word).toLowerCase(), k.toLowerCase());
|
|
24
|
+
if (n < score) { best = k; score = n; }
|
|
25
|
+
}
|
|
26
|
+
const room = Math.max(1, Math.floor(String(word).length / 3));
|
|
27
|
+
return score <= room || (best && best.startsWith(word) && word.length >= 2) ? best : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The error for a role the policy does not have, always with the list. */
|
|
31
|
+
export function unknownRole(config, role) {
|
|
32
|
+
const known = Object.keys(config.roles ?? {});
|
|
33
|
+
const near = nearest(role, known);
|
|
34
|
+
return new Error(
|
|
35
|
+
`unknown role "${role}"${near ? ` — did you mean "${near}"?` : ""}` +
|
|
36
|
+
`\n known roles: ${known.join(", ") || "none — add a [roles.<name>] section"}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Flags a command does not take are refused, never ignored. An ignored
|
|
41
|
+
* `--verbsoe` is a question answered as if it had not been asked.
|
|
42
|
+
* `values` are the flags that take the next word as their value.
|
|
43
|
+
*/
|
|
44
|
+
export function checkFlags(command, argv, known, values = []) {
|
|
45
|
+
const all = [...known, ...values];
|
|
46
|
+
for (let i = 0; i < argv.length; i++) {
|
|
47
|
+
const a = argv[i];
|
|
48
|
+
if (a === "--") break;
|
|
49
|
+
if (values.includes(a)) { i++; continue; }
|
|
50
|
+
if (!a.startsWith("-") || a === "-") continue;
|
|
51
|
+
if (all.includes(a)) continue;
|
|
52
|
+
const near = nearest(a, all);
|
|
53
|
+
const e = new Error(`unknown flag "${a}" for ${command}${near ? ` — did you mean "${near}"?` : ""}` +
|
|
54
|
+
`\n seisin ${command} --help lists its flags`);
|
|
55
|
+
e.usage = true;
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
}
|