@hank-warren/pi-auto-permissions 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # @hank-warren/pi-auto-permissions
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 488b4bd: Auto Permissions becomes a review-by-default competitor to Claude Code auto mode.
8
+
9
+ **Migration note:** a fresh install (or a config with no `rules` key) now runs the built-in default ruleset — deny rules for agent-oversight bypasses and critical-path destruction, guardian review for force pushes, infrastructure destroys, credential access, and the other default groups. `"rules": []` restores the old gate-nothing behavior; an authored `rules` array is unchanged; `"$defaults"` inside an authored array splices the built-ins in.
10
+
11
+ New in pi-auto-permissions:
12
+
13
+ - `deny` rule level: a hard boundary that outranks every other match and cannot be cleared by `request_override`, `.pi/trusted-ops`, or user approval
14
+ - Built-in default ruleset (9 groups) active when no `rules` key is authored, with `"$defaults"` splice support
15
+ - `reviewAllShell`: blanket guardian review for commands no rule matches
16
+ - `guardianPolicy`: prose trust configuration (environment / allow / softDeny / hardDeny) with four-tier precedence
17
+ - Session-start trust snapshot: remotes captured once at session start; remotes added mid-session sit outside the trust baseline
18
+ - Unresolvable destructive targets (`rm -rf "$VAR"` with no visible assignment) get `revise` naming the literal-path fix
19
+ - Optional two-stage review: a stateless single-token SAFE/REVIEW prefilter (`reviewer.prefilter`) before full review, fail-closed
20
+ - Delegated (subagent) tasks held to an explicit-intent standard; non-allowlisted custom messages become capped tool-source evidence
21
+ - Denial ledger (`denials.jsonl`), Recent-denials + Allow-on-retry in `/auto-permissions`, an `auto-permissions:denied` event, and permission overrides that persist across session resume
22
+ - `/auto-permissions setup`: a wizard that scans the project and recent session command strings (never user messages) and drafts `guardianPolicy.environment` entries plus trusted-ops suggestions
23
+
24
+ pi-permission-selector: `OptionSelector` title lines now wrap to the render width instead of overflowing — an unwrapped reviewer-failure detail in the title crashed the whole session under pi-tui's width assertion.
25
+
26
+ ### Patch Changes
27
+
28
+ - Updated dependencies [488b4bd]
29
+ - @hank-warren/pi-permission-selector@1.4.1
30
+
31
+ ## 0.12.1
32
+
33
+ ### Patch Changes
34
+
35
+ - 895b323: Require `@hank-warren/pi-permission-selector` `^1.4.0`, the release where option
36
+ descriptions wrap instead of being truncated. The old `^1.3.0` range resolved to
37
+ 1.4.0 only on a host that also installed a sibling asking for it; installed on
38
+ its own, `pi-auto-permissions` could still satisfy the range with 1.3.x and
39
+ present a silently different selector.
40
+
3
41
  ## 0.12.0
4
42
 
5
43
  ### Minor Changes
package/README.md CHANGED
@@ -19,6 +19,23 @@ Pi Auto Permissions pauses configured Bash commands before execution. A guardian
19
19
 
20
20
  Only user messages can grant permission or impose constraints. The assistant cannot authorize its own command.
21
21
 
22
+ The posture is review-by-default: a fresh install ships a live ruleset (deny rules for oversight bypasses and critical-path destruction, guardian review for force pushes, infrastructure destroys, credential access, and the rest of the default groups), prose trust configuration in `guardianPolicy`, a session-start trust snapshot, an optional blanket `reviewAllShell` mode with a cheap prefilter stage, a denial ledger with allow-on-retry, and a setup wizard that drafts the trust config from your project.
23
+
24
+ ## Compared with Claude Code auto mode
25
+
26
+ The design deliberately mirrors Claude Code's auto mode (default ruleset content, trust tiers, `"$defaults"` splice, session-start remote baseline, two-stage review, denial review) while keeping the pieces pi does better. Honestly stated, both directions:
27
+
28
+ | | pi-auto-permissions | Claude Code auto mode |
29
+ |---|---|---|
30
+ | **Loop/retry budgets** | Three orthogonal budgets (per-concern, per-gate, streak); the streak resets only when an approval lands on a gate that was actually blocking; persisted across compaction and resume | 3-consecutive/20-total; *any* allowed action resets the consecutive counter, so interleaved reads defeat it; not configurable |
31
+ | **Verdicts and reasons** | `approve`/`revise`/`ask_user`; always a concrete one-sentence reason; `revise` tells the agent exactly how to clear the objection | Deny only; fixed `Blocked by classifier` text in most sessions |
32
+ | **Evidence caching** | Append-only reviewer lineage: full envelope once, deltas after, fingerprint-invalidated | Stage-1→stage-2 prompt cache; no cross-review evidence cache |
33
+ | **Deterministic surface** | Regex gates are testable; the false-positive surface is bounded by the patterns; the guardian runs only after a mechanical match | Prose rules are checkable only by running the classifier |
34
+ | **Evaluation loop** | Labelled evaluation JSONL (three-way user labels, prefilter FNR records) + per-review cost sidecar | `/feedback`; no per-review cost or label surface |
35
+ | **Coverage** | **Bash only.** Edits, writes, fetches, MCP tools, and subagent spawns are ungated (the beyond-bash plan is the named follow-up) | All tool calls Tier 3-reviewed; in-project edits skip review by design |
36
+ | **Sandbox** | None — pair with OS sandboxing for a hard boundary | Integrated sandbox + network-domain review |
37
+ | **Injection posture** | Keeps assistant text and tool-call summaries as (non-authorizing) evidence — better provenance, larger injection surface | Strips assistant text and tool output entirely; input-layer injection probe |
38
+
22
39
  ## Example
