seisin 0.4.2 → 0.6.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 +506 -162
- package/package.json +14 -6
- package/src/browsers.js +52 -0
- package/src/canary.js +149 -0
- package/src/cli.js +175 -52
- package/src/commands/check.js +157 -5
- package/src/commands/explain.js +31 -13
- package/src/commands/hook.js +20 -6
- package/src/commands/init.js +183 -88
- package/src/commands/log.js +48 -9
- package/src/commands/requests.js +122 -70
- package/src/commands/review.js +73 -21
- package/src/commands/run.js +422 -78
- package/src/commands/scan.js +18 -3
- package/src/commands/ui.js +18 -5
- package/src/commands/walls.js +62 -11
- package/src/commands/whose.js +9 -7
- package/src/commands/wire.js +156 -17
- package/src/config.js +505 -37
- package/src/controls.js +244 -0
- package/src/diagnose.js +102 -49
- package/src/env.js +14 -8
- package/src/grants.js +122 -9
- package/src/handoff.js +285 -10
- package/src/help.js +179 -0
- package/src/hook.js +101 -42
- package/src/index.d.ts +217 -0
- package/src/index.js +24 -16
- package/src/inspect/codex.js +64 -0
- package/src/inspect/credentials.js +48 -0
- package/src/inspect/enforce.js +24 -0
- package/src/inspect/files.js +71 -0
- package/src/inspect/home.js +43 -0
- package/src/inspect/inject.js +68 -0
- package/src/inspect/keys.js +74 -0
- package/src/inspect/layout.js +258 -0
- package/src/inspect/limits.js +98 -0
- package/src/inspect/machine.js +107 -0
- package/src/inspect/network.js +37 -0
- package/src/inspect/providers.js +71 -0
- package/src/inspect/read-territory.js +105 -0
- package/src/inspect/role-keys.js +136 -0
- package/src/inspect/shared.js +37 -0
- package/src/inspect/warnings.js +58 -0
- package/src/inspect.js +20 -756
- package/src/intake.js +150 -35
- package/src/keys.js +231 -73
- package/src/kinds.js +249 -0
- package/src/lines.js +26 -0
- package/src/log.js +242 -52
- package/src/mcp.js +57 -58
- package/src/nested.js +12 -3
- package/src/notify.js +66 -20
- package/src/owners.js +358 -39
- package/src/paths.js +54 -2
- package/src/provider-run.js +81 -0
- package/src/redact.js +46 -13
- package/src/render.js +101 -17
- package/src/requests.js +506 -87
- package/src/review.js +50 -25
- package/src/rundir.js +33 -14
- package/src/sandboxes/command.js +93 -0
- package/src/sandboxes/index.js +113 -0
- package/src/sandboxes/mach-profile.js +31 -0
- package/src/sandboxes/srt-mach.mjs +43 -0
- package/src/sandboxes/srt.js +163 -0
- package/src/sandboxes/tartaros.js +80 -0
- package/src/scan.js +181 -19
- package/src/serve.js +396 -230
- package/src/spool.js +16 -45
- package/src/srt.js +296 -49
- package/src/suggest.js +58 -0
- package/src/surface.js +314 -54
- package/src/territory.js +261 -0
- package/src/trace.js +33 -0
- package/src/uilink.js +24 -9
- package/src/views.js +315 -0
- package/src/violations.js +247 -23
- package/src/walls.js +76 -46
- package/src/worktree.js +3 -11
- package/ui/index.html +1429 -538
- package/ui/favicon.svg +0 -1
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, source = "in the console") {
|
|
84
|
+
return `# set ${source} ${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
|
+
export function setKey(text, table, key, value, note, source) {
|
|
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, source)}${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, source)}${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,10 +28,14 @@
|
|
|
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 { isAbsolute } from "node:path";
|
|
32
|
+
import { readEntries } from "./log.js";
|
|
32
33
|
import { explain } from "./owners.js";
|
|
33
|
-
import {
|
|
34
|
+
import { hitsOf, againWords, walls } from "./walls.js";
|
|
34
35
|
import { collapseSidecars } from "./render.js";
|
|
36
|
+
import { decideHandoff, toAgent, isStopped, ELSEWHERE } from "./handoff.js";
|
|
37
|
+
import { keyOf } from "./requests.js";
|
|
38
|
+
import { destinationOf } from "./intake.js";
|
|
35
39
|
|
|
36
40
|
/** What a refused write or read looks like in a tool's output. A trigger to look, not a verdict. */
|
|
37
41
|
export const DENIAL_SIGNS = /Operation not permitted|EPERM|EACCES|Permission denied|Read-only file system/i;
|
|
@@ -64,38 +68,19 @@ export function knownRefusals(evidence) {
|
|
|
64
68
|
|
|
65
69
|
/** How far back a refusal still belongs to the call that just failed. */
|
|
66
70
|
const WINDOW_MS = 120_000;
|
|
67
|
-
/** Read only the end of the log: this runs on every failed command. */
|
|
71
|
+
/** Read only the end of the log: this runs on every failed command. The line the cut lands in is dropped (readLines). */
|
|
68
72
|
const TAIL_BYTES = 512 * 1024;
|
|
69
73
|
/** How many paths to name. More than this is a wall of text nobody acts on. */
|
|
70
74
|
const MAX_NAMED = 3;
|
|
71
75
|
|
|
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
76
|
/** This role's refusals by the kernel since `sinceMs`, one per path, newest first. */
|
|
94
77
|
export function recentKernelDenials(file, role, sinceMs) {
|
|
95
78
|
const since = new Date(sinceMs).toISOString();
|
|
96
79
|
const seen = new Map();
|
|
97
|
-
for (const e of
|
|
80
|
+
for (const e of readEntries(file, { tail: TAIL_BYTES }).reverse()) {
|
|
98
81
|
if (e.role !== role || e.verdict !== "denied" || e.source !== "kernel") continue;
|
|
82
|
+
// A system service is not a path the agent can act on, and the sentence below is about paths.
|
|
83
|
+
if (e.kind === "system") continue;
|
|
99
84
|
if (!e.at || e.at < since || !e.target) continue;
|
|
100
85
|
const k = `${e.action}\u0000${e.target}`;
|
|
101
86
|
if (!seen.has(k)) seen.set(k, e);
|
|
@@ -104,41 +89,80 @@ export function recentKernelDenials(file, role, sinceMs) {
|
|
|
104
89
|
}
|
|
105
90
|
|
|
106
91
|
/** The sentence for one refusal, in the words the PreToolUse hook uses. */
|
|
107
|
-
function sentence(config, role, file, e) {
|
|
92
|
+
function sentence(config, role, file, e, chain = null, run = null) {
|
|
108
93
|
const action = e.kind === "key" ? "read" : e.action === "read" || e.action === "connect" ? e.action : "write";
|
|
94
|
+
// An atomic save refused at its temporary file is about the file it was
|
|
95
|
+
// saving. Said about that file, or the agent goes looking for a missing
|
|
96
|
+
// directory (measured).
|
|
97
|
+
const destination = action === "write" ? destinationOf(e.target) : null;
|
|
98
|
+
if (destination) {
|
|
99
|
+
if (explain(config, role, "write", destination).allowed)
|
|
100
|
+
return `${destination} was not saved: it is ${role}'s to write, but the sandbox denied the temporary file ` +
|
|
101
|
+
`${e.target} that the save writes beside it, and no grant can name that file. Write ${destination} with the shell ` +
|
|
102
|
+
"instead (a redirect writes the file itself).";
|
|
103
|
+
e = { ...e, target: destination };
|
|
104
|
+
}
|
|
109
105
|
const v = explain(config, role, action, e.target);
|
|
110
106
|
if (v.allowed) return null; // the policy moved since: nothing to say
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
if (v.
|
|
115
|
-
|
|
116
|
-
|
|
107
|
+
// Read after the refusal was logged, so the counts include it.
|
|
108
|
+
const { total: times, inRun } = hitsOf(file, role, action, e.target, run);
|
|
109
|
+
const again = againWords({ times, inRun });
|
|
110
|
+
if (v.neverWrites) return `${v.reason}. Do not ask for it. ${ELSEWHERE}${again}`;
|
|
111
|
+
// A write, or a key: the denials that become requests, worded from the same
|
|
112
|
+
// handoff decision the hook and the queue use. Everything else — a protected
|
|
113
|
+
// file, a connection, a read the mode shuts — keeps the policy's own reason.
|
|
114
|
+
const handed = (action === "write" || e.kind === "key") && !v.gitMetadata && !v.protected && !v.privateTo;
|
|
115
|
+
if (handed)
|
|
116
|
+
return toAgent(decideHandoff({ request: { role, action, target: e.target }, config, chain }),
|
|
117
|
+
{ target: e.target, role, key: keyOf({ role, action, target: e.target }), again });
|
|
117
118
|
return `${e.target}: ${v.reason}.${again}`;
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
const PREFACE =
|
|
121
|
-
"
|
|
122
|
+
"The sandbox this agent runs in, set up by seisin, denied part of that. This is not a Unix permission " +
|
|
122
123
|
"or a macOS privacy prompt: chmod, sudo or a different path to the same file will not change it.";
|
|
123
124
|
|
|
125
|
+
/**
|
|
126
|
+
* What the tool said, and whether it said it failed.
|
|
127
|
+
*
|
|
128
|
+
* PostToolUseFailure is a failure by definition, and its evidence is all of it.
|
|
129
|
+
* PostToolUse is a Bash result, and most of those are successes: a command that
|
|
130
|
+
* prints "Permission denied" to stdout — a grep through logs, a test that
|
|
131
|
+
* checks for it — used to pay the full retry schedule (413 ms measured) to
|
|
132
|
+
* find nothing. So there, only stderr counts, and only an explicit failure
|
|
133
|
+
* (a non-zero exit code, or `is_error`) is a reason to wait for the kernel.
|
|
134
|
+
* A success with the words on stderr — `cmd; true` swallowed the status —
|
|
135
|
+
* still gets one look at the log, just not the waits.
|
|
136
|
+
*/
|
|
137
|
+
function evidenceOf(event) {
|
|
138
|
+
if (event.hook_event_name !== "PostToolUse")
|
|
139
|
+
return { text: JSON.stringify({ r: event.tool_response, e: event.error, o: event.tool_output }), failed: true };
|
|
140
|
+
const r = event.tool_response;
|
|
141
|
+
if (typeof r === "string") return { text: JSON.stringify(r), failed: false };
|
|
142
|
+
const code = r?.exit_code ?? r?.exitCode ?? r?.code;
|
|
143
|
+
const failed = r?.is_error === true || (typeof code === "number" && code !== 0);
|
|
144
|
+
return { text: JSON.stringify(typeof r?.stderr === "string" ? r.stderr : ""), failed };
|
|
145
|
+
}
|
|
146
|
+
|
|
124
147
|
/**
|
|
125
148
|
* After a tool call failed. Returns the hook output, or null to say nothing.
|
|
126
149
|
*
|
|
127
150
|
* `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.
|
|
151
|
+
* refusal, and a loaded machine can be slower than the hook. Only when the tool
|
|
152
|
+
* reported a failure — see {@link evidenceOf}.
|
|
129
153
|
*/
|
|
130
|
-
export async function afterTool(config, role, event, { file, now = Date.now, wait = [0, 100, 300] } = {}) {
|
|
131
|
-
const
|
|
154
|
+
export async function afterTool(config, role, event, { file, now = Date.now, wait = [0, 100, 300], chain = null, run = process.env.SEISIN_RUN_ID ?? null } = {}) {
|
|
155
|
+
const { text: evidence, failed } = evidenceOf(event);
|
|
132
156
|
if (!DENIAL_SIGNS.test(evidence)) return null;
|
|
133
157
|
// JSON escapes what the program printed; the signatures are written against the text.
|
|
134
158
|
const known = knownRefusals(evidence.replace(/\\n/g, "\n").replace(/\\"/g, '"'));
|
|
135
159
|
let found = [];
|
|
136
|
-
for (const ms of wait) {
|
|
160
|
+
for (const ms of failed ? wait : [0]) {
|
|
137
161
|
if (ms) await new Promise((ok) => setTimeout(ok, ms));
|
|
138
162
|
found = recentKernelDenials(file, role, now() - WINDOW_MS);
|
|
139
163
|
if (found.length) break;
|
|
140
164
|
}
|
|
141
|
-
const lines = [...known, ...found.map((e) => sentence(config, role, file, e)).filter(Boolean)].slice(0, MAX_NAMED);
|
|
165
|
+
const lines = [...known, ...found.map((e) => sentence(config, role, file, e, chain, run)).filter(Boolean)].slice(0, MAX_NAMED);
|
|
142
166
|
if (!lines.length) return null;
|
|
143
167
|
const total = known.length + found.length;
|
|
144
168
|
const more = total > MAX_NAMED ? ` (and ${total - MAX_NAMED} more — \`seisin walls ${role}\`)` : "";
|
|
@@ -151,7 +175,7 @@ export async function afterTool(config, role, event, { file, now = Date.now, wai
|
|
|
151
175
|
}
|
|
152
176
|
|
|
153
177
|
/** When a session starts, resumes or is compacted: the map, before the first wall. */
|
|
154
|
-
export function atSessionStart(config, role, event, { file } = {}) {
|
|
178
|
+
export function atSessionStart(config, role, event, { file, chain = null } = {}) {
|
|
155
179
|
const r = config.roles[role];
|
|
156
180
|
const writes = collapseSidecars(r.writesDeclared ?? r.writes);
|
|
157
181
|
const lines = [
|
|
@@ -160,13 +184,42 @@ export function atSessionStart(config, role, event, { file } = {}) {
|
|
|
160
184
|
];
|
|
161
185
|
if ((r.neverWritesDeclared ?? r.neverWrites ?? []).length)
|
|
162
186
|
lines.push(`Never, even inside that: ${(r.neverWritesDeclared ?? r.neverWrites).join(", ")}.`);
|
|
187
|
+
if (r.private?.length) lines.push(`Private to you (yours to read and write; no other role reads it): ${r.private.join(", ")}.`);
|
|
188
|
+
const theirs = Object.values(config.roles).filter((o) => o.name !== role && o.private?.length)
|
|
189
|
+
.map((o) => `${o.private.join(", ")} (${o.name})`);
|
|
190
|
+
if (theirs.length) lines.push(`Private to another role — you can neither read nor write it: ${theirs.join("; ")}.`);
|
|
163
191
|
if (r.keys.length) lines.push(`Keys you hold: ${r.keys.join(", ")}.`);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
192
|
+
// An expected refusal (Claude Code's plugin updater) is not the agent's to
|
|
193
|
+
// stop trying, so it does not take one of the five lines.
|
|
194
|
+
const w = file ? walls(config, role, { file, limit: 0, fresh: true }).filter((x) => !x.expected).slice(0, 5) : [];
|
|
195
|
+
// Outside the repository nobody owns anything, so there is no one to hand
|
|
196
|
+
// it to: under "hand it over", ~/.npm/_logs read as work for somebody else.
|
|
197
|
+
const outside = (x) => isAbsolute(x.target) || x.target.startsWith("~");
|
|
198
|
+
// Where this run's handoff chain stops, the wall names no owner: measured,
|
|
199
|
+
// an agent told the owner here repeated it after the sentence its denial
|
|
200
|
+
// told it to pass on alone.
|
|
201
|
+
const stops = (x) => isStopped(decideHandoff({ request: { role, action: x.action, target: x.target }, config, chain }));
|
|
202
|
+
const inRepo = w.filter((x) => !outside(x)), out = w.filter(outside);
|
|
203
|
+
const stopped = inRepo.filter(stops), mine = inRepo.filter((x) => !stops(x));
|
|
204
|
+
const wall = (x) => `- ${x.action} ${x.target} (${x.times}×) — ${x.owners?.length ? `belongs to ${x.owners.join(", ")}` : x.reason}`;
|
|
205
|
+
if (mine.length) {
|
|
206
|
+
lines.push("Already denied more than once, and still denied — do not try again, hand it over:");
|
|
207
|
+
for (const x of mine) lines.push(wall(x));
|
|
208
|
+
}
|
|
209
|
+
if (stopped.length) {
|
|
210
|
+
lines.push("Already denied more than once, where this run's handoff chain stops — do not try again; a person decides, " +
|
|
211
|
+
"and the denial gives you the one sentence to pass on:");
|
|
212
|
+
for (const x of stopped) lines.push(`- ${x.action} ${x.target} (${x.times}×)`);
|
|
213
|
+
}
|
|
214
|
+
if (out.length) {
|
|
215
|
+
lines.push("Denied more than once outside the repository, where no role owns anything — nothing to hand over; leave it:");
|
|
216
|
+
for (const x of out) lines.push(`- ${x.action} ${x.target} (${x.times}×)`);
|
|
169
217
|
}
|
|
170
|
-
|
|
218
|
+
// In a handed run a denial can be where the chain stops, and there the agent
|
|
219
|
+
// is to say one sentence and not whose the file is: the map cannot ask the
|
|
220
|
+
// opposite.
|
|
221
|
+
lines.push(chain?.visited?.length
|
|
222
|
+
? "A file outside that is not yours to change: do not work around it. Where a denial gives you a sentence to pass on, pass on that sentence and nothing else about the file."
|
|
223
|
+
: "A file outside that is somebody else's: say whose it is and hand it over, rather than working around it.");
|
|
171
224
|
return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } };
|
|
172
225
|
}
|
package/src/env.js
CHANGED
|
@@ -29,6 +29,9 @@ export const BASE = [
|
|
|
29
29
|
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME",
|
|
30
30
|
"TERM", "TERM_PROGRAM", "COLORTERM", "LANG", "LC_ALL", "LC_CTYPE", "TZ",
|
|
31
31
|
"SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
|
|
32
|
+
// The launcher's W3C trace context: ids that join the agent's own telemetry to
|
|
33
|
+
// the launcher's trace (trace.js). By the spec they are not secrets.
|
|
34
|
+
"TRACEPARENT", "TRACESTATE",
|
|
32
35
|
];
|
|
33
36
|
|
|
34
37
|
/**
|
|
@@ -39,8 +42,8 @@ export const BASE = [
|
|
|
39
42
|
* needs **set**, not forwarded.
|
|
40
43
|
*
|
|
41
44
|
* `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
|
|
45
|
+
* denial log looks like without it. Measured on a multi-repo workspace: of the last
|
|
46
|
+
* 60 denials, **58 were `.git/index.lock`** — 97% — and most carried no owner
|
|
44
47
|
* at all, so not one of them was a territory question. `git status` and
|
|
45
48
|
* `git diff` refresh the index as a courtesy, refreshing it takes the lock, and
|
|
46
49
|
* a role reading a repo it does not own trips the boundary while doing nothing
|
|
@@ -64,7 +67,7 @@ export const DEFAULTS = {
|
|
|
64
67
|
GIT_OPTIONAL_LOCKS: "0",
|
|
65
68
|
/**
|
|
66
69
|
* Node's built-in `fetch()` ignores HTTP_PROXY/HTTPS_PROXY unless told
|
|
67
|
-
* otherwise, so inside the
|
|
70
|
+
* otherwise, so inside the sandbox it tries DNS directly and dies with ENOTFOUND
|
|
68
71
|
* while `npm`, which reads the proxy itself, works. Measured on Node 24.2:
|
|
69
72
|
* ENOTFOUND without it, 200 with it. It only makes Node use the proxy the
|
|
70
73
|
* sandbox already routes everything through — nothing is widened. Older
|
|
@@ -90,12 +93,15 @@ export const DEFAULTS = {
|
|
|
90
93
|
};
|
|
91
94
|
|
|
92
95
|
/**
|
|
93
|
-
* Names that
|
|
96
|
+
* Names that ride along only when the role names them itself.
|
|
94
97
|
*
|
|
95
98
|
* A role can name a variable explicitly and get it — that is the point of the
|
|
96
|
-
* `env` list.
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
+
* `env` list. Everything else that crosses comes from BASE or from a caller's
|
|
100
|
+
* `extra`, and this keeps a credential-shaped name out of both. Today it
|
|
101
|
+
* filters nothing: no BASE name matches it and `run` passes no `extra`. It is
|
|
102
|
+
* kept, not deleted, as the guard for the day either list grows — a
|
|
103
|
+
* `SSH_AUTH_SOCK` added "for git" would otherwise cross for every role
|
|
104
|
+
* silently — and for embedders who pass `extra`.
|
|
99
105
|
*/
|
|
100
106
|
const NEVER = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|SESSION|COOKIE|PRIVATE)(_|$)/i;
|
|
101
107
|
|
|
@@ -118,7 +124,7 @@ export function buildEnv(parent, role, extra = []) {
|
|
|
118
124
|
if (!wanted.has(name)) { dropped.push(name); continue; }
|
|
119
125
|
if (NEVER.test(name) && !(role.env ?? []).includes(name)) { dropped.push(name); continue; }
|
|
120
126
|
// Not even when named: whoever can read the notify URL can send the person
|
|
121
|
-
// a fake "
|
|
127
|
+
// a fake "grant #3". Only the parent uses it (notify.js).
|
|
122
128
|
if (name === "SEISIN_NOTIFY_URL") { dropped.push(name); continue; }
|
|
123
129
|
env[name] = value;
|
|
124
130
|
}
|