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).