23
40
 
24
41
  Suppose Git commits are globally guarded.
@@ -59,7 +76,11 @@ Remove or disable other extensions that gate the same commands to avoid duplicat
59
76
 
60
77
  ## Define your policy
61
78
 
62
- The extension ships with an empty command policy. Nothing is guarded until you add rules, and commands that do not match a rule run normally.
79
+ The extension ships with a default policy, derived from the mechanically matchable rows of Claude Code auto mode's blocked-by-default list. Out of the box — no config file at all — it denies agent-oversight bypasses and critical-path destruction outright, and sends force pushes, history rewrites, infrastructure destroys, piped remote code execution, credential access, registry overrides, PR merges/self-approvals, and shell writes to protected dotfiles to the guardian. A normal dev session (build, test, commit, push a feature branch) triggers zero gates.
80
+
81
+ The policy is a list of rules. When your config has no `rules` key at all, the built-in ruleset is active. An authored `rules` array fully replaces the built-ins — unless it splices them back in with the literal string `"$defaults"`, which expands to the built-in ruleset at that position so your entries can sit before or after them. An explicit `"rules": []` gates nothing (the pre-0.13 out-of-box behavior, now an explicit opt-out). Commands that do not match a rule run normally.
82
+
83
+ The default groups — usable in `.pi/trusted-ops` (guarded rules only) — are `oversight`, `destructive`, `git`, `iac`, `net-exec`, `secrets`, `supply-chain`, `review`, and `protected-paths`. See [`default-rules.ts`](default-rules.ts) for the exact patterns and levels.
63
84
 
64
85
  Configuration is read before every Bash command from:
65
86
 
@@ -69,11 +90,12 @@ $PI_CODING_AGENT_DIR/pi-auto-permissions/config.json
69
90
 
70
91
  `PI_CODING_AGENT_DIR` defaults to `~/.pi/agent`.
71
92
 
72
- Here is a small starter policy for commits, pushes, and publishing:
93
+ Here is a policy that keeps the defaults and additionally gates commits, pushes, and publishing:
73
94
 
