prae-cli 0.9.2 → 0.10.1

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