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,191 @@
1
+ # Pilot: PRAE on one Claude Code laptop
2
+
3
+ PRAE runs as a Claude Code `PreToolUse` hook. Before every tool call (built-in
4
+ tools such as `Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`, `WebFetch`, and
5
+ every MCP tool `mcp__<server>__<tool>`), Claude Code runs `prae hook
6
+ claude-code`. The hook judges the call against a policy pack with the same
7
+ kernel the other PRAE harnesses use, and appends the verdict to a signed,
8
+ hash-chained ledger on this machine.
9
+
10
+ It starts in **shadow mode**. Shadow mode records every verdict and blocks
11
+ nothing. When the record looks right, one command switches it to **enforce**.
12
+
13
+ There is no cloud account, no open port, no network call and no service to run.
14
+
15
+ **Verified on Claude Code 2.1.233**, from a fresh clone, by following this
16
+ guide in a temporary project:
17
+
18
+ - **Shadow.** One headless session made 10 tool calls: `Read`, `Write`, `Edit`,
19
+ `Bash`, `Glob`, `Grep`, `WebFetch` and `mcp__prae__read_file`. All 10 ran,
20
+ and each one was sealed with its verdict. The `.env` read was sealed as a
21
+ shadow deny (`BASE-005`), and `rm -rf` as a shadow hold (`BASE-002`).
22
+ - **Enforce.** Claude Code refused these: the `.env` read, the same read
23
+ through the MCP tool, a fetch to an unlisted host, edits to the pack and
24
+ to the hook settings, a shell command truncating the pack, and a read of
25
+ the signing key. `rm -rf` and a write outside the workspace were held, and
26
+ `claude -p` refused them. Nothing protected changed on disk.
27
+ - **Record.** `prae verify` passed the chain, with 25 of 25 signatures.
28
+
29
+ ## 1. Install (one command)
30
+
31
+ You need Node 22.19 or later and pnpm 10 (`corepack enable`). Until the
32
+ release that carries this hook is on npm (`prae-cli` 0.9.2 does not have it),
33
+ run it from a checkout of this repository, built once:
34
+
35
+ ```bash
36
+ git clone https://github.com/taltara/prae-harness && cd prae-harness && pnpm install && pnpm --filter prae-cli build
37
+ PRAE="node $PWD/packages/cli/lib/bin.js" # used below; after the npm release this is just `prae`
38
+ ```
39
+
40
+ Then preview the change, and apply it:
41
+
42
+ ```bash
43
+ $PRAE init claude-code --scope user # prints the settings diff, writes nothing
44
+ $PRAE init claude-code --scope user --apply # installs, shadow mode
45
+ ```
46
+
47
+ You must name a target. `--scope user` is `~/.claude/settings.json`, which
48
+ covers every project. `--scope project` is `./.claude/settings.json`.
49
+ `--settings <file>` is any file you name, for example
50
+ `.claude/settings.local.json`. Nothing is written without `--apply`. The file
51
+ is written back as 2-space JSON, the format Claude Code itself writes, so a
52
+ hand-formatted file shows layout changes in the diff. Its values do not change.
53
+
54
+ `init` adds one entry and leaves every other setting and hook as it was:
55
+
56
+ ```json
57
+ "PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "timeout": 10,
58
+ "command": "'/opt/homebrew/bin/node' '…/packages/cli/lib/bin.js' hook claude-code --shadow --pack '~/.prae/claude-code/prae-baseline.prae.json' --ledger '~/.prae/claude-code/ledger.jsonl' --key '~/.prae/claude-code/prae-signing.pem' --settings '~/.claude/settings.json'" }] }]
59
+ ```
60
+
61
+ The real file holds absolute paths. The command names the node binary and
62
+ PRAE's own script by absolute path, so it does not depend on `PATH`. If you
63
+ move the checkout, run `init` again. Claude Code reloads settings on its own,
64
+ so new tool calls go through PRAE without a restart.
65
+
66
+ Options:
67
+
68
+ - `--pack <file>`: use your own pack. The default copies the built-in baseline
69
+ (8 rules: download-and-execute, `rm -rf`, privilege escalation, block devices,
70
+ credential files, writes outside the workspace, force-push, publishes).
71
+ `prae check <file>` summarises a pack.
72
+ - `--ledger <file>`: write the ledger somewhere else.
73
+ - `--dir <dir>`: keep PRAE's state somewhere other than `~/.prae/claude-code/`.
74
+
75
+ ## 2. What it records, and where
76
+
77
+ Everything lives in `~/.prae/claude-code/`, which is created with mode 0700:
78
+
79
+ | File | What it is |
80
+ | --- | --- |
81
+ | `ledger.jsonl` | One row for each tool call. Every Claude Code session on the machine appends to this one file, under a lock, so parallel tool calls and parallel sessions stay one chain. |
82
+ | `prae-signing.pem` | The Ed25519 key that signs each row. It is generated once with mode 0600. |
83
+ | `prae-signing.pub.pem` | The public half. Give it to whoever verifies the record. |
84
+ | `prae-baseline.prae.json` | The pack in force, unless you passed `--pack`. |
85
+
86
+ Each row holds the time, the Claude Code `session_id`, the `tool_use_id` (as
87
+ `correlation`), the tool, its capability (`fs:read`, `fs:write`,
88
+ `proc:spawn`, `net:fetch`), the subject, the verdict, the rule and its reason,
89
+ the pack's name, version and sha256, and, in shadow mode, `shadow: true`. The
90
+ subject is the path or URL only. A shell call is stored as its program word
91
+ and a hash, never the command line. File contents and tool output are never
92
+ stored.
93
+
94
+ Read the record:
95
+
96
+ ```bash
97
+ P=~/.prae/claude-code
98
+ $PRAE verify $P/ledger.jsonl --key $P/prae-signing.pub.pem
99
+ $PRAE report $P/ledger.jsonl --pack $P/prae-baseline.prae.json --out report.html
100
+ $PRAE console $P/ledger.jsonl --pack $P/prae-baseline.prae.json --out console/
101
+ ```
102
+
103
+ In the report and the console, a shadow verdict is marked `· SHADOW`. It is
104
+ never counted as vetoed or held.
105
+
106
+ ## 3. From shadow to enforce
107
+
108
+ ```bash
109
+ $PRAE init claude-code --scope user --enforce --apply
110
+ ```
111
+
112
+ This replaces PRAE's entry. It does not add a second one. In enforce mode:
113
+
114
+ | Verdict | What Claude Code does |
115
+ | --- | --- |
116
+ | allow | The hook prints nothing. Claude Code's own permission flow decides, as before. PRAE never answers `allow`, because that would skip your permission prompt. |
117
+ | deny | `permissionDecision: "deny"`. The call does not run, and Claude reads the reason, for example `PRAE BASE-005: … (ledger row 12)`. |
118
+ | ask | `permissionDecision: "ask"`. You get a permission prompt. In a headless `claude -p` run nobody can answer it, so Claude Code refuses the call (verified on 2.1.233). |
119
+ | gate error | The pack does not parse, the ledger cannot be written, the key is unreadable, the payload is broken, or the ledger lock is held for more than 3 seconds. The hook refuses the call (exit 2) and records the error in the ledger when it can. |
120
+
121
+ In shadow mode a gate error lets the call run. It shows a `systemMessage`
122
+ warning and records a `PRAE-ERROR` row marked `shadow`.
123
+
124
+ ## 4. Uninstall
125
+
126
+ ```bash
127
+ $PRAE init claude-code --scope user --remove --apply
128
+ ```
129
+
130
+ This removes PRAE's hook entry and nothing else. The ledger and the key stay
131
+ in place, because they are the evidence of what ran. Delete
132
+ `~/.prae/claude-code/` yourself when you no longer need them.
133
+
134
+ ## 5. Privacy
135
+
136
+ Nothing leaves the machine. The hook reads the payload Claude Code passes on
137
+ stdin, reads the pack, appends to a local file, and exits. It opens no port
138
+ and makes no network request. A `WebFetch` is judged by its URL and is never
139
+ fetched by PRAE. For credential checks the gate reads the file the agent is
140
+ about to read, in its own process, and only the finding (type, line,
141
+ confidence) reaches the ledger.
142
+
143
+ ## 6. Limits, stated plainly
144
+
145
+ - **A hook that cannot start does not block, on current Claude Code.** In
146
+ enforce mode, PRAE refuses whenever it runs and fails: a bad pack, an
147
+ unwritable ledger, an exception, its own 3 s lock deadline, or broken
148
+ input. Before Claude Code 2.1.295, though, a hook that cannot start at all
149
+ (node or the checkout moved) or that hits Claude Code's 10 s timeout lets
150
+ the call through. This was checked on 2.1.233. Enforce writes
151
+ `onFailure: "block"`, which Claude Code 2.1.295 and later honour, and
152
+ which 2.1.233 ignores.
153
+ - **The shell is judged by its command line, not by its effect.** The baseline
154
+ stops `Read` of `.env`, but `cat .env` in `Bash` is a different call, and
155
+ no path rule sees it. Write shell rules (`command`) for the programs you care
156
+ about. PRAE does not yet screen tool output in Claude Code. A `PostToolUse`
157
+ hook can only replace output in each tool's exact output shape, which this
158
+ release does not take on.
159
+ - **PRAE's own files are protected only against plain attempts.** Writes to the
160
+ pack, the ledger and the hook settings are refused (`PRAE-SELF`), and so are
161
+ reads of the signing key. Shell commands that name the pack, the settings or
162
+ the key are refused too, even read-only ones such as `wc -c <pack>`. But the agent runs as you. A shell can spell a path
163
+ in ways no string check sees, so a determined agent with `Bash` can still
164
+ edit them. An edited ledger breaks its chain and its signatures. A wholesale
165
+ rewrite by someone who holds the key needs an external checkpoint
166
+ (`prae checkpoint`) to catch.
167
+ - **What the hook never sees.** Files you attach with `@` in a prompt are read
168
+ without a tool call, so no hook fires. Managed settings can switch user hooks
169
+ off (`allowManagedHooksOnly`, `disableAllHooks`).
170
+ - **Coverage depends on knowing what a tool does.** Built-in tools map to
171
+ capabilities by name. MCP tools map by their tool name: the reference
172
+ `filesystem` and `fetch` servers' tools, and `prae mcp`'s own, are
173
+ recognised. Any other MCP tool is recorded and matched only by rules that
174
+ name the tool. `WebSearch` has a query, not a URL, so host rules do not
175
+ apply to it. A path that only appears inside an array (for example
176
+ `read_multiple_files`) is not path-checked.
177
+ - **What happens after a hold is not recorded.** The row says the call was
178
+ held (`ask`). Whether you then approved it in the prompt is not written
179
+ back, because a `PreToolUse` hook gets no outcome. The report lists every
180
+ enforced hold under "awaiting human".
181
+ - **The agent's own account is not evidence.** In the verification runs the
182
+ model twice reported a refused call as having run. The ledger, Claude
183
+ Code's `permission_denials` and the file system agreed with each other, and
184
+ they are what to read.
185
+ - **Budgets cost a ledger read.** A pack with a `count` rule makes each call
186
+ count this session's earlier rows in the ledger. Packs without one never
187
+ read the ledger.
188
+ - **Latency.** Each tool call starts one node process. Measured on an M-series
189
+ Mac, the built `lib/bin.js` took 30 ms at p50 and 34 ms at p95. Run from
190
+ `src/bin.ts` with Node's type stripping, it took 60 ms at p50 and 64 ms at
191
+ p95. A bare `node -e 0` takes 20 ms on the same machine.
@@ -272,6 +272,28 @@ function withinAny(path, globs) {
272
272
  return globs.some((glob) => within(path, glob));
273
273
  }
