@hank-warren/pi-auto-permissions 0.16.2 → 0.18.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,44 @@
1
1
  # @hank-warren/pi-auto-permissions
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Review pending guarded commands in parallel, and show the guardian the codemode script a command came from.
8
+
9
+ - When several guarded commands are pending at once, their guardian reviews now overlap, up to the new `reviewConcurrency` setting (1–16, default 4; `1` restores fully serial reviews), also editable as **Parallel reviews** in `/auto-permissions`. Verdicts are still applied, and you are still asked, one command at a time. A codemode script running 50 guarded commands under `Promise.all` went from about 64 s of reviews to about 13 s.
10
+ - Several `bash` calls in one assistant message are reviewed together too: the first call's handler starts the reviews of the later guarded ones, and each call uses its early review only if its input is unchanged when Pi reaches it.
11
+ - A verdict that waited behind another command's approval prompt is reviewed again when your answer changed the evidence, so blocking one command still reaches the commands queued behind it.
12
+ - When a codemode script ends (it returns, throws, times out or is aborted), its calls still queued, under review or waiting on an approval prompt are released at once, and an open prompt closes by itself. Pi never runs those calls, so this only removes reviews and prompts that could change nothing.
13
+ - The guardian now sees the codemode script behind a call as an assistant-source `SCRIPT` record, finished scripts list the commands they ran, and the proposed action names its script in `issuedByScript`. A new policy section tells the guardian the script is context, never authorization, that the command under review is the literal one the script built (a `${…}` in the script is a JavaScript value, not a shell variable), and to judge a command whose prerequisite was blocked, failed or runs alongside it as if that step never happened: a delete running concurrently with its backup now comes back as a revision.
14
+ - The review widget collapses several unsettled reviews into one summary line.
15
+
16
+ Existing reviewer lineages rebuild once after upgrade, because the reviewer policy prompt gained the script section.
17
+
18
+ ## 0.17.0
19
+
20
+ ### Minor Changes
21
+
22
+ - 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.
23
+
24
+ Breaking changes:
25
+
26
+ - 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.
27
+ - 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.
28
+ - The reviewer `prefilter` stage is removed. Every guarded command gets the full guardian review.
29
+ - `ui.placement: "toolRow"` is removed. Review status always renders in the widget above the editor.
30
+ - The approval prompt's pulsing indicator is now a static warning-colored `●`.
31
+ - The `reviewEvidence` pruning limits (`toolRecordMaxChars`, `assistantRecordMaxChars`, `compactionRecordMaxChars`, `fullRebuildKeepToolRecords`) are no longer configurable. They are fixed at their defaults.
32
+
33
+ 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.
34
+
35
+ Fixes:
36
+
37
+ - `review-evals.jsonl` now rotates to `review-evals.jsonl.1` at 64 MiB, keeping one previous generation.
38
+ - A full-rebuild tool eviction no longer rewrites injected `CUSTOM` records as `TOOL call → success`.
39
+ - Saving `/auto-permissions` settings keeps every hand-written `reviewer` key.
40
+ - Stale README and settings-menu text is corrected. The Enabled row now says that turning it off disables all gating, deny rules included.
41
+
3
42
  ## 0.16.2
4
43
 
5
44
  ### 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
 
@@ -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,13 @@ 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` |
224
+ | Parallel reviews | `reviewConcurrency`, cycling through 1, 2, 4, 8 and 16; a hand-written value in between stays in the cycle |
231
225
  | 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 |
226
+ | Recent denials | read-only view of the denial log with **Allow on retry**; shown while `denialLog` is enabled (the default) |
233
227
 
234
228
  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
229
 
@@ -261,6 +255,16 @@ The first review sends the complete compact evidence. Later reviews reuse the sa
261
255
 
262
256
  Assistant and tool evidence provide context but never grant permission. Later user messages override earlier conflicting user instructions.
263
257
 
258
+ ### Codemode scripts
259
+
260
+ A codemode script runs shell commands from inside it, so judging one of its `bash` calls alone misses what the script is doing around it: a loop over forty targets, or a delete that only makes sense because a backup runs first. The guardian therefore sees the script itself:
261
+
262
+ - **The running script.** A call a script makes is reviewed with that script's source as an assistant-source `SCRIPT codemode [<call id>]` record, and the proposed action names it in `issuedByScript`. It sits in the evidence stream rather than in the proposed action, so every call from one script shares it through the cached reviewer lineage instead of re-sending it.
263
+ - **Finished scripts.** Their `SCRIPT` record stays, and their `TOOL` record lists every call they made and whether it succeeded, from the `nestedCalls` record Pi keeps on the result.
264
+ - **Never authorization.** The script is model-written: comments, strings and names in it grant nothing. A policy section appended outside `systemPrompt` tells the guardian to use it only for context, and to judge an action whose prerequisite was blocked, failed, or runs concurrently as if that step never happened.
265
+
266
+ Scripts are capped at 8000 characters (head and tail, with an elision marker), and a full reviewer rebuild keeps the source of only the five newest; older ones collapse to `SCRIPT codemode [<call id>] (source elided)`.
267
+
264
268
  Trusted projects may optionally provide their root `AGENTS.md`, or `CLAUDE.md` when no `AGENTS.md` exists, as policy evidence:
265
269
 
266
270
  ```json
