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/commands/wire.js
CHANGED
|
@@ -13,18 +13,76 @@
|
|
|
13
13
|
* config: a permission tool that edits your machine's settings to install
|
|
14
14
|
* itself has misread the room.
|
|
15
15
|
*/
|
|
16
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
17
|
-
import { join } from "node:path";
|
|
16
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from "node:fs";
|
|
17
|
+
import { join, dirname, delimiter } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
18
20
|
import { C, out } from "../render.js";
|
|
21
|
+
import { TOOL_MATCHER } from "../hook.js";
|
|
19
22
|
|
|
20
23
|
const SETTINGS = join(".claude", "settings.json");
|
|
24
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
|
|
21
25
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
/** A hook command that is seisin's, however it was spelled when it was wired. */
|
|
27
|
+
const isOurs = (c) => typeof c === "string" && /\bhook"?\s*$/.test(c) && (c.includes("seisin") || runsSeisinScript(c));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A hook written by its full path (`"<node>" "<…>/src/cli.js" hook`) names no
|
|
31
|
+
* `seisin` when the checkout sits in a folder called something else. It is
|
|
32
|
+
* ours when the script it runs belongs to a package named seisin.
|
|
33
|
+
*/
|
|
34
|
+
function runsSeisinScript(command) {
|
|
35
|
+
const script = /"([^"]+\.js)"\s+hook\s*$/.exec(command)?.[1];
|
|
36
|
+
if (!script) return false;
|
|
37
|
+
try { return JSON.parse(readFileSync(join(dirname(script), "..", "package.json"), "utf8")).name === "seisin"; } catch { return false; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The command that reaches THIS seisin from a hook, and how it was found.
|
|
42
|
+
*
|
|
43
|
+
* `seisin hook` was written for everyone. With seisin installed in a project
|
|
44
|
+
* rather than globally there is no `seisin` on PATH, the hook exits 127, and
|
|
45
|
+
* Claude Code carries on without it — nothing on screen, nothing in the log.
|
|
46
|
+
* So: the one on PATH when it is this version; the project's own
|
|
47
|
+
* node_modules/.bin when there is one; otherwise this file, by absolute path.
|
|
48
|
+
*/
|
|
49
|
+
export function hookCommand(root, { path = process.env.PATH, cli = CLI, version = ownVersion() } = {}) {
|
|
50
|
+
const found = (path ?? "").split(delimiter).map((d) => d && join(d, "seisin")).find((p) => p && existsSync(p));
|
|
51
|
+
let onPath = null;
|
|
52
|
+
if (found) {
|
|
53
|
+
const same = realOrSelf(found) === realOrSelf(cli);
|
|
54
|
+
const v = same ? version : spawnSync(found, ["--version"], { encoding: "utf8", timeout: 5000 }).stdout?.trim();
|
|
55
|
+
onPath = { at: found, version: v };
|
|
56
|
+
if (v === version) return { command: "seisin hook", via: "path", onPath };
|
|
57
|
+
}
|
|
58
|
+
const local = join(root, "node_modules", ".bin", "seisin");
|
|
59
|
+
if (existsSync(local)) return { command: '"$CLAUDE_PROJECT_DIR"/node_modules/.bin/seisin hook', via: "local", onPath };
|
|
60
|
+
return { command: `"${process.execPath}" "${realOrSelf(cli)}" hook`, via: "absolute", onPath };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ownVersion() {
|
|
64
|
+
try { return JSON.parse(readFileSync(join(dirname(CLI), "..", "package.json"), "utf8")).version; } catch { return null; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Is `dir` inside a git checkout? Kept here so wire does not load the scanner. */
|
|
68
|
+
function insideRepo(dir) {
|
|
69
|
+
for (let d = dir; ; d = dirname(d)) {
|
|
70
|
+
if (existsSync(join(d, ".git"))) return true;
|
|
71
|
+
if (dirname(d) === d) return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function realOrSelf(p) {
|
|
76
|
+
try { return realpathSync(p); } catch { return p; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One sentence: which seisin the hook runs, and why that one. */
|
|
80
|
+
function saysWhich(h) {
|
|
81
|
+
const other = h.onPath && h.onPath.version !== ownVersion()
|
|
82
|
+
? ` (the seisin on your PATH is ${h.onPath.version || "another version"}, not ${ownVersion()})` : "";
|
|
83
|
+
if (h.via === "path") return `the hook runs "seisin hook" — seisin ${ownVersion()} is on your PATH`;
|
|
84
|
+
if (h.via === "local") return `the hook runs this project's node_modules/.bin/seisin${other || " — seisin is not on your PATH"}`;
|
|
85
|
+
return `the hook runs this seisin by its full path${other || " — seisin is not on your PATH"}`;
|
|
28
86
|
}
|
|
29
87
|
|
|
30
88
|
/**
|
|
@@ -39,7 +97,7 @@ export function hookEntry(command = "seisin hook") {
|
|
|
39
97
|
export function hookEntries(command = "seisin hook") {
|
|
40
98
|
const h = [{ type: "command", command }];
|
|
41
99
|
return {
|
|
42
|
-
PreToolUse: [{ matcher:
|
|
100
|
+
PreToolUse: [{ matcher: TOOL_MATCHER, hooks: h }],
|
|
43
101
|
PostToolUse: [{ matcher: "Bash", hooks: h }],
|
|
44
102
|
PostToolUseFailure: [{ matcher: "*", hooks: h }],
|
|
45
103
|
SessionStart: [{ matcher: "startup|resume|compact", hooks: h }],
|
|
@@ -55,14 +113,59 @@ export function wired(root) {
|
|
|
55
113
|
// session-start hooks existed has only PreToolUse, and "already wired" there
|
|
56
114
|
// would keep it from ever getting the rest.
|
|
57
115
|
const hooks = JSON.parse(readFileSync(file, "utf8")).hooks ?? {};
|
|
58
|
-
return Object.keys(hookEntries()).every((ev) =>
|
|
116
|
+
return Object.keys(hookEntries()).every((ev) =>
|
|
117
|
+
(hooks[ev] ?? []).some((e) => (e?.hooks ?? []).some((h) => isOurs(h?.command))));
|
|
59
118
|
} catch {
|
|
60
119
|
return false; // unreadable settings is not "wired"
|
|
61
120
|
}
|
|
62
121
|
}
|
|
63
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Narrows seisin's own `"*"` PreToolUse entry to {@link TOOL_MATCHER}. True if
|
|
125
|
+
* it changed anything.
|
|
126
|
+
*
|
|
127
|
+
* Only an entry that holds nothing but `seisin hook`: a `"*"` entry that also
|
|
128
|
+
* runs somebody else's command is theirs as much as ours, and narrowing it
|
|
129
|
+
* would silently stop their hook on every other tool.
|
|
130
|
+
*/
|
|
131
|
+
function narrow(settings) {
|
|
132
|
+
let changed = false;
|
|
133
|
+
for (const entry of settings.hooks?.PreToolUse ?? []) {
|
|
134
|
+
if (broadOurs(entry)) {
|
|
135
|
+
entry.matcher = TOOL_MATCHER;
|
|
136
|
+
changed = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return changed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A PreToolUse entry that runs only `seisin hook`, on every tool. */
|
|
143
|
+
function broadOurs(entry) {
|
|
144
|
+
const ours = Array.isArray(entry?.hooks) && entry.hooks.length > 0 &&
|
|
145
|
+
entry.hooks.every((h) => isOurs(h?.command));
|
|
146
|
+
return ours && (entry.matcher === "*" || entry.matcher === "" || entry.matcher === undefined);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Does this repo still run the hook on every tool call — the `"*"` matcher that
|
|
151
|
+
* versions before 0.5.0 installed? Correct, just slow: every Read, Glob and
|
|
152
|
+
* TodoWrite pays for a process start that answers nothing. `seisin wire` narrows
|
|
153
|
+
* it in place; this is how `check` knows to say so.
|
|
154
|
+
*/
|
|
155
|
+
export function broadlyWired(root) {
|
|
156
|
+
const file = join(root, SETTINGS);
|
|
157
|
+
if (!existsSync(file)) return false;
|
|
158
|
+
try {
|
|
159
|
+
const pre = JSON.parse(readFileSync(file, "utf8")).hooks?.PreToolUse;
|
|
160
|
+
return Array.isArray(pre) && pre.some(broadOurs);
|
|
161
|
+
} catch {
|
|
162
|
+
return false; // unreadable settings is reported elsewhere, if at all
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
64
166
|
export function wire(config) {
|
|
65
167
|
const file = join(config.root, SETTINGS);
|
|
168
|
+
const how = hookCommand(config.root);
|
|
66
169
|
|
|
67
170
|
// Merged, never replaced. Someone's hooks are their own and a tool that
|
|
68
171
|
// overwrites them to add itself does not get a second chance.
|
|
@@ -76,24 +179,60 @@ export function wire(config) {
|
|
|
76
179
|
}
|
|
77
180
|
|
|
78
181
|
if (wired(config.root)) {
|
|
79
|
-
|
|
182
|
+
// A bare `seisin hook` where no seisin is on PATH fails on every call
|
|
183
|
+
// without a word. Rewritten in place to the command that works here.
|
|
184
|
+
// Both repairs in one pass: an install from before 0.5.0 on a machine
|
|
185
|
+
// without seisin on PATH needs the command repointed AND the matcher
|
|
186
|
+
// narrowed, and returning after the first left it half-fixed.
|
|
187
|
+
const repointed = how.via !== "path" && repoint(settings, how.command);
|
|
188
|
+
// Wired before the matcher was narrowed: still correct, just slow, so
|
|
189
|
+
// `wired()` keeps saying yes and nobody is told to redo anything. Running
|
|
190
|
+
// `wire` again is the way to pick up the narrower one, in place.
|
|
191
|
+
const narrowed = narrow(settings);
|
|
192
|
+
if (repointed || narrowed) {
|
|
193
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
194
|
+
if (repointed)
|
|
195
|
+
out(`\n ${C.green}repointed${C.off} ${SETTINGS}\n ${C.dim}"seisin hook" is not on your PATH, so it never ran. ${saysWhich(how)}.${C.off}\n\n`);
|
|
196
|
+
if (narrowed)
|
|
197
|
+
out(`\n ${C.green}narrowed${C.off} ${SETTINGS}\n` +
|
|
198
|
+
` ${C.dim}PreToolUse now runs seisin's hook only for the tools it reads, not for every tool${C.off}\n\n`);
|
|
199
|
+
return { changed: true, file, ...(repointed && { how }) };
|
|
200
|
+
}
|
|
201
|
+
out(`\n ${C.dim}already wired — ${SETTINGS} runs seisin's hook${C.off}\n\n`);
|
|
80
202
|
return { changed: false, file };
|
|
81
203
|
}
|
|
82
204
|
|
|
83
205
|
settings.hooks ??= {};
|
|
84
|
-
for (const [ev, entries] of Object.entries(hookEntries())) {
|
|
206
|
+
for (const [ev, entries] of Object.entries(hookEntries(how.command))) {
|
|
85
207
|
settings.hooks[ev] ??= [];
|
|
86
|
-
if (!
|
|
208
|
+
if (!settings.hooks[ev].some((e) => (e?.hooks ?? []).some((h) => isOurs(h?.command)))) settings.hooks[ev].push(...entries);
|
|
87
209
|
}
|
|
210
|
+
narrow(settings); // an older PreToolUse entry that was kept
|
|
88
211
|
|
|
89
212
|
mkdirSync(join(config.root, ".claude"), { recursive: true });
|
|
90
213
|
writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
91
214
|
|
|
92
215
|
out(
|
|
93
216
|
`\n ${C.green}wired${C.off} ${SETTINGS}\n` +
|
|
94
|
-
` ${C.dim}the agent now runs
|
|
95
|
-
` ${C.dim}the log. Without it the boundary still holds — you just cannot see it work.${C.off}\n
|
|
96
|
-
` ${C.dim}
|
|
217
|
+
` ${C.dim}the agent now runs seisin's hook before each tool call, which is what fills${C.off}\n` +
|
|
218
|
+
` ${C.dim}the log. Without it the boundary still holds — you just cannot see it work.${C.off}\n` +
|
|
219
|
+
` ${C.dim}${saysWhich(how)}.${C.off}\n\n` +
|
|
220
|
+
// Only in a repository: outside one there is nothing to commit it to.
|
|
221
|
+
(insideRepo(config.root)
|
|
222
|
+
? how.via === "absolute"
|
|
223
|
+
? ` ${C.dim}The command names a path on this machine; teammates should run seisin wire themselves.${C.off}\n\n`
|
|
224
|
+
: ` ${C.dim}Commit this file if the rest of your team should get it too.${C.off}\n\n`
|
|
225
|
+
: "")
|
|
97
226
|
);
|
|
98
|
-
return { changed: true, file };
|
|
227
|
+
return { changed: true, file, how };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Replaces a bare `seisin hook` command with `command`. True if it changed anything. */
|
|
231
|
+
function repoint(settings, command) {
|
|
232
|
+
let changed = false;
|
|
233
|
+
for (const entries of Object.values(settings.hooks ?? {}))
|
|
234
|
+
for (const e of Array.isArray(entries) ? entries : [])
|
|
235
|
+
for (const h of e?.hooks ?? [])
|
|
236
|
+
if (h?.command === "seisin hook") { h.command = command; changed = true; }
|
|
237
|
+
return changed;
|
|
99
238
|
}
|
package/src/config.js
CHANGED
|
@@ -31,7 +31,6 @@ export function findConfig(from = process.cwd()) {
|
|
|
31
31
|
const RE_TABLE = /^\[([A-Za-z0-9_.\-]+)\]$/;
|
|
32
32
|
const RE_PAIR = /^([A-Za-z0-9_\-]+)\s*=\s*(.+)$/;
|
|
33
33
|
|
|
34
|
-
/** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
|
|
35
34
|
/**
|
|
36
35
|
* Names a table or key may not have.
|
|
37
36
|
*
|
|
@@ -59,8 +58,20 @@ export function own(obj, key) {
|
|
|
59
58
|
return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
60
59
|
}
|
|
61
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Where each table and key of a parsed file was written, by dotted name.
|
|
63
|
+
*
|
|
64
|
+
* Kept beside the result rather than in it, so the object stays exactly the
|
|
65
|
+
* data that was written; loadConfig reads it to put a line number on a name it
|
|
66
|
+
* does not know.
|
|
67
|
+
*/
|
|
68
|
+
const LINES = new WeakMap();
|
|
69
|
+
|
|
70
|
+
/** Minimal TOML subset -> plain object. Throws with a line number on bad input. */
|
|
62
71
|
export function parseToml(text) {
|
|
63
72
|
const out = dict();
|
|
73
|
+
const where = new Map();
|
|
74
|
+
LINES.set(out, where);
|
|
64
75
|
let table = out;
|
|
65
76
|
let tableName = "";
|
|
66
77
|
// "Last one wins" is TOML-ish and wrong for a permission file. A policy can
|
|
@@ -87,9 +98,13 @@ export function parseToml(text) {
|
|
|
87
98
|
if (seenTables.has(tableName))
|
|
88
99
|
throw new Error(
|
|
89
100
|
`${CONFIG_NAME}:${i + 1}: [${tableName}] appears twice. ` +
|
|
90
|
-
`A later
|
|
101
|
+
`A later table would silently override the earlier one — put every setting for ` +
|
|
91
102
|
`${tableName} in one place.`);
|
|
92
103
|
seenTables.add(tableName);
|
|
104
|
+
tableName.split(".").forEach((_, k, parts) => {
|
|
105
|
+
const name = parts.slice(0, k + 1).join(".");
|
|
106
|
+
if (!where.has(name)) where.set(name, i + 1);
|
|
107
|
+
});
|
|
93
108
|
table = tableName.split(".").reduce((node, key, k, parts) => {
|
|
94
109
|
if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
|
|
95
110
|
else if (!isTable(node[key]))
|
|
@@ -105,16 +120,18 @@ export function parseToml(text) {
|
|
|
105
120
|
if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
|
|
106
121
|
|
|
107
122
|
let [, key, value] = pair;
|
|
123
|
+
const at = i + 1;
|
|
108
124
|
// An array may span lines; keep pulling until the brackets balance. Only
|
|
109
125
|
// the new line is searched: searching the whole growing value made an
|
|
110
|
-
// unclosed array of 100k lines take 20 s to be refused.
|
|
111
|
-
|
|
126
|
+
// unclosed array of 100k lines take 20 s to be refused. A `]` inside a
|
|
127
|
+
// quoted item does not close it — `["a]b",` used to end the array there.
|
|
128
|
+
if (value.startsWith("[") && !closesArray(value)) {
|
|
112
129
|
const parts = [value];
|
|
113
130
|
let closed = false;
|
|
114
131
|
while (!closed && i + 1 < lines.length) {
|
|
115
132
|
const next = stripComment(lines[++i]).trim();
|
|
116
133
|
parts.push(next);
|
|
117
|
-
closed = next
|
|
134
|
+
closed = closesArray(next);
|
|
118
135
|
}
|
|
119
136
|
value = parts.join(" ");
|
|
120
137
|
}
|
|
@@ -126,6 +143,7 @@ export function parseToml(text) {
|
|
|
126
143
|
`${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
|
|
127
144
|
`The second would win, which is not a thing a permission file should do quietly.`);
|
|
128
145
|
seenKeys.add(seen);
|
|
146
|
+
where.set(tableName ? `${tableName}.${key}` : key, at);
|
|
129
147
|
/**
|
|
130
148
|
* A key may not be a table that is declared elsewhere, in either order.
|
|
131
149
|
*
|
|
@@ -145,8 +163,19 @@ export function parseToml(text) {
|
|
|
145
163
|
return out;
|
|
146
164
|
}
|
|
147
165
|
|
|
166
|
+
/** Is there a `]` on this line outside a quoted string? Strings do not span lines. */
|
|
167
|
+
export function closesArray(line) {
|
|
168
|
+
let quoted = false;
|
|
169
|
+
for (let i = 0; i < line.length; i++) {
|
|
170
|
+
const c = line[i];
|
|
171
|
+
if (c === '"') quoted = !quoted;
|
|
172
|
+
else if (c === "]" && !quoted) return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
148
177
|
/** Strips a trailing `#` comment, but not one inside a quoted string. */
|
|
149
|
-
function stripComment(line) {
|
|
178
|
+
export function stripComment(line) {
|
|
150
179
|
let quoted = false;
|
|
151
180
|
for (let i = 0; i < line.length; i++) {
|
|
152
181
|
const c = line[i];
|
|
@@ -241,14 +270,6 @@ function readValue(value, lineNo) {
|
|
|
241
270
|
bad("value must be a string, an array of strings, a whole number, or a boolean");
|
|
242
271
|
}
|
|
243
272
|
|
|
244
|
-
/**
|
|
245
|
-
* Turns the parsed file into the shape the rest of the program uses, and
|
|
246
|
-
* rejects anything ambiguous.
|
|
247
|
-
*
|
|
248
|
-
* Rejecting is the point. The sandbox runtime refuses to start on an invalid
|
|
249
|
-
* settings file rather than falling back to a permissive default, and seisin
|
|
250
|
-
* matches that: a permission tool that guesses is worse than no permission tool.
|
|
251
|
-
*/
|
|
252
273
|
/**
|
|
253
274
|
* `[runtime] isolate` — one setting, two levels, because they were one and
|
|
254
275
|
* should not have been.
|
|
@@ -290,8 +311,19 @@ export function readIsolate(v) {
|
|
|
290
311
|
`An unknown value cannot silently turn credential isolation off.`);
|
|
291
312
|
}
|
|
292
313
|
|
|
293
|
-
|
|
294
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Turns the parsed file into the shape the rest of the program uses, and
|
|
316
|
+
* rejects anything ambiguous.
|
|
317
|
+
*
|
|
318
|
+
* Rejecting is the point. The sandbox runtime refuses to start on an invalid
|
|
319
|
+
* settings file rather than falling back to a permissive default, and seisin
|
|
320
|
+
* matches that: a permission tool that guesses is worse than no permission tool.
|
|
321
|
+
*/
|
|
322
|
+
export function loadConfig(path, text = readFileSync(path, "utf8")) {
|
|
323
|
+
// `text` lets a caller validate an edit before writing it — the console
|
|
324
|
+
// previews a policy that does not exist on disk yet, against the real root.
|
|
325
|
+
const parsed = parseToml(text);
|
|
326
|
+
checkShape(parsed, SHAPE, "", path, LINES.get(parsed));
|
|
295
327
|
// Read only what the file actually declared. The parser refuses the names
|
|
296
328
|
// that make inheritance possible, and this is the second half of the same
|
|
297
329
|
// rule: if one of them ever gets through, a role still cannot pick up a
|
|
@@ -353,7 +385,11 @@ export function loadConfig(path) {
|
|
|
353
385
|
// than one kind of secret, and making people flatten them to satisfy the tool
|
|
354
386
|
// is how a tool gets kept out.
|
|
355
387
|
const keyDir = own(own(parsed, "keys"), "dir");
|
|
356
|
-
|
|
388
|
+
// `.secrets/` and `.secrets` are one directory, and every consumer compares
|
|
389
|
+
// them as prefixes: with the slash kept, `scan` walked into the protected
|
|
390
|
+
// directory and a bare key resolved to `.secrets//x`, which matched nothing.
|
|
391
|
+
const keyDirs = (keyDir === undefined ? [] : asArray(keyDir, "keys.dir"))
|
|
392
|
+
.map((d) => d.replace(/\/+$/, "") || d);
|
|
357
393
|
const keyProviders = readProviders(own(own(parsed, "keys"), "providers"), path);
|
|
358
394
|
// `undefined` means "use the defaults"; an explicit empty array means "none".
|
|
359
395
|
// The difference matters: one is a user who has not thought about it, the
|
|
@@ -363,14 +399,17 @@ export function loadConfig(path) {
|
|
|
363
399
|
|
|
364
400
|
const out = {
|
|
365
401
|
root: dirname(path), path, keyDirs, keyProviders,
|
|
366
|
-
|
|
402
|
+
// Through asArray like every other list: `allow = "github.com"` was kept as
|
|
403
|
+
// a string, and a string iterated is its characters.
|
|
404
|
+
allowedDomains: asArray(own(own(parsed, "network"), "allow"), "network.allow"),
|
|
367
405
|
runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
|
|
368
406
|
// `[runtime] isolate` — how much of your home a role stops reaching. See
|
|
369
407
|
// srt.js for what each level emits, and readIsolate() below for why there
|
|
370
408
|
// are two of them.
|
|
371
409
|
isolate: readIsolate(own(runtime, "isolate")),
|
|
372
|
-
redact: own(runtime, "redact"),
|
|
410
|
+
redact: readBoolean(own(runtime, "redact"), `${path}: runtime.redact`),
|
|
373
411
|
scanIgnore: asArray(own(own(parsed, "scan"), "ignore"), "scan.ignore"),
|
|
412
|
+
protect: readProtect(own(parsed, "protect"), path),
|
|
374
413
|
roles: {},
|
|
375
414
|
};
|
|
376
415
|
|
|
@@ -432,6 +471,7 @@ export function loadConfig(path) {
|
|
|
432
471
|
throw new Error(`${path}: roles.${name}.local_binding must be true or false, not ${JSON.stringify(localBinding)}`);
|
|
433
472
|
const localPorts = readLocalPorts(own(r, "local_ports"), `${path}: roles.${name}.local_ports`);
|
|
434
473
|
const mcp = readMcp(own(r, "mcp"), `${path}: roles.${name}.mcp`);
|
|
474
|
+
const controlFiles = readControlFiles(own(r, "control_files"), `${path}: roles.${name}.control_files`);
|
|
435
475
|
out.roles[name] = {
|
|
436
476
|
name,
|
|
437
477
|
/**
|
|
@@ -505,6 +545,13 @@ export function loadConfig(path) {
|
|
|
505
545
|
* the role says so. See srt.js for what it costs.
|
|
506
546
|
*/
|
|
507
547
|
trustd: trustd === true,
|
|
548
|
+
/**
|
|
549
|
+
* The families of control files this role may edit inside its own
|
|
550
|
+
* territory: "ide" (`.vscode/`, `.cursor/`, `.windsurf/`) and
|
|
551
|
+
* "instructions" (`CLAUDE.md`, `AGENTS.md`…). surface.js says what each
|
|
552
|
+
* is and why only these two can be handed out. Absent is none.
|
|
553
|
+
*/
|
|
554
|
+
controlFiles,
|
|
508
555
|
// Kept so `check` can name a misspelt key instead of ignoring it. An
|
|
509
556
|
// unknown key in a role table used to be dropped silently, and for a
|
|
510
557
|
// subtraction that is failing open: `never_write` would read as a rule
|
|
@@ -564,7 +611,7 @@ function readProviders(table, path) {
|
|
|
564
611
|
/**
|
|
565
612
|
* A SQLite database is four files, and a policy should say it once.
|
|
566
613
|
*
|
|
567
|
-
* `
|
|
614
|
+
* `data/x/app.sqlite` is not the database — it is one of the files the
|
|
568
615
|
* database is made of. Writing to it in WAL mode also writes `-wal` and
|
|
569
616
|
* `-shm`; in rollback mode, `-journal`. So "this role writes this database"
|
|
570
617
|
* takes four lines, and a policy with eight databases and thirty-one roles
|
|
@@ -579,7 +626,7 @@ function readProviders(table, path) {
|
|
|
579
626
|
* it, or corrupt it. The sidecar is not a second resource — it is the same one.
|
|
580
627
|
*
|
|
581
628
|
* It is also not a glob. The kernel grants a path and everything under it, so
|
|
582
|
-
* `
|
|
629
|
+
* `app.sqlite*` is unenforceable and `seisin check` refuses it; these are
|
|
583
630
|
* emitted as literal paths, exactly as if they had been typed.
|
|
584
631
|
*
|
|
585
632
|
* **The suffix list is closed on purpose** and this is the line that keeps the
|
|
@@ -603,8 +650,123 @@ export function withSidecars(paths) {
|
|
|
603
650
|
return [...new Set(out)];
|
|
604
651
|
}
|
|
605
652
|
|
|
653
|
+
/**
|
|
654
|
+
* Every table and key a policy may have outside `[roles.*]`. `true` is a value,
|
|
655
|
+
* an object is a table, and `*` stands for any name (a provider's).
|
|
656
|
+
*
|
|
657
|
+
* Anything else is refused at load, with its line. The roles table reports an
|
|
658
|
+
* unknown key through `check` because a role's typo is usually one extra
|
|
659
|
+
* grant; here the settings are global, and the one that made this necessary
|
|
660
|
+
* was `isolaet = true` under [runtime] — read as nothing, so isolation stayed
|
|
661
|
+
* off for every role and nothing on screen said so. The same "fail open by
|
|
662
|
+
* typo" that was closed for roles, closed harder because it covers them all.
|
|
663
|
+
*/
|
|
664
|
+
const SHAPE = {
|
|
665
|
+
roles: null, // validated where roles are read
|
|
666
|
+
keys: { dir: true, providers: { "*": { command: true, mode: true } } },
|
|
667
|
+
network: { allow: true },
|
|
668
|
+
runtime: { writes: true, isolate: true, redact: true },
|
|
669
|
+
scan: { ignore: true },
|
|
670
|
+
protect: { instructions: true },
|
|
671
|
+
notify: { url: true, url_file: true, format: true },
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
function checkShape(table, shape, prefix, path, lines) {
|
|
675
|
+
const known = Object.keys(shape).filter((k) => k !== "*");
|
|
676
|
+
for (const key of Object.keys(table)) {
|
|
677
|
+
const name = prefix ? `${prefix}.${key}` : key;
|
|
678
|
+
const at = `${path}:${lines?.get(name) ?? "?"}`;
|
|
679
|
+
const rule = Object.prototype.hasOwnProperty.call(shape, key) ? shape[key] : shape["*"];
|
|
680
|
+
if (rule === undefined) {
|
|
681
|
+
const near = closest(key, known);
|
|
682
|
+
throw new Error(
|
|
683
|
+
`${at}: ${prefix ? `[${prefix}] ` : ""}"${key}" is not a setting` +
|
|
684
|
+
`${prefix ? "" : " or a table"} seisin knows${near ? ` — did you mean "${near}"?` : "."}\n` +
|
|
685
|
+
` Known${prefix ? ` in [${prefix}]` : " at the top level"}: ${known.join(", ")}. ` +
|
|
686
|
+
`An unknown name is refused rather than ignored: ignored, a misspelt setting ` +
|
|
687
|
+
`reads as a rule and does nothing.`);
|
|
688
|
+
}
|
|
689
|
+
if (rule === null) continue;
|
|
690
|
+
const value = table[key];
|
|
691
|
+
if (rule === true && isTable(value))
|
|
692
|
+
throw new Error(`${at}: [${name}] is a table, but ${name} is a setting — write ${key} = … under [${prefix}].`);
|
|
693
|
+
if (typeof rule === "object" && !isTable(value))
|
|
694
|
+
throw new Error(`${at}: ${name} = ${JSON.stringify(value)} — ${name} is a table, [${name}], with its settings under it.`);
|
|
695
|
+
if (typeof rule === "object") checkShape(value, rule, name, path, lines);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** The candidate within edit distance 3 of `word`, or null. Shared with `check`. */
|
|
700
|
+
export function closest(word, candidates) {
|
|
701
|
+
let best = null, bestD = 4;
|
|
702
|
+
for (const c of candidates) {
|
|
703
|
+
const d = distance(word.toLowerCase(), c);
|
|
704
|
+
if (d < bestD) { best = c; bestD = d; }
|
|
705
|
+
}
|
|
706
|
+
return best;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Levenshtein distance. */
|
|
710
|
+
function distance(a, b) {
|
|
711
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
712
|
+
for (let i = 1; i <= a.length; i++) {
|
|
713
|
+
let prev = row[0]; row[0] = i;
|
|
714
|
+
for (let j = 1; j <= b.length; j++) {
|
|
715
|
+
const cur = row[j];
|
|
716
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
717
|
+
prev = cur;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return row[b.length];
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** true, false, or absent (undefined). Anything else is a mistake, said as one. */
|
|
724
|
+
function readBoolean(value, where) {
|
|
725
|
+
if (value === undefined || typeof value === "boolean") return value;
|
|
726
|
+
throw new Error(`${where} must be true or false, not ${JSON.stringify(value)}`);
|
|
727
|
+
}
|
|
728
|
+
|
|
606
729
|
/** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
|
|
607
|
-
export const ROLE_KEYS = ["writes", "keys", "key_mode", "env", "network", "never_writes", "local_binding", "local_ports", "mcp", "trustd"];
|
|
730
|
+
export const ROLE_KEYS = ["writes", "keys", "key_mode", "env", "network", "never_writes", "local_binding", "local_ports", "mcp", "trustd", "control_files"];
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* `control_files`: which families of control files a role may edit. Refused at
|
|
734
|
+
* load when it names anything else — `.claude`, git hooks and the rest are not
|
|
735
|
+
* on the list on purpose, and a word the reader does not know must not load as
|
|
736
|
+
* a permission it thinks it wrote.
|
|
737
|
+
*/
|
|
738
|
+
export const CONTROL_FAMILIES = ["ide", "instructions"];
|
|
739
|
+
|
|
740
|
+
function readControlFiles(value, where) {
|
|
741
|
+
if (value === undefined) return [];
|
|
742
|
+
const out = [];
|
|
743
|
+
for (const f of asArray(value, where)) {
|
|
744
|
+
if (!CONTROL_FAMILIES.includes(f))
|
|
745
|
+
throw new Error(`${where}: ${JSON.stringify(f)} is not a family of control files. ` +
|
|
746
|
+
`Known: ${CONTROL_FAMILIES.map((x) => JSON.stringify(x)).join(", ")}. ` +
|
|
747
|
+
`The others (.claude, git hooks, .mcp.json, .envrc) are never handed to a role.`);
|
|
748
|
+
if (!out.includes(f)) out.push(f);
|
|
749
|
+
}
|
|
750
|
+
return out;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* `[protect]` — what is protected beyond the defaults. One key today:
|
|
755
|
+
* `instructions = true` protects `CLAUDE.md`, `AGENTS.md` and their kin from
|
|
756
|
+
* every role that is not handed them. Anything else in the table is refused.
|
|
757
|
+
*/
|
|
758
|
+
function readProtect(value, path) {
|
|
759
|
+
if (value === undefined) return { instructions: false };
|
|
760
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
761
|
+
throw new Error(`${path}: [protect] must be a table`);
|
|
762
|
+
for (const k of Object.keys(value))
|
|
763
|
+
if (k !== "instructions")
|
|
764
|
+
throw new Error(`${path}: [protect] ${k} is not a setting. Known: instructions.`);
|
|
765
|
+
const v = own(value, "instructions");
|
|
766
|
+
if (v !== undefined && typeof v !== "boolean")
|
|
767
|
+
throw new Error(`${path}: [protect] instructions must be true or false, not ${JSON.stringify(v)}`);
|
|
768
|
+
return { instructions: v === true };
|
|
769
|
+
}
|
|
608
770
|
|
|
609
771
|
/**
|
|
610
772
|
* `mcp`: names of MCP servers, as the CLI's config calls them. Absent is null,
|
|
@@ -672,9 +834,9 @@ function readNeverWrites(value, where) {
|
|
|
672
834
|
* for a path on the machine and make `explain` (which reads absolute as outside
|
|
673
835
|
* the repo) disagree with what the profile grants — see the CODEOWNERS `/apps/`
|
|
674
836
|
* case. `..` is allowed on purpose: a role legitimately writes a sibling
|
|
675
|
-
* directory (`../
|
|
676
|
-
* territory that climbs above it grants what it resolves to, by the
|
|
677
|
-
*
|
|
837
|
+
* directory (`../shared/...`), and it is resolved relative to the root, so a
|
|
838
|
+
* territory that climbs above it grants what it resolves to, by the choice of
|
|
839
|
+
* whoever wrote the policy.
|
|
678
840
|
*/
|
|
679
841
|
function assertNotAbsolute(list, where) {
|
|
680
842
|
for (const g of list) {
|