feat(permission): integrate auto-review and hard guards

This commit is contained in:
云服务部-叶林立
2026-09-02 15:34:29 +08:00
parent a4807dde67
commit 180da95ab7
79 changed files with 4263 additions and 9203 deletions
@@ -605,40 +605,30 @@ Like yolo it is deny-preserving (only `allow` is touched) and applied at composi
Global is excluded — it is the lowest precedence, so nothing more permissive is inherited when it fails.
The two overlays stack in order: fail-closed floors `allow`→`ask` first, then yolo (if enabled) rewrites `ask`→`allow`, so an explicit yolo opt-in still wins.
### Discriminating delegation: a model `Authorizer`
### Discriminating delegation: the built-in model `Authorizer`
Nothing constrains an `Authorizer` to be deterministic.
`LocalUserAuthorizer` is already a non-deterministic oracle — the human — and the determinism principle governs *recorded* authority (`evaluate()`), never the live-authority layer.
A model (e.g. Claude Haiku) can hold an `Authorizer` role on the same terms: it is live authority, so it never touches `evaluate()` or the deterministic core.
Nothing constrains an `Authorizer` to be deterministic. `LocalUserAuthorizer` is already a non-deterministic oracle — the human — and the determinism principle governs *recorded* authority (`evaluate()`), never the live-authority layer.
The design is settled in [ADR 0007](../decisions/0007-model-judge-authorizer-chain-adr.md); the essentials follow.
This package now owns the default model reviewer as the named `auto-review` authorizer. It is still downstream of deterministic policy: only an ordinary `ask` enters the authorizer chain, and a hard-guard `deny` never reaches it. The reviewer returns `allow`, `deny`, or `defer`; only `defer` falls through to the terminal human authority. Its transcript boundary admits direct user messages and recognized non-cancelled structured answers, never assistant/tool/compaction content.
**The live-authority layer is a Chain of Responsibility.**
Each link returns `allow | deny | defer`; on `defer` the next link decides.
The chain ends at a **terminal that cannot defer** — today the human (`LocalUserAuthorizer`), the headless `DenyingAuthorizer`, or `ParentAuthorizer` (terminal for its node, forwarding up to the parent node's chain — the [recursion](#the-recursion) above).
The invariant is type-level: a terminal returns only `allow | deny`, so a deferring link cannot occupy the terminal slot.
`selectAuthorizer` becomes the terminal-selection step of `composeAuthorizerChain` — registered non-terminal links, then the context-selected terminal.
The composition root registers the authorizer on the same `LocalPermissionsService` instance it publishes. `installBuiltinAutoReview()` returns lifecycle methods instead of registering a second set of Pi session handlers. The root's single `session_start` and `session_shutdown` handlers coordinate service publication and reviewer state; `turn_start` resets the denial circuit breaker, and `agent_settled` is the safe boundary for optional count-based analysis.
**One chain per node.**
An ask is adjudicated by exactly one node's chain: the node whose terminal decides it (ADR 0007 §7).
A subagent node's terminal relays the ask to a serving node, which escalates it through *its* chain over the same child-fixed facts — so a relaying node resolves no links, and records `authorizer_chain_delegated` rather than reporting each configured name as a fail-safe skip.
An adjudicating node records `authorizer_chain_resolved` with the names it consulted, since a deferring link decides nothing and otherwise leaves no evidence it ran.
A separate `HardGuardPolicyLoader` supplies a global-only manager to `PermissionResolver`. Resolution is deliberately asymmetric:
```text
ask -> [ model-judge link ] --defer--> … --defer--> [ terminal: human | Parent | Denying ]
├─ deny (with teaching reason) -> denied
├─ allow (slice 2, if not excluded) -> permitted
└─ defer -> next link
hardGuardManager.check(intent, no session rules)
deny -> final
allow -> continue
ordinaryManager.check(intent, session rules)
allow/deny -> final
ask -> authorizer chain -> human terminal fallback
```
**The model judge is a non-terminal link**, not a decorator or a fourth channel.
It reviews an `ask`, decides the ones it is confident about, and defers the rest to its successor — a middle rung between prompt-everything and allow-everything.
Denies are decided by recorded authority and structurally never reach an `Authorizer`, so a model link cannot grant a hard deny; the safeguard for a sensitive resource stays an explicit `deny` rule, which survives the model just as it survives the yolo rewrite.
Thus hard-file `allow` means “not blocked by hard guards,” not “approved overall.” Project config, per-agent config, session grants, yolo mode, and model authority cannot loosen a hard deny.
The verdict range is `allow | deny | defer` — a superset of the earlier allow-or-escalate framing — because the first use case is **deny-first**.
A light model reviews `external_directory` asks, denies an errant "typo" path with a teaching `reason` (wrong path; correct location) so the invoking model self-corrects, and defers everything else.
A second use case adjudicates **opaque bash**: the model decomposes a `bash -c "…"` / `eval` command and queries the deterministic engine per sub-command through an injected, narrow `PermissionQuery` (never a reach-through to `PermissionsService`), allowing only what the engine already grants for the pieces it identifies.
The two are one link on a **capability gradient**: the deny/defer reviewer is strictly more restrictive and ships first; the allow-capable adjudicator loosens privilege and is gated behind the full envelope (hard exclusions, audit `origin: "authorizer:model"`, non-persistence, off by default), because its safety property holds only if the model's decomposition is faithful.
Completed AutoReview verdicts flow to an owner-only count ledger. The reviewer callback strips rationale/transcript/raw payload and persists bounded fingerprints. Reaching the configured checkpoint marks analysis pending. At `agent_settled`, a tool-free, extension-free, in-memory AgentSession receives only aggregated records. Its strict JSON proposal is validated against the evidence, persisted, and injected into the same session as a follow-up. The checkpoint advances only after a valid proposal is stored; shutdown leaves pending work durable. No analysis path mutates either policy file.
The public authorizer registry remains available for other named authorities. Configuration order still determines chain order, unregistered names are skipped fail-safe, and the delegation envelope remains the final boundary on model `allow` decisions.
Registration mirrors `registerToolAccessExtractor`: a downstream extension offers a **named** capability (`registerAuthorizer("model-judge", …)`) on `permissions:ready`, and this package makes no LLM call itself.
Three invariants govern the seam: config order (not registration order) fixes the security-relevant chain order; a missing configured link is skipped fail-safe (more prompting, never less); and **registration alone grants no authority** — a link decides nothing until the operator names it in the `authorizerChain` config (opt-in).
@@ -0,0 +1,20 @@
# AutoReview upstream source
The built-in AutoReview implementation under `src/auto-review/` was imported from:
- Repository: <https://github.com/mzwing/pi-packages/tree/main/packages/pi-permission-auto-review>
- Snapshot: `8d196e4ef0884cac8326c366191dad3f585d470a`
- Package version: `@mzwing/pi-permission-auto-review@0.2.0`
The original MIT license is preserved at
`LICENSES/pi-permission-auto-review-MIT.txt`.
Local integration changes:
- AutoReview is an internal authorizer of `pi-permission-system` rather than a
separately loaded Pi extension.
- Configuration is read from the unified `policy.jsonc`.
- Completed automatic decisions feed a count-based ledger and an isolated,
tool-free background analysis agent.
- Analysis proposals are delivered back into the active Pi session and never
modify policy without explicit user confirmation.
+43 -32
View File
@@ -2,29 +2,21 @@
## Config File Locations
One unified config file per scope:
Pi Permission System has an ordinary JSONC policy and an independent global hard-guard JSONC policy:
| Scope | Path |
| ------- | ------------------------------------------------------------------------------------------ |
| Global | `~/.pi/agent/extensions/pi-permission-system/config.json` (respects `PI_CODING_AGENT_DIR`) |
| Project | `<cwd>/.pi/extensions/pi-permission-system/config.json` |
| Purpose | Scope | Path |
| ------- | ----- | ---- |
| Ordinary policy + runtime | Global | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` |
| Ordinary policy override | Project | `<cwd>/.pi/extensions/pi-permission-system/policy.jsonc` |
| Hard guards | Global only | `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc` |
Project config overrides global config; per-agent frontmatter overrides both.
All paths respect `PI_CODING_AGENT_DIR`. The bundle creates missing global files once with owner-only permissions. It does not overwrite an existing file. If `policy.jsonc` is absent but the old global `config.json` exists, the bundle copies the old content into `policy.jsonc` and leaves the source file intact.
**Project config requires project trust.**
Project and project-agent scopes (both permission policy and runtime config such as `yoloMode`) are loaded only when Pi reports the project as trusted (`ctx.isProjectTrusted()`).
In an untrusted directory, only global (and global-agent) config applies, so an untrusted repository cannot loosen your global policy; the extension surfaces a loud warning plus a `project_trust.skipped` review-log entry when it skips a project scope.
Grant project trust (or configure `defaultProjectTrust`) to load the project's config; a trust grant reloads project policy on the next `resources_discover` reload.
See [migration/0644-project-trust-gating.md](migration/0644-project-trust-gating.md).
Project policy overrides global ordinary policy and per-agent YAML frontmatter overrides both. Project policy and project agent definitions are loaded only when Pi reports that project as trusted.
> **Coming from OpenCode?**
> This extension's permission model was inspired by OpenCode's.
> See [OpenCode Compatibility](opencode-compatibility.md) for shared concepts, divergences, and a porting guide.
Hard guards are deliberately outside this merge. They are global-only and evaluated first, without session rules. A matching hard `deny` is final and cannot be loosened by project policy, per-agent policy, a session grant, or AutoReview. A hard-file `allow` can only form a narrower exception to another pattern in that same hard-guard file. `ask` is invalid in the hard-guard file.
<!-- -->
> **Tip:** All `~/.pi/agent` paths shown in this document are defaults.
> If the `PI_CODING_AGENT_DIR` environment variable is set, Pi uses that directory instead.
> `~/.pi/agent` is the default. When `PI_CODING_AGENT_DIR` is set, Pi uses that directory instead.
## Merge Precedence
@@ -65,8 +57,22 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }
},
// Ordered names of registered live-authority chain links (empty = none)
"authorizerChain": [],
// Unmatched ordinary asks are reviewed before the terminal human prompt.
"authorizerChain": ["auto-review"],
"autoReview": {
"enabled": true,
"provider": "openai-codex",
"model": "codex-auto-review",
"reasoning": "low",
"timeoutMs": 90000,
"includeBaselinePolicy": true
},
"analysis": {
"enabled": true,
"threshold": 500,
"retainAnalyzedBatches": 2,
"timeoutMs": 90000
},
// Flat permission policy
"permission": {
@@ -95,6 +101,18 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
> **Note:** Trailing commas are **not** supported.
> If parsing fails, the extension falls back to `ask` for all categories.
## Built-in AutoReview and Count-Based Analysis
`autoReview.enabled` controls the built-in authorizer named `auto-review`. It only receives requests that ordinary policy resolved to `ask`; it cannot bypass a hard deny. Add `"auto-review"` to `authorizerChain` to make it the primary case-by-case authority. An `allow` or `deny` verdict is final for that request. `defer` continues to the next authorizer and ultimately the human permission dialog.
The reviewer uses only trusted direct-user transcript evidence and recognized structured answers. Assistant, tool, compaction, and cancelled-answer content cannot authorize an operation. Model lookup, authentication, timeout, malformed output, or internal errors all return `defer`.
`analysis.threshold` counts completed automatic verdicts, not Pi turns and not deferrals. Each `allow` or `deny` appends one sanitized record under the global extension state directory. Records contain bounded surface/pattern fingerprints and risk/authorization classifications; they omit rationale, raw payload, transcript, and credentials.
When the count since the last accepted checkpoint reaches the threshold (500 by default), the ledger marks analysis pending. At the next `agent_settled` boundary the extension starts an isolated AgentSession with no tools, extensions, skills, prompt templates, themes, or project context. Its JSON proposal is schema-validated and filtered so authorization-dependent, denied, or high-risk evidence cannot become an unconditional allow candidate.
A successful proposal advances the checkpoint, is retained according to `retainAnalyzedBatches`, and is delivered with `deliverAs: "followUp"` and `triggerTurn: true` to this conversation. The proposal is advisory only. Neither `policy.jsonc` nor `hard-guards.jsonc` is modified until the user explicitly chooses and confirms rules. Shutdown aborts the running worker but leaves the pending checkpoint durable for the next session.
## Runtime Knobs
| Key | Default | Description |
@@ -1010,18 +1028,11 @@ Practical guidance:
## Schema Validation
Validate your config against the included schema:
The package includes two schemas:
```bash
npx --yes ajv-cli@5 validate \
-s ./schemas/permissions.schema.json \
-d ./config.json
```
- `schemas/permissions.schema.json` for `policy.jsonc`;
- `schemas/hard-guards.schema.json` for `hard-guards.jsonc`.
**Editor tip:** Add the hosted schema URL as the `$schema` key in your config for autocomplete and validation support:
Associate those packaged files with the corresponding JSONC filenames in your editor. Command-line validators that only accept JSON must receive a comment-stripped temporary copy; do not rewrite the user's JSONC merely to validate it.
```json
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/schemas/permissions.schema.json"
```
The schema is generated from the extension's zod source of truth (`src/config-schema.ts`); regenerate it with `pnpm run gen:schema` after changing the config shape.
The ordinary policy schema is generated from `src/config-schema.ts`; regenerate it with `npm run gen:schema` after changing that shape. Keep the hard-guard schema synchronized with `HardGuardPolicyLoader` whenever its accepted document changes.
@@ -26,7 +26,7 @@ Project **runtime** config (e.g. `yoloMode`) is re-read on the next session star
If you only use global config, nothing changes.
If you rely on a project's `.pi/extensions/pi-permission-system/config.json`, **grant the project trust** when Pi prompts (or configure `defaultProjectTrust` to always trust).
If you rely on a project's `.pi/extensions/pi-permission-system/policy.jsonc` (formerly `config.json`), **grant the project trust** when Pi prompts (or configure `defaultProjectTrust` to always trust).
Until then, the project's permission rules and runtime knobs are ignored and only your global policy applies.
If a project's rules stop taking effect after upgrading (surfaces you allowed at the project scope start prompting or denying per global policy), check whether the project is trusted — the review log will contain a `project_trust.skipped` entry naming the untrusted `cwd`.
@@ -49,7 +49,7 @@ If your OpenCode config uses these features, the equivalent works in this extens
| `mcp` surface | Not a documented permission surface | First-class with server/tool-level granularity |
| Top-level string shorthand | `"permission": "allow"` sets all surfaces | Not supported; must use an object |
| Per-agent config location | `agent` key in config JSON or YAML frontmatter | YAML frontmatter in agent `.md` files only |
| Config file paths | `~/.config/opencode/opencode.json` | `~/.pi/agent/extensions/pi-permission-system/config.json` |
| Config file paths | `~/.config/opencode/opencode.json` | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` plus global `hard-guards.jsonc` |
| Subagent prompt forwarding | Not documented | `ask` policies work in non-UI subagent contexts |
| Infrastructure auto-allow | N/A | Read-only tools to Pi infra dirs bypass the gate |
| Permission review log | No equivalent documented | Writes decisions to a JSONL audit log |
+3 -3
View File
@@ -4,7 +4,7 @@
| Problem | Cause | Solution |
| ----------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Config not applied (everything asks) | File not found or parse error | Verify the global config at `~/.pi/agent/extensions/pi-permission-system/config.json` (respects `PI_CODING_AGENT_DIR`); check for trailing commas |
| Config not applied (everything asks) | File not found or parse error | Verify the global policy at `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` (respects `PI_CODING_AGENT_DIR`); check JSONC syntax |
| Per-agent override not applied | Frontmatter parsing issue | Ensure `---` delimiters at file top; keep YAML simple; restart session |
| Tool blocked as unregistered | Unknown tool name | Use a registered `mcp` tool for server tools: `{ "tool": "server:tool" }` |
| `/skill:<name>` blocked | Deny policy or confirmation unavailable | Check merged `skill` policy (global/project/agent layers). `ask` still requires UI or forwarded confirmation. |
@@ -22,9 +22,9 @@ This makes it easy to verify which files the extension actually loaded:
```jsonc
{
"event": "config.resolved",
"globalConfigPath": "/…/.pi/agent/extensions/pi-permission-system/config.json",
"globalConfigPath": "/…/.pi/agent/extensions/pi-permission-system/policy.jsonc",
"globalConfigExists": true,
"projectConfigPath": "/…/my-project/.pi/extensions/pi-permission-system/config.json",
"projectConfigPath": "/…/my-project/.pi/extensions/pi-permission-system/policy.jsonc",
"projectConfigExists": false,
"agentsDir": "/…/.pi/agent/agents",
"agentsDirExists": true,