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.
@@ -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-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 "./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 };