@hank-warren/pi-auto-permissions 0.16.1 → 0.17.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,35 @@
1
1
  # @hank-warren/pi-auto-permissions
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Simplify the extension and remove features that were never used. Permission is now granted only by the config you write and by what the user says in the current session.
8
+
9
+ Breaking changes:
10
+
11
+ - The `convention` rule level and the `request_override` tool are removed. A legacy `"level": "convention"` rule still loads, as a `deny` rule: it blocks without review, and `.pi/trusted-ops` can no longer lift it.
12
+ - The standing-approvals ledger is removed, along with the "Allow and stop asking about comparable commands" prompt option and the settings-menu row. Prompt decisions stay session-scoped and still survive `--continue`. Use `guardianPolicy.allow` for durable trust.
13
+ - The reviewer `prefilter` stage is removed. Every guarded command gets the full guardian review.
14
+ - `ui.placement: "toolRow"` is removed. Review status always renders in the widget above the editor.
15
+ - The approval prompt's pulsing indicator is now a static warning-colored `●`.
16
+ - The `reviewEvidence` pruning limits (`toolRecordMaxChars`, `assistantRecordMaxChars`, `compactionRecordMaxChars`, `fullRebuildKeepToolRecords`) are no longer configurable. They are fixed at their defaults.
17
+
18
+ Configs that still set `standingApprovals`, `reviewer.prefilter`, `ui.placement` or the pruning keys load without error, and those keys are ignored. Existing reviewer lineages rebuild once after upgrade.
19
+
20
+ Fixes:
21
+
22
+ - `review-evals.jsonl` now rotates to `review-evals.jsonl.1` at 64 MiB, keeping one previous generation.
23
+ - A full-rebuild tool eviction no longer rewrites injected `CUSTOM` records as `TOOL call → success`.
24
+ - Saving `/auto-permissions` settings keeps every hand-written `reviewer` key.
25
+ - Stale README and settings-menu text is corrected. The Enabled row now says that turning it off disables all gating, deny rules included.
26
+
27
+ ## 0.16.2
28
+
29
+ ### Patch Changes
30
+
31
+ - Comment-only: the docblock describing `OVERRIDE_FEEDBACK_SYSTEM_PROMPT` sat orphaned above `INJECTED_USER_MESSAGE_SYSTEM_PROMPT`; it is on the constant it describes now. No behaviour change.
32
+
3
33
  ## 0.16.1
4
34
 
5
35
  ### Patch Changes