74
95
  ```json
75
96
  {
76
97
  "rules": [
98
+ "$defaults",
77
99
  {
78
100
  "pattern": "\\bgit\\s+commit\\b",
79
101
  "flags": "i",
@@ -103,19 +125,74 @@ Rule fields are:
103
125
 
104
126
  - `pattern`: JavaScript regular expression source
105
127
  - `flags`: optional regular expression flags, defaulting to `i`
106
- - `level`: `guarded` for guardian review or `convention` for a direct policy block
128
+ - `level`: `guarded` for guardian review, `convention` for an overridable policy block, or `deny` for a hard block
107
129
  - `group`: policy group used by trusted-project bypasses
108
130
  - `label`: short description shown during review
109
- - `message`: optional feedback; required for convention rules
131
+ - `message`: optional feedback; required for convention and deny rules
110
132
 
111
133
  A convention rule blocks directly with its configured feedback instead of asking the guardian. The agent can call `request_override` for a legitimate one-session exception. Overrides require user confirmation and cannot bypass guarded rules.
112
134
 
135
+ A deny rule is a hard policy boundary: it blocks immediately with its message, is evaluated before every other level, and nothing lifts it — not `request_override`, not a trusted group, not user approval at a prompt. Reserve it for operations that should never happen in an agent session (disabling agent oversight, deleting critical paths), and use `guarded` where a human judgment call is legitimate.
136
+
137
+ When several rules match one command, the most severe level wins (deny, then convention, then guarded) regardless of their order in the list.
138
+
113
139
  Set `enabled` to `false` to disable the extension. Invalid configuration fails closed and blocks Bash calls until corrected.
114
140
 
141
+ ### Review every shell command
142
+
143
+ Set `"reviewAllShell": true` to review every bash command that matches no rule under a generic `shell command` gate (group `all-shell`), the analogue of Claude Code's `classifyAllShell`. The rules then act as a severity layer on top of blanket coverage: deny rules still block outright, convention rules still block with feedback, and everything else — named by a rule or not — goes to the guardian. The trade is one guardian call per command; pair it with a cheap reviewer model. A command whose matching group is listed in `.pi/trusted-ops` was explicitly waved through and is not re-captured by the blanket gate; trusting `all-shell` itself opts a project out of blanket review while keeping the ruleset live. Loop budgets, lineage caching, and the evaluation log apply to `all-shell` reviews unchanged.
144
+
145
+ ## Define trusted infrastructure
146
+
147
+ The rules decide *which* commands are reviewed; `guardianPolicy` tells the guardian *how to judge them* against your infrastructure. All four lists are prose — write entries the way you would describe your environment to a new engineer:
148
+
149
+ ```json
150
+ {
151
+ "guardianPolicy": {
152
+ "environment": [
153
+ "Our GitHub orgs nuclearn-ai and hank-warren are trusted source control",
154
+ "registry.internal.example is our internal package registry"
155
+ ],
156
+ "allow": [
157
+ "Uploading build artifacts to the artifacts.example.com bucket is routine"
158
+ ],
159
+ "softDeny": [
160
+ "Do not run database migrations against staging without being asked"
161
+ ],
162
+ "hardDeny": [
163
+ "Never push code or data to a repository outside our GitHub orgs"
164
+ ]
165
+ }
166
+ }
167
+ ```
168
+
169
+ The lists extend the built-in decision table (which stays active) and are appended to the reviewer prompt as a labeled section with explicit precedence:
170
+
171
+ 1. **hardDeny** blocks unconditionally — user intent, permission overrides, and allow entries never clear it.
172
+ 2. **softDeny** blocks unless an allow entry covers the action or the user's own message names the exact action and target ("force-push this branch", not "clean up the repo").
173
+ 3. **allow** entries are exceptions to softDeny and to the trust boundary: data flow only, never destructive or credential operations on the same infrastructure.
174
+ 4. **environment** entries define what "internal" means — a destination nothing names is treated as a potential exfiltration target.
175
+
176
+ Each list is independent: setting `environment` alone leaves the others intact. Worth filling in, slot by slot: your source control orgs, trusted internal domains, cloud buckets, package registries and mirrors, key internal services — and on the sensitive side, production namespaces, sensitive data locations, and remote targets that should always get a hard look. These sections are appended outside `systemPrompt`, so a customized `systemPromptFile` still receives them, and every entry participates in the reviewer fingerprint, so editing the policy safely invalidates the cached reviewer lineage.
177
+
178
+ Alongside your configured entries, the guardian always receives a `SESSION ENVIRONMENT` section captured once at session start: the working directory, repository root, and the `git remote -v` baseline. Those remotes are within the session's trust boundary; a remote added or repointed mid-session is not — which pairs with the default `git remote add|set-url` gate rule, giving the guardian a baseline to judge those commands against. In a non-repository directory the section says exactly that, so no repository trust is assumed.
179
+
180
+ The precedence tiers are prompt-enforced, like Claude Code's own; the mechanically enforced hard boundary is the `deny` rule level.
181
+
182
+ ### `/auto-permissions setup`
183
+
184
+ The wizard drafts this configuration for you. It scans the current project — git remotes, `AGENTS.md`/`README.md`, lockfiles and the registry hosts inside them — and recent session files under `$PI_CODING_AGENT_DIR/sessions` for **command strings only** (hosts, registries, buckets, CLI names — never user messages, matching Claude Code's privacy line for `/auto-mode-setup`). One model call turns the facts into proposed `guardianPolicy.environment` entries plus suggested `.pi/trusted-ops` groups, rendered for accept-or-discard as a whole. Accepting appends the environment entries through the narrow config writer (nothing else in the file is rewritten); the trusted-ops suggestions are display-only — a wizard must not widen a project-level bypass file on its own. And deliberately, `guardianPolicy` is read only from the user-scoped config file — never from a project file — so a checked-in repository cannot inject its own trust entries.
185
+
115
186
  ## Guardian configuration
116
187
 
117
188
  By default, the guardian uses Pi's active model with low reasoning effort and a 30-second timeout. You can select a separate low-cost model.
118
189
 
190
+ ### Two-stage review
191
+
192
+ Set `"prefilter": true` inside the `reviewer` block for the Claude Code stage-1/stage-2 structure: before the full lineage review, a stateless single-token pass at minimal reasoning answers `SAFE` or `REVIEW` over the same envelope text. `SAFE` approves immediately (the review row shows reason `prefilter`); `REVIEW` — and any parse or infrastructure failure — falls through to the full review unchanged, so the prefilter can only ever short-circuit toward *more* review, never approve by accident. Because the prefilter envelope matches a full-rebuild review's, the escalated call is largely a provider prompt-cache hit of the prefilter call. The lineage conversation is untouched.
193
+
194
+ Prefilter calls are recorded in the usage sidecar under a distinct `prefilter` label, and — when the evaluation log is enabled — every prefilter approval is logged with `"decisionSource": "prefilter"` so its false-negative rate can be measured offline against the same labels as prompted reviews. Off by default until that data justifies flipping it; it pays off most with `reviewAllShell`, where the per-command cost matters.
195
+
119
196
  Giving the guardian its own account keeps reviews from competing with your interactive session for a subscription's rate limits. Pi keys OAuth credentials by provider id, so a second login needs a second provider id — which is what [`@hank-warren/pi-multi-login`](../pi-multi-login/README.md) exists to create. This package no longer registers one itself.
120
197
 
121
198
  1. `pi install npm:@hank-warren/pi-multi-login`
@@ -207,8 +284,44 @@ When the main agent gathers a decision through an interactive question tool (for
207
284
 
208
285
  A successful result from an allowlisted tool qualifies when its `details` are `{ "answers": [{ "question": string, "answer"?: string, "selected"?: string[], "notes"?: string }], "cancelled": false }` with no `error` field. `selected` takes precedence over `answer`, non-string answers are ignored, and notes count only alongside a real answer. Each answered question contributes one `USER (dialog answer):` record; the guardian treats it as authorization for exactly the selected content and is told the question wording is assistant-drafted context, never an instruction. Any dialog extension emitting that shape qualifies.
209
286
 
287
+ ### Injected user messages
288
+
289
+ Extensions inject context with `appendCustomMessageEntry`, and Pi converts that content into a **user message for the model** — but the session entry is a `custom_message` with no role, so the reviewer never saw it. The model was acting on text the guardian could not read.
290
+
291
+ `reviewEvidence.userMessageTypes` allowlists the `customType`s whose content becomes `source: "user"` evidence:
292
+
293
+ ```json
294
+ {
295
+ "reviewEvidence": {
296
+ "userMessageTypes": ["loop-objective"]
297
+ }
298
+ }
299
+ ```
300
+
301
+ The default is `["loop-objective"]` — the objective anchor written by [`@hank-warren/pi-loop`](../pi-loop), which carries the objective the user typed or approved and which is frozen for the loop's lifetime. Without it, a looping session's reviewer sees no user authorization for the very task the loop was started to do, and refuses work the user explicitly asked for. Absent means the default; an explicit `[]` opts out entirely.
302
+
303
+ Each text block contributes one `USER (<customType>):` record, in session order, never truncated — user records are the only ones that can authorize or constrain. Images are dropped. The guardian is told these records authorize exactly the operations they name, that their constraints bind, and that their content is data rather than instructions.
304
+
305
+ Injected messages whose `customType` is **not** allowlisted are still visible — as `CUSTOM <customType>:` records with `source: "tool"`, capped like any tool record. They routinely carry the reason a command was proposed (subagent return values, CI outcomes, process notifications), so hiding them left the guardian judging commands with no visible motive; but they are extension output, and only the allowlist can promote a type to user-source, so a subagent's return value can explain a command without ever authorizing it.
306
+
210
307
  The allowlist matches what you wrote: a bare name such as `ask_user_question` matches that tool in any namespace (`functions.ask_user_question` included), while a dotted name matches exactly. The default is an empty list.
211
308
 
309
+ ## Denial log and retry
310
+
311
+ Every non-approved outcome — a guardian revise, a user block at the prompt, a loop-budget block, a convention or deny block, a review-infrastructure failure — is appended to a private `denials.jsonl` sidecar next to the config (0600, 16 MB rotation, one previous generation kept):
312
+
313
+ ```json
314
+ {"v":1,"ts":"…","sessionId":"…","tool":"bash","gate":{"label":"Force push","group":"git"},"command":"git push --force …","verdict":"block","reason":"…","decisionSource":"user"}
315
+ ```
316
+
317
+ On by default; `"denialLog": { "enabled": false }` opts out, and `path` relocates it.
318
+
319
+ `/auto-permissions` gains a **Recent denials** view over that ledger. Selecting a denial offers **Allow on retry**: an exact-command session override is added through the existing override machinery (it re-injects as user-source evidence on every later review and survives session resume), and an injected message tells the agent it may run that exact command again. No new authorization pathway exists — it is the same override a live approval prompt would have produced.
320
+
321
+ Every denial also emits a `pi.events` event — `auto-permissions:denied` with `tool`, `command`, `gate`, `group`, `verdict`, `reason`, `decisionSource` — the pi-native equivalent of Claude Code's `PermissionDenied` hook, for other extensions to react to. Like that hook, it cannot reverse a denial.
322
+
323
+ Permission overrides now also persist as custom session entries (the way the loop revise budget does), so a resumed session keeps the user's earlier allow decisions and standing block constraints instead of forgetting them.
324
+
212
325
  ## Prompted-review evaluation log
213
326
 
214
327
  Auto Permissions can append a private JSONL regression record whenever the guardian asks for confirmation and the user gives explicit feedback:
@@ -305,7 +418,7 @@ git
305
418
  gh
306
419
  ```
