@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 +39 -0
- package/README.md +52 -74
- package/auto-permissions-setup/SKILL.md +4 -2
- package/classify.ts +7 -26
- package/config-writer.ts +12 -51
- package/config.ts +100 -236
- package/denial-log.ts +5 -22
- package/evaluation-log.ts +8 -51
- package/gates.ts +6 -13
- package/guardian-reviewer.ts +190 -209
- package/index.ts +484 -306
- package/jsonl-sidecar.ts +22 -0
- package/override-evidence.ts +3 -17
- package/package.json +3 -8
- package/prompt-select.ts +4 -15
- package/review-display.ts +134 -220
- package/review-queue.ts +88 -21
- package/review-scope.ts +15 -4
- package/review.ts +136 -68
- package/session-overrides.ts +27 -145
- package/settings-command.ts +11 -48
- package/settings-menu.ts +34 -132
- package/usage-log.ts +3 -20
- package/widget-status.ts +45 -21
- package/approval-pulse.ts +0 -80
- package/loop-context.ts +0 -41
- package/loop-revise-budget.ts +0 -348
- package/openai-codex-transport.ts +0 -9
- package/standing-overrides.ts +0 -266
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.
|
|
9
|
-
>
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
130
|
+
- `message`: optional feedback; required for deny rules
|
|
132
131
|
|
|
133
|
-
A
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
|
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": ["
|
|
303
|
+
"userMessageTypes": ["my-objective-anchor"]
|
|
300
304
|
}
|
|
301
305
|
}
|
|
302
306
|
```
|
|
303
307
|
|
|
304
|
-
The default is
|
|
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
|
-
##
|
|
316
|
+
## Parallel reviews
|
|
313
317
|
|
|
314
|
-
|
|
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
|
-
{
|
|
321
|
+
{
|
|
322
|
+
"reviewConcurrency": 4
|
|
323
|
+
}
|
|
318
324
|
```
|
|
319
325
|
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
332
|
+
## Denial log and retry
|
|
329
333
|
|
|
330
|
-
|
|
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":"
|
|
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
|
-
|
|
340
|
+
On by default; `"denialLog": { "enabled": false }` opts out, and `path` relocates it.
|
|
337
341
|
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
417
|
+
While several commands are under review at once, the widget collapses them into one summary line:
|
|
423
418
|
|
|
424
419
|
```text
|
|
425
|
-
|
|
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
|
-
`
|
|
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
|
|
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 —
|
|
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
|
|
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
|
|
8
|
+
* are ones this project trusts.
|
|
9
9
|
*/
|
|
10
10
|
export type CommandClassification =
|
|
11
11
|
| { kind: "pass" }
|
|
12
|
-
| { kind: "deny" | "
|
|
12
|
+
| { kind: "deny" | "review"; gate: Gate };
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
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
|
|
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 {
|
|
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?:
|
|
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
|
|
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 });
|