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,42 @@
# Migration guide: forwarded asks and the bounded-delegation checkpoint
Starting with the release that closes #635, a permission request forwarded up from a subagent carries the **child-fixed access facts** through to the authorizer chain.
As a side effect, the bounded-delegation checkpoint now applies to forwarded asks the same way it already applied to local ones.
This is a **breaking change** for one narrow configuration.
## Who is affected
You are affected only if **all** of the following hold:
- You name an authorizer chain link in `authorizerChain`, and
- that link is **allow-capable** (it can return `allow`, not only `deny` / `defer`), and
- a subagent forwards an `ask` to your session that was raised by the `path` or `external_directory` gate.
If you run no chain link, or run only a deny-first link — including the first-party [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge), which only ever denies or defers — **nothing changes for you**.
## What changed
The checkpoint downgrades a link's `allow` to `defer` on the excluded surfaces (`path`, `external_directory`), so the request falls through to an interactive prompt instead.
It selects the surface from the **gate** surface — the surface the matching rule lives on — falling back to the displayed surface when no gate surface is available.
A forwarded request previously arrived with no gate surface, so the checkpoint fell back to the child's *display* surface, which is the tool name (`write`, `edit`, `read`).
A tool name is not an excluded surface, so a link's `allow` on a forwarded `path`-gated request was honored.
The identical request made directly in the same session was capped, because a local request always carries its gate surface.
Forwarding was therefore a way around your own delegation boundary: a link could approve `~/.ssh/config` for a subagent that it could not approve for the main session.
Now the gate surface crosses the forwarding hop, and both paths behave identically.
Unchanged: a forwarded `bash` ask, and a forwarded tool ask gated by a per-tool rule (`write: ask`), are both already on non-excluded surfaces and are not capped.
`deny` and `defer` verdicts were never capped and still are not.
## What you need to do
Nothing, unless you run an allow-capable link.
If you do, expect an interactive prompt where that link previously auto-approved a forwarded subagent request for a path.
To keep such a request from prompting, grant it in policy rather than through the link — the `path` and `external_directory` rules in your config are consulted before the chain runs, so an `allow` there resolves the request without reaching an authorizer at all.
## Related
Issue #620 will replace the whole-`path` exclusion with a narrower secret-shaped one, letting a link allow a non-secret path again while keeping secret-shaped paths capped.
That refinement applies to local and forwarded asks alike, so the two paths stay aligned.
@@ -0,0 +1,32 @@
# Migration guide: project-trust gating
Starting with the release that closes #644, the permission-system loads project-scoped configuration only after Pi reports the project as **trusted** (`ctx.isProjectTrusted()`).
This is a **breaking change** in how config is loaded in an untrusted directory.
## What changed
The extension used to load project-scoped config from the current working directory unconditionally — it never consulted Pi's project-trust decision.
Because project scope has higher precedence than global, an untrusted repository could ship a `.pi/extensions/pi-permission-system/config.json` that **loosened** an operator's global policy before the user granted trust — for example flipping a global `bash: deny` to `bash: allow`, or setting `yoloMode: true`.
Now, when the project is **not** trusted:
- Project and project-agent **permission policy** scopes are not loaded — only global (and global-agent) policy participates in resolution.
- Project **runtime config** (`yoloMode`, `permissionReviewLog`, `piInfrastructureReadPaths`, `shellTools`, `authorizerChain`, …) is not merged.
- Each skip is surfaced loudly: a UI warning and a `project_trust.skipped` entry in the permission review log.
This aligns the extension with Pi's own trust model, which already withholds project-local skills, prompts, and agents from untrusted directories.
## Timing and recovery
Pi resolves the trust decision (including any `defaultProjectTrust` setting) before `session_start`, so the guard sees the effective decision from the first tool call.
If you grant trust after the session starts, Pi fires `resources_discover` with `reason: "reload"`, and the extension re-reads trust and loads the project **policy** at that point.
Project **runtime** config (e.g. `yoloMode`) is re-read on the next session start.
## What you need to do
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).
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`.
@@ -0,0 +1,68 @@
# Migration guide: the prompt payload replaces the assembled message
Starting with the release that closes #745, a permission ask crosses two boundaries as **structured facts** rather than as a pre-rendered sentence: the forwarded-permission-request file a subagent writes for its parent, and the `permissions:ui_prompt` broadcast.
A third change deprecates the two tool-preview cap config fields.
This is a **breaking change** on three surfaces.
Each is independent — you may be affected by one and not the others.
| Surface | Break | Who is affected |
| -------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------- |
| `ForwardedPermissionRequest` (on-disk wire) | `message` removed; `payload` added | Anyone running **out-of-process** subagents across mixed versions |
| `PermissionUiPromptEvent` (`permissions:ui_prompt`) | `message` removed; `request` added | Any extension reading `event.message` |
| `toolInputPreviewMaxLength` / `toolTextSummaryMaxLength` | Accepted but ignored | Anyone who set either in `config.json` |
## Upgrade the parent session first
The forwarded-request wire is the only surface with an ordering constraint, and it only exists for an **out-of-process** subagent (one whose parent session is resolved through `PI_SUBAGENT_PARENT_SESSION` or a sibling env var).
An in-process child shares its parent's loaded extension, so the two can never skew.
A serving node on this version accepts an older child's `message`-only request: the field is no longer required, and the ask is rendered from the `surface`, `value`, and requester provenance the request does carry.
The reverse does not hold.
An **older** parent still demands `message` and rejects a newer child's request outright, deleting the file; the child then waits out its forwarding timeout (ten minutes by default) and reports the block as `confirmationUnavailable` rather than as a user denial.
So: upgrade the session that serves prompts before the sessions that forward to it.
Carrying both fields indefinitely was declined deliberately — it would keep the child's un-budgeted prose alive on the wire, which is the defect this change exists to remove.
## What changed on the wire
The child used to assemble a sentence under **its** configuration and write it into the request file; the serving node carried that string forward as a single evidence entry, so the parent's own render budget never applied to it.
A forwarded ask therefore could not be made consistent with a local one.
Now the child writes its complete `PromptPayload`, and the serving node renders the child's own facts under the **parent's** budget.
A forwarded bash ask reads `command : …` exactly as a local one does, because the serving node holds the child's real payload kind.
The request files are still mode-restricted and still not redacted — the parent reads them to render the ask.
The payload's evidence is the same disclosure class the `message` string already was, so this is not a widening.
## What changed on the broadcast
`permissions:ui_prompt` drops `message` and gains `request`, the ask's invariant core, verbatim from the prompt payload.
```typescript
// Before
notify(event.surface, event.value, event.message);
// After
notify(event.surface, event.value, event.request.matchedPattern);
```
`request.value` is the decision-relevant value (the command, path, MCP target, or skill name) and `request.matchedPattern` is the rule that fired, including a sentinel such as `<indirection-bash-wrapper>`.
See the [`PromptRequestFacts` table](../cross-extension-api.md#promptrequestfacts) for every field.
`surface`, `value`, `agentName`, `source`, `requestId`, and `forwarding` are unchanged — what narrows here is evidence, never correlation.
A forwarded ask's broadcast still carries its full `forwarding.requesterAgentName` / `forwarding.requesterSessionId` provenance.
For a `write`, an `edit`, or an MCP call this is a **net narrowing** of what the bus discloses: an incidental tool-input preview of up to 200 characters used to ride `message`, and now nothing from the payload's evidence reaches the bus at all.
That is deliberate — the bus is the narrowest renderer, observable by any loaded extension without the operator having named it, whereas every other route to an ask's evidence requires that consent.
## The deprecated tool-preview caps
`toolInputPreviewMaxLength` and `toolTextSummaryMaxLength` are still accepted by the config schema but **no longer take effect**.
Setting either logs a deprecation notice through the ordinary config-issue channel at session start.
They bounded one preview *inside* a prompt, never the prompt itself — which is why they never bounded it.
Use `promptMaxRows` (default `24`) and `promptFieldMaxWidth` (default `400`) instead: those bound what a permission prompt renders, and `Ctrl+O` still expands the prompt to the complete request.
Remove both fields from your `config.json`.
They remain valid so that an existing config is not rejected fail-closed — which would empty that scope's whole policy — but they will be removed in a later major.
@@ -0,0 +1,69 @@
# Review-log fields and width bound (breaking)
Two changes to the permission review log (`logs/pi-permission-system-permission-review.jsonl`) take effect on upgrade without a config edit.
Both come from [ADR 0011] §6, which makes the log a renderer over the prompt payload with its own configured limits, rather than a place the assembled prompt sentence happened to land.
Nothing about **redaction** changes.
Key-name masking applies exactly as before, and the boundary is unchanged: a value bound to a sensitive key name is masked; a secret embedded in a bash command string is not.
See [ADR 0010].
## The `message` field is removed
Every `permission_request.*` entry previously carried `message`, the same assembled sentence the prompt showed.
It is gone.
What the log accumulated was a side effect of how a prompt happened to be worded, and the sentence duplicated facts the entry already carried in structured form.
In its place each entry carries the ask's own request facts:
| Field | Present when | Meaning |
| -------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `surface` | always | The gate surface the rule fired on (`bash`, `path`, `external_directory`, `skill`, a tool name) |
| `matchedPattern` | a rule matched | The rule that fired, including a sentinel such as `<indirection-bash-wrapper>` |
| `executedUnit` | a wrapper hides an inner command | The unit that will actually run, e.g. `grep foo` inside `xargs grep foo` |
| `commandContext` | the unit came from a substitution or subshell | `command_substitution`, `process_substitution`, or `subshell` |
| `invokedToolName` | a shell alias re-exposed bash | The tool name the agent actually called, e.g. `exec_command` |
| `forwarded` | the ask arrived from a subagent | `true` |
| `requesterSessionId` | the ask arrived from a subagent | The requesting session's id |
A field the ask does not carry is omitted rather than written as `null`.
The existing structured columns are unchanged: `requestId`, `source`, `agentName`, `toolCallId`, `toolName`, `skillName`, `path`, `command`, `target`, `toolInputPreview`, `resolution`, and `denialReason`.
If you parse the log, read those fields instead of `message`.
Two of the new ones close gaps a `message` reader never had: a `permission_request.blocked` entry recorded that policy denied the call but never which rule, and `executedUnit` had not reached the log at all.
## Every review-log value is bounded
Each string the review log writes is now narrowed to `reviewLogFieldMaxWidth` characters and marked with a trailing ellipsis.
The default is `1000` — the width that already bounded `toolInputPreview`, applied to every field so the log has one limit rather than one limit and an unbounded remainder.
The practical effect is on `command`: a bash command longer than the width is stored shortened, where it was previously written whole.
On a real 9,484-entry log, 188 of 4,325 command entries (4.3%) exceed the default.
To keep longer values, raise the setting:
```jsonc
{
"reviewLogFieldMaxWidth": 8000
}
```
There is no "unbounded" value by design — the point of the bound is that log growth is a decision rather than an accident.
The **debug** log is unaffected: it is opt-in and exists to be read in full.
This bound is a length cap, not redaction.
It narrows by length alone and never inspects a value to decide what to hide, and the two compose independently: a sensitive-keyed value is masked whole however long it was.
## Removed exports
`ToolPreviewFormatterOptions.toolInputLogPreviewMaxLength` and `TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH` are removed, superseded by `reviewLogFieldMaxWidth`.
`ToolPreviewFormatter` no longer truncates what it produces for the log; the writer bounds it instead.
## Agent-facing denial text
Not a contract, but visible: the text returned to the agent when a call is refused no longer echoes the bash command or the tool input.
It names the surface, the tool, the rule that fired, the flagged path or MCP target or skill, and the operator's or human's reason.
[ADR 0011] §7 states the rule — the agent renderer identifies the call; it does not reproduce it — and the agent already holds its own arguments, which the harness keeps beside the refusal.
[ADR 0010]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0010-permission-log-secret-exposure.md
[ADR 0011]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0011-prompt-presentation-contract.md
@@ -0,0 +1,365 @@
# Migration guide: legacy format → flat permission format
This guide covers migration from the pre-#66 config format to the flat `permission` format introduced in #66.
## Summary of changes
The old format had six top-level policy keys (`defaultPolicy`, `tools`, `bash`, `mcp`, `skills`, `special`).
The new format has a single `permission` key whose top-level entries map surface names to actions.
Runtime knobs (`debugLog`, `permissionReviewLog`, `yoloMode`) are **unchanged** and stay at the top level.
## Checklist
Go through each section below.
For each key present in your config, apply the translation and remove the old key.
- [ ] `defaultPolicy`
- [ ] `tools`
- [ ] `bash`
- [ ] `mcp`
- [ ] `skills`
- [ ] `special`
- [ ] Per-agent frontmatter
## Translation reference
### `defaultPolicy`
`defaultPolicy` set per-surface fallback actions.
In the flat format the universal fallback is `permission["*"]`; per-surface catch-alls are entries in the `permission` object.
```jsonc
// Before
{
"defaultPolicy": {
"tools": "ask",
"bash": "ask",
"mcp": "ask",
"skills": "ask",
"special": "ask"
}
}
// After — all surfaces default to "ask" via the universal fallback
{
"permission": {
"*": "ask"
}
}
```
If surfaces had **different** defaults, express each one explicitly:
```jsonc
// Before
{
"defaultPolicy": {
"tools": "allow",
"bash": "ask",
"mcp": "ask",
"skills": "ask",
"special": "deny"
}
}
// After
{
"permission": {
"*": "allow",
"bash": "ask",
"mcp": "ask",
"skill": "ask",
"external_directory": "deny"
}
}
```
### `tools`
Each entry in `tools` maps a tool name to a permission.
In the flat format, tool names are surface keys directly inside `permission`.
```jsonc
// Before
{
"tools": {
"read": "allow",
"write": "deny",
"edit": "ask"
}
}
// After
{
"permission": {
"read": "allow",
"write": "deny",
"edit": "ask"
}
}
```
**Special case — `tools.bash` and `tools.mcp`:** These were catch-all overrides for their respective surfaces.
In the flat format, use a string shorthand or an explicit `"*"` pattern:
```jsonc
// Before
{
"tools": { "bash": "allow", "mcp": "deny" }
}
// After — string shorthand (equivalent to { "*": "allow" })
{
"permission": {
"bash": "allow",
"mcp": "deny"
}
}
```
### `bash`
Bash patterns translate directly; the surface name stays `bash`.
If you also had a `tools.bash` or `defaultPolicy.bash` value different from `defaultPolicy.tools`, add an explicit `"*"` catch-all pattern at the **start** of the object (so specific patterns placed after it override it via last-match-wins).
```jsonc
// Before
{
"defaultPolicy": { "tools": "allow", "bash": "ask" },
"bash": {
"git status": "allow",
"git diff": "allow",
"git *": "ask",
"rm -rf *": "deny"
}
}
// After
{
"permission": {
"*": "allow",
"bash": {
"*": "ask",
"git status": "allow",
"git diff": "allow",
"git *": "ask",
"rm -rf *": "deny"
}
}
}
```
> **Note:** Pattern ordering matters within an object.
> `normalizeFlatConfig` preserves insertion order, and `evaluate` uses last-match-wins.
> Put broad catch-alls **first** and specific overrides **after** them.
### `mcp`
MCP patterns translate directly; the surface name stays `mcp`.
```jsonc
// Before
{
"mcp": {
"mcp_status": "allow",
"mcp_list": "allow",
"exa:*": "allow",
"dangerous-server": "deny"
}
}
// After
{
"permission": {
"mcp": {
"mcp_status": "allow",
"mcp_list": "allow",
"exa:*": "allow",
"dangerous-server": "deny"
}
}
}
```
### `skills`
The surface name changes from `skills` (plural) to `skill` (singular).
```jsonc
// Before
{
"skills": {
"*": "ask",
"librarian": "allow",
"dangerous-*": "deny"
}
}
// After — note: "skills" → "skill"
{
"permission": {
"skill": {
"*": "ask",
"librarian": "allow",
"dangerous-*": "deny"
}
}
}
```
### `special`
`special.external_directory` becomes a top-level surface key in `permission`.
Other deprecated keys (`doom_loop`, `tool_call_limit`) are simply dropped.
```jsonc
// Before
{
"special": {
"external_directory": "ask",
"doom_loop": "deny",
"tool_call_limit": "deny"
}
}
// After — doom_loop and tool_call_limit are removed entirely
{
"permission": {
"external_directory": "ask"
}
}
```
> **Note:** In the old format, `special.external_directory: "deny"` produced a rule with `matchedPattern: "external_directory"`.
> In the flat format, the string shorthand produces `pattern: "*"`, so `matchedPattern` is now `"*"` when the explicit rule matches.
## Full before/after example
```jsonc
// Before (legacy format)
{
"$schema": "...",
"debugLog": false,
"permissionReviewLog": true,
"yoloMode": false,
"defaultPolicy": {
"tools": "ask",
"bash": "ask",
"mcp": "ask",
"skills": "ask",
"special": "ask"
},
"tools": {
"read": "allow",
"write": "deny"
},
"bash": {
"git status": "allow",
"git *": "ask"
},
"mcp": {
"mcp_status": "allow"
},
"skills": {
"*": "ask"
},
"special": {
"external_directory": "ask"
}
}
```
```jsonc
// After (flat format)
{
"$schema": "...",
"debugLog": false,
"permissionReviewLog": true,
"yoloMode": false,
"permission": {
"*": "ask",
"read": "allow",
"write": "deny",
"bash": {
"git status": "allow",
"git *": "ask"
},
"mcp": {
"mcp_status": "allow"
},
"skill": { "*": "ask" },
"external_directory": "ask"
}
}
```
## Per-agent frontmatter
Frontmatter in agent `.md` files uses the same flat shape under the `permission` key.
```yaml
# Before (legacy nested shape)
---
permission:
defaultPolicy:
tools: allow
bash:
"git *": allow
tools:
mcp: deny
mcp:
exa_web_search_exa: allow
special:
external_directory: allow
---
```
```yaml
# After (flat shape)
---
permission:
"*": allow
bash:
"git *": allow
mcp:
"*": deny
exa_web_search_exa: allow
external_directory: allow
---
```
Key differences from the old frontmatter:
1. The `"*"` key (quoted in YAML) replaces `defaultPolicy.tools`.
2. `tools.bash` / `tools.mcp` catch-alls become `bash: <state>` or `mcp: { "*": <state>, ... }`.
3. `special.external_directory` becomes `external_directory` at the top level of `permission`.
4. Any surface key now works in frontmatter — extension tool names and `mcp` are no longer silently ignored.
## Behavioral differences
### Agent scope catch-alls override parent scope patterns
In the old format, `tools.bash: allow` (override layer) was lower priority than config-layer patterns from any scope, including global.
In the flat format, `bash: allow` in an agent scope is a config-layer catch-all with **higher** priority than global-scope patterns (last-match-wins, agent rules come later).
If you relied on global `rm -rf *: deny` surviving an agent's `tools.bash: allow`, you must now explicitly deny the pattern within the agent's own `bash` object:
```yaml
# Old agent frontmatter — global "rm -rf *": "deny" survived
permission:
tools:
bash: allow
# New agent frontmatter — must repeat the deny if you want it preserved
permission:
bash:
"*": allow
"rm -rf *": deny
```
### `matchedPattern` for `external_directory`
In the old format, an explicit `special.external_directory: "deny"` rule had `matchedPattern: "external_directory"`.
In the flat format, `external_directory: "deny"` (string shorthand) has `matchedPattern: "*"`.
Code that inspected `matchedPattern` to detect explicit external-directory config must be updated.
@@ -0,0 +1,52 @@
# Migration guide: strict config validation
Starting with the release that closes #547, the permission-system config loader validates each config file against a JSON Schema derived from a zod source of truth.
This is a **breaking change** in how malformed config is handled.
## What changed
The loader used to be **tolerant**: it silently discarded a malformed field and loaded the rest.
For example, a config with `"debugLog": "yes"` (a string, not a boolean) simply dropped `debugLog` and kept going; an unknown key like `"debugLo": true` was ignored.
The loader is now **strict and fail-closed**:
- A config file with **any** invalid field is rejected as a whole scope (global or project).
- The rejected scope contributes **no** permission rules.
- Each problem is reported as a clear, path-qualified issue in the permission review log (and the debug log when `debugLog` is on).
Nothing about the config **format** changed — a config that was already valid keeps working unchanged.
## Cross-scope hardening (fail closed on an invalid higher scope)
Rejecting a scope's rules is only half the story.
Because a higher-precedence scope that contributes no rules leaves the **lower** scope's rules in place, an invalid *higher* scope used to silently inherit the lower scope's policy — including a permissive `allow`.
For example, a global `bash: allow` remained effective even when a project config meant to deny bash but contained a typo.
The loader now fails closed across scopes as well: when a **non-global** scope (project config, global agent frontmatter, or project agent frontmatter) is present but invalid, the effective policy is floored so nothing resolves more permissively than `ask`.
Every `allow` — including one inherited from a lower scope — is clamped to `ask`; `deny` and `ask` are unchanged.
Alongside the per-problem validation issues, a distinct notice is reported: `Invalid <scope> configuration detected — failing closed: 'allow' rules are clamped to 'ask' …`.
An invalid **global** scope does not trigger the cross-scope clamp — it is the lowest precedence, so nothing more permissive is inherited when it fails.
The clamp is deny-preserving, and (like `yoloMode`) applied at composition; when `yoloMode` is on it re-permits the floored `ask` back to `allow`.
Fix the reported problems and reload to restore the intended policy.
## What you need to do
If your config was valid, nothing.
If a scope stops taking effect after upgrading (surfaces start prompting with `ask`), open the permission review log and look for `Invalid config value at '<path>': …` or `Unrecognized config key '<key>'.` messages.
Fix each reported problem, then reload.
Common fixes:
- **Wrong type** — e.g. `"toolInputPreviewMaxLength": "400"` (string) → `400` (number); `"debugLog": "true"``true`.
- **Unknown key** — a typo (`"debugLo"``"debugLog"`) or a legacy top-level policy key (`defaultPolicy`, `tools`, `bash`, …) that belongs under `permission` (see `legacy-to-flat.md`).
- **Invalid permission action** — an action must be `"allow"`, `"deny"`, or `"ask"` (or a `{ "action": "deny", "reason": "…" }` object).
## Editor support
Add the hosted schema to your config for autocomplete and inline validation, so these problems surface as you type:
```json
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/schemas/permissions.schema.json"
```