@@ -296,12 +300,12 @@ Extensions inject context with `appendCustomMessageEntry`, and Pi converts that
296
300
  ```json
297
301
  {
298
302
  "reviewEvidence": {
299
- "userMessageTypes": ["loop-objective"]
303
+ "userMessageTypes": ["my-objective-anchor"]
300
304
  }
301
305
  }
302
306
  ```
303
307
 
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.
308
+ The default is an empty list: no injected type is trusted until you name it.
305
309
 
306
310
  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
311
 
@@ -309,44 +313,37 @@ Injected messages whose `customType` is **not** allowlisted are still visible
309
313
 
310
314
  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.
311
315
 
312
- ## Denial log and retry
316
+ ## Parallel reviews
313
317
 
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):
318
+ When several guarded commands are pending at once, their guardian reviews run in parallel: a codemode script that runs `bash` calls under `Promise.all`, or several `bash` calls in one assistant message. Verdicts are still applied, and you are still asked, one command at a time.
315
319
 
316
320
  ```json
317
- {"v":1,"ts":"…","sessionId":"…","tool":"bash","gate":{"label":"Force push","group":"git"},"command":"git push --force …","verdict":"block","reason":"…","decisionSource":"user"}
321
+ {
322
+ "reviewConcurrency": 4
323
+ }
318
324
  ```
319
325
 
320
- On by default; `"denialLog": { "enabled": false }` opts out, and `path` relocates it.
321
-
322
- `/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.
323
-
324
- 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.
326
+ `reviewConcurrency` (1–16, default 4) caps how many guardian calls run at once; the **Parallel reviews** row in `/auto-permissions` sets it too. Set it to `1` for fully serial reviews. Parallel reviews all build on the same cached reviewer lineage: the first to finish extends it and the others' exchanges are dropped, which loses nothing because prior reviewer responses are non-authoritative. When there is no usable lineage yet (the first review of a session, or after a compaction, reset or cancellation), one review builds it from the full evidence and the others wait for it and extend it, so a batch pays for one cold request rather than one each. If that build fails, the waiting reviews go ahead on their own instead of queuing behind each other.
325
327
 
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.
328
+ - **Codemode scripts.** Pi runs each `bash` call a script makes through the same `tool_call` gate as a model-issued call, concurrently, so their reviews simply overlap. When a script ends — it returns, throws, times out or is aborted — while some of its calls are still queued, under review or waiting on your approval, those are released at once: the guardian request is cancelled (the reviewer lineage is kept) and an open prompt closes by itself. Pi never runs such a call anyway, because it hands it an already-aborted signal, so this only saves you from answering a prompt that can change nothing.
329
+ - **Several calls in one message.** Pi runs the `tool_call` handlers for one message's calls one after another, so the handler of the first `bash` call starts the reviews of the later guarded ones. Each call later takes its early review only when the input Pi finally hands the handler is exactly the input that was reviewed; otherwise it is reviewed afresh. An early review whose call never arrives is dropped when the turn ends. With `reviewConcurrency: 1` nothing is reviewed early.
330
+ - **Your answers still bind.** Applying a verdict and prompting you share one slot, and a verdict whose evidence changed while it waited — because you answered another command's prompt, or a tool result landed — is reviewed again before it is applied (up to three reviews per command; the third runs holding that slot, so no other prompt can be answered while it runs). A block you give on one command therefore reaches a sibling that was reviewed while your prompt was open.
327
331
 
328
- ## Standing approvals
332
+ ## Denial log and retry
329
333
 
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:
334
+ 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):
331
335
 
332
336
  ```json
333
- {"v":1,"ts":"…","gate":{"label":"…","group":"…"},"command":"…","scope":"comparable","project":"…","reason":"…"}
337
+ {"v":1,"ts":"…","sessionId":"…","tool":"bash","gate":{"label":"Force push","group":"git"},"command":"git push --force …","verdict":"block","reason":"…","decisionSource":"user"}
334
338
  ```
335
339
 
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.
340
+ On by default; `"denialLog": { "enabled": false }` opts out, and `path` relocates it.
337
341
 
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:
342
+ `/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.
339
343
 
340
- ```json
341
- {
342
- "standingApprovals": {
343
- "enabled": true,
344
- "path": "./standing-approvals.jsonl"
345
- }
346
- }
347
- ```
344
+ 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.
348
345
 
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.
346
+ 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
347
 
351
348
  ## Prompted-review evaluation log
352
349
 
@@ -361,16 +358,15 @@ Auto Permissions can append a private JSONL regression record whenever the guard
361
358
  }
362
359
  ```
363
360
 
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`.
361
+ 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
362
 
366
- When logging is enabled, prompted reviews offer the three labeling choices plus the standing option on guardian-sourced prompts:
363
+ When logging is enabled, prompted reviews offer the three labeling choices:
367
364
 
