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/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
|
*
|
|
@@ -52,13 +51,27 @@ function dict() {
|
|
|
52
51
|
return Object.create(null);
|
|
53
52
|
}
|
|
54
53
|
|
|
54
|
+
const isTable = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
55
|
+
|
|
55
56
|
/** Read a property only if the object actually has it. */
|
|
56
57
|
export function own(obj, key) {
|
|
57
58
|
return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
58
59
|
}
|
|
59
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. */
|
|
60
71
|
export function parseToml(text) {
|
|
61
72
|
const out = dict();
|
|
73
|
+
const where = new Map();
|
|
74
|
+
LINES.set(out, where);
|
|
62
75
|
let table = out;
|
|
63
76
|
let tableName = "";
|
|
64
77
|
// "Last one wins" is TOML-ish and wrong for a permission file. A policy can
|
|
@@ -85,11 +98,19 @@ export function parseToml(text) {
|
|
|
85
98
|
if (seenTables.has(tableName))
|
|
86
99
|
throw new Error(
|
|
87
100
|
`${CONFIG_NAME}:${i + 1}: [${tableName}] appears twice. ` +
|
|
88
|
-
`A later
|
|
101
|
+
`A later table would silently override the earlier one — put every setting for ` +
|
|
89
102
|
`${tableName} in one place.`);
|
|
90
103
|
seenTables.add(tableName);
|
|
91
|
-
|
|
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
|
+
});
|
|
108
|
+
table = tableName.split(".").reduce((node, key, k, parts) => {
|
|
92
109
|
if (!Object.prototype.hasOwnProperty.call(node, key)) node[key] = dict();
|
|
110
|
+
else if (!isTable(node[key]))
|
|
111
|
+
throw new Error(
|
|
112
|
+
`${CONFIG_NAME}:${i + 1}: [${tableName}] — "${parts.slice(0, k + 1).join(".")}" is already ` +
|
|
113
|
+
`a value, not a table. One name cannot be both.`);
|
|
93
114
|
return node[key];
|
|
94
115
|
}, out);
|
|
95
116
|
continue;
|
|
@@ -99,9 +120,20 @@ export function parseToml(text) {
|
|
|
99
120
|
if (!pair) throw new Error(`${CONFIG_NAME}:${i + 1}: cannot read "${raw.trim()}"`);
|
|
100
121
|
|
|
101
122
|
let [, key, value] = pair;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
123
|
+
const at = i + 1;
|
|
124
|
+
// An array may span lines; keep pulling until the brackets balance. Only
|
|
125
|
+
// the new line is searched: searching the whole growing value made an
|
|
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)) {
|
|
129
|
+
const parts = [value];
|
|
130
|
+
let closed = false;
|
|
131
|
+
while (!closed && i + 1 < lines.length) {
|
|
132
|
+
const next = stripComment(lines[++i]).trim();
|
|
133
|
+
parts.push(next);
|
|
134
|
+
closed = closesArray(next);
|
|
135
|
+
}
|
|
136
|
+
value = parts.join(" ");
|
|
105
137
|
}
|
|
106
138
|
if (FORBIDDEN.has(key))
|
|
107
139
|
throw new Error(`${CONFIG_NAME}:${i + 1}: "${key}" is a reserved name and cannot be a setting.`);
|
|
@@ -111,13 +143,39 @@ export function parseToml(text) {
|
|
|
111
143
|
`${CONFIG_NAME}:${i + 1}: "${key}" is set twice in [${tableName || "the root"}]. ` +
|
|
112
144
|
`The second would win, which is not a thing a permission file should do quietly.`);
|
|
113
145
|
seenKeys.add(seen);
|
|
146
|
+
where.set(tableName ? `${tableName}.${key}` : key, at);
|
|
147
|
+
/**
|
|
148
|
+
* A key may not be a table that is declared elsewhere, in either order.
|
|
149
|
+
*
|
|
150
|
+
* `[roles.a]` with `network = []`, then `[roles]` and `a = true` forty lines
|
|
151
|
+
* down, used to replace the whole role with `true`: its `network = []` came
|
|
152
|
+
* back as "not set", which means the global allow list. `check` said
|
|
153
|
+
* nothing. It is the "last one wins" this parser refuses everywhere else,
|
|
154
|
+
* arriving by a different spelling.
|
|
155
|
+
*/
|
|
156
|
+
if (Object.prototype.hasOwnProperty.call(table, key))
|
|
157
|
+
throw new Error(
|
|
158
|
+
`${CONFIG_NAME}:${i + 1}: "${key}" in [${tableName || "the root"}] is already the table ` +
|
|
159
|
+
`[${tableName ? tableName + "." : ""}${key}]. One name cannot be both — the value would replace ` +
|
|
160
|
+
`everything that table says.`);
|
|
114
161
|
table[key] = readValue(value, i + 1);
|
|
115
162
|
}
|
|
116
163
|
return out;
|
|
117
164
|
}
|
|
118
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
|
+
|
|
119
177
|
/** Strips a trailing `#` comment, but not one inside a quoted string. */
|
|
120
|
-
function stripComment(line) {
|
|
178
|
+
export function stripComment(line) {
|
|
121
179
|
let quoted = false;
|
|
122
180
|
for (let i = 0; i < line.length; i++) {
|
|
123
181
|
const c = line[i];
|
|
@@ -212,14 +270,6 @@ function readValue(value, lineNo) {
|
|
|
212
270
|
bad("value must be a string, an array of strings, a whole number, or a boolean");
|
|
213
271
|
}
|
|
214
272
|
|
|
215
|
-
/**
|
|
216
|
-
* Turns the parsed file into the shape the rest of the program uses, and
|
|
217
|
-
* rejects anything ambiguous.
|
|
218
|
-
*
|
|
219
|
-
* Rejecting is the point. The sandbox runtime refuses to start on an invalid
|
|
220
|
-
* settings file rather than falling back to a permissive default, and seisin
|
|
221
|
-
* matches that: a permission tool that guesses is worse than no permission tool.
|
|
222
|
-
*/
|
|
223
273
|
/**
|
|
224
274
|
* `[runtime] isolate` — one setting, two levels, because they were one and
|
|
225
275
|
* should not have been.
|
|
@@ -255,13 +305,25 @@ function readValue(value, lineNo) {
|
|
|
255
305
|
export function readIsolate(v) {
|
|
256
306
|
if (v === true || v === "home") return "home";
|
|
257
307
|
if (v === "credentials") return "credentials";
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
308
|
+
if (v === false || v === undefined) return false;
|
|
309
|
+
throw new Error(
|
|
310
|
+
`runtime.isolate must be false, true, "credentials", or "home", not ${JSON.stringify(v)}. ` +
|
|
311
|
+
`An unknown value cannot silently turn credential isolation off.`);
|
|
261
312
|
}
|
|
262
313
|
|
|
263
|
-
|
|
264
|
-
|
|
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));
|
|
265
327
|
// Read only what the file actually declared. The parser refuses the names
|
|
266
328
|
// that make inheritance possible, and this is the second half of the same
|
|
267
329
|
// rule: if one of them ever gets through, a role still cannot pick up a
|
|
@@ -269,6 +331,16 @@ export function loadConfig(path) {
|
|
|
269
331
|
// they prevent is invisible in review — the config reads one way and the
|
|
270
332
|
// policy is another.
|
|
271
333
|
const roles = own(parsed, "roles") ?? {};
|
|
334
|
+
// `roles = "x"` or `roles = [...]` at the root, or `[roles]` with `a = true`,
|
|
335
|
+
// would read as roles named "0", "1" or "a" with no settings of their own —
|
|
336
|
+
// and a role with nothing set gets the global defaults.
|
|
337
|
+
if (!isTable(roles))
|
|
338
|
+
throw new Error(`${path}: "roles" must be tables — [roles.<name>] — not ${JSON.stringify(roles)}`);
|
|
339
|
+
for (const [name, r] of Object.entries(roles))
|
|
340
|
+
if (!isTable(r))
|
|
341
|
+
throw new Error(
|
|
342
|
+
`${path}: roles.${name} = ${JSON.stringify(r)} — a role is a table, [roles.${name}], ` +
|
|
343
|
+
`with its settings under it.`);
|
|
272
344
|
|
|
273
345
|
/**
|
|
274
346
|
* A role is a leaf. `[roles.a.b]` is a typo, with certainty.
|
|
@@ -295,12 +367,29 @@ export function loadConfig(path) {
|
|
|
295
367
|
}
|
|
296
368
|
const names = Object.keys(roles);
|
|
297
369
|
if (names.length === 0) throw new Error(`${path}: no [roles.<name>] sections found`);
|
|
370
|
+
// Case-colliding names would share a HOME under `isolate = "home"` on a
|
|
371
|
+
// case-insensitive disk (APFS), so `dev` and `Dev` would read each other's
|
|
372
|
+
// secrets while `check` said nothing. Refuse the ambiguity at load.
|
|
373
|
+
{
|
|
374
|
+
const byLower = new Map();
|
|
375
|
+
for (const n of names) {
|
|
376
|
+
const k = n.toLowerCase();
|
|
377
|
+
if (byLower.has(k))
|
|
378
|
+
throw new Error(`${path}: roles "${byLower.get(k)}" and "${n}" differ only in case. ` +
|
|
379
|
+
`On a case-insensitive disk they would share an isolated HOME. Rename one.`);
|
|
380
|
+
byLower.set(k, n);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
298
383
|
|
|
299
384
|
// One directory or several. Several is the common case once a repo has more
|
|
300
385
|
// than one kind of secret, and making people flatten them to satisfy the tool
|
|
301
386
|
// is how a tool gets kept out.
|
|
302
387
|
const keyDir = own(own(parsed, "keys"), "dir");
|
|
303
|
-
|
|
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);
|
|
304
393
|
const keyProviders = readProviders(own(own(parsed, "keys"), "providers"), path);
|
|
305
394
|
// `undefined` means "use the defaults"; an explicit empty array means "none".
|
|
306
395
|
// The difference matters: one is a user who has not thought about it, the
|
|
@@ -310,20 +399,23 @@ export function loadConfig(path) {
|
|
|
310
399
|
|
|
311
400
|
const out = {
|
|
312
401
|
root: dirname(path), path, keyDirs, keyProviders,
|
|
313
|
-
|
|
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"),
|
|
314
405
|
runtimeWrites: runtimeWrites === undefined ? undefined : asArray(runtimeWrites, "runtime.writes"),
|
|
315
406
|
// `[runtime] isolate` — how much of your home a role stops reaching. See
|
|
316
407
|
// srt.js for what each level emits, and readIsolate() below for why there
|
|
317
408
|
// are two of them.
|
|
318
409
|
isolate: readIsolate(own(runtime, "isolate")),
|
|
319
|
-
redact: own(runtime, "redact"),
|
|
410
|
+
redact: readBoolean(own(runtime, "redact"), `${path}: runtime.redact`),
|
|
320
411
|
scanIgnore: asArray(own(own(parsed, "scan"), "ignore"), "scan.ignore"),
|
|
412
|
+
protect: readProtect(own(parsed, "protect"), path),
|
|
321
413
|
roles: {},
|
|
322
414
|
};
|
|
323
415
|
|
|
324
416
|
for (const name of names) {
|
|
325
417
|
const r = own(roles, name);
|
|
326
|
-
const writes = asArray(own(r, "writes"), `roles.${name}.writes`);
|
|
418
|
+
const writes = assertNotAbsolute(asArray(own(r, "writes"), `${path}: roles.${name}.writes`), `${path}: roles.${name}.writes`);
|
|
327
419
|
const keys = asArray(own(r, "keys"), `roles.${name}.keys`);
|
|
328
420
|
// Read once, here, so every consumer sees the same answer about what an
|
|
329
421
|
// entry is. `keys` itself stays the array of strings that was written: it
|
|
@@ -379,6 +471,7 @@ export function loadConfig(path) {
|
|
|
379
471
|
throw new Error(`${path}: roles.${name}.local_binding must be true or false, not ${JSON.stringify(localBinding)}`);
|
|
380
472
|
const localPorts = readLocalPorts(own(r, "local_ports"), `${path}: roles.${name}.local_ports`);
|
|
381
473
|
const mcp = readMcp(own(r, "mcp"), `${path}: roles.${name}.mcp`);
|
|
474
|
+
const controlFiles = readControlFiles(own(r, "control_files"), `${path}: roles.${name}.control_files`);
|
|
382
475
|
out.roles[name] = {
|
|
383
476
|
name,
|
|
384
477
|
/**
|
|
@@ -452,6 +545,13 @@ export function loadConfig(path) {
|
|
|
452
545
|
* the role says so. See srt.js for what it costs.
|
|
453
546
|
*/
|
|
454
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,
|
|
455
555
|
// Kept so `check` can name a misspelt key instead of ignoring it. An
|
|
456
556
|
// unknown key in a role table used to be dropped silently, and for a
|
|
457
557
|
// subtraction that is failing open: `never_write` would read as a rule
|
|
@@ -511,7 +611,7 @@ function readProviders(table, path) {
|
|
|
511
611
|
/**
|
|
512
612
|
* A SQLite database is four files, and a policy should say it once.
|
|
513
613
|
*
|
|
514
|
-
* `
|
|
614
|
+
* `data/x/app.sqlite` is not the database — it is one of the files the
|
|
515
615
|
* database is made of. Writing to it in WAL mode also writes `-wal` and
|
|
516
616
|
* `-shm`; in rollback mode, `-journal`. So "this role writes this database"
|
|
517
617
|
* takes four lines, and a policy with eight databases and thirty-one roles
|
|
@@ -526,7 +626,7 @@ function readProviders(table, path) {
|
|
|
526
626
|
* it, or corrupt it. The sidecar is not a second resource — it is the same one.
|
|
527
627
|
*
|
|
528
628
|
* It is also not a glob. The kernel grants a path and everything under it, so
|
|
529
|
-
* `
|
|
629
|
+
* `app.sqlite*` is unenforceable and `seisin check` refuses it; these are
|
|
530
630
|
* emitted as literal paths, exactly as if they had been typed.
|
|
531
631
|
*
|
|
532
632
|
* **The suffix list is closed on purpose** and this is the line that keeps the
|
|
@@ -550,8 +650,123 @@ export function withSidecars(paths) {
|
|
|
550
650
|
return [...new Set(out)];
|
|
551
651
|
}
|
|
552
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
|
+
|
|
553
729
|
/** The keys a `[roles.<name>]` table can hold. Anything else is reported by `check`. */
|
|
554
|
-
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
|
+
}
|
|
555
770
|
|
|
556
771
|
/**
|
|
557
772
|
* `mcp`: names of MCP servers, as the CLI's config calls them. Absent is null,
|
|
@@ -614,6 +829,25 @@ function readNeverWrites(value, where) {
|
|
|
614
829
|
return list;
|
|
615
830
|
}
|
|
616
831
|
|
|
832
|
+
/**
|
|
833
|
+
* A territory is relative to the repo root. An absolute one would ask the kernel
|
|
834
|
+
* for a path on the machine and make `explain` (which reads absolute as outside
|
|
835
|
+
* the repo) disagree with what the profile grants — see the CODEOWNERS `/apps/`
|
|
836
|
+
* case. `..` is allowed on purpose: a role legitimately writes a sibling
|
|
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.
|
|
840
|
+
*/
|
|
841
|
+
function assertNotAbsolute(list, where) {
|
|
842
|
+
for (const g of list) {
|
|
843
|
+
if (typeof g !== "string" || g.trim() === "")
|
|
844
|
+
throw new Error(`${where}: every entry must be a non-empty string`);
|
|
845
|
+
if (g.startsWith("/"))
|
|
846
|
+
throw new Error(`${where}: "${g}" is absolute. Territories are relative to the repo root.`);
|
|
847
|
+
}
|
|
848
|
+
return list;
|
|
849
|
+
}
|
|
850
|
+
|
|
617
851
|
function asArray(value, where) {
|
|
618
852
|
if (value === undefined) return [];
|
|
619
853
|
if (typeof value === "string") return [value];
|