307
420
 
308
- Group names come from your configured rules. A trusted group bypasses all review for that group, so use it only in projects you control.
421
+ Group names come from your configured rules. A trusted group bypasses guarded review and convention blocks for that group, so use it only in projects you control. Deny rules are never bypassed: `.pi/trusted-ops` is a project-scoped file, and a checked-in file must not be able to disarm a hard policy boundary.
309
422
 
310
423
  ## Subagent sessions
311
424
 
@@ -349,6 +462,8 @@ Pi Auto Permissions is a permission layer for normal agent behavior. It is not a
349
462
 
350
463
  `reviewEvidence.userAnswerTools` widens what counts as user authorization: any code that can record a tool result under an allowlisted tool name can mint `USER (dialog answer)` evidence. Allowlist only tool names served by extensions you trust.
351
464
 
465
+ `reviewEvidence.userMessageTypes` widens it the same way, and is why it is an allowlist rather than "project every injected message": any installed extension can append a custom message under any `customType`, so a blanket rule would let any of them mint user authorization. Allowlist only types written by extensions you trust.
466
+
352
467
  ## License
353
468
 
354
469
  MIT
package/config-writer.ts CHANGED
@@ -26,6 +26,12 @@ export interface ReviewerBlock {
26
26
  export interface ConfigPatch {
27
27
  enabled?: boolean;
28
28
  reviewer?: ReviewerBlock;
29
+ /**
30
+ * Entries to append to `guardianPolicy.environment` (the setup wizard's
31
+ * accept path). Deduplicated against what is already there; every other
32
+ * `guardianPolicy` list and unrelated key is left exactly as written.
33
+ */
34
+ appendEnvironment?: string[];
29
35
  }
30
36
 
31
37
  const DEFAULT_INDENT = " ";
@@ -82,14 +88,36 @@ export function patchAutoPermissionsConfig(
82
88
  else merged.enabled = false;
83
89
  }
84
90
  if (patch.reviewer !== undefined) {
91
+ // Preserve file-only reviewer keys the menu does not edit (prefilter):
92
+ // this writer must never silently drop what the human wrote by hand.
93
+ const existingReviewer = merged.reviewer && typeof merged.reviewer === "object" && !Array.isArray(merged.reviewer)
94
+ ? merged.reviewer as Record<string, unknown>
95
+ : {};
85
96
  merged.reviewer = {
86
97
  provider: patch.reviewer.provider,
87
98
  model: patch.reviewer.model,
88
99
  reasoningEffort: patch.reviewer.reasoningEffort,
89
100
  timeoutMs: patch.reviewer.timeoutMs,
101
+ ...(existingReviewer.prefilter !== undefined ? { prefilter: existingReviewer.prefilter } : {}),
90
102
  };
91
103
  }
92
104
 
105
+ if (patch.appendEnvironment !== undefined && patch.appendEnvironment.length > 0) {
106
+ const policy = merged.guardianPolicy && typeof merged.guardianPolicy === "object" && !Array.isArray(merged.guardianPolicy)
107
+ ? merged.guardianPolicy as Record<string, unknown>
108
+ : {};
109
+ const existingEnvironment = Array.isArray(policy.environment)
110
+ ? policy.environment.filter((entry): entry is string => typeof entry === "string")
111
+ : [];
112
+ policy.environment = [
113
+ ...new Set([
114
+ ...existingEnvironment,
115
+ ...patch.appendEnvironment.map((entry) => entry.trim()).filter(Boolean),
116
+ ]),
117
+ ];
118
+ merged.guardianPolicy = policy;
119
+ }
120
+
93
121
  const payload = `${JSON.stringify(merged, null, detectIndent(existing))}\n`;
94
122
  const temporary = `${path}.${pid}.tmp`;
95
123
  mkdirSync(dirname(path), { recursive: true });
package/config.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import type { Gate, GateLevel } from "./gates.js";
6
+ import { DEFAULT_RULES } from "./default-rules.js";
6
7
  import { AUTO_PERMISSIONS_SYSTEM_PROMPT } from "./review.js";
7
8
 
8
9
  export const CONFIG_FILENAME = "pi-auto-permissions/config.json";
@@ -33,12 +34,43 @@ export interface AutoPermissionsConfig {
33
34
  model: string;
34
35
  reasoningEffort: ReasoningEffort;
35
36
  timeoutMs: number;
37
+ /**
38
+ * Two-stage review: a stateless single-token SAFE/REVIEW pass at minimal
39
+ * reasoning before the full lineage review. SAFE approves; REVIEW and
40
+ * every parse or infrastructure failure fall through to the full review
41
+ * (fail closed). Opt-in until evaluation-log data justifies default-on;
42
+ * recommended together with `reviewAllShell`.
43
+ */
44
+ prefilter: boolean;
36
45
  };
37
46
  systemPrompt: string;
38
47
  systemPromptSource: SystemPromptSource;
39
48
  reviewEvidence: {
40
49
  projectInstructions: boolean;
41
50
  userAnswerTools: string[];
51
+ /**
52
+ * `custom_message` customTypes whose content counts as user-source
53
+ * evidence.
54
+ *
55
+ * Extensions inject context with `appendCustomMessageEntry`, and Pi turns
56
+ * that content into a user message for the model — but the session entry
57
+ * is a `custom_message`, not a message with a role, so the evidence
58
+ * collector never saw it. The model read the text as the user's; the
59
+ * reviewer read nothing at all.
60
+ *
61
+ * pi-loop's objective is the case that matters: user-typed or
62
+ * user-approved, frozen at start, and the whole reason the session is
63
+ * doing what it is doing. Reviewing a looping session without it means
64
+ * refusing work the user explicitly asked for — observed live, on a
65
+ * `git worktree add` whose objective said "in a worktree branched off
66
+ * origin/main".
67
+ *
68
+ * An allowlist rather than "project every custom message", for the same
69
+ * reason `userAnswerTools` is one: any installed extension can append a
70
+ * custom message, and a blanket rule would let any of them mint user
71
+ * authorization. Naming the types keeps that an explicit choice.
72
+ */
73
+ userMessageTypes: string[];
42
74
  /** Per-record caps at evidence creation; 0 disables. User records are never truncated. */
43
75
  toolRecordMaxChars: number;
44
76
  assistantRecordMaxChars: number;
@@ -54,7 +86,37 @@ export interface AutoPermissionsConfig {
54
86
  enabled: boolean;
55
87
  path: string;
56
88
  };
89
+ /** Every non-approved outcome, for the Recent denials view. Default on. */
90
+ denialLog: {
91
+ enabled: boolean;
92
+ path: string;
93
+ };
57
94
  rules: Gate[];
95
+ /**
96
+ * Review every bash command that matches no rule under the generic
97
+ * `shell command` gate (`ALL_SHELL_GATE`). Default false: the ruleset is
98
+ * the reviewed surface. The trade is deliberate and mirrors Claude Code's
99
+ * `classifyAllShell` — full coverage for one guardian call per command.
100
+ */
101
+ reviewAllShell: boolean;
102
+ /**
103
+ * Prose trust configuration, appended to the reviewer policy prompt as a
104
+ * labeled section (outside `systemPrompt`, so customized prompt files still
105
+ * receive it). Entries are natural-language rules — "write them the way you
106
+ * would describe your infrastructure to a new engineer" — mirroring Claude
107
+ * Code's `autoMode.environment`/`allow`/`soft_deny`/`hard_deny`.
108
+ *
109
+ * Each list is independent: setting one leaves the others (and the built-in
110
+ * decision table, which stays the default policy) intact. Deliberately
111
+ * user-scoped-config only — no project-file read — closing by construction
112
+ * the checked-in-file injection hole CC patched in v2.1.207.
113
+ */
114
+ guardianPolicy: {
115
+ environment: string[];
116
+ allow: string[];
117
+ softDeny: string[];
118
+ hardDeny: string[];
119
+ };
58
120
  ui: {
59
121
  enabled: boolean;
60
122
  resultDisplayMs: number;
@@ -109,11 +171,13 @@ function compileRule(value: unknown, index: number): Gate {
109
171
  const flags = input.flags === undefined ? "i" : input.flags;
110
172
  if (typeof flags !== "string") throw new Error(`rules[${index}].flags must be a string`);
111
173
  const level: GateLevel = input.level === undefined ? "guarded" : input.level as GateLevel;
112
- if (level !== "guarded" && level !== "convention") {
113
- throw new Error(`rules[${index}].level must be guarded or convention`);
174
+ if (level !== "guarded" && level !== "convention" && level !== "deny") {
175
+ throw new Error(`rules[${index}].level must be guarded, convention, or deny`);
114
176
  }
115
177
  const message = optionalString(input.message, `rules[${index}].message`);
116
- if (level === "convention" && !message) throw new Error(`rules[${index}].message is required for convention rules`);
178
+ if ((level === "convention" || level === "deny") && !message) {
179
+ throw new Error(`rules[${index}].message is required for ${level} rules`);
180
+ }
117
181
 
118
182
  return {
119
183
  pattern: new RegExp(pattern, flags),
@@ -124,6 +188,36 @@ function compileRule(value: unknown, index: number): Gate {
124
188
  };
125
189
  }
126
190
 
191
+ /** The literal a `rules` array uses to splice the built-in ruleset in place. */
192
+ export const DEFAULT_RULES_TOKEN = "$defaults";
193
+
194
+ /**
195
+ * Compile an authored `rules` array, splicing `defaults` wherever the literal
196
+ * string `"$defaults"` appears (mirroring Claude Code's splice semantics: the
197
+ * built-ins keep updating across releases, and custom entries can sit before
198
+ * or after them). The token may appear at most once — a second occurrence is
199
+ * far more likely a copy-paste error than a request for double gating.
200
+ *
201
+ * The caller handles the other two shapes: an absent `rules` key means the
202
+ * defaults are active, and an authored array without the token is a full
203
+ * replacement (an explicit `[]` gates nothing).
204
+ */
205
+ export function expandRules(rawRules: readonly unknown[], defaults: readonly Gate[]): Gate[] {
206
+ const rules: Gate[] = [];
207
+ let spliced = false;
208
+ for (let index = 0; index < rawRules.length; index++) {
209
+ const value = rawRules[index];
210
+ if (value === DEFAULT_RULES_TOKEN) {
211
+ if (spliced) throw new Error(`rules may contain "${DEFAULT_RULES_TOKEN}" at most once`);
212
+ spliced = true;
213
+ rules.push(...defaults);
214
+ continue;
215
+ }
216
+ rules.push(compileRule(value, index));
217
+ }
218
+ return rules;
219
+ }
220
+
127
221
  function resolvePrompt(
128
222
  raw: Record<string, unknown>,
129
223
  path: string,
@@ -141,6 +235,16 @@ function resolvePrompt(
141
235
  return { prompt, source: { kind: "file", path: resolved } };
142
236
  }
143
237
 
238
+ /**
239
+ * The one injected message type trusted out of the box.
240
+ *
241
+ * pi-loop's kickoff anchor (`LOOP_ANCHOR_MESSAGE_TYPE`), which carries the
242
+ * objective the user typed or approved. Named as a plain string, the way
243
+ * `loop-context.ts` names `PI_LOOP_ACTIVE`: no import, no dependency, and no
244
+ * behaviour at all when pi-loop is not installed.
245
+ */
246
+ export const DEFAULT_USER_MESSAGE_TYPES = ["loop-objective"] as const;
247
+
144
248
  const EVIDENCE_PRUNING_DEFAULTS = {
145
249
  toolRecordMaxChars: 500,
146
250
  assistantRecordMaxChars: 1000,
@@ -159,7 +263,12 @@ function resolvePruningKnob(evidence: Record<string, unknown>, name: keyof typeo
159
263
 
160
264
  function resolveReviewEvidence(raw: Record<string, unknown>): AutoPermissionsConfig["reviewEvidence"] {
161
265
  if (raw.reviewEvidence === undefined) {
162
- return { projectInstructions: false, userAnswerTools: [], ...EVIDENCE_PRUNING_DEFAULTS };
266
+ return {
267
+ projectInstructions: false,
268
+ userAnswerTools: [],
269
+ userMessageTypes: [...DEFAULT_USER_MESSAGE_TYPES],
270
+ ...EVIDENCE_PRUNING_DEFAULTS,
271
+ };
163
272
  }
164
273
  if (!raw.reviewEvidence || typeof raw.reviewEvidence !== "object" || Array.isArray(raw.reviewEvidence)) {
165
274
  throw new Error("reviewEvidence must be an object");
@@ -172,9 +281,22 @@ function resolveReviewEvidence(raw: Record<string, unknown>): AutoPermissionsCon
172
281
  if (!Array.isArray(rawTools) || rawTools.some((tool) => typeof tool !== "string" || !tool.trim())) {
173
282
  throw new Error("reviewEvidence.userAnswerTools must be an array of non-empty strings");
174
283
  }
284
+ // Absent means the default, and an explicit empty array means none: a user
285
+ // who wants the loop objective out of the envelope must be able to say so,
286
+ // and cannot if omission and `[]` are the same thing.
287
+ const rawMessageTypes = evidence.userMessageTypes === undefined
288
+ ? [...DEFAULT_USER_MESSAGE_TYPES]
289
+ : evidence.userMessageTypes;
290
+ if (
291
+ !Array.isArray(rawMessageTypes)
292
+ || rawMessageTypes.some((type) => typeof type !== "string" || !type.trim())
293
+ ) {
294
+ throw new Error("reviewEvidence.userMessageTypes must be an array of non-empty strings");
295
+ }
175
296
  return {
176
297
  projectInstructions: evidence.projectInstructions === true,
177
298
  userAnswerTools: [...new Set((rawTools as string[]).map((tool) => tool.trim()))],
299
+ userMessageTypes: [...new Set((rawMessageTypes as string[]).map((type) => type.trim()))],
178
300
  toolRecordMaxChars: resolvePruningKnob(evidence, "toolRecordMaxChars"),
179
301
  assistantRecordMaxChars: resolvePruningKnob(evidence, "assistantRecordMaxChars"),
180
302
  compactionRecordMaxChars: resolvePruningKnob(evidence, "compactionRecordMaxChars"),
@@ -224,6 +346,56 @@ function resolveUsageLog(
224
346
  return { enabled, path: isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded) };
225
347
  }
226
348
 
349
+ const GUARDIAN_POLICY_KEYS = ["environment", "allow", "softDeny", "hardDeny"] as const;
350
+
351
+ function resolveGuardianPolicy(raw: Record<string, unknown>): AutoPermissionsConfig["guardianPolicy"] {
352
+ const empty = { environment: [], allow: [], softDeny: [], hardDeny: [] };
353
+ if (raw.guardianPolicy === undefined) return empty;
354
+ if (!raw.guardianPolicy || typeof raw.guardianPolicy !== "object" || Array.isArray(raw.guardianPolicy)) {
355
+ throw new Error("guardianPolicy must be an object");
356
+ }
357
+ const policy = raw.guardianPolicy as Record<string, unknown>;
358
+ for (const key of Object.keys(policy)) {
359
+ if (!(GUARDIAN_POLICY_KEYS as readonly string[]).includes(key)) {
360
+ throw new Error(`guardianPolicy.${key} is not a recognized list (use environment, allow, softDeny, hardDeny)`);
361
+ }
362
+ }
363
+ const resolveList = (key: typeof GUARDIAN_POLICY_KEYS[number]): string[] => {
364
+ const value = policy[key];
365
+ if (value === undefined) return [];
366
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || !entry.trim())) {
367
+ throw new Error(`guardianPolicy.${key} must be an array of non-empty strings`);
368
+ }
369
+ return [...new Set((value as string[]).map((entry) => entry.trim()))];
370
+ };
371
+ return {
372
+ environment: resolveList("environment"),
373
+ allow: resolveList("allow"),
374
+ softDeny: resolveList("softDeny"),
375
+ hardDeny: resolveList("hardDeny"),
376
+ };
377
+ }
378
+
379
+ function resolveDenialLog(
380
+ raw: Record<string, unknown>,
381
+ configFilePath: string,
382
+ ): AutoPermissionsConfig["denialLog"] {
383
+ const defaultPath = resolve(dirname(configFilePath), "denials.jsonl");
384
+ if (raw.denialLog === undefined) return { enabled: true, path: defaultPath };
385
+ if (!raw.denialLog || typeof raw.denialLog !== "object" || Array.isArray(raw.denialLog)) {
386
+ throw new Error("denialLog must be an object");
387
+ }
388
+ const denialLog = raw.denialLog as Record<string, unknown>;
389
+ if (denialLog.enabled !== undefined && typeof denialLog.enabled !== "boolean") {
390
+ throw new Error("denialLog.enabled must be boolean");
391
+ }
392
+ const enabled = denialLog.enabled !== false;
393
+ const configuredPath = optionalString(denialLog.path, "denialLog.path");
394
+ if (!configuredPath) return { enabled, path: defaultPath };
395
+ const expanded = configuredPath.startsWith("~/") ? join(homedir(), configuredPath.slice(2)) : configuredPath;
396
+ return { enabled, path: isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded) };
397
+ }
398
+
227
399
  function resolveUi(raw: Record<string, unknown>): AutoPermissionsConfig["ui"] {
228
400
  if (raw.ui === undefined) return { enabled: true, resultDisplayMs: 2500, placement: "widget" };
229
401
  if (!raw.ui || typeof raw.ui !== "object" || Array.isArray(raw.ui)) {
@@ -266,14 +438,29 @@ function resolveReviewer(raw: Record<string, unknown>): AutoPermissionsConfig["r
266
438
  ) {
267
439
  throw new Error("reviewer.timeoutMs must be an integer between 1000 and 300000");
268
440
  }
269
- return { provider, model, reasoningEffort, timeoutMs: Number(timeoutMs) };
441
+ if (reviewer.prefilter !== undefined && typeof reviewer.prefilter !== "boolean") {
442
+ throw new Error("reviewer.prefilter must be boolean");
443
+ }
444
+ return {
445
+ provider,
446
+ model,
447
+ reasoningEffort,
448
+ timeoutMs: Number(timeoutMs),
449
+ prefilter: reviewer.prefilter === true,
450
+ };
270
451
  }
271
452
 
272
453
  export function loadAutoPermissionsConfig(path = autoPermissionsConfigPath()): AutoPermissionsConfig {
273
454
  const raw = readObject(path);
274
455
  if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") throw new Error("enabled must be boolean");
456
+ if (raw.reviewAllShell !== undefined && typeof raw.reviewAllShell !== "boolean") {
457
+ throw new Error("reviewAllShell must be boolean");
458
+ }
275
459
  if (raw.rules !== undefined && !Array.isArray(raw.rules)) throw new Error("rules must be an array");
276
- const customRules = (raw.rules ?? []).map(compileRule);
460
+ // Absent means the built-in ruleset is active; an authored array replaces it
461
+ // entirely unless it splices "$defaults" back in; an explicit [] gates
462
+ // nothing. Existing configs with their own rules keep exactly their rules.
463
+ const rules = raw.rules === undefined ? [...DEFAULT_RULES] : expandRules(raw.rules, DEFAULT_RULES);
277
464
  const prompt = resolvePrompt(raw, path);
278
465
 
279
466
  return {
@@ -284,7 +471,10 @@ export function loadAutoPermissionsConfig(path = autoPermissionsConfigPath()): A
284
471
  reviewEvidence: resolveReviewEvidence(raw),
285
472
  evaluationLog: resolveEvaluationLog(raw, path),
286
473
  usageLog: resolveUsageLog(raw, path),
287
- rules: customRules,
474
+ denialLog: resolveDenialLog(raw, path),
475
+ rules,
476
+ reviewAllShell: raw.reviewAllShell === true,
477
+ guardianPolicy: resolveGuardianPolicy(raw),
288
478
  ui: resolveUi(raw),
289
479
  };
290
480
  }