mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -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"
|
||||
```
|
||||
Reference in New Issue
Block a user