seisin 0.4.0 → 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 +151 -67
- 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 -30
- package/src/commands/log.js +55 -10
- package/src/commands/requests.js +124 -39
- package/src/commands/review.js +41 -16
- package/src/commands/run.js +174 -39
- 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 +261 -27
- 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 +24 -16
- package/src/keys.js +13 -11
- package/src/kinds.js +207 -0
- package/src/log.js +312 -55
- package/src/mcp.js +96 -43
- package/src/notify.js +15 -10
- package/src/owners.js +323 -41
- package/src/paths.js +37 -1
- package/src/redact.js +19 -5
- package/src/render.js +81 -12
- package/src/requests.js +250 -72
- package/src/review.js +42 -21
- package/src/rundir.js +4 -6
- package/src/scan.js +177 -19
- package/src/serve.js +395 -205
- package/src/spool.js +9 -29
- package/src/srt.js +38 -21
- package/src/suggest.js +58 -0
- package/src/surface.js +203 -42
- 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 +1366 -529
package/src/controls.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handing a role a family of control files, and protecting instruction files,
|
|
3
|
+
* as edits a person makes from the console.
|
|
4
|
+
*
|
|
5
|
+
* Both settings were only reachable by opening seisin.toml in an editor. That
|
|
6
|
+
* is fine for whoever wrote the policy and a wall for whoever inherited it:
|
|
7
|
+
* the console showed the refusal ("protected: .vscode, whose settings … VS
|
|
8
|
+
* Code applies outside the sandbox") and offered no way to act on it except
|
|
9
|
+
* approving a request that a grant can never satisfy (refuseIfBarred says so).
|
|
10
|
+
*
|
|
11
|
+
* What this module does not do is widen the menu. The families are exactly
|
|
12
|
+
* config.js's CONTROL_FAMILIES; `.claude/`, git hooks, `.mcp.json` and `.envrc`
|
|
13
|
+
* are never offered, because they are what a program outside the sandbox executes
|
|
14
|
+
* and no role writes them — not from the file, not from here.
|
|
15
|
+
*
|
|
16
|
+
* Every edit is text, bounded to one table, like applyGrant: comments and the
|
|
17
|
+
* order a person gave the file survive, and a change for one role can never
|
|
18
|
+
* land in another role's table.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { loadConfig, CONTROL_FAMILIES, stripComment, closesArray } from "./config.js";
|
|
22
|
+
import { settingsFor } from "./srt.js";
|
|
23
|
+
import { denyFor } from "./surface.js";
|
|
24
|
+
import { cleanReason } from "./requests.js";
|
|
25
|
+
|
|
26
|
+
const RE_HEADER = /^\[([A-Za-z0-9_.\-]+)\]$/;
|
|
27
|
+
const RE_KEY = /^([A-Za-z0-9_\-]+)\s*=/;
|
|
28
|
+
|
|
29
|
+
/** An error that is the caller's fault: the server answers it with 400. */
|
|
30
|
+
function bad(message) {
|
|
31
|
+
return Object.assign(new Error(message), { status: 400 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const hashOf = (text) => createHash("sha256").update(text).digest("hex").slice(0, 24);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The families as the caller sent them, validated and in the canonical order.
|
|
38
|
+
*
|
|
39
|
+
* Refused, not filtered: a request for ".claude" is somebody asking for the
|
|
40
|
+
* one thing this tool will not hand out, and silently dropping it would save a
|
|
41
|
+
* policy that is not what they asked for.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeFamilies(families) {
|
|
44
|
+
if (!Array.isArray(families) || families.some((f) => typeof f !== "string"))
|
|
45
|
+
throw bad(`families must be a list of names — some of ${CONTROL_FAMILIES.map((f) => JSON.stringify(f)).join(", ")}`);
|
|
46
|
+
for (const f of families)
|
|
47
|
+
if (!CONTROL_FAMILIES.includes(f))
|
|
48
|
+
throw bad(`${JSON.stringify(f)} is not a family of control files. Known: ` +
|
|
49
|
+
`${CONTROL_FAMILIES.map((x) => JSON.stringify(x)).join(", ")}. The others ` +
|
|
50
|
+
`(.claude, git hooks, .mcp.json, .envrc) are never handed to a role.`);
|
|
51
|
+
return CONTROL_FAMILIES.filter((f) => families.includes(f));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The lines of one table: where its header is, where it ends, and each
|
|
56
|
+
* key's first and last line. A multi-line array is one key, so a line inside
|
|
57
|
+
* one is never taken for a header or for another key.
|
|
58
|
+
*/
|
|
59
|
+
function tableOf(lines, name) {
|
|
60
|
+
let header = -1;
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
const h = RE_HEADER.exec(stripComment(lines[i]).trim());
|
|
63
|
+
if (h && h[1] === name) { header = i; break; }
|
|
64
|
+
}
|
|
65
|
+
if (header === -1) return null;
|
|
66
|
+
const keys = [];
|
|
67
|
+
let end = lines.length;
|
|
68
|
+
for (let i = header + 1; i < lines.length; i++) {
|
|
69
|
+
const line = stripComment(lines[i]).trim();
|
|
70
|
+
if (!line) continue;
|
|
71
|
+
if (RE_HEADER.test(line)) { end = i; break; }
|
|
72
|
+
const k = RE_KEY.exec(line);
|
|
73
|
+
if (!k) continue;
|
|
74
|
+
const from = i;
|
|
75
|
+
const value = line.slice(line.indexOf("=") + 1).trim();
|
|
76
|
+
if (value.startsWith("[") && !closesArray(value))
|
|
77
|
+
while (i + 1 < lines.length && !closesArray(stripComment(lines[++i]).trim()));
|
|
78
|
+
keys.push({ key: k[1], from, to: i });
|
|
79
|
+
}
|
|
80
|
+
return { header, end, keys };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stamp(note) {
|
|
84
|
+
return `# set in the console ${new Date().toISOString().slice(0, 10)}` +
|
|
85
|
+
(note ? ` · «${cleanReason(note)}»` : "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Sets `key = value` inside one table: replaces the line (or the lines, for a
|
|
90
|
+
* multi-line array) where the key was, or adds it after the table's last key.
|
|
91
|
+
* The replaced line's own trailing comment goes with it — it described the
|
|
92
|
+
* value being replaced, and the stamp now describes the new one. Every other
|
|
93
|
+
* line, comments included, stays byte for byte.
|
|
94
|
+
*/
|
|
95
|
+
function setKey(text, table, key, value, note) {
|
|
96
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
97
|
+
const lines = text.split("\n");
|
|
98
|
+
const cr = eol === "\r\n" ? "\r" : "";
|
|
99
|
+
const t = tableOf(lines, table);
|
|
100
|
+
if (!t) return null;
|
|
101
|
+
const at = t.keys.find((k) => k.key === key);
|
|
102
|
+
if (at) {
|
|
103
|
+
// The same value is no edit: re-stamping it would record a decision
|
|
104
|
+
// nobody made, and move the date of the one somebody did.
|
|
105
|
+
const was = lines.slice(at.from, at.to + 1).map((l) => stripComment(l).trim()).join(" ");
|
|
106
|
+
if (was.slice(was.indexOf("=") + 1).replace(/\s+/g, "").replace(/,\]$/, "]") === value.replace(/\s+/g, "")) return text;
|
|
107
|
+
const indent = /^\s*/.exec(lines[at.from])[0];
|
|
108
|
+
const line = `${indent}${key} = ${value} ${stamp(note)}${cr}`;
|
|
109
|
+
lines.splice(at.from, at.to - at.from + 1, line);
|
|
110
|
+
} else {
|
|
111
|
+
// After the last key, not the last line: a comment above the next table
|
|
112
|
+
// belongs to that table, and a line inserted under it would read as the
|
|
113
|
+
// next role's.
|
|
114
|
+
const after = t.keys.length ? t.keys[t.keys.length - 1].to : t.header;
|
|
115
|
+
lines.splice(after + 1, 0, `${key} = ${value} ${stamp(note)}${cr}`);
|
|
116
|
+
}
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const listOf = (families) => `[${families.map((f) => `"${f}"`).join(", ")}]`;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The policy text with `control_files` of one role set to `families`.
|
|
124
|
+
*
|
|
125
|
+
* An empty list is written as `control_files = []` with its stamp when the key
|
|
126
|
+
* was there, rather than deleted: the line is the record that somebody took
|
|
127
|
+
* the family away, and when. A role that never had the key and still has none
|
|
128
|
+
* is left alone.
|
|
129
|
+
*/
|
|
130
|
+
export function applyControlFiles(toml, role, families, note = "") {
|
|
131
|
+
const want = normalizeFamilies(families);
|
|
132
|
+
const lines = toml.split("\n");
|
|
133
|
+
const t = tableOf(lines, `roles.${role}`);
|
|
134
|
+
if (!t) throw bad(`no [roles.${role}] section in the policy`);
|
|
135
|
+
if (!want.length && !t.keys.some((k) => k.key === "control_files")) return { toml, changed: false };
|
|
136
|
+
const out = setKey(toml, `roles.${role}`, "control_files", listOf(want), note);
|
|
137
|
+
return { toml: out, changed: out !== toml };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The policy text with `[protect] instructions` set.
|
|
142
|
+
*
|
|
143
|
+
* A missing `[protect]` table is created only to turn the protection on
|
|
144
|
+
* (off is the default and needs no line), and it goes above the first role:
|
|
145
|
+
* it is a setting about every role, and a reader meets it before the roles
|
|
146
|
+
* it changes. A comment block that sits directly above that role stays with
|
|
147
|
+
* the role.
|
|
148
|
+
*/
|
|
149
|
+
export function applyProtect(toml, on, note = "") {
|
|
150
|
+
if (typeof on !== "boolean") throw bad("protect must be true or false");
|
|
151
|
+
const lines = toml.split("\n");
|
|
152
|
+
if (tableOf(lines, "protect")) {
|
|
153
|
+
const out = setKey(toml, "protect", "instructions", String(on), note);
|
|
154
|
+
return { toml: out, changed: out !== toml };
|
|
155
|
+
}
|
|
156
|
+
if (!on) return { toml, changed: false };
|
|
157
|
+
const cr = toml.includes("\r\n") ? "\r" : "";
|
|
158
|
+
let at = lines.findIndex((l) => /^roles\./.test(RE_HEADER.exec(stripComment(l).trim())?.[1] ?? ""));
|
|
159
|
+
const block = [`[protect]${cr}`, `instructions = true ${stamp(note)}${cr}`, cr];
|
|
160
|
+
if (at === -1) {
|
|
161
|
+
// Unreachable for a policy that loads (it has roles), kept total anyway.
|
|
162
|
+
const sep = toml.endsWith("\n") || !toml ? "" : "\n";
|
|
163
|
+
return { toml: `${toml}${sep}\n${block[0]}\n${block[1]}\n`, changed: true };
|
|
164
|
+
}
|
|
165
|
+
while (at > 0 && /^\s*#/.test(lines[at - 1])) at--;
|
|
166
|
+
lines.splice(at, 0, ...block);
|
|
167
|
+
return { toml: lines.join("\n"), changed: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Which roles may write each family after the edit. `null`: nothing protects it. */
|
|
171
|
+
export function accessOf(config) {
|
|
172
|
+
const roles = Object.values(config.roles);
|
|
173
|
+
return {
|
|
174
|
+
ide: roles.filter((r) => r.controlFiles.includes("ide")).map((r) => r.name),
|
|
175
|
+
instructions: config.protect?.instructions
|
|
176
|
+
? roles.filter((r) => r.controlFiles.includes("instructions")).map((r) => r.name)
|
|
177
|
+
: null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A role's denyWrite, each entry with the reason denyFor gives for it. */
|
|
182
|
+
function deniesOf(config, name) {
|
|
183
|
+
const why = new Map();
|
|
184
|
+
try { for (const e of denyFor(config, config.roles[name])) why.set(e.path, e.why); } catch {}
|
|
185
|
+
const list = settingsFor(config, name).filesystem?.denyWrite ?? [];
|
|
186
|
+
return new Map(list.map((p) => [p, why.get(p) ?? "never_writes, or a runtime default"]));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* What the edit changes in each role's sandbox profile, by denyWrite entry.
|
|
191
|
+
*
|
|
192
|
+
* Computed from the two policies as `seisin run` would build them — settingsFor
|
|
193
|
+
* over the text before and the text after — never from a description of the
|
|
194
|
+
* families. A role whose territory holds no project where these files could
|
|
195
|
+
* be gets an empty diff, and that is the honest answer: the toggle changes
|
|
196
|
+
* nothing for it today.
|
|
197
|
+
*/
|
|
198
|
+
export function profileDiff(before, after, names) {
|
|
199
|
+
return names.map((name) => {
|
|
200
|
+
try {
|
|
201
|
+
const a = deniesOf(before, name);
|
|
202
|
+
const b = deniesOf(after, name);
|
|
203
|
+
return {
|
|
204
|
+
role: name,
|
|
205
|
+
added: [...b].filter(([p]) => !a.has(p)).map(([path, why]) => ({ path, why })),
|
|
206
|
+
removed: [...a].filter(([p]) => !b.has(p)).map(([path, why]) => ({ path, why })),
|
|
207
|
+
};
|
|
208
|
+
} catch (e) {
|
|
209
|
+
return { role: name, error: e.message, added: [], removed: [] };
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* One edit, described without writing it: the new text, and what it changes.
|
|
216
|
+
*
|
|
217
|
+
* The new text is loaded with the real loader against the real root, so an
|
|
218
|
+
* edit that would leave a policy that no longer loads is refused here, before
|
|
219
|
+
* anything is written.
|
|
220
|
+
*/
|
|
221
|
+
export function planEdit(config, text, edit) {
|
|
222
|
+
let result;
|
|
223
|
+
if (edit.kind === "role") {
|
|
224
|
+
if (typeof edit.role !== "string" || !Object.hasOwn(config.roles, edit.role))
|
|
225
|
+
throw bad(`unknown role ${JSON.stringify(edit.role)}`);
|
|
226
|
+
result = applyControlFiles(text, edit.role, edit.families, edit.reason);
|
|
227
|
+
} else {
|
|
228
|
+
result = applyProtect(text, edit.on, edit.reason);
|
|
229
|
+
}
|
|
230
|
+
let after;
|
|
231
|
+
try { after = loadConfig(config.path, result.toml); }
|
|
232
|
+
catch (e) { throw bad(`the edited policy would not load, so nothing was written: ${e.message}`); }
|
|
233
|
+
const names = edit.kind === "role" ? [edit.role] : Object.keys(config.roles);
|
|
234
|
+
const diff = result.changed ? profileDiff(config, after, names).filter((d) => d.error || d.added.length || d.removed.length) : [];
|
|
235
|
+
return {
|
|
236
|
+
changed: result.changed,
|
|
237
|
+
toml: result.toml,
|
|
238
|
+
base: hashOf(text),
|
|
239
|
+
diff,
|
|
240
|
+
access: accessOf(after),
|
|
241
|
+
protect: { instructions: after.protect.instructions },
|
|
242
|
+
...(edit.kind === "role" && { role: edit.role, controlFiles: after.roles[edit.role].controlFiles }),
|
|
243
|
+
};
|
|
244
|
+
}
|
package/src/diagnose.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* The refusal the agent never heard, said after the fact.
|
|
3
3
|
*
|
|
4
4
|
* PreToolUse sees the path a tool call names, and that is all it sees. What the
|
|
5
|
-
* kernel
|
|
5
|
+
* kernel denies on its own — a child process, `/bin/rm` inside a script, a
|
|
6
6
|
* path the regex did not catch — reaches the agent as `Operation not permitted`
|
|
7
|
-
* with no path and no reason, and it retries. Measured on a
|
|
8
|
-
* 2026-09-14 to 2026-09-22: 5,617
|
|
7
|
+
* with no path and no reason, and it retries. Measured on a multi-repo workspace from
|
|
8
|
+
* 2026-09-14 to 2026-09-22: 5,617 denials, all of them from the kernel, 72%
|
|
9
9
|
* the same role hitting the same path again.
|
|
10
10
|
*
|
|
11
11
|
* Two hooks close that, the shape nono uses for its sandbox diagnostics:
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* refusals from the last moments and hand the agent the same sentence the
|
|
16
16
|
* PreToolUse hook would have — whose it is, how many times, and that it
|
|
17
17
|
* should hand it over rather than retry. The kernel's line reaches the log
|
|
18
|
-
* about 30 ms after the refusal (measured inside the
|
|
18
|
+
* about 30 ms after the refusal (measured inside the sandbox), so this reads it.
|
|
19
19
|
* - when a session starts, resumes or is compacted (SessionStart): the role's
|
|
20
20
|
* territory and what it keeps being refused, before the first refusal —
|
|
21
21
|
* and right after compaction, which is when an agent forgets what it was
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* On Linux the runtime's refusals cannot be read from outside (violations.js),
|
|
29
29
|
* so the after-the-fact half finds nothing there and stays quiet.
|
|
30
30
|
*/
|
|
31
|
-
import {
|
|
31
|
+
import { readEntries } from "./log.js";
|
|
32
32
|
import { explain } from "./owners.js";
|
|
33
33
|
import { timesHit, walls } from "./walls.js";
|
|
34
34
|
import { collapseSidecars } from "./render.js";
|
|
@@ -64,37 +64,16 @@ export function knownRefusals(evidence) {
|
|
|
64
64
|
|
|
65
65
|
/** How far back a refusal still belongs to the call that just failed. */
|
|
66
66
|
const WINDOW_MS = 120_000;
|
|
67
|
-
/** Read only the end of the log: this runs on every failed command. */
|
|
67
|
+
/** Read only the end of the log: this runs on every failed command. The line the cut lands in is dropped (readLines). */
|
|
68
68
|
const TAIL_BYTES = 512 * 1024;
|
|
69
69
|
/** How many paths to name. More than this is a wall of text nobody acts on. */
|
|
70
70
|
const MAX_NAMED = 3;
|
|
71
71
|
|
|
72
|
-
function tail(file) {
|
|
73
|
-
if (!existsSync(file)) return [];
|
|
74
|
-
const { size } = statSync(file);
|
|
75
|
-
const from = Math.max(0, size - TAIL_BYTES);
|
|
76
|
-
const fd = openSync(file, "r");
|
|
77
|
-
try {
|
|
78
|
-
const buf = Buffer.alloc(size - from);
|
|
79
|
-
readSync(fd, buf, 0, buf.length, from);
|
|
80
|
-
const lines = buf.toString("utf8").split("\n");
|
|
81
|
-
if (from > 0) lines.shift(); // the first line is cut in half
|
|
82
|
-
const out = [];
|
|
83
|
-
for (const l of lines) {
|
|
84
|
-
if (!l.trim()) continue;
|
|
85
|
-
try { out.push(JSON.parse(l)); } catch { /* a line being written */ }
|
|
86
|
-
}
|
|
87
|
-
return out;
|
|
88
|
-
} finally {
|
|
89
|
-
closeSync(fd);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
72
|
/** This role's refusals by the kernel since `sinceMs`, one per path, newest first. */
|
|
94
73
|
export function recentKernelDenials(file, role, sinceMs) {
|
|
95
74
|
const since = new Date(sinceMs).toISOString();
|
|
96
75
|
const seen = new Map();
|
|
97
|
-
for (const e of
|
|
76
|
+
for (const e of readEntries(file, { tail: TAIL_BYTES }).reverse()) {
|
|
98
77
|
if (e.role !== role || e.verdict !== "denied" || e.source !== "kernel") continue;
|
|
99
78
|
if (!e.at || e.at < since || !e.target) continue;
|
|
100
79
|
const k = `${e.action}\u0000${e.target}`;
|
|
@@ -118,22 +97,45 @@ function sentence(config, role, file, e) {
|
|
|
118
97
|
}
|
|
119
98
|
|
|
120
99
|
const PREFACE =
|
|
121
|
-
"
|
|
100
|
+
"The sandbox this agent runs in, set up by seisin, denied part of that. This is not a Unix permission " +
|
|
122
101
|
"or a macOS privacy prompt: chmod, sudo or a different path to the same file will not change it.";
|
|
123
102
|
|
|
103
|
+
/**
|
|
104
|
+
* What the tool said, and whether it said it failed.
|
|
105
|
+
*
|
|
106
|
+
* PostToolUseFailure is a failure by definition, and its evidence is all of it.
|
|
107
|
+
* PostToolUse is a Bash result, and most of those are successes: a command that
|
|
108
|
+
* prints "Permission denied" to stdout — a grep through logs, a test that
|
|
109
|
+
* checks for it — used to pay the full retry schedule (413 ms measured) to
|
|
110
|
+
* find nothing. So there, only stderr counts, and only an explicit failure
|
|
111
|
+
* (a non-zero exit code, or `is_error`) is a reason to wait for the kernel.
|
|
112
|
+
* A success with the words on stderr — `cmd; true` swallowed the status —
|
|
113
|
+
* still gets one look at the log, just not the waits.
|
|
114
|
+
*/
|
|
115
|
+
function evidenceOf(event) {
|
|
116
|
+
if (event.hook_event_name !== "PostToolUse")
|
|
117
|
+
return { text: JSON.stringify({ r: event.tool_response, e: event.error, o: event.tool_output }), failed: true };
|
|
118
|
+
const r = event.tool_response;
|
|
119
|
+
if (typeof r === "string") return { text: JSON.stringify(r), failed: false };
|
|
120
|
+
const code = r?.exit_code ?? r?.exitCode ?? r?.code;
|
|
121
|
+
const failed = r?.is_error === true || (typeof code === "number" && code !== 0);
|
|
122
|
+
return { text: JSON.stringify(typeof r?.stderr === "string" ? r.stderr : ""), failed };
|
|
123
|
+
}
|
|
124
|
+
|
|
124
125
|
/**
|
|
125
126
|
* After a tool call failed. Returns the hook output, or null to say nothing.
|
|
126
127
|
*
|
|
127
128
|
* `wait` retries the read a few times: the kernel's line lands ~30 ms after the
|
|
128
|
-
* refusal, and a loaded machine can be slower than the hook.
|
|
129
|
+
* refusal, and a loaded machine can be slower than the hook. Only when the tool
|
|
130
|
+
* reported a failure — see {@link evidenceOf}.
|
|
129
131
|
*/
|
|
130
132
|
export async function afterTool(config, role, event, { file, now = Date.now, wait = [0, 100, 300] } = {}) {
|
|
131
|
-
const
|
|
133
|
+
const { text: evidence, failed } = evidenceOf(event);
|
|
132
134
|
if (!DENIAL_SIGNS.test(evidence)) return null;
|
|
133
135
|
// JSON escapes what the program printed; the signatures are written against the text.
|
|
134
136
|
const known = knownRefusals(evidence.replace(/\\n/g, "\n").replace(/\\"/g, '"'));
|
|
135
137
|
let found = [];
|
|
136
|
-
for (const ms of wait) {
|
|
138
|
+
for (const ms of failed ? wait : [0]) {
|
|
137
139
|
if (ms) await new Promise((ok) => setTimeout(ok, ms));
|
|
138
140
|
found = recentKernelDenials(file, role, now() - WINDOW_MS);
|
|
139
141
|
if (found.length) break;
|
|
@@ -163,7 +165,7 @@ export function atSessionStart(config, role, event, { file } = {}) {
|
|
|
163
165
|
if (r.keys.length) lines.push(`Keys you hold: ${r.keys.join(", ")}.`);
|
|
164
166
|
const w = file ? walls(config, role, { file, limit: 5, fresh: true }) : [];
|
|
165
167
|
if (w.length) {
|
|
166
|
-
lines.push("Already
|
|
168
|
+
lines.push("Already denied more than once, and still denied — do not try again, hand it over:");
|
|
167
169
|
for (const x of w)
|
|
168
170
|
lines.push(`- ${x.action} ${x.target} (${x.times}×) — ${x.owners?.length ? `belongs to ${x.owners.join(", ")}` : x.reason}`);
|
|
169
171
|
}
|
package/src/env.js
CHANGED
|
@@ -39,8 +39,8 @@ export const BASE = [
|
|
|
39
39
|
* needs **set**, not forwarded.
|
|
40
40
|
*
|
|
41
41
|
* `GIT_OPTIONAL_LOCKS=0` is the whole list, and it is here because of what the
|
|
42
|
-
* denial log looks like without it. Measured on a
|
|
43
|
-
* 60
|
|
42
|
+
* denial log looks like without it. Measured on a multi-repo workspace: of the last
|
|
43
|
+
* 60 denials, **58 were `.git/index.lock`** — 97% — and most carried no owner
|
|
44
44
|
* at all, so not one of them was a territory question. `git status` and
|
|
45
45
|
* `git diff` refresh the index as a courtesy, refreshing it takes the lock, and
|
|
46
46
|
* a role reading a repo it does not own trips the boundary while doing nothing
|
|
@@ -64,7 +64,7 @@ export const DEFAULTS = {
|
|
|
64
64
|
GIT_OPTIONAL_LOCKS: "0",
|
|
65
65
|
/**
|
|
66
66
|
* Node's built-in `fetch()` ignores HTTP_PROXY/HTTPS_PROXY unless told
|
|
67
|
-
* otherwise, so inside the
|
|
67
|
+
* otherwise, so inside the sandbox it tries DNS directly and dies with ENOTFOUND
|
|
68
68
|
* while `npm`, which reads the proxy itself, works. Measured on Node 24.2:
|
|
69
69
|
* ENOTFOUND without it, 200 with it. It only makes Node use the proxy the
|
|
70
70
|
* sandbox already routes everything through — nothing is widened. Older
|
|
@@ -90,12 +90,15 @@ export const DEFAULTS = {
|
|
|
90
90
|
};
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Names that
|
|
93
|
+
* Names that ride along only when the role names them itself.
|
|
94
94
|
*
|
|
95
95
|
* A role can name a variable explicitly and get it — that is the point of the
|
|
96
|
-
* `env` list.
|
|
97
|
-
*
|
|
98
|
-
*
|
|
96
|
+
* `env` list. Everything else that crosses comes from BASE or from a caller's
|
|
97
|
+
* `extra`, and this keeps a credential-shaped name out of both. Today it
|
|
98
|
+
* filters nothing: no BASE name matches it and `run` passes no `extra`. It is
|
|
99
|
+
* kept, not deleted, as the guard for the day either list grows — a
|
|
100
|
+
* `SSH_AUTH_SOCK` added "for git" would otherwise cross for every role
|
|
101
|
+
* silently — and for embedders who pass `extra`.
|
|
99
102
|
*/
|
|
100
103
|
const NEVER = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|SESSION|COOKIE|PRIVATE)(_|$)/i;
|
|
101
104
|
|
|
@@ -118,7 +121,7 @@ export function buildEnv(parent, role, extra = []) {
|
|
|
118
121
|
if (!wanted.has(name)) { dropped.push(name); continue; }
|
|
119
122
|
if (NEVER.test(name) && !(role.env ?? []).includes(name)) { dropped.push(name); continue; }
|
|
120
123
|
// Not even when named: whoever can read the notify URL can send the person
|
|
121
|
-
// a fake "
|
|
124
|
+
// a fake "grant #3". Only the parent uses it (notify.js).
|
|
122
125
|
if (name === "SEISIN_NOTIFY_URL") { dropped.push(name); continue; }
|
|
123
126
|
env[name] = value;
|
|
124
127
|
}
|
package/src/grants.js
CHANGED
|
@@ -14,11 +14,12 @@ import { join } from "node:path";
|
|
|
14
14
|
import { homedir, tmpdir } from "node:os";
|
|
15
15
|
import { realpathSync, lstatSync } from "node:fs";
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
|
+
import { WILD } from "./paths.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* What every agent needs to write no matter which role it is.
|
|
20
21
|
*
|
|
21
|
-
* Found by running a
|
|
22
|
+
* Found by running a policy with 32 roles through the sandbox: territory alone
|
|
22
23
|
* looks correct and is unusable. An agent writes its session state under its
|
|
23
24
|
* own config directory and its tools write scratch files to the temp dir, so a
|
|
24
25
|
* policy of "your folders and nothing else" stops the agent before it starts.
|
|
@@ -67,7 +68,7 @@ export function roleHomeRoot(config) {
|
|
|
67
68
|
*
|
|
68
69
|
* This was `base64url(root).slice(-16)` — the TAIL of the encoded path, which
|
|
69
70
|
* is the tail of the path itself. Two checkouts that end the same way get the
|
|
70
|
-
* same id: `/Users/ana/dev/
|
|
71
|
+
* same id: `/Users/ana/dev/project` and `/Users/bob/dev/project` collide, and
|
|
71
72
|
* so do `/home/a/work/api` and `/home/b/work/api`. A collision here is not a
|
|
72
73
|
* cosmetic clash — both repos' role `dev` would share one HOME, which is the
|
|
73
74
|
* session token of one handed to the other. Measured on three of four ordinary
|
|
@@ -145,10 +146,18 @@ export function isLink(p) {
|
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* The path with symlinks followed, or the path itself if it is not on disk.
|
|
151
|
+
*
|
|
152
|
+
* `.native` is realpath(3): one call instead of an lstat per component, and on
|
|
153
|
+
* a case-insensitive disk (APFS) it answers with the case the disk has. The JS
|
|
154
|
+
* version keeps whatever case it was given, so `/users/x` and `/Users/x` —
|
|
155
|
+
* one directory — compared as two, and a path spelled the other way came out
|
|
156
|
+
* "outside the repo" while the kernel treated it as inside.
|
|
157
|
+
*/
|
|
149
158
|
export function realOrSelf(p) {
|
|
150
159
|
try {
|
|
151
|
-
return realpathSync(p);
|
|
160
|
+
return realpathSync.native(p);
|
|
152
161
|
} catch {
|
|
153
162
|
return p;
|
|
154
163
|
}
|
|
@@ -159,7 +168,7 @@ export function expand(p) {
|
|
|
159
168
|
if (p === "$TMPDIR") out = tmpdir();
|
|
160
169
|
else if (p === "~" || p.startsWith("~/")) out = join(homedir(), p.slice(2));
|
|
161
170
|
try {
|
|
162
|
-
return realpathSync(out);
|
|
171
|
+
return realpathSync.native(out);
|
|
163
172
|
} catch {
|
|
164
173
|
return out; // not on disk yet; hand the literal through rather than drop it
|
|
165
174
|
}
|
|
@@ -195,7 +204,6 @@ export function toWritePath(glob, key = "writes") {
|
|
|
195
204
|
* prefixes, and "one level down" is not a prefix. Refusing is not a placeholder
|
|
196
205
|
* here, it is the answer.
|
|
197
206
|
*/
|
|
198
|
-
const WILD = /[*?[\]]/; // owners.js treats all four as wildcards
|
|
199
207
|
const EXPRESSIBLE = (g) =>
|
|
200
208
|
g === "**" || // the whole repo
|
|
201
209
|
!WILD.test(g) || // a literal path
|
|
@@ -217,7 +225,7 @@ function unexpressible(glob, key = "writes") {
|
|
|
217
225
|
* grants the whole repo, so `observe: true` answers for that case.
|
|
218
226
|
*
|
|
219
227
|
* `.seisin/` is deliberately NOT here. It used to be, because the hook runs
|
|
220
|
-
* inside the
|
|
228
|
+
* inside the sandbox and has to record what it decided — which made the log and
|
|
221
229
|
* the queue writable by the process they are a record of. The hook now sends
|
|
222
230
|
* its lines to the parent over a socket and the parent holds the file. See
|
|
223
231
|
* spool.js.
|
package/src/help.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `seisin <command> --help` — one command's flags, examples and exit codes.
|
|
3
|
+
*
|
|
4
|
+
* The flags listed here are also the flags each command accepts: cli.js
|
|
5
|
+
* refuses any other, so a typo is an error instead of a question answered as
|
|
6
|
+
* if it had not been asked. One table, so the help and the check cannot
|
|
7
|
+
* disagree about what a command takes.
|
|
8
|
+
*
|
|
9
|
+
* `values` are flags that take the next word as their value.
|
|
10
|
+
*/
|
|
11
|
+
export const COMMAND_HELP = {
|
|
12
|
+
run: {
|
|
13
|
+
usage: ["seisin run <role> [--observe] [--debug-env] -- <command...>"],
|
|
14
|
+
flags: [
|
|
15
|
+
["--observe", "open the repo for this run and record what the agent writes; keys and network stay shut"],
|
|
16
|
+
["--debug-env", "print the environment variables the role does not get"],
|
|
17
|
+
],
|
|
18
|
+
notes: ["Everything after -- is the command's own, flags included: `-- claude --settings x.json` goes to claude."],
|
|
19
|
+
examples: ['seisin run frontend -- claude', 'seisin run backend --observe -- claude -p "…"'],
|
|
20
|
+
exits: [["n", "the command's own exit status"], ["127", "the command is not on the role's PATH"],
|
|
21
|
+
["128+n", "killed by signal n (130 for ctrl-c)"], ["2", "seisin could not start it (usage, policy, runtime)"]],
|
|
22
|
+
},
|
|
23
|
+
check: {
|
|
24
|
+
usage: ["seisin check [role] [--verbose]"],
|
|
25
|
+
flags: [["--verbose", "also print the protected paths, each warning's explanation and the standing limits"]],
|
|
26
|
+
examples: ["seisin check", "seisin check frontend --verbose"],
|
|
27
|
+
exits: [["0", "the policy can be enforced as written (warnings included)"], ["1", "a role cannot be enforced as written"], ["2", "usage or policy error"]],
|
|
28
|
+
},
|
|
29
|
+
explain: {
|
|
30
|
+
usage: ["seisin explain <role> read|write <path>", "seisin explain <role> read <key>", "seisin explain <role> mcp <server>"],
|
|
31
|
+
flags: [],
|
|
32
|
+
notes: ["A relative path is relative to the directory you are in."],
|
|
33
|
+
examples: ["seisin explain frontend write src/app.js", "seisin explain backend read stripe.txt", "seisin explain frontend mcp github"],
|
|
34
|
+
exits: [["0", "allowed"], ["1", "denied"], ["2", "usage error"]],
|
|
35
|
+
},
|
|
36
|
+
whose: {
|
|
37
|
+
usage: ["seisin whose <path>"],
|
|
38
|
+
flags: [],
|
|
39
|
+
notes: ["Safe to call from inside the sandbox. A relative path is relative to the directory you are in."],
|
|
40
|
+
examples: ["seisin whose backend/src/server.py"],
|
|
41
|
+
exits: [["0", "answered"], ["2", "usage error"]],
|
|
42
|
+
},
|
|
43
|
+
scan: {
|
|
44
|
+
usage: ["seisin scan [--all]"],
|
|
45
|
+
flags: [["--all", "walk the whole folder, not only where the territories are"]],
|
|
46
|
+
examples: ["seisin scan", "seisin scan --all"],
|
|
47
|
+
exits: [["0", "no certain credential outside the key dirs"], ["1", "at least one certain credential"], ["2", "usage error"]],
|
|
48
|
+
},
|
|
49
|
+
review: {
|
|
50
|
+
usage: ["seisin review [--all] [--min <n>]"],
|
|
51
|
+
values: ["--min"],
|
|
52
|
+
flags: [["--all", "every row, not the first ten per section"], ["--min <n>", "denials before a place counts as repeated (default 3)"]],
|
|
53
|
+
examples: ["seisin review", "seisin review --min 5 --all"],
|
|
54
|
+
exits: [["0", "no role denied repeatedly"], ["1", "a role was denied repeatedly"], ["2", "usage error"]],
|
|
55
|
+
},
|
|
56
|
+
walls: {
|
|
57
|
+
usage: ["seisin walls <role> [--all] [--since <iso>] [--min <n>]"],
|
|
58
|
+
values: ["--since", "--min"],
|
|
59
|
+
flags: [["--all", "include walls not hit in the last few runs"], ["--since <iso>", "only denials after this time"],
|
|
60
|
+
["--min <n>", "times denied before it is a wall (default 2)"]],
|
|
61
|
+
examples: ["seisin walls frontend", "seisin walls frontend --since 2026-09-01 --min 3"],
|
|
62
|
+
exits: [["0", "no walls"], ["1", "walls listed"], ["2", "usage error"]],
|
|
63
|
+
},
|
|
64
|
+
wire: {
|
|
65
|
+
usage: ["seisin wire"],
|
|
66
|
+
flags: [],
|
|
67
|
+
notes: ["Writes .claude/settings.json in this repo, merged with what is there."],
|
|
68
|
+
examples: ["seisin wire"],
|
|
69
|
+
exits: [["0", "wired, or already wired"], ["2", "usage error, or settings.json is not valid JSON"]],
|
|
70
|
+
},
|
|
71
|
+
requests: {
|
|
72
|
+
usage: ["seisin requests"],
|
|
73
|
+
flags: [],
|
|
74
|
+
notes: ["#n is a position and shifts as requests are settled; the id under each one does not."],
|
|
75
|
+
examples: ["seisin requests"],
|
|
76
|
+
exits: [["0", "nothing pending"], ["1", "requests pending"], ["2", "usage error"]],
|
|
77
|
+
},
|
|
78
|
+
grant: {
|
|
79
|
+
usage: ['seisin grant <n|id> [--reason "…"]'],
|
|
80
|
+
values: ["--reason"],
|
|
81
|
+
flags: [['--reason "…"', "recorded beside the grant in the policy"]],
|
|
82
|
+
examples: ["seisin grant 1", "seisin grant 'frontend:write:docs' --reason \"owns the docs now\""],
|
|
83
|
+
exits: [["0", "granted, or already granted"], ["2", "no such request, or it cannot be granted"]],
|
|
84
|
+
},
|
|
85
|
+
decline: {
|
|
86
|
+
usage: ['seisin decline <n|id> [--reason "…"]'],
|
|
87
|
+
values: ["--reason"],
|
|
88
|
+
flags: [['--reason "…"', "recorded with the decision"]],
|
|
89
|
+
examples: ["seisin decline 2 --reason \"not frontend's key\""],
|
|
90
|
+
exits: [["0", "declined"], ["2", "no such request"]],
|
|
91
|
+
},
|
|
92
|
+
log: {
|
|
93
|
+
usage: ["seisin log [--role <r>] [--verdict allowed|denied|observed] [--limit <n>]", "seisin log verify"],
|
|
94
|
+
values: ["--role", "--verdict", "--limit"],
|
|
95
|
+
flags: [["--role <r>", "only this role"], ["--verdict <v>", "allowed, denied or observed"], ["--limit <n>", "how many entries (default 40)"]],
|
|
96
|
+
examples: ["seisin log --role frontend --verdict denied", "seisin log verify"],
|
|
97
|
+
exits: [["0", "printed; for verify, the hash chain holds"], ["1", "verify: the hash chain is broken"], ["2", "usage error"]],
|
|
98
|
+
},
|
|
99
|
+
watch: {
|
|
100
|
+
usage: ["seisin watch"],
|
|
101
|
+
flags: [],
|
|
102
|
+
examples: ["seisin watch"],
|
|
103
|
+
exits: [["0", "stopped with ctrl-c"], ["2", "usage error"]],
|
|
104
|
+
},
|
|
105
|
+
init: {
|
|
106
|
+
usage: ["seisin init [--force]", "seisin init --from-observations"],
|
|
107
|
+
flags: [["--force", "overwrite an existing seisin.toml, keeping the old one as seisin.toml.bak"],
|
|
108
|
+
["--from-observations", "write seisin.toml.observed from what observed runs recorded"]],
|
|
109
|
+
examples: ["seisin init", "seisin init --from-observations"],
|
|
110
|
+
exits: [["0", "written"], ["2", "a policy is already there, or nothing was observed"]],
|
|
111
|
+
},
|
|
112
|
+
ui: {
|
|
113
|
+
usage: ["seisin ui [--port <n>] [--link]"],
|
|
114
|
+
values: ["--port"],
|
|
115
|
+
flags: [["--port <n>", "port on 127.0.0.1 (default 4178)"], ["--link", "print the link of a console already running, start nothing"]],
|
|
116
|
+
examples: ["seisin ui", "seisin ui --link"],
|
|
117
|
+
exits: [["0", "stopped"], ["2", "could not start"]],
|
|
118
|
+
},
|
|
119
|
+
mcp: {
|
|
120
|
+
usage: ["seisin mcp"],
|
|
121
|
+
flags: [],
|
|
122
|
+
notes: ["An MCP server on stdio, read-only: it can explain and draft, never grant."],
|
|
123
|
+
examples: ["claude mcp add seisin -- seisin mcp"],
|
|
124
|
+
exits: [["0", "stdin closed"]],
|
|
125
|
+
},
|
|
126
|
+
hook: {
|
|
127
|
+
usage: ["seisin hook"],
|
|
128
|
+
flags: [],
|
|
129
|
+
notes: ["Claude Code's hook: one event on stdin, a decision on stdout. `seisin wire` installs it."],
|
|
130
|
+
examples: [],
|
|
131
|
+
exits: [["0", "always — the hook explains, the kernel enforces"]],
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
COMMAND_HELP.deny = COMMAND_HELP.decline;
|
|
135
|
+
|
|
136
|
+
/** The flag words a command accepts, for the unknown-flag check. */
|
|
137
|
+
export function flagsOf(name) {
|
|
138
|
+
const h = COMMAND_HELP[name];
|
|
139
|
+
if (!h) return null;
|
|
140
|
+
const values = h.values ?? [];
|
|
141
|
+
const known = h.flags.map(([f]) => f.split(" ")[0]).filter((f) => !values.includes(f));
|
|
142
|
+
return { known, values };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The help for one command, as text. */
|
|
146
|
+
export function renderHelp(name, C) {
|
|
147
|
+
const h = COMMAND_HELP[name];
|
|
148
|
+
const lines = ["", "usage:", ...h.usage.map((u) => ` ${u}`)];
|
|
149
|
+
if (h.flags.length) {
|
|
150
|
+
const w = Math.max(...h.flags.map(([f]) => f.length));
|
|
151
|
+
lines.push("", "flags:", ...h.flags.map(([f, d]) => ` ${f.padEnd(w)} ${d}`));
|
|
152
|
+
}
|
|
153
|
+
if (h.notes?.length) lines.push("", ...h.notes.map((n) => ` ${n}`));
|
|
154
|
+
if (h.examples.length) lines.push("", "examples:", ...h.examples.map((e) => ` ${e}`));
|
|
155
|
+
const w = Math.max(...h.exits.map(([c]) => c.length));
|
|
156
|
+
lines.push("", "exit codes:", ...h.exits.map(([c, d]) => ` ${c.padEnd(w)} ${d}`));
|
|
157
|
+
lines.push("", ` ${C.dim}seisin --help for every command${C.off}`, "", "");
|
|
158
|
+
return lines.join("\n");
|
|
159
|
+
}
|