368
365
  - **Allow — asking was unnecessary** executes the command and records `userChoice: "allow_unnecessary"` with `expectedDecision: "approve"`.
369
366
  - **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
367
  - **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
368
 
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.
369
+ When logging is disabled, the prompt retains the normal **Allow** and **Block** choices. Prompts from Pi or other extensions are unchanged.
374
370
 
375
371
  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
372
 
@@ -407,26 +403,24 @@ The default UI shows guardian progress as a single animated status line in a tem
407
403
  auto permissions · Git commit · ✶ waiting for openai-codex-auto-permissions/gpt-5.6-luna
408
404
  ```
409
405
 
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:
406
+ 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
407
 
412
408
  ```json
413
409
  {
414
410
  "ui": {
415
411
  "enabled": true,
416
- "resultDisplayMs": 2500,
417
- "placement": "widget"
412
+ "resultDisplayMs": 2500
418
413
  }
419
414
  }
420
415
  ```
421
416
 
422
- Set `placement` to `toolRow` to show the review inside Pi's Bash tool row:
417
+ While several commands are under review at once, the widget collapses them into one summary line:
423
418
 
424
419
  ```text
425
- $ git commit --dry-run -m "fix auth"
426
- ◌ guardian running · Git commit · openai-codex-auto-permissions/gpt-5.6-luna
420
+ auto permissions · 3 commands · ✶ 2 waiting for openai-codex-auto-permissions/gpt-5.6-luna · ⋯ 1 queued
427
421
  ```
428
422
 
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.
423
+ `queued` counts commands waiting for a review slot, or with a verdict waiting to be applied behind another command's prompt.
430
424
 
431
425
  Set `ui.enabled` to `false` to hide review state without disabling enforcement.
432
426
 
@@ -445,7 +439,7 @@ git
445
439
  gh
446
440
  ```
447
441
 
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.
442
+ 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
443
 
450
444
  ## Subagent sessions
451
445
 
@@ -453,22 +447,6 @@ When a session is a [pi-subagents](https://github.com/nicobailon/pi-subagents) c
453
447
 
454
448
  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
449
 
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
450
  ## Guardian dispatch
473
451
 
474
452
  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`.
@@ -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,12 @@
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 { DEFAULT_REVIEW_CONCURRENCY, 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;
19
+ reviewConcurrency?: number;
37
20
  }
38
21
 
39
22
  const DEFAULT_INDENT = " ";
@@ -73,15 +56,13 @@ function parseObject(text: string): Record<string, unknown> {
73
56
  * Sparse `enabled`: the loader reads a missing key as enabled, so turning the
74
57
  * guardian back on deletes the key rather than writing `true`. That keeps a
75
58
  * default flip reaching hosts that never touched the setting, matching how
76
- * pi-statusline serializes its settings.
59
+ * pi-statusline serializes its settings. `reviewConcurrency` is sparse the
60
+ * same way: setting it back to the default deletes the key.
77
61
  *
78
62
  * Throws on an unparsable or non-object file rather than clobbering it; the
79
63
  * caller surfaces that as a notification.
80
64
  */
81
- export function patchAutoPermissionsConfig(
82
- path: string = autoPermissionsConfigPath(),
83
- patch: ConfigPatch = {},
84
- ): void {
65
+ export function patchAutoPermissionsConfig(path: string, patch: ConfigPatch): void {
85
66
  const existing = readText(path);
86
67
  const merged = parseObject(existing);
87
68
 
@@ -89,44 +70,24 @@ export function patchAutoPermissionsConfig(
89
70
  if (patch.enabled) delete merged.enabled;
90
71
  else merged.enabled = false;
91
72
  }
73
+ if (patch.reviewConcurrency !== undefined) {
74
+ if (patch.reviewConcurrency === DEFAULT_REVIEW_CONCURRENCY) delete merged.reviewConcurrency;
75
+ else merged.reviewConcurrency = patch.reviewConcurrency;
76
+ }
92
77
  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.
78
+ // Preserve every file-only reviewer key the menu does not edit: this writer must never silently drop what the human wrote by hand.
95
79
  const existingReviewer = merged.reviewer && typeof merged.reviewer === "object" && !Array.isArray(merged.reviewer)
96
80
  ? merged.reviewer as Record<string, unknown>
97
81
  : {};
98
82
  merged.reviewer = {
83
+ ...existingReviewer,
99
84
  provider: patch.reviewer.provider,
100
85
  model: patch.reviewer.model,
101
86
  reasoningEffort: patch.reviewer.reasoningEffort,
102
87
  timeoutMs: patch.reviewer.timeoutMs,
103
- ...(existingReviewer.prefilter !== undefined ? { prefilter: existingReviewer.prefilter } : {}),
104
88
  };
105
89
  }
106
90
 
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
91
  const payload = `${JSON.stringify(merged, null, detectIndent(existing))}\n`;
131
92
  const temporary = `${path}.${pid}.tmp`;
132
93
  mkdirSync(dirname(path), { recursive: true });