prae-cli 0.9.2 → 0.10.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/lib/bin.js +2 -6
- package/lib/{checkpoint-C_q_Rpa5.js → checkpoint-BqJ8xwky.js} +1 -1
- package/lib/cli-BcAJlB9B.js +8 -0
- package/lib/{cli-B_B6-bUZ.js → cli-DPldZ2ND.js} +259 -526
- package/lib/docs/pilot-claude-code.md +191 -0
- package/lib/{evaluate-BqLxhYiP.js → evaluate-DhoGQw0Z.js} +23 -1
- package/lib/frameworks-BgZHGKiH.js +118 -0
- package/lib/hook-Gll2spTP.js +695 -0
- package/lib/index.js +6 -4
- package/lib/init-DRd-IRR6.js +266 -0
- package/lib/{instructions-ya0LVnZy.js → instructions-DihPiiC2.js} +2 -1
- package/lib/{mcp-BCXbpqu4.js → mcp-D521EoP6.js} +2 -2
- package/lib/pack-h5n43S0F.js +448 -0
- package/lib/playground.js +4 -0
- package/lib/{receipt-check-DQK_gVPc.js → receipt-check-CUuEjGHL.js} +1 -1
- package/lib/{src-LA7NdLKb.js → src-Cy-Acrgj.js} +104 -25
- package/lib/{src-CwBEFhww.js → src-DMoYsdfo.js} +1 -1
- package/lib/src-wG36kT6K.js +3 -0
- package/lib/{test-run-CCDgJ0Dp.js → test-run-C8dHjIE2.js} +1 -1
- package/lib/views-CUcixte0.js +1396 -0
- package/package.json +4 -4
- package/lib/src-7X6dO7gC.js +0 -3
- package/lib/views-D4UEfbzt.js +0 -1505
- /package/lib/{ocsf-DhVGbBUw.js → ocsf-DF9cMiQJ.js} +0 -0
- /package/lib/{src-CBGZVvt7.js → src-BlmsTGdY.js} +0 -0
- /package/lib/{src-B25SHWcz.js → src-BuaOiL2v.js} +0 -0
- /package/lib/{src-d4c_n6wd.js → src-CDYyZyeU.js} +0 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
import { i as relativeTo, n as PATH_ARGS, o as segments, t as evaluate } from "./evaluate-DhoGQw0Z.js";
|
|
2
|
+
import "./frameworks-BgZHGKiH.js";
|
|
3
|
+
import { t as parsePack } from "./pack-h5n43S0F.js";
|
|
4
|
+
import { n as Ledger } from "./src-Cy-Acrgj.js";
|
|
5
|
+
import { a as scan, n as MAX_SCAN_BYTES } from "./src-BuaOiL2v.js";
|
|
6
|
+
import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs";
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { capabilityForTool } from "capmark";
|
|
10
|
+
|
|
11
|
+
//#region ../dsh/src/facts.ts
|
|
12
|
+
function resolveArgs(args, resolvePath, cwd) {
|
|
13
|
+
if (resolvePath === void 0 && cwd === void 0) return args;
|
|
14
|
+
const out = { ...args };
|
|
15
|
+
for (const key of PATH_ARGS) {
|
|
16
|
+
const value = out[key];
|
|
17
|
+
if (typeof value !== "string") continue;
|
|
18
|
+
let path = value;
|
|
19
|
+
if (resolvePath !== void 0) try {
|
|
20
|
+
path = resolvePath(path);
|
|
21
|
+
} catch {}
|
|
22
|
+
out[key] = cwd === void 0 ? path : relativeTo(path, cwd);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Claude Code names its built-in tools in PascalCase (`Read`, `Bash`, …),
|
|
28
|
+
* which capmark's catalogue does not list. Each takes the capability of its
|
|
29
|
+
* lowercase counterpart; without this a Claude Code call carries no
|
|
30
|
+
* capability, and the rules scoped by one (paths, egress, the shell) never
|
|
31
|
+
* see it. An MCP tool, `mcp__<server>__<tool>`, takes its tool's.
|
|
32
|
+
*/
|
|
33
|
+
const CLAUDE_CODE = {
|
|
34
|
+
Read: "read",
|
|
35
|
+
Write: "write",
|
|
36
|
+
Edit: "edit",
|
|
37
|
+
MultiEdit: "edit",
|
|
38
|
+
NotebookEdit: "edit",
|
|
39
|
+
Bash: "bash",
|
|
40
|
+
PowerShell: "bash",
|
|
41
|
+
Grep: "grep",
|
|
42
|
+
Glob: "glob",
|
|
43
|
+
LS: "glob",
|
|
44
|
+
WebFetch: "web_fetch",
|
|
45
|
+
WebSearch: "web_search"
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* MCP tools whose names capmark's catalogue does not list, from the
|
|
49
|
+
* reference servers an agent most often mounts (modelcontextprotocol
|
|
50
|
+
* `filesystem` and `fetch`) and `prae mcp`'s own. Applied only after an
|
|
51
|
+
* `mcp__<server>__` prefix: without it, a Claude Code session reading
|
|
52
|
+
* `.env` through `mcp__filesystem__read_file` carries no capability, and
|
|
53
|
+
* no `fs:read` rule sees it.
|
|
54
|
+
*/
|
|
55
|
+
const MCP_TOOLS = {
|
|
56
|
+
read_file: "read",
|
|
57
|
+
read_text_file: "read",
|
|
58
|
+
read_media_file: "read",
|
|
59
|
+
list_directory: "glob",
|
|
60
|
+
list_dir: "glob",
|
|
61
|
+
directory_tree: "glob",
|
|
62
|
+
search_files: "glob",
|
|
63
|
+
get_file_info: "glob",
|
|
64
|
+
write_file: "write",
|
|
65
|
+
edit_file: "edit",
|
|
66
|
+
create_directory: "write",
|
|
67
|
+
fetch: "web_fetch"
|
|
68
|
+
};
|
|
69
|
+
function capabilityOf(tool) {
|
|
70
|
+
const direct = capabilityForTool(tool)?.id;
|
|
71
|
+
if (direct !== void 0) return direct;
|
|
72
|
+
const alias = CLAUDE_CODE[tool];
|
|
73
|
+
if (alias !== void 0) return capabilityForTool(alias)?.id;
|
|
74
|
+
const mcp = tool.startsWith("mcp__") ? tool.lastIndexOf("__") : -1;
|
|
75
|
+
if (mcp <= 5) return void 0;
|
|
76
|
+
const name = tool.slice(mcp + 2);
|
|
77
|
+
return capabilityOf(MCP_TOOLS[name] ?? name);
|
|
78
|
+
}
|
|
79
|
+
function factsFor(exec, options = {}) {
|
|
80
|
+
const args = exec.arguments ?? {};
|
|
81
|
+
const capability = capabilityOf(exec.name);
|
|
82
|
+
const plugin = options.owners?.get(exec.name);
|
|
83
|
+
const id = exec.agent?.id;
|
|
84
|
+
const agent = typeof id === "string" ? id : void 0;
|
|
85
|
+
return {
|
|
86
|
+
tool: exec.name,
|
|
87
|
+
...capability === void 0 ? {} : { capability },
|
|
88
|
+
...plugin === void 0 ? {} : { plugin },
|
|
89
|
+
...agent === void 0 ? {} : { agent },
|
|
90
|
+
args: resolveArgs(args, options.resolvePath, options.cwd),
|
|
91
|
+
...options.counts === void 0 ? {} : { counts: options.counts }
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region ../dsh/src/judge.ts
|
|
97
|
+
/**
|
|
98
|
+
* Judge one call against a pack, without a harness anywhere near it.
|
|
99
|
+
*
|
|
100
|
+
* Exported separately from {@link apply} so the whole decision path is
|
|
101
|
+
* testable without booting a profile, and so a host with its own event plumbing
|
|
102
|
+
* can reuse it.
|
|
103
|
+
*/
|
|
104
|
+
function judge(pack, exec, options = {}) {
|
|
105
|
+
const started = process.hrtime.bigint();
|
|
106
|
+
const bare = factsFor(exec, {
|
|
107
|
+
...options.owners === void 0 ? {} : { owners: options.owners },
|
|
108
|
+
...options.counts === void 0 ? {} : { counts: options.counts },
|
|
109
|
+
...options.cwd === void 0 ? {} : { cwd: options.cwd }
|
|
110
|
+
});
|
|
111
|
+
const inspect = options.inspect;
|
|
112
|
+
const cwd = options.cwd;
|
|
113
|
+
const findings = options.findings ?? (inspect === void 0 ? void 0 : inspectTarget(bare.args, (path) => inspect(cwd === void 0 ? path : resolve(cwd, path))));
|
|
114
|
+
const facts = findings !== void 0 && findings.length > 0 ? {
|
|
115
|
+
...bare,
|
|
116
|
+
secrets: findings
|
|
117
|
+
} : bare;
|
|
118
|
+
const result = facts.capability !== void 0 && options.withheld?.has(facts.capability) ? {
|
|
119
|
+
kind: "deny",
|
|
120
|
+
rule: "baseline",
|
|
121
|
+
reason: `\`${facts.capability}\` is not permitted by pack ${pack.pack} ${pack.version}`,
|
|
122
|
+
trace: [{
|
|
123
|
+
rule: "baseline",
|
|
124
|
+
effect: "deny",
|
|
125
|
+
because: `\`${facts.capability}\` is outside the operator baseline`
|
|
126
|
+
}],
|
|
127
|
+
evaluated: pack.rules.length
|
|
128
|
+
} : evaluate(pack, facts);
|
|
129
|
+
const micros = Number((process.hrtime.bigint() - started) / 1000n);
|
|
130
|
+
const at = options.at ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
131
|
+
let decision;
|
|
132
|
+
let verdict;
|
|
133
|
+
switch (result.kind) {
|
|
134
|
+
case "allow":
|
|
135
|
+
decision = { kind: "allow" };
|
|
136
|
+
verdict = "allow";
|
|
137
|
+
break;
|
|
138
|
+
case "deny":
|
|
139
|
+
decision = {
|
|
140
|
+
kind: "deny",
|
|
141
|
+
reason: (pack.rules.find((rule) => rule.id === result.rule)?.when.secrets !== void 0 && facts.secrets !== void 0 ? JSON.stringify({
|
|
142
|
+
decision: "deny",
|
|
143
|
+
reason: "secret_detected",
|
|
144
|
+
...subjectOf(facts.args) === void 0 ? {} : { resource: subjectOf(facts.args) },
|
|
145
|
+
secret_types: [...new Set(facts.secrets.map((f) => f.type))],
|
|
146
|
+
policy: result.rule,
|
|
147
|
+
...options.phase === "output" ? {
|
|
148
|
+
phase: "output",
|
|
149
|
+
executed: true
|
|
150
|
+
} : {},
|
|
151
|
+
retry: false,
|
|
152
|
+
message: result.reason ?? "refused by policy"
|
|
153
|
+
}) : void 0) ?? result.reason ?? "refused by policy"
|
|
154
|
+
};
|
|
155
|
+
verdict = "deny";
|
|
156
|
+
break;
|
|
157
|
+
case "ask":
|
|
158
|
+
decision = {
|
|
159
|
+
kind: "ask",
|
|
160
|
+
reason: result.reason ?? "held for a human"
|
|
161
|
+
};
|
|
162
|
+
verdict = "ask";
|
|
163
|
+
break;
|
|
164
|
+
case "correct": {
|
|
165
|
+
const changed = Object.entries(result.args ?? {}).filter(([key, value]) => facts.args[key] !== value).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(", ");
|
|
166
|
+
decision = {
|
|
167
|
+
kind: "deny",
|
|
168
|
+
reason: `${result.reason ?? "corrected by policy"}. Retry with ${changed}.`
|
|
169
|
+
};
|
|
170
|
+
verdict = "correct";
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const corrected = result.kind === "correct" ? Object.fromEntries(Object.entries(result.args ?? {}).filter(([key, value]) => facts.args[key] !== value).map(([key, value]) => [key, {
|
|
175
|
+
from: facts.args[key],
|
|
176
|
+
to: value
|
|
177
|
+
}])) : void 0;
|
|
178
|
+
const record = {
|
|
179
|
+
at,
|
|
180
|
+
...options.session === void 0 ? {} : { session: options.session },
|
|
181
|
+
...facts.agent === void 0 ? {} : { agent: facts.agent },
|
|
182
|
+
tool: facts.tool,
|
|
183
|
+
...subjectOf(facts.args) === void 0 ? {} : { subject: subjectOf(facts.args) },
|
|
184
|
+
...facts.capability === void 0 ? {} : { capability: facts.capability },
|
|
185
|
+
...facts.plugin === void 0 ? {} : { plugin: facts.plugin },
|
|
186
|
+
verdict,
|
|
187
|
+
...result.rule === void 0 ? {} : { rule: result.rule },
|
|
188
|
+
...result.reason === void 0 ? {} : { reason: result.reason },
|
|
189
|
+
trace: result.trace,
|
|
190
|
+
pack: {
|
|
191
|
+
name: pack.pack,
|
|
192
|
+
version: pack.version
|
|
193
|
+
},
|
|
194
|
+
...corrected === void 0 ? {} : { corrected },
|
|
195
|
+
micros,
|
|
196
|
+
...exec.callId === void 0 ? {} : { correlation: exec.callId },
|
|
197
|
+
...options.phase === void 0 ? {} : { phase: options.phase },
|
|
198
|
+
...fingerprintOf(facts.args) === void 0 ? {} : { command: fingerprintOf(facts.args) },
|
|
199
|
+
...findings !== void 0 && findings.length > 0 ? { secrets: findings.map(({ rule, type, line, confidence }) => ({
|
|
200
|
+
rule,
|
|
201
|
+
type,
|
|
202
|
+
line,
|
|
203
|
+
confidence
|
|
204
|
+
})) } : {}
|
|
205
|
+
};
|
|
206
|
+
return {
|
|
207
|
+
decision,
|
|
208
|
+
record
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Argument names worth inspecting: the ones that name a local file a read
|
|
213
|
+
* tool is about to return. `url` is deliberately absent — fetching a URL to
|
|
214
|
+
* scan it would make the gate the exfiltration channel.
|
|
215
|
+
*/
|
|
216
|
+
const INSPECT_ARGS = [
|
|
217
|
+
"path",
|
|
218
|
+
"file_path",
|
|
219
|
+
"filePath",
|
|
220
|
+
"file",
|
|
221
|
+
"target",
|
|
222
|
+
"source"
|
|
223
|
+
];
|
|
224
|
+
function inspectTarget(args, inspect) {
|
|
225
|
+
for (const key of INSPECT_ARGS) {
|
|
226
|
+
const value = args[key];
|
|
227
|
+
if (typeof value === "string" && value !== "") return inspect(value);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The host-side inspector: reads the file the agent asked for, within limits,
|
|
232
|
+
* and reduces it to findings. Built once per mount, and only when the pack
|
|
233
|
+
* actually has a `secrets` rule — a pack that never asks about content should
|
|
234
|
+
* not pay a file read per call, or grow one silently.
|
|
235
|
+
*
|
|
236
|
+
* A file it cannot read, or one over {@link MAX_SCAN_BYTES}, yields no
|
|
237
|
+
* findings rather than an error: the read itself will fail or succeed on its
|
|
238
|
+
* own terms, and a pack that wants oversized files stopped can say so with a
|
|
239
|
+
* plain path rule.
|
|
240
|
+
*/
|
|
241
|
+
function inspectorFor(pack) {
|
|
242
|
+
if (!pack.rules.some((rule) => rule.when.secrets !== void 0)) return;
|
|
243
|
+
return (path) => {
|
|
244
|
+
try {
|
|
245
|
+
const stat = statSync(path);
|
|
246
|
+
if (!stat.isFile() || stat.size > MAX_SCAN_BYTES) return void 0;
|
|
247
|
+
return scan(readFileSync(path, "utf8"));
|
|
248
|
+
} catch {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The one argument worth keeping in a durable record: what the call was aimed at.
|
|
255
|
+
*
|
|
256
|
+
* Only these keys, and only strings. Recording the whole argument object would
|
|
257
|
+
* put file contents and command bodies into an append-only log — this plugin
|
|
258
|
+
* exists partly to refuse reads of `.env`, and a ledger that copies every
|
|
259
|
+
* argument would become the leak it was installed to prevent. Long values are
|
|
260
|
+
* truncated because a record row is meant to be quotable, not to hold a
|
|
261
|
+
* payload.
|
|
262
|
+
*/
|
|
263
|
+
function subjectOf(args) {
|
|
264
|
+
for (const key of [
|
|
265
|
+
"path",
|
|
266
|
+
"file_path",
|
|
267
|
+
"filePath",
|
|
268
|
+
"target",
|
|
269
|
+
"url"
|
|
270
|
+
]) {
|
|
271
|
+
const value = args[key];
|
|
272
|
+
if (typeof value === "string" && value !== "") return value.length > 200 ? `${value.slice(0, 197)}...` : value;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* A shell line, reduced to what a record may hold: the program word and a
|
|
277
|
+
* hash. The design partner's first bypass test showed `subject: null` on
|
|
278
|
+
* every shell row, which is correct — a command line can carry a secret and
|
|
279
|
+
* the ledger must never quote one — and also useless to an auditor. This is
|
|
280
|
+
* the part that can be shown.
|
|
281
|
+
*/
|
|
282
|
+
function fingerprintOf(args) {
|
|
283
|
+
const raw = args.command ?? args.cmd ?? args.script;
|
|
284
|
+
if (typeof raw !== "string" || raw.trim() === "") return void 0;
|
|
285
|
+
return {
|
|
286
|
+
program: segments(raw)[0]?.name ?? "?",
|
|
287
|
+
hash: createHash("sha256").update(raw).digest("hex").slice(0, 16)
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region ../dsh/src/symlink.ts
|
|
293
|
+
/**
|
|
294
|
+
* Symbolic links (PRA-144; review §6.3, gap 3). A path rule judges the path
|
|
295
|
+
* as written; a link inside an allowed directory can point anywhere. The
|
|
296
|
+
* gate therefore also judges where the path really leads, and the stricter
|
|
297
|
+
* of the two verdicts stands.
|
|
298
|
+
*/
|
|
299
|
+
/**
|
|
300
|
+
* Where `path` really leads: through every link on the way, including a
|
|
301
|
+
* dangling one (a write through it would create its target) and a file
|
|
302
|
+
* that does not exist yet (its directory is resolved). `undefined` when the
|
|
303
|
+
* chain does not end.
|
|
304
|
+
*/
|
|
305
|
+
function realLocation(path, depth = 0) {
|
|
306
|
+
if (depth > 40) return void 0;
|
|
307
|
+
try {
|
|
308
|
+
return realpathSync(path);
|
|
309
|
+
} catch {}
|
|
310
|
+
try {
|
|
311
|
+
if (lstatSync(path).isSymbolicLink()) return realLocation(resolve(dirname(path), readlinkSync(path)), depth + 1);
|
|
312
|
+
} catch {}
|
|
313
|
+
const parent = dirname(path);
|
|
314
|
+
if (parent === path) return path;
|
|
315
|
+
const real = realLocation(parent, depth + 1);
|
|
316
|
+
return real === void 0 ? void 0 : join(real, basename(path));
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The call's path arguments as they really resolve, when any of them goes
|
|
320
|
+
* through a link: inside the working directory as `./…`, outside it as an
|
|
321
|
+
* absolute path. `undefined` when no path argument changes.
|
|
322
|
+
*/
|
|
323
|
+
function throughLinks(args, cwd) {
|
|
324
|
+
const realCwd = realLocation(cwd) ?? cwd;
|
|
325
|
+
let changed;
|
|
326
|
+
const out = { ...args };
|
|
327
|
+
for (const key of PATH_ARGS) {
|
|
328
|
+
const value = args[key];
|
|
329
|
+
if (typeof value !== "string" || value === "") continue;
|
|
330
|
+
const written = resolve(cwd, value);
|
|
331
|
+
const real = realLocation(written);
|
|
332
|
+
if (real === void 0) continue;
|
|
333
|
+
const lexical = relative(cwd, written);
|
|
334
|
+
const actual = relative(realCwd, real);
|
|
335
|
+
if (lexical === actual) continue;
|
|
336
|
+
const shown = actual === "" ? "." : actual.startsWith("..") || isAbsolute(actual) ? real : `./${actual}`;
|
|
337
|
+
out[key] = shown;
|
|
338
|
+
changed ??= {
|
|
339
|
+
from: value,
|
|
340
|
+
to: shown
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return changed === void 0 ? void 0 : {
|
|
344
|
+
args: out,
|
|
345
|
+
...changed
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/claude-code/hook.ts
|
|
351
|
+
/**
|
|
352
|
+
* A Claude Code tool call as the arguments the kernel's rules read.
|
|
353
|
+
*
|
|
354
|
+
* Every field is kept, so `arg` rules see what the tool sees; where Claude
|
|
355
|
+
* Code spells a target differently from the kernel's conventional names
|
|
356
|
+
* (`PATH_ARGS`, `url`, `command`) an alias is added beside it:
|
|
357
|
+
*
|
|
358
|
+
* - `NotebookEdit.notebook_path` → `file_path`.
|
|
359
|
+
* - `Glob` / `Grep` search `path`, which defaults to the session's working
|
|
360
|
+
* directory: a search with no path still has a place it reads, and a scope
|
|
361
|
+
* rule must see it. An absolute or `..` Glob pattern with no path reads
|
|
362
|
+
* from the pattern's fixed prefix.
|
|
363
|
+
*/
|
|
364
|
+
function argsOf(tool, input, cwd) {
|
|
365
|
+
const args = { ...input };
|
|
366
|
+
if (tool === "NotebookEdit" && typeof input.notebook_path === "string") args.file_path ??= input.notebook_path;
|
|
367
|
+
if ((tool === "Glob" || tool === "Grep") && typeof input.path !== "string") {
|
|
368
|
+
const pattern = typeof input.pattern === "string" ? input.pattern : "";
|
|
369
|
+
const fixed = pattern.split("/").findIndex((part) => /[*?[{]/.test(part));
|
|
370
|
+
const prefix = (fixed === -1 ? pattern : pattern.split("/").slice(0, fixed).join("/")) || "/";
|
|
371
|
+
args.path = tool === "Glob" && (isAbsolute(pattern) || pattern.startsWith("..")) ? resolve(cwd, prefix) : cwd;
|
|
372
|
+
}
|
|
373
|
+
return args;
|
|
374
|
+
}
|
|
375
|
+
/** A path and its real location, both spelled for absolute and `./` rules. */
|
|
376
|
+
function spellings(paths, cwd) {
|
|
377
|
+
const out = /* @__PURE__ */ new Set();
|
|
378
|
+
for (const p of paths) {
|
|
379
|
+
const abs = resolve(p);
|
|
380
|
+
let real = abs;
|
|
381
|
+
try {
|
|
382
|
+
real = realpathSync(abs);
|
|
383
|
+
} catch {}
|
|
384
|
+
for (const one of [abs, real]) {
|
|
385
|
+
out.add(one);
|
|
386
|
+
for (const base of [cwd, realOr(cwd)]) {
|
|
387
|
+
const rel = relativeTo(one, base);
|
|
388
|
+
if (rel !== one) out.add(rel);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return [...out];
|
|
393
|
+
}
|
|
394
|
+
function realOr(path) {
|
|
395
|
+
try {
|
|
396
|
+
return realpathSync(path);
|
|
397
|
+
} catch {
|
|
398
|
+
return path;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* PRAE's own files are not the agent's to change: the pack that judges it,
|
|
403
|
+
* the ledger that records it, the settings entry that runs this hook, and
|
|
404
|
+
* the private key that signs the rows (not even to read). Evaluated by the
|
|
405
|
+
* kernel like any pack rule, ahead of the operator's own.
|
|
406
|
+
*/
|
|
407
|
+
function selfRules(options, cwd) {
|
|
408
|
+
return [{
|
|
409
|
+
id: "PRAE-SELF",
|
|
410
|
+
title: "PRAE’s own files are protected",
|
|
411
|
+
when: {
|
|
412
|
+
capability: "fs:write",
|
|
413
|
+
path: { inside: spellings([
|
|
414
|
+
options.pack,
|
|
415
|
+
options.ledger,
|
|
416
|
+
`${options.ledger}.lock`,
|
|
417
|
+
...options.settings === void 0 ? [] : [options.settings],
|
|
418
|
+
...options.key === void 0 ? [] : [options.key]
|
|
419
|
+
], cwd) }
|
|
420
|
+
},
|
|
421
|
+
effect: {
|
|
422
|
+
kind: "deny",
|
|
423
|
+
because: "PRAE’s own pack, ledger, key and hook settings are not the agent’s to change"
|
|
424
|
+
}
|
|
425
|
+
}, ...options.key === void 0 ? [] : [{
|
|
426
|
+
id: "PRAE-SELF-KEY",
|
|
427
|
+
title: "The ledger signing key is not readable by the agent",
|
|
428
|
+
when: {
|
|
429
|
+
capability: "fs:read",
|
|
430
|
+
path: { inside: spellings([options.key], cwd) }
|
|
431
|
+
},
|
|
432
|
+
effect: {
|
|
433
|
+
kind: "deny",
|
|
434
|
+
because: "the key that signs the ledger is not the agent’s to read"
|
|
435
|
+
}
|
|
436
|
+
}]];
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* A shell line naming the pack, the hook settings or the key. Best effort,
|
|
440
|
+
* and said so: a shell can spell a path in ways no string test sees
|
|
441
|
+
* (variables, globs, `cd` first). It catches the plain attempt, not a
|
|
442
|
+
* determined one. The ledger is left out on purpose: reading it (`prae
|
|
443
|
+
* report`) is legitimate, and an edit to it breaks its chain and signatures.
|
|
444
|
+
*/
|
|
445
|
+
function mentionsSelf(command, options, cwd) {
|
|
446
|
+
return spellings([
|
|
447
|
+
options.pack,
|
|
448
|
+
...options.settings === void 0 ? [] : [options.settings],
|
|
449
|
+
...options.key === void 0 ? [] : [options.key]
|
|
450
|
+
], cwd).map((s) => s.startsWith("./") ? s.slice(2) : s).find((s) => s !== "" && s !== "." && command.includes(s));
|
|
451
|
+
}
|
|
452
|
+
const rank = (v) => v === "deny" ? 3 : v === "ask" ? 2 : 1;
|
|
453
|
+
function override(j, rule, reason) {
|
|
454
|
+
return {
|
|
455
|
+
decision: {
|
|
456
|
+
kind: "deny",
|
|
457
|
+
reason
|
|
458
|
+
},
|
|
459
|
+
record: {
|
|
460
|
+
...j.record,
|
|
461
|
+
verdict: "deny",
|
|
462
|
+
rule,
|
|
463
|
+
reason,
|
|
464
|
+
trace: [...j.record.trace, {
|
|
465
|
+
rule,
|
|
466
|
+
effect: "deny",
|
|
467
|
+
because: reason
|
|
468
|
+
}]
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
/** Attempts this session, by capability, from the ledger: only when a rule counts. */
|
|
473
|
+
function countsFrom(ledger, session, capability) {
|
|
474
|
+
if (capability === void 0) return {};
|
|
475
|
+
const seen = ledger.read().filter((e) => e.session === session && e.capability === capability && e.phase === void 0).length;
|
|
476
|
+
return { [capability]: seen + 1 };
|
|
477
|
+
}
|
|
478
|
+
function loadPack(path) {
|
|
479
|
+
let bytes;
|
|
480
|
+
try {
|
|
481
|
+
bytes = readFileSync(path);
|
|
482
|
+
} catch (e) {
|
|
483
|
+
throw new Error(`cannot read the pack ${path}: ${e.message}`);
|
|
484
|
+
}
|
|
485
|
+
const read = parsePack(bytes.toString("utf8"));
|
|
486
|
+
if (!read.ok) throw new Error(`the pack ${path} did not parse: ${read.errors.map((e) => `${e.path || "<root>"}: ${e.message}`).join("; ")}`);
|
|
487
|
+
return {
|
|
488
|
+
pack: read.pack,
|
|
489
|
+
sha256: createHash("sha256").update(bytes).digest("hex")
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function appendWithin(ledger, record, waitMs) {
|
|
493
|
+
const deadline = Date.now() + waitMs;
|
|
494
|
+
const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
495
|
+
for (;;) {
|
|
496
|
+
const entry = ledger.tryAppend(record);
|
|
497
|
+
if (entry !== void 0) return entry;
|
|
498
|
+
if (Date.now() > deadline) throw new Error(`the ledger ${ledger.path} stayed locked for ${waitMs} ms; nothing was sealed`);
|
|
499
|
+
Atomics.wait(nap, 0, 0, 2);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
function openLedger(options) {
|
|
503
|
+
const signWith = options.key === void 0 ? void 0 : readFileSync(options.key, "utf8");
|
|
504
|
+
return Ledger.open(options.ledger, signWith === void 0 ? {} : { signWith });
|
|
505
|
+
}
|
|
506
|
+
/** Judge one payload, seal it, and say what the hook prints and exits with. */
|
|
507
|
+
function decide(payload, options) {
|
|
508
|
+
const wait = options.lockWaitMs ?? 3e3;
|
|
509
|
+
let ledger;
|
|
510
|
+
try {
|
|
511
|
+
if (options.pack === "" || options.ledger === "") throw new Error("hook claude-code needs --pack <file> and --ledger <file>");
|
|
512
|
+
if (payload.hook_event_name !== "PreToolUse") throw new Error(`expected a PreToolUse payload, got ${JSON.stringify(payload.hook_event_name)}`);
|
|
513
|
+
const tool = payload.tool_name;
|
|
514
|
+
if (typeof tool !== "string" || tool === "") throw new Error("the payload names no tool_name");
|
|
515
|
+
const cwd = typeof payload.cwd === "string" && isAbsolute(payload.cwd) ? payload.cwd : process.cwd();
|
|
516
|
+
const input = payload.tool_input !== null && typeof payload.tool_input === "object" ? payload.tool_input : {};
|
|
517
|
+
const loaded = loadPack(options.pack);
|
|
518
|
+
const pack = {
|
|
519
|
+
...loaded.pack,
|
|
520
|
+
rules: [...selfRules(options, cwd), ...loaded.pack.rules]
|
|
521
|
+
};
|
|
522
|
+
ledger = openLedger(options);
|
|
523
|
+
const exec = {
|
|
524
|
+
name: tool,
|
|
525
|
+
arguments: argsOf(tool, input, cwd),
|
|
526
|
+
...payload.tool_use_id === void 0 ? {} : { callId: payload.tool_use_id },
|
|
527
|
+
...payload.agent_type === void 0 ? {} : { agent: { id: payload.agent_type } }
|
|
528
|
+
};
|
|
529
|
+
const inspect = inspectorFor(pack);
|
|
530
|
+
const counts = pack.rules.some((r) => r.when.count !== void 0) ? countsFrom(ledger, payload.session_id, capabilityOf(tool)) : void 0;
|
|
531
|
+
const judging = {
|
|
532
|
+
...payload.session_id === void 0 ? {} : { session: payload.session_id },
|
|
533
|
+
...inspect === void 0 ? {} : { inspect },
|
|
534
|
+
...counts === void 0 ? {} : { counts },
|
|
535
|
+
cwd
|
|
536
|
+
};
|
|
537
|
+
const written = judge(pack, exec, judging);
|
|
538
|
+
const linked = throughLinks(exec.arguments ?? {}, cwd);
|
|
539
|
+
let judged = written;
|
|
540
|
+
if (linked !== void 0) {
|
|
541
|
+
const real = judge(pack, {
|
|
542
|
+
...exec,
|
|
543
|
+
arguments: linked.args
|
|
544
|
+
}, judging);
|
|
545
|
+
if (rank(real.record.verdict) > rank(written.record.verdict)) {
|
|
546
|
+
const why = `\`${linked.from}\` resolves through a symbolic link to \`${linked.to}\`: ${real.record.reason ?? ""}`;
|
|
547
|
+
judged = {
|
|
548
|
+
decision: real.decision.kind === "deny" ? {
|
|
549
|
+
kind: "deny",
|
|
550
|
+
reason: why
|
|
551
|
+
} : real.decision,
|
|
552
|
+
record: {
|
|
553
|
+
...real.record,
|
|
554
|
+
...written.record.subject === void 0 ? {} : { subject: written.record.subject },
|
|
555
|
+
reason: why,
|
|
556
|
+
trace: [...real.record.trace, {
|
|
557
|
+
rule: "PRAE-SYMLINK",
|
|
558
|
+
effect: real.record.verdict,
|
|
559
|
+
because: `judged at the link's target, ${linked.to}`
|
|
560
|
+
}]
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (pack.expires_at !== void 0 && Date.now() > Date.parse(pack.expires_at)) judged = override(judged, "PRAE-EXPIRED", `the policy ${pack.pack} ${pack.version} expired at ${pack.expires_at}; every call is refused until a current policy is in force (fail closed)`);
|
|
566
|
+
const command = exec.arguments?.command;
|
|
567
|
+
const named = typeof command === "string" && judged.record.verdict !== "deny" ? mentionsSelf(command, options, cwd) : void 0;
|
|
568
|
+
if (named !== void 0) judged = override(judged, "PRAE-SELF", `the command names \`${named}\`; PRAE’s own pack, ledger, key and hook settings are not the agent’s to change`);
|
|
569
|
+
const record = {
|
|
570
|
+
...judged.record,
|
|
571
|
+
pack: {
|
|
572
|
+
...judged.record.pack,
|
|
573
|
+
sha256: loaded.sha256
|
|
574
|
+
},
|
|
575
|
+
...options.mode === "shadow" ? { shadow: true } : {}
|
|
576
|
+
};
|
|
577
|
+
const entry = appendWithin(ledger, record, wait);
|
|
578
|
+
if (options.mode === "shadow" || judged.decision.kind === "allow") return {
|
|
579
|
+
stdout: "",
|
|
580
|
+
stderr: "",
|
|
581
|
+
exitCode: 0,
|
|
582
|
+
entry
|
|
583
|
+
};
|
|
584
|
+
return {
|
|
585
|
+
stdout: JSON.stringify({ hookSpecificOutput: {
|
|
586
|
+
hookEventName: "PreToolUse",
|
|
587
|
+
permissionDecision: judged.decision.kind,
|
|
588
|
+
permissionDecisionReason: `PRAE ${record.rule ?? ""}: ${judged.decision.reason} (ledger row ${entry.seq})`
|
|
589
|
+
} }),
|
|
590
|
+
stderr: "",
|
|
591
|
+
exitCode: 0,
|
|
592
|
+
entry
|
|
593
|
+
};
|
|
594
|
+
} catch (error) {
|
|
595
|
+
return failed(payload, options, ledger, error, wait);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/** The gate could not judge: enforce refuses, shadow proceeds; both say so. */
|
|
599
|
+
function failed(payload, options, opened, error, wait) {
|
|
600
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
601
|
+
const enforce = options.mode === "enforce";
|
|
602
|
+
const reason = enforce ? `PRAE gate error, call refused (fail closed): ${message}` : `PRAE gate error, call proceeded unjudged (shadow mode): ${message}`;
|
|
603
|
+
let entry;
|
|
604
|
+
try {
|
|
605
|
+
entry = appendWithin(opened ?? openLedger(options), {
|
|
606
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
607
|
+
...payload.session_id === void 0 ? {} : { session: payload.session_id },
|
|
608
|
+
tool: typeof payload.tool_name === "string" ? payload.tool_name : "prae:hook",
|
|
609
|
+
verdict: "deny",
|
|
610
|
+
rule: "PRAE-ERROR",
|
|
611
|
+
reason,
|
|
612
|
+
trace: [{
|
|
613
|
+
rule: "PRAE-ERROR",
|
|
614
|
+
effect: "deny",
|
|
615
|
+
because: message
|
|
616
|
+
}],
|
|
617
|
+
pack: {
|
|
618
|
+
name: "(unavailable)",
|
|
619
|
+
version: "-"
|
|
620
|
+
},
|
|
621
|
+
...payload.tool_use_id === void 0 ? {} : { correlation: payload.tool_use_id },
|
|
622
|
+
...enforce ? {} : { shadow: true }
|
|
623
|
+
}, Math.min(wait, 1e3));
|
|
624
|
+
} catch {}
|
|
625
|
+
const sealed = entry === void 0 ? " (not sealed: the ledger is unavailable)" : "";
|
|
626
|
+
if (!enforce) return {
|
|
627
|
+
stdout: JSON.stringify({ systemMessage: `${reason}${sealed}` }),
|
|
628
|
+
stderr: `${reason}${sealed}\n`,
|
|
629
|
+
exitCode: 0,
|
|
630
|
+
...entry === void 0 ? {} : { entry }
|
|
631
|
+
};
|
|
632
|
+
return {
|
|
633
|
+
stdout: JSON.stringify({ hookSpecificOutput: {
|
|
634
|
+
hookEventName: "PreToolUse",
|
|
635
|
+
permissionDecision: "deny",
|
|
636
|
+
permissionDecisionReason: `${reason}${sealed}`
|
|
637
|
+
} }),
|
|
638
|
+
stderr: `${reason}${sealed}\n`,
|
|
639
|
+
exitCode: 2,
|
|
640
|
+
...entry === void 0 ? {} : { entry }
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function flag(argv, name) {
|
|
644
|
+
const at = argv.indexOf(`--${name}`);
|
|
645
|
+
return at === -1 ? void 0 : argv[at + 1];
|
|
646
|
+
}
|
|
647
|
+
/** Parse `prae hook claude-code …` flags. A missing one is a gate error in {@link decide}. */
|
|
648
|
+
function hookOptions(argv) {
|
|
649
|
+
const key = flag(argv, "key");
|
|
650
|
+
const settings = flag(argv, "settings");
|
|
651
|
+
return {
|
|
652
|
+
mode: argv.includes("--enforce") ? "enforce" : "shadow",
|
|
653
|
+
pack: flag(argv, "pack") ?? "",
|
|
654
|
+
ledger: flag(argv, "ledger") ?? "",
|
|
655
|
+
...key === void 0 ? {} : { key },
|
|
656
|
+
...settings === void 0 ? {} : { settings }
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
/** Read stdin to its end, or give up at the deadline. */
|
|
660
|
+
function readStdin(ms) {
|
|
661
|
+
return new Promise((done, fail) => {
|
|
662
|
+
const chunks = [];
|
|
663
|
+
const timer = setTimeout(() => {
|
|
664
|
+
process.stdin.pause();
|
|
665
|
+
fail(/* @__PURE__ */ new Error(`no complete payload on stdin within ${ms} ms`));
|
|
666
|
+
}, ms);
|
|
667
|
+
process.stdin.on("data", (c) => chunks.push(c));
|
|
668
|
+
process.stdin.on("end", () => {
|
|
669
|
+
clearTimeout(timer);
|
|
670
|
+
done(Buffer.concat(chunks).toString("utf8"));
|
|
671
|
+
});
|
|
672
|
+
process.stdin.on("error", (e) => {
|
|
673
|
+
clearTimeout(timer);
|
|
674
|
+
fail(e);
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
/** The process entry: `prae hook claude-code [--enforce|--shadow] --pack … --ledger …`. */
|
|
679
|
+
async function runHook(argv) {
|
|
680
|
+
const options = hookOptions(argv);
|
|
681
|
+
let result;
|
|
682
|
+
let payload = {};
|
|
683
|
+
try {
|
|
684
|
+
payload = JSON.parse(await readStdin(5e3));
|
|
685
|
+
result = decide(payload, options);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
result = failed(payload, options, void 0, error, 1e3);
|
|
688
|
+
}
|
|
689
|
+
if (result.stdout !== "") process.stdout.write(`${result.stdout}\n`);
|
|
690
|
+
if (result.stderr !== "") process.stderr.write(result.stderr);
|
|
691
|
+
process.exitCode = result.exitCode;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
//#endregion
|
|
695
|
+
export { argsOf, decide, hookOptions, runHook };
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import "./evaluate-
|
|
2
|
-
import "./
|
|
3
|
-
import
|
|
4
|
-
import "./src-
|
|
1
|
+
import "./evaluate-DhoGQw0Z.js";
|
|
2
|
+
import "./frameworks-BgZHGKiH.js";
|
|
3
|
+
import "./pack-h5n43S0F.js";
|
|
4
|
+
import "./src-Cy-Acrgj.js";
|
|
5
|
+
import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-DPldZ2ND.js";
|
|
6
|
+
import "./views-CUcixte0.js";
|
|
5
7
|
|
|
6
8
|
export { applyWrite, defaultProfile, dshHome, main, planWrite, praeRow, profileAt };
|