274
274
  /**
275
+ * An absolute path under `cwd`, rewritten as the workspace-relative path a
276
+ * pack's `./` globs speak about; anything else comes back as written.
277
+ *
278
+ * A model is free to name a workspace file by its absolute path — the first
279
+ * real agent run through the gate did exactly that for its first three
280
+ * reads — and a scope rule written as `outside: ["./src/**"]` must decide
281
+ * that file by where it is, not by how it was spelled. The anchoring in
282
+ * {@link within} is right to keep `/etc/hosts` out of `./**`; this is the
283
+ * other half, applied by an adapter that knows the session's directory.
284
+ * A path that climbs out of `cwd` with `..` stays absolute, so it stays
285
+ * outside.
286
+ */
287
+ function relativeTo(path, cwd) {
288
+ const target = normalize(path);
289
+ const base = normalize(cwd);
290
+ if (!target.absolute || !base.absolute || target.escaped) return path;
291
+ if (base.segments.length > target.segments.length) return path;
292
+ if (!base.segments.every((s, i) => s === target.segments[i])) return path;
293
+ const rest = target.segments.slice(base.segments.length);
294
+ return rest.length === 0 ? "." : `./${rest.join("/")}`;
295
+ }
296
+ /**
275
297
  * The host of a URL, or undefined when it is not a URL we can read.
276
298
  *
277
299
  * Unparseable input returns undefined, and callers treat that as "no host" —
@@ -496,4 +518,4 @@ function evaluate(pack, facts) {
496
518
  }
497
519
 
498
520
  //#endregion
499
- export { hostOf as n, withinAny as r, evaluate as t };
521
+ export { withinAny as a, relativeTo as i, PATH_ARGS as n, segments as o, hostOf as r, evaluate as t };
@@ -0,0 +1,118 @@
1
+ //#region ../kernel/src/frameworks.ts
2
+ const FRAMEWORKS = {
3
+ "owasp-llm-top-10-2026": {
4
+ name: "OWASP Top 10 for LLM Applications — 2026",
5
+ short: "OWASP LLM 2026",
6
+ controls: {
7
+ LLM01: {
8
+ name: "Prompt Injection",
9
+ summary: "Instructions in a prompt or in retrieved content alter what the model does."
10
+ },
11
+ LLM02: {
12
+ name: "Sensitive Information Disclosure",
13
+ summary: "Credentials, personal data or proprietary content reach the model or its output."
14
+ },
15
+ LLM03: {
16
+ name: "Excessive Agency",
17
+ summary: "The model can take actions, or holds permissions, beyond what its task needs."
18
+ },
19
+ LLM04: {
20
+ name: "Supply Chain",
21
+ summary: "A compromised model, dataset, plugin or dependency enters the application."
22
+ },
23
+ LLM05: {
24
+ name: "Data and Model Poisoning",
25
+ summary: "Training, fine-tuning or embedding data is manipulated to change behaviour."
26
+ },
27
+ LLM06: {
28
+ name: "Unbounded Consumption",
29
+ summary: "The application permits resource use without a limit, exhausting budget or service."
30
+ },
31
+ LLM07: {
32
+ name: "Misinformation",
33
+ summary: "The model produces false or misleading content that is acted on as if true."
34
+ },
35
+ LLM08: {
36
+ name: "Hidden Context Exposure",
37
+ summary: "System prompts, hidden instructions or internal context are revealed."
38
+ },
39
+ LLM09: {
40
+ name: "Vector and Embedding Weaknesses",
41
+ summary: "Retrieval stores are manipulated or leak across tenants and permissions."
42
+ },
43
+ LLM10: {
44
+ name: "Improper Output Handling",
45
+ summary: "Model output is passed to downstream systems without validation."
46
+ }
47
+ }
48
+ },
49
+ "owasp-agentic-top-10-2026": {
50
+ name: "OWASP Top 10 for Agentic Applications — 2026",
51
+ short: "OWASP Agentic 2026",
52
+ controls: {
53
+ ASI01: {
54
+ name: "Agent Goal Hijack",
55
+ summary: "Untrusted input redirects what the agent is trying to do, turning its objectives against its owner."
56
+ },
57
+ ASI02: {
58
+ name: "Tool Misuse and Exploitation",
59
+ summary: "A legitimate tool the agent already holds is turned to an attacker's ends."
60
+ },
61
+ ASI03: {
62
+ name: "Identity and Privilege Abuse",
63
+ summary: "The agent's own authority, credentials or delegated access is inherited or escalated."
64
+ },
65
+ ASI04: {
66
+ name: "Agentic Supply Chain",
67
+ summary: "A poisoned tool, model, plugin, skill or dependency is trusted before the agent runs."
68
+ },
69
+ ASI05: {
70
+ name: "Unexpected Code Execution",
71
+ summary: "The agent's output reaches something that runs it: a shell, an interpreter, a parser."
72
+ },
73
+ ASI06: {
74
+ name: "Memory and Context Poisoning",
75
+ summary: "Corrupted memory or retrieved context persists across sessions and steers future reasoning."
76
+ },
77
+ ASI07: {
78
+ name: "Insecure Inter-Agent Communication",
79
+ summary: "One agent trusts another's messages by default, so a compromised agent can steer its peers."
80
+ },
81
+ ASI08: {
82
+ name: "Cascading Failures",
83
+ summary: "A single fault propagates across agents, tools and workflows into system-wide impact."
84
+ },
85
+ ASI09: {
86
+ name: "Human-Agent Trust Exploitation",
87
+ summary: "Persuasive or misleading agent output moves a person to an unsafe action or approval."
88
+ },
89
+ ASI10: {
90
+ name: "Rogue Agents",
91
+ summary: "A compromised, misaligned or drifting agent keeps operating in unintended ways."
92
+ }
93
+ }
94
+ }
95
+ };
96
+ /**
97
+ * Every framework control a rule cites, resolved to names.
98
+ *
99
+ * A pack that parsed cites only ids the registry knows, so the lookups here
100
+ * cannot miss; the fallbacks exist for a `Rule` built by hand in code.
101
+ */
102
+ function cite(rule) {
103
+ const out = [];
104
+ for (const [framework, controls] of Object.entries(rule.frameworks ?? {})) {
105
+ const known = FRAMEWORKS[framework];
106
+ for (const control of controls) out.push({
107
+ framework,
108
+ frameworkName: known?.name ?? framework,
109
+ frameworkShort: known?.short ?? framework,
110
+ control,
111
+ controlName: known?.controls[control]?.name ?? control
112
+ });
113
+ }
114
+ return out;
115
+ }
116
+
117
+ //#endregion
118
+ export { cite as n, FRAMEWORKS as t };