package/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
  > [ogulcancelik/pi-extensions#24](https://github.com/ogulcancelik/pi-extensions/issues/24) /
6
6
  > [PR #25](https://github.com/ogulcancelik/pi-extensions/pull/25). Switch back to the
7
7
  > upstream package once it ships the feature. Never install both packages at once, or
8
- > every Bash call is reviewed twice. The upstream `index.test.ts` harness suite is not
9
- > vendored; the pure config/evidence tests live in `test/`.
8
+ > every Bash call is reviewed twice. Tests live in `test/`: pure module tests plus
9
+ > `test/extension.test.ts`, which drives the `tool_call` pipeline through a mock `ExtensionAPI`.
10
10
 
11
11
  A context-aware permission system for Pi shell commands, with an automated guardian that checks what the user actually authorized.
12
12
 
@@ -19,19 +19,18 @@ 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 bundled setup skill that co-authors the trust config with you from observed friction.
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, a denial ledger with allow-on-retry, and a bundled setup skill that co-authors the trust config with you from observed friction.
23
23
 
24
24
  ## Compared with Claude Code auto mode
25
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:
26
+ The design deliberately mirrors Claude Code's auto mode (default ruleset content, trust tiers, `"$defaults"` splice, session-start remote baseline, denial review) while keeping the pieces pi does better. Honestly stated, both directions:
27
27
 
28
28
  | | pi-auto-permissions | Claude Code auto mode |
29
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
30
  | **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
31
  | **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
32
  | **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 |
33
+ | **Evaluation loop** | Labelled evaluation JSONL (three-way user labels) + per-review cost sidecar | `/feedback`; no per-review cost or label surface |
35
34
  | **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
35
  | **Sandbox** | None — pair with OS sandboxing for a hard boundary | Integrated sandbox + network-domain review |
37
36
  | **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 |
@@ -58,7 +57,7 @@ git commit -m "Fix the Failing Retry Test and Update Documentation"
58
57
 
59
58
  If the conversation never authorized a commit, an interactive Pi session asks the user before running it. A non-interactive session blocks it.
60
59
 
61
- Permissions are contextual by default. Each command is judged against the current conversation and the exact action being proposed; the user may explicitly promote one guardian-approved prompt decision into a revocable standing approval for comparable commands.
60
+ Permissions are contextual by default. Each command is judged against the current conversation and the exact action being proposed.
62
61
 
63
62
  ## Install
64
63
 
@@ -125,22 +124,22 @@ Rule fields are:
125
124
 
126
125
  - `pattern`: JavaScript regular expression source
127
126
  - `flags`: optional regular expression flags, defaulting to `i`
128
- - `level`: `guarded` for guardian review, `convention` for an overridable policy block, or `deny` for a hard block
127
+ - `level`: `guarded` for guardian review, or `deny` for a hard block
129
128
  - `group`: policy group used by trusted-project bypasses
130
129
  - `label`: short description shown during review
131
- - `message`: optional feedback; required for convention and deny rules
130
+ - `message`: optional feedback; required for deny rules
132
131
 
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.
132
+ 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 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.
134
133
 
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.
134
+ When several rules match one command, the most severe level wins (deny, then guarded) regardless of their order in the list.
136
135
 
137
- When several rules match one command, the most severe level wins (deny, then convention, then guarded) regardless of their order in the list.
136
+ A legacy `"level": "convention"` rule still loads, as a deny rule: it blocks without review, and `.pi/trusted-ops` cannot lift it.
138
137
 
139
138
  Set `enabled` to `false` to disable the extension. Invalid configuration fails closed and blocks Bash calls until corrected.
140
139
 
141
140
  ### Review every shell command
142
141
 
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.
142
+ 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, 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. Lineage caching and the evaluation log apply to `all-shell` reviews unchanged.
144
143
 
145
144
  ## Define trusted infrastructure
146
145
 
@@ -150,7 +149,7 @@ The rules decide *which* commands are reviewed; `guardianPolicy` tells the guard
150
149
  {
151
150
  "guardianPolicy": {
152
151
  "environment": [
153
- "Our GitHub orgs nuclearn-ai and hank-warren are trusted source control",
152
+ "Our GitHub orgs acme-corp and example-labs are trusted source control",
154
153
  "registry.internal.example is our internal package registry"
155
154
  ],
156
155
  "allow": [
@@ -189,12 +188,6 @@ There used to be a second path here — a one-shot wizard that scanned the proje
189
188
 
190
189
  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.
191
190
 
192
- ### Two-stage review
193
-
194
- 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.
195
-
196
- 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.
197
-
198
191
  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.
199
192
 
200
193
  1. `pi install npm:@hank-warren/pi-multi-login`
@@ -224,12 +217,12 @@ The reviewer settings that change most often are editable from a settings menu i
224
217
 
225
218
  | Row | What it edits |
226
219
  | --- | --- |
227
- | Enabled | `enabled` — off lets every command run without guardian review |
220
+ | Enabled | `enabled` — off disables all gating, including deny rules; every command runs unreviewed |
228
221
  | Reviewer model | `reviewer.provider` / `reviewer.model`, picked from the models you are signed in to |
229
222
  | Thinking level | `reviewer.reasoningEffort` |
230
223
  | Review timeout | `reviewer.timeoutMs`, entered as `30s` or `45000ms` |
231
224
  | System prompt | read-only: the resolved path of the active `systemPromptFile`, or whether the built-in or an inline prompt is in use |
232
- | Standing approvals | count plus a picker for revoking user-scoped comparable-command approvals |
225
+ | Recent denials | read-only view of the denial log with **Allow on retry**; shown while `denialLog` is enabled (the default) |
233
226
 
234
227
  Saves are applied immediately — the config is re-read on every guarded command, so there is nothing to restart, in this session or any other. The menu is a narrow writer: it merges only the keys above into whatever is on disk, so rules, prompts, evidence settings and log paths stay exactly as you wrote them and remain file-only. A config that fails validation is never rewritten; the menu reports the error and refuses to open.
235
228
 
@@ -296,12 +289,12 @@ Extensions inject context with `appendCustomMessageEntry`, and Pi converts that
296
289
  ```json
297
290
  {
298
291
  "reviewEvidence": {
299
- "userMessageTypes": ["loop-objective"]
292
+ "userMessageTypes": ["my-objective-anchor"]
300
293
  }
301
294
  }
302
295
  ```
303
296
 
304
- 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.
297
+ The default is an empty list: no injected type is trusted until you name it.
305
298
 
306
299
  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.
307
300
 
@@ -311,7 +304,7 @@ The allowlist matches what you wrote: a bare name such as `ask_user_question` ma
311
304
 
312
305
  ## Denial log and retry
313
306
 
314
- 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):
307
+ Every non-approved outcome — a guardian revise, a user block at the prompt, a 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):
315
308
 
316
309
  ```json
317
310
  {"v":1,"ts":"…","sessionId":"…","tool":"bash","gate":{"label":"Force push","group":"git"},"command":"git push --force …","verdict":"block","reason":"…","decisionSource":"user"}
@@ -323,30 +316,7 @@ On by default; `"denialLog": { "enabled": false }` opts out, and `path` relocate
323
316
 
324
317
  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.
325
318
 
326
- 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.
327
-
328
- ## Standing approvals
329
-
330
- A prompt caused by a guardian `ask_user` verdict includes **Allow and stop asking about comparable commands**. Choosing it executes the current command and appends a user-scoped record to `standing-approvals.jsonl` beside the config:
331
-
332
- ```json
333
- {"v":1,"ts":"…","gate":{"label":"…","group":"…"},"command":"…","scope":"comparable","project":"…","reason":"…"}
334
- ```
335
-
336
- At the next session start, these records become `USER (standing permission override, granted … in …):` evidence for the guardian. The guardian still reviews every mechanically guarded command: a comparable action may approve silently, while an action of a materially higher risk class remains uncovered and can still prompt. The origin project is context, not a scope limit, and later user statements or blocks take precedence.
337
-
338
- The ledger is mode `0600`, keeps the newest 200 valid records, and reports once when adding a record evicts the oldest. It is on by default; relocate or disable it with:
339
-
340
- ```json
341
- {
342
- "standingApprovals": {
343
- "enabled": true,
344
- "path": "./standing-approvals.jsonl"
345
- }
346
- }
347
- ```
348
-
349
- Use `/auto-permissions` → **Standing approvals** to select and revoke a record. Revocation takes effect immediately in that session; other already-running sessions drop it at their next session start. Review-infrastructure-failure prompts never offer standing trust, cancelled prompts write nothing, and deny rules never reach a prompt at all.
319
+ Permission overrides also persist as custom session entries, so a resumed session keeps the user's earlier allow decisions and standing block constraints instead of forgetting them.
350
320
 
351
321
  ## Prompted-review evaluation log
352
322
 
@@ -361,16 +331,15 @@ Auto Permissions can append a private JSONL regression record whenever the guard
361
331
  }
362
332
  ```
363
333
 
364
- The path defaults to `review-evals.jsonl` beside the Auto Permissions config and resolves relative to that config. The file is created with mode `0600`.
334
+ The path defaults to `review-evals.jsonl` beside the Auto Permissions config and resolves relative to that config. The file is created with mode `0600` and rotates to `review-evals.jsonl.1` once it passes 64 MiB, keeping one previous generation, so older rows are discarded. Writing is best effort: a failing log never blocks or changes a permission decision.
365
335
 
366
- When logging is enabled, prompted reviews offer the three labeling choices plus the standing option on guardian-sourced prompts:
336
+ When logging is enabled, prompted reviews offer the three labeling choices:
367
337
 
368
338
  - **Allow — asking was unnecessary** executes the command and records `userChoice: "allow_unnecessary"` with `expectedDecision: "approve"`.
369
339
  - **Block — asking was appropriate** remains the second choice, blocks the command, and records `userChoice: "block"` with `expectedDecision: "ask_user"`. (A block always affirms the prompt: the guardian has no reject verdict — its only non-approve outcomes are asking you or bouncing the command back to the agent as `revise` — so the only true rejection in the system is yours at this prompt.)
370
340
  - **Allow — asking was appropriate** executes the command and records `userChoice: "allow_appropriate"` with `expectedDecision: "ask_user"`.
371
- - **Allow and stop asking about comparable commands** executes the command, writes the standing ledger, and records the evaluation label as `userChoice: "allow_unnecessary"`. This fourth choice appears only when the prompt came from a guardian verdict, never when review infrastructure failed.
372
341
 
373
- When logging is disabled, the prompt retains the normal **Allow** and **Block** choices, and **Allow and stop asking about comparable commands** still appears on guardian-sourced prompts when standing approvals are enabled — the standing ledger does not depend on evaluation logging. Prompts from Pi or other extensions are unchanged.
342
+ When logging is disabled, the prompt retains the normal **Allow** and **Block** choices. Prompts from Pi or other extensions are unchanged.
374
343
 
375
344
  Each version 2 record contains the collected user request, exact command, compact reviewer evidence, guardian reason, gate and session metadata, raw user choice, and both labels used for evaluation. The guardian's `actualDecision` is `ask_user`. Automatic-review failures are identified separately with `decisionSource: "review_failure"`. Existing version 1 records can remain in the same JSONL file.
376
345
 
@@ -407,27 +376,17 @@ The default UI shows guardian progress as a single animated status line in a tem
407
376
  auto permissions · Git commit · ✶ waiting for openai-codex-auto-permissions/gpt-5.6-luna
408
377
  ```
409
378
 
410
- A sparkle spinner (`✶ ✸ ✻ ✽`) cycles while the guardian is reviewing and resolves to `✓ approved`, `↻ revision requested`, `? waiting for your approval`, or `✗ blocked`. When approval is needed, the selector's leading `●` pulses between warning-bright and dim so the active prompt remains visually distinct from the transcript. The guardian's reason, when present, appears on a dim second line; the command itself is not repeated because it is already visible in the Bash tool box. Configure the widget with:
379
+ A sparkle spinner (`✶ ✸ ✻ ✽`) cycles while the guardian is reviewing and resolves to `✓ approved`, `↻ revision requested`, `? waiting for your approval`, or `✗ blocked`. When approval is needed, a static warning-colored `●` precedes the prompt heading so the active prompt remains visually distinct from the transcript. The guardian's reason, when present, appears on a dim second line; the command itself is not repeated because it is already visible in the Bash tool box. Configure the widget with:
411
380
 
412
381
  ```json
413
382
  {
414
383
  "ui": {
415
384
  "enabled": true,
416
- "resultDisplayMs": 2500,
417
- "placement": "widget"
385
+ "resultDisplayMs": 2500
418
386
  }
419
387
  }
420
388
  ```
421
389
 
422
- Set `placement` to `toolRow` to show the review inside Pi's Bash tool row:
423
-
424
- ```text
425
- $ git commit --dry-run -m "fix auth"
426
- ◌ guardian running · Git commit · openai-codex-auto-permissions/gpt-5.6-luna
427
- ```
428
-
429
- `toolRow` reconstructs Pi's standard local Bash definition because Pi does not expose renderer-only decoration. Do not use it with SDK-provided, remote, sandboxed, or otherwise replaced Bash backends. The extension detects non-native Bash tools and falls back to the widget instead of replacing them.
430
-
431
390
  Set `ui.enabled` to `false` to hide review state without disabling enforcement.
432
391
 
433
392
  ### Herdr pane indicator
@@ -445,7 +404,7 @@ git
445
404
  gh
446
405
  ```
447
406
 
448
- 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.
407
+ Group names come from your configured rules. A trusted group bypasses guarded review 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.
449
408
 
450
409
  ## Subagent sessions
451
410
 
@@ -453,22 +412,6 @@ When a session is a [pi-subagents](https://github.com/nicobailon/pi-subagents) c
453
412
 
454
413
  Subagent sessions have no interactive user, so an `ask_user` verdict blocks the command immediately with a reason instructing the child to route around the gated operation or report the blocker. Reviewer usage records from subagent sessions carry `"subagent": true` in the usage sidecar.
455
414
 
456
- ## Unattended loop sessions
457
-
458
- When the session is running an unattended loop, the guardian's decisions are unchanged and only their *delivery* changes: an `ask_user` verdict returns the concern to the agent as a block instead of opening a prompt. This is not a relaxation. A session waiting on a modal is busy, and a busy session starves the loop's own continuation path — no turn completes, no cap trips, and nothing ends the loop until it expires, potentially days later. The prompt would not be answered; it would simply deadlock.
459
-
460
- Detection is an environment contract, not a dependency. [pi-loop](https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-loop) sets `PI_LOOP_ACTIVE=1` and `PI_LOOP_ID=<id>` on the process while a loop is active and removes them when it is not; this extension reads them exactly as it reads `PI_SUBAGENT_CHILD`. Neither package imports or depends on the other, and with the variables absent — pi-loop not installed, or no loop running — behavior is identical to today.
461
-
462
- The agent is given a bounded number of revision rounds against the guardian's stated concern, after which the block stops offering that option and instructs it to call `loop_wait` (pi-loop's non-deadlocking way to reach a human). The bound is what keeps "address the objection" from becoming "retry until something is approved":
463
-
464
- - **3 blocked attempts per concern**, so there are two real revisions: enough for a misread of a terse objection plus a considered fix.
465
- - **5 blocked attempts per gate.**
466
- - **5 consecutive blocked attempts**, at any gate, for any concern — the backstop. An approved command resets it, since an agent getting real work past the guardian is not grinding against it.
467
-
468
- The block reports whichever bound is closest, and the stated remainder falls every time. Both of the first two bounds are keyed on something the *command* determines, and a canary walked through each in turn. The guardian rewords its objection between rounds, so a count keyed on that prose stops advancing and the block repeats "1 revision round remains" forever — the agent read two identical blocks, called the number ambiguous, and stopped trusting it. Then, blocked on `git branch -D`, it tried `git branch --delete`: the same operation under a different rule, which reset both the gate counter and the concern key embedded in it. The consecutive-block count is immune to both, because it measures the argument rather than the command, and a concern or gate first seen mid-argument inherits the argument's length instead of starting over.
469
-
470
- Counts are held in memory *and* persisted as a session entry, so neither a context compaction nor a session resume hands back a fresh budget. A review that fails for infrastructure reasons blocks without charging a round, since that is not a guardian judgment.
471
-
472
415
  ## Guardian dispatch
473
416
 
474
417
  Reviewer requests dispatch through the host's model runtime rather than pi-ai's compat layer, so provider transports registered by other extensions (for example `@gotgenes/pi-anthropic-auth` OAuth request shaping) apply to guardian calls. When the runtime seam is unavailable, dispatch falls back to `compat.completeSimple`.
@@ -491,6 +434,10 @@ Pi Auto Permissions is a permission layer for normal agent behavior. It is not a
491
434
 
492
435
  `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.
493
436
 
437
+ ## Changelog
438
+
439
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
440
+
494
441
  ## License
495
442
 
496
443
  MIT
@@ -22,6 +22,8 @@ Find the active config through `PI_AUTO_PERMISSIONS_CONFIG` or `$PI_CODING_AGENT
22
22
  2. `denials.jsonl`
23
23
  3. `review-evals.jsonl`
24
24
 
25
+ Each sidecar may have a rotated previous generation beside it (for example `usage.jsonl.1`); read it too when present.
26
+
25
27
  These show observed friction: what prompted, what was denied, and which asks the user labeled unnecessary. They do **not** prove that a hostname is owned or safe. Keep counts so the interview can surface repeated friction, for example: “SSH status checks prompted 14 times.”
26
28
 
27
29
  ### Session history
@@ -40,7 +42,7 @@ Use `ask_user_question`; do not replace the interview with assumptions in prose.
40
42
 
41
43
  - Classify ambiguous hosts as production, staging, development, or not theirs.
42
44
  - Confirm source-control organizations, registries, buckets, and internal domains.
43
- - Surface measured friction with counts and a concrete choice: “SSH status checks prompted 14 times — standing approval or a softDeny carve-out?”
45
+ - Surface measured friction with counts and a concrete choice: “SSH status checks prompted 14 times — a guardianPolicy.allow entry or a softDeny carve-out?”
44
46
  - Ask which boundaries are absolute. User statements such as “never push outside our orgs” are candidates for `hardDeny`.
45
47
 
46
48
  Production-looking or customer-looking names are not proof, but they require classification. If a hostname matches production/customer patterns or the user says it is production or customer-facing, propose it as `softDeny`, never `environment`.
@@ -61,7 +63,7 @@ Use these meanings precisely:
61
63
  - `softDeny`: blocks unless an allow entry covers the action or the user names the exact operation and target. For production/customer targets, use wording like: “Do not perform mutating operations against HOST unless the user names the exact operation and instance.”
62
64
  - `hardDeny`: unconditional policy boundary. Propose it only for boundaries the user states absolutely.
63
65
 
64
- Never propose editing `rules`. Never write `.pi/trusted-ops` unless the user explicitly asks. If asked, explain that a trusted group only bypasses matching guarded/convention rules and may be a no-op under a catch-all rule or when no matching group exists.
66
+ Never propose editing `rules`. Never write `.pi/trusted-ops` unless the user explicitly asks. If asked, explain that a trusted group only bypasses matching guarded rules and may be a no-op under a catch-all rule or when no matching group exists.
65
67
 
66
68
  ## 4. Apply only after confirmation
67
69
 
package/classify.ts CHANGED
@@ -5,40 +5,26 @@ import { ALL_SHELL_GATE, findGates, type Gate } from "./gates.js";
5
5
  * What the ruleset says about one command, before any guardian is involved.
6
6
  *
7
7
  * `pass` means nothing gated it: no rule matched, or the only rules that did
8
- * are ones this project trusts or has already granted an override for.
8
+ * are ones this project trusts.
9
9
  */
10
10
  export type CommandClassification =
11
11
  | { kind: "pass" }
12
- | { kind: "deny" | "convention" | "review"; gate: Gate };
12
+ | { kind: "deny" | "review"; gate: Gate };
13
13
 
14
14
  /**
15
- * Rules that matched and still apply.
15
+ * Decide a command's fate from the ruleset alone.
16
+ *
17
+ * Level priority across all matches, never first-match: a guarded rule
18
+ * earlier in config order must not shadow a deny rule later.
16
19
  *
17
20
  * Deny rules are hard policy boundaries: a project-scoped trusted-ops file
18
21
  * must not be able to lift one, or a checked-in file could disarm the
19
- * circuit breaker. Only guarded and convention matches honor the bypass.
20
- */
21
- export function untrustedMatches(
22
- command: string,
23
- config: AutoPermissionsConfig,
24
- trustedGroups: ReadonlySet<string>,
25
- ): Gate[] {
26
- return findGates(command, config.rules).filter(
27
- (gate) => gate.level === "deny" || !trustedGroups.has(gate.group),
28
- );
29
- }
30
-
31
- /**
32
- * Decide a command's fate from the ruleset alone.
33
- *
34
- * Level priority across all matches, never first-match: a convention or
35
- * guarded rule earlier in config order must not shadow a deny rule later.
22
+ * circuit breaker. Only guarded matches honor the bypass.
36
23
  */
37
24
  export function classifyCommand(
38
25
  command: string,
39
26
  config: AutoPermissionsConfig,
40
27
  trustedGroups: ReadonlySet<string>,
41
- allowedConventionCommands: ReadonlySet<string>,
42
28
  ): CommandClassification {
43
29
  const ruleMatches = findGates(command, config.rules);
44
30
  const matches = ruleMatches.filter(
@@ -48,11 +34,6 @@ export function classifyCommand(
48
34
  const deny = matches.find((gate) => gate.level === "deny");
49
35
  if (deny) return { kind: "deny", gate: deny };
50
36
 
51
- const convention = matches.find((gate) => gate.level === "convention");
52
- if (convention && !allowedConventionCommands.has(command)) {
53
- return { kind: "convention", gate: convention };
54
- }
55
-
56
37
  let gate = matches.find((candidate) => candidate.level === "guarded");
57
38
  // With reviewAllShell on, a command no rule names at all still gets a
58
39
  // guardian review under the generic gate. "No rule" is judged before the
package/config-writer.ts CHANGED
@@ -11,29 +11,11 @@
11
11
  import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
12
12
  import { dirname } from "node:path";
13
13
  import { pid } from "node:process";
14
- import { autoPermissionsConfigPath, type ReasoningEffort } from "./config.js";
15
-
16
- export { autoPermissionsConfigPath };
17
-
18
- /** The subset of the config the settings UI owns. */
19
- export interface ReviewerBlock {
20
- provider: string;
21
- model: string;
22
- reasoningEffort: ReasoningEffort;
23
- timeoutMs: number;
24
- }
14
+ import type { ReviewerConfig } from "./config.js";
25
15
 
26
16
  interface ConfigPatch {
27
17
  enabled?: boolean;
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[];
35
- /** Entries to append to `guardianPolicy.softDeny`, with the same semantics. */
36
- appendSoftDeny?: string[];
18
+ reviewer?: ReviewerConfig;
37
19
  }
38
20
 
39
21
  const DEFAULT_INDENT = " ";
@@ -78,10 +60,7 @@ function parseObject(text: string): Record<string, unknown> {
78
60
  * Throws on an unparsable or non-object file rather than clobbering it; the
79
61
  * caller surfaces that as a notification.
80
62
  */
81
- export function patchAutoPermissionsConfig(
82
- path: string = autoPermissionsConfigPath(),
83
- patch: ConfigPatch = {},
84
- ): void {
63
+ export function patchAutoPermissionsConfig(path: string, patch: ConfigPatch): void {
85
64
  const existing = readText(path);
86
65
  const merged = parseObject(existing);
87
66
 
@@ -90,43 +69,19 @@ export function patchAutoPermissionsConfig(
90
69
  else merged.enabled = false;
91
70
  }
92
71
  if (patch.reviewer !== undefined) {
93
- // Preserve file-only reviewer keys the menu does not edit (prefilter):
94
- // this writer must never silently drop what the human wrote by hand.
72
+ // Preserve every file-only reviewer key the menu does not edit: this writer must never silently drop what the human wrote by hand.
95
73
  const existingReviewer = merged.reviewer && typeof merged.reviewer === "object" && !Array.isArray(merged.reviewer)
96
74
  ? merged.reviewer as Record<string, unknown>
97
75
  : {};
98
76
  merged.reviewer = {
77
+ ...existingReviewer,
99
78
  provider: patch.reviewer.provider,
100
79
  model: patch.reviewer.model,
101
80
  reasoningEffort: patch.reviewer.reasoningEffort,
102
81
  timeoutMs: patch.reviewer.timeoutMs,
103
- ...(existingReviewer.prefilter !== undefined ? { prefilter: existingReviewer.prefilter } : {}),
104
82
  };
105
83
  }
106
84
 
107
- const policyAppends = [
108
- ["environment", patch.appendEnvironment],
109
- ["softDeny", patch.appendSoftDeny],
110
- ] as const;
111
- if (policyAppends.some(([, entries]) => entries?.length)) {
112
- const policy = merged.guardianPolicy && typeof merged.guardianPolicy === "object" && !Array.isArray(merged.guardianPolicy)
113
- ? merged.guardianPolicy as Record<string, unknown>
114
- : {};
115
- for (const [key, entries] of policyAppends) {
116
- if (!entries?.length) continue;
117
- const existingEntries = Array.isArray(policy[key])
118
- ? policy[key].filter((entry): entry is string => typeof entry === "string")
119
- : [];
120
- policy[key] = [
121
- ...new Set([
122
- ...existingEntries,
123
- ...entries.map((entry) => entry.trim()).filter(Boolean),
124
- ]),
125
- ];
126
- }
127
- merged.guardianPolicy = policy;
128
- }
129
-
130
85
  const payload = `${JSON.stringify(merged, null, detectIndent(existing))}\n`;
131
86
  const temporary = `${path}.${pid}.tmp`;
132
87
  mkdirSync(dirname(path), { recursive: true });