feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,254 @@
---
status: accepted
date: 2026-07-15
---
# 0007 — Model-assisted permission judge as an Authorizer chain
## Status
Accepted.
This decision settles the design of a case-by-case model judge ([#472]); it does not implement it.
[#472] stays open, tracking the implementation, and carries this ADR.
It supersedes the reverted ADR 0007 attempted under [#581].
Amended 2026-08-14 with §7 (one chain per node), which settles where a subagent's ask is adjudicated ([#727]).
## Context
`yoloMode` is the only non-static path in a permission decision today: a single boolean that rewrites every `ask` rule to `allow` at composition time (`origin: "yolo"`), suppressing prompts while preserving hard denies.
It is all-or-nothing — it cannot approve one clearly safe `ask` and still prompt on the rest.
[#472] asks for a case-by-case judge: a light model (e.g. Claude Haiku) that participates in a decision, deciding per ask.
It was deferred by name in Phases 9 and 10.
[#581] then attempted a decision record but treated it as transcription of the architecture doc's settled `ModelTriageAuthorizer` prose — an ask-only, allow-or-escalate decorator — and that ADR was found premature and reverted.
An ADR's value is the deliberation behind it; the prose itself was the wrong input.
Two concrete use cases, surfaced during the [#581] retro, show the real design is broader than — and in one respect contradicts — the reverted prose:
1. **Reject errant "typo" paths automatically.**
Models frequently invoke tools against a malformed path (e.g. `…/pi-permission-system/packages/pi-permission-system/src/x.ts`, where the first segment should be `pi-packages`).
These land as `external_directory` asks that a human hand-denies one by one.
A light model should review such asks, *defer* the ones that do not match a configured typo pattern, and *deny* the ones that do — returning a teaching reason (wrong path; correct location) so the invoking model self-corrects.
2. **Adjudicate opaque bash commands.**
Commands the deterministic parser cannot decompose (`bash -c "…"`, `eval`, unparseable constructs) floor to `ask` via sentinels.
A model should decompose the opaque command, query the deterministic engine per sub-command, and *allow* if clearly fine, *deny* if it hits a denied path, or *defer* if it cannot decide confidently.
The enabling premise is that 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 (ADR `docs/decisions/0005-serving-authorizer-provenance.md`).
A model can hold the `Authorizer` role on the same terms.
## Decision
Model the live-authority layer as a **Chain of Responsibility**, and place the model judge in it as a non-terminal link.
### 1. Verdict range is `allow | deny | defer`
Each link either decides (`allow` / `deny`) or defers to the next link.
This is a superset of the reverted ADR's ask-only allow-or-escalate framing: use case 1 is deny-first, and an `Authorizer` already denies (the human does; `DenyingAuthorizer` always does), so a model in that role can deny an ask too.
A `deny` carries an optional `reason` — the teaching signal use case 1 needs.
```typescript
type AuthorizerVerdict =
| { kind: "allow" }
| { kind: "deny"; reason?: string }
| { kind: "defer" };
```
### 2. The terminal link cannot defer
The chain ends at a terminal that must decide; until it does, the system pauses.
Today that terminal is the human (`LocalUserAuthorizer`), the headless `DenyingAuthorizer`, or `ParentAuthorizer` (terminal *for its node* — it forwards up and returns the parent node's `allow | deny`, the multi-hop recursion).
The invariant is enforced **at the type level**, not by a runtime assertion: a terminal returns only `allow | deny`, so a link that could defer cannot occupy the terminal slot.
```typescript
/** A non-terminal chain link: may decide or defer. */
interface Authorizer {
authorize(details: PromptPermissionDetails, query: PermissionQuery, log: AuthorizerLog): Promise<AuthorizerVerdict>;
}
/** The terminal link: structurally cannot defer. */
interface TerminalAuthorizer {
authorize(details: PromptPermissionDetails, query: PermissionQuery): Promise<TerminalVerdict>;
}
// TerminalVerdict = { kind: "allow" } | { kind: "deny"; reason?: string }
```
`selectAuthorizer` (which returns a single `Authorizer` today) generalizes to `composeAuthorizerChain`: registered non-terminal links, then the context-selected terminal last.
The terminal selection is unchanged.
### 3. The query capability is injected, not imported
A link never reaches for the cross-extension `PermissionsService` via `Symbol.for()` (a Law-of-Demeter reach-through to a global).
The chain injects a narrow, session-scoped `PermissionQuery` into each link at `authorize` time — a projection limited to what a link needs (ISP), backed by the same resolver the gates use so it answers at gate parity.
```typescript
/** Narrow, injected projection of PermissionsService. */
interface PermissionQuery {
checkPermission(surface: string, value?: string, agentName?: string): PermissionCheckResult;
getToolPermission(toolName: string, agentName?: string): PermissionState;
}
```
The tool-augmented adjudication (use case 2) exposes these primitives to the model *as tools*: the model decomposes an opaque command and calls `checkPermission("bash", subCommand)` / `checkPermission("external_directory", token)` per piece; the deterministic engine answers every sub-question.
The model's non-determinism is confined to *how it decomposes*, never *what the rules decide* — determinism-of-decision survives at the leaf.
#### The review-log seam is injected the same way
A link is handed a second narrow capability at `authorize` time: an `AuthorizerLog`, for recording its decision trail.
The motivation is observability — without it a link's verdict (especially a `defer`) is unobservable, so a misbehaving link deferring every ask is indistinguishable from a link never running (the `pi-permission-model-judge` auth-failure that motivated this addition).
The seam follows the same injection discipline as `PermissionQuery`: a link never reaches for the session logger via `Symbol.for()`; the chain owner passes the session's own logger straight through, so a link's entries land in the same `pi-permission-system-permission-review.jsonl` as the gate decisions, keyed by `requestId`.
```typescript
/** Narrow, injected review-log seam. */
interface AuthorizerLog {
review(event: string, details?: Record<string, unknown>): void; // durable, default-on audit entry
debug(event: string, details?: Record<string, unknown>): void; // verbose detail, gated by `debugLog`
}
```
The seam only *records*; it grants no authority and cannot alter a verdict, so it is inert with respect to the bounded-delegation invariant below.
### 4. Named-capability registration, opt-in activation
Registration mirrors `registerToolAccessExtractor`: a downstream extension offers a **named** capability on the published service.
```typescript
registerAuthorizer(name: string, authorize: Authorizer["authorize"]): () => void;
```
The downstream extension registers in a `permissions:ready` handler, so registration is robust to load order and survives `/reload`; it must land before the session's first ask.
Composition then reads the operator's configured chain and binds names to registered capabilities.
Three invariants govern the seam:
1. **Config order wins, never registration order.**
Chain order is security-relevant (an allow-capable link ahead of a deny-capable one changes outcomes), so it is deterministic operator policy — never a function of nondeterministic extension load order.
2. **Skipping any non-terminal link is always fail-safe.**
A missing or unregistered configured name removes only allow/deny *shortcuts*; the ask still reaches the terminal.
Absence of a judge means *more* prompting, never less — so a missing name is skipped with a warning.
3. **Registration alone grants no authority.**
A registered link decides nothing until the operator names it in the `authorizerChain` config — the opt-in activation model.
Installing a judge extension does not silently hand it decision authority.
### 5. Config split: policy here, mechanism downstream
Two independent extension config files, joined only by the link name — no merged schema.
This package declares and *enforces* the safety policy; the downstream extension declares and *uses* the model mechanism.
```jsonc
// pi-permission-system config.json — operator-owned policy (read + enforced HERE)
{
"authorizerChain": ["model-judge"],
"modelDelegation": {
"allowedSurfaces": ["bash"],
"excludedSurfaces": ["external_directory"] // + secret-shaped path always excluded
}
}
```
```jsonc
// pi-permission-model-judge config.json — downstream-owned mechanism (read THERE)
{ "provider": "anthropic", "model": "claude-haiku-…", "instructions": "…", "timeoutMs": 5000 }
```
The bounded-delegation policy is enforced at an **enforcement checkpoint** the chain owner (this package) applies to every verdict: a link's `allow` on an excluded surface is downgraded to `defer`.
So the safety envelope lives where it is enforced, and a buggy or over-eager external judge can never exceed the operator's policy.
This package holds no model-prompt config it does not read (the "declared-but-unread config is a maintenance trap" priority).
### 6. Two slices, a capability gradient
Both use cases are the *same* judge link; they differ only by which verdicts are enabled and how much envelope guards them.
| Aspect | Slice 1 — deny-first reviewer (use case 1) | Slice 2 — allow-capable adjudicator (use case 2) |
| ------------ | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Verdicts | `deny`, `defer` | `+ allow` |
| Risk | strictly more restrictive — always safe | loosens privilege — needs full envelope |
| Envelope | fail-closed only (unreachable/uncertain → defer) | + hard exclusions, audit `origin:"authorizer:model"`, non-persistence, off by default, bounded-delegation ruleset |
| Failure mode | a wrong deny — recoverable (agent self-corrects on the reason) | decomposition infidelity — mis-split obfuscation (`bash -c "safe; evil"`) allowed on the safe piece alone |
The "a tool-augmented model can never grant more than the engine grants for the pieces it identifies" safety property holds *only if decomposition is faithful*.
Obfuscation is the residual risk, and it is exactly why slice 2 is gated behind the whole envelope while slice 1 needs almost none.
The gradient is the argument for shipping deny-first.
### 7. One chain per node
An ask is adjudicated by exactly one node's chain: the node whose terminal decides it.
A node with UI (`LocalUserAuthorizer`) and a headless node with no reachable authority (`DenyingAuthorizer`) both decide locally, so both run their chain.
A subagent node whose terminal is `ParentAuthorizer` does not decide — it relays the ask to a serving node, which resolves it against its own recorded authority and escalates it through its own chain over the same child-fixed facts ([#635]).
So a relaying node resolves no links, and its terminal's forwarding *is* how the ask reaches a chain.
This is a consequence of §2's terminal-for-its-node framing, made explicit because the observable behavior contradicted it: a child's chain resolution found no links (a child cannot host one — [#699]) and reported each configured name as a fail-safe skip, which reads as the configured judge never running.
It runs; it runs one hop up.
The rule is not merely descriptive of the current implementation.
Running links on a relaying node as well would adjudicate one ask twice — the same link, over the same facts, once in the child and again on the serving node — with a second model call and a second latency, and would let a link decide an ask the serving node's policy owns.
Two review-log records make the placement observable, since a deferring link decides nothing and otherwise leaves no evidence it was consulted:
- `authorizer_chain_resolved` — an adjudicating node's resolved link names, recorded before any of them runs.
- `authorizer_chain_delegated` — a relaying node's configured names, recorded as deliberately not run.
This leaves `authorizer_chain_unregistered_link` meaning what it says: a name the adjudicating node could not resolve, which is a real misconfiguration.
The rule also settles the shape of [#699]'s fix: a sibling extension should skip registering a link in a registered child rather than registering one that would never be consulted.
### Relationship to `evaluate()` and rule-driven promotion
The judge sits on the ask-*consuming* side of `evaluate()`, distinct from the ask-*producing* side (rule-driven promotion, [#509]).
Rule-driven promotion produces the fail-safe false-positive ask (`git grep id_rsa` prompts); the judge dismisses it on the consuming side without hard-coding per-command file-argument tables.
The two compose cleanly: a promoted token emits the same structured descriptor a prefixed path does, so a link needs no promotion-specific knowledge.
## Consequences
- [#472] carries a linked, settled ADR and becomes schedulable in a future phase on its own merits.
- The `Authorizer` role generalizes from a single per-session selection into a composed chain; `selectAuthorizer` becomes the terminal-selection step of `composeAuthorizerChain`, and the interface gains a `defer` verdict and an injected `PermissionQuery`.
- The chain is the **one** live-authority extensibility seam.
A model judge is a non-terminal link; a future terminal-replacement backend (a chat-bot or remote reviewer *as* the authority) is the same seam's terminal role.
This subsumes the architecture doc's separately-sketched "pluggable escalation seam" — registering a link *is* the seam, not a mechanism beside it.
- The review log gains a fourth grant provenance (`authorizer:model`, slice 2) alongside human, policy, and yolo.
- **Dogfooding is slice 1's acceptance criterion.**
A first-party package in this monorepo (`packages/pi-permission-model-judge`) implements the deny-first typo-path reviewer against the real seam.
This is a design safeguard, not a demo: the [#267] history guard warns that an inbound registration surface nobody consumes goes vacant; a first-party consumer registering `"model-judge"` on day one makes `registerAuthorizer` born consumed, and its own config file exercises the config split end to end.
The concrete issue is filed by the next `/plan-improvements` pass when the phase is scoped.
- No code, config, schema, or default changes in this documentation step.
### Rejected alternatives
- **Ask-only, allow-or-escalate verdict range** (the reverted ADR).
Rejected: use case 1 is deny-first, and an `Authorizer` already denies, so confining a model link to allow-or-escalate cannot express the typo-path reviewer.
- **A single terminal instead of a chain.**
Rejected: the judge fundamentally decides *some* asks and hands the rest to the real authority — it needs a successor.
A chain with a non-deferring terminal models exactly this, and the operator's mental model was a chain, not a decorated singleton.
- **The judge imports `PermissionsService` via `Symbol.for()`.**
Rejected: a Law-of-Demeter reach-through to a global, and it forces the external extension to import two surfaces.
Injecting a narrow `PermissionQuery` gives one import and an ISP-clean contract.
- **Opt-out activation** (a registered link joins the chain automatically; config can only disable it).
Rejected: it lets a loaded extension gain decision authority unless explicitly disabled, and lets load order influence security-relevant chain order.
Opt-in (config names the chain) is least-privilege by construction.
- **A process-global `AuthorizerRegistry`, so a child resolves its parent's links** ([#727]).
Rejected: it converts every deferring ask into two link runs, and lets a link's verdict short-circuit before the serving node ever sees the request — a privilege change dressed as a plumbing fix.
The forwarding round trip is not the cost being avoided; the serving node resolves the request against its own ruleset regardless.
- **The model applies the ruleset itself, or emits a static intent.**
Rejected: the former couples the model to rule semantics; the latter weakens determinism.
Tool-augmented decomposition keeps the model decoupled from rule semantics (a rule edit is honored automatically) and confines its non-determinism to decomposition.
### Accepted limitations
- **Open implementation parameters.**
Model provider, prompt, confidence threshold, and timeout are deliberately left to [#472] and the downstream package — they are tuning and mechanism, not architecture.
- **[#472]'s decomposition is deferred.**
Whether [#472] splits into staged issues (chain infrastructure; deny-first slice; allow-capable slice; the dogfood package) is [#472]'s own planning decision, sequenced by the next `/plan-improvements` pass.
- **Terminal-replacement registration is deferred.**
Registering a backend *as* the terminal authority is the chain seam's other role, built when a real non-subagent backend needs it — not now.
- **The pre-`evaluate()` classifier stays out of scope.**
A model that *classifies* access intent before `evaluate()` feeds *recorded* authority and weakens the "same `(toolName, input)` yields the same ruling" property more subtly than this live-authority judge; it warrants its own decision record (see the architecture doc's "Beyond the target: a non-deterministic access-intent classifier").
[#267]: https://github.com/gotgenes/pi-packages/issues/267
[#472]: https://github.com/gotgenes/pi-packages/issues/472
[#509]: https://github.com/gotgenes/pi-packages/issues/509
[#581]: https://github.com/gotgenes/pi-packages/issues/581
[#635]: https://github.com/gotgenes/pi-packages/issues/635
[#699]: https://github.com/gotgenes/pi-packages/issues/699
[#727]: https://github.com/gotgenes/pi-packages/issues/727