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,288 @@
---
issue: 486
issue_title: "pi-permission-system: should the path surface match the canonical (symlink-resolved) form like external_directory?"
---
# Make the `path` surface match the canonical (symlink-resolved) form
## Release Recommendation
**Release:** ship independently
Issue #486 is a follow-on filed and deferred during Phase 6 ([#478]); it is not a member of any active roadmap batch, and Phase 6 is closed.
It is a self-contained behavior change to one surface, so it ships on its own — and because it is breaking (see Goals), it warrants its own major-bump release rather than batching.
## Problem Statement
The `path` and `external_directory` surfaces match against different value sets today:
- `path` matches the **lexical** aliases only — the as-typed form and its cwd/effective-base absolute resolution (`getPathPolicyValues`).
- `external_directory` matches the lexical aliases **plus the canonical (symlink-resolved) form** (`AccessPath.matchValues()`), the [#418] fix: a rule keyed on `/tmp/*` matches even when the access resolves to `/private/tmp`.
This asymmetry means a `path` deny on a sensitive spelling (`*.env`, `~/.ssh/*`) can be evaded through a symlink alias, whereas the same rule on `external_directory` cannot.
The operator has decided (issue thread) that `path` **should** also match the canonical form, so a `path` deny on `/etc/passwd` catches a symlink to it.
After this change the two surfaces match the identical value set and the asymmetry dissolves.
## Goals
- Make the `path` surface match the lexical aliases **plus** the canonical (symlink-resolved) form — the same set `AccessPath.matchValues()` already computes — across **both** producers (the tool path gate and the bash-path gate), so no new tool-vs-bash asymmetry is introduced.
- Route both `path` producers through `AccessPath` (the "full" scope the operator chose), pulling the bash-path `AccessPath` migration forward from [#487].
- Collapse the now-unproduced emitted `path-values` `AccessIntent` variant, completing one item the [#487] direction listed.
- Preserve the [#393] unknown-base behavior (a relative bash token after a non-literal `cd` keeps its literal value only — no canonical, no spurious absolute) and the [#418] / [#382] external-directory invariants.
This is a **breaking change**: adding the canonical alias to the `path` match set alters which rules fire on upgrade with no user edit.
A symlink whose resolved target matches a `path` deny (or allow) pattern now matches it where it previously did not.
The suggested commit for the behavior step is `feat(pi-permission-system)!:` with a `BREAKING CHANGE:` footer.
## Non-Goals
- No migration of config-pattern or prompt-input path handling onto `AccessPath` — those remain the residual [#487] scope after this change.
- No change to what `external_directory` matches (it already matches lexical canonical); this change only brings `path` to parity.
- No change to dedup/approval-key identity: keys continue to derive from the **lexical** form (`AccessPath.value()`), so existing session approvals stay stable.
- No principal identity on `AccessIntent`; cross-session path portability stays deferred.
## Background
Relevant modules (all in `packages/pi-permission-system/`):
- `src/access-intent/access-path.ts``AccessPath` value object.
`matchValues()` returns lexical aliases canonical; `boundaryValue()` the canonical form; `value()` the lexical absolute form.
Built today via the private constructor through `forExternalDirectory(pathValue, cwd)`.
- `src/access-intent/access-intent.ts` — the `AccessIntent` emitted union (`tool` | `path-values` | `access-path`) and the `ResolvedAccessIntent` manager-consumed union (`tool` | `path-values`).
- `src/permission-resolver.ts``toResolvedIntent` unwraps an `access-path` intent to `path-values` via `matchValues()` before handing it to the manager; the manager stays string-based.
- `src/permission-manager.ts``check(intent)`: the `path-values` branch evaluates `intent.values` directly against `intent.surface`; the `tool` branch normalizes raw input via `normalizeInput``normalizePathSurfaceValues``getPathPolicyValues` (lexical only).
- `src/handlers/gates/path.ts` — tool path gate.
Emits `{ kind: "tool", surface: "path", input: { path } }`; the manager normalizes it lexically.
- `src/handlers/gates/bash-path.ts` — bash path gate.
Emits `{ kind: "path-values", surface: "path", values }`, the values coming from `BashProgram.pathRuleCandidates()`.
- `src/access-intent/bash/cwd-projection.ts``projectRuleCandidates` builds `BashPathRuleCandidate[]` (`{ token, policyValues }`); `getPolicyValuesForRuleCandidate` returns lexical values, and for an unknown base + relative candidate returns the literal only ([#393]).
- `src/handlers/gates/external-directory.ts`, `src/access-intent/bash/cwd-projection.ts` (`projectExternalPaths`) — the existing `forExternalDirectory` callers (the external-directory surface).
Key constraint (AGENTS.md / SKILL): the manager stays string-based and never imports `AccessPath`; the resolver does the `matchValues()` unwrap.
This change preserves that — both `path` producers emit `access-path`, the resolver unwraps, the manager is untouched.
The reason the asymmetry exists is documented in `docs/architecture/architecture.md` (the access-path narrative) and in the [#478] retro: forcing bash-path through `AccessPath` would inject a canonical alias the `path` surface did not match — a behavior change deferred to this issue.
That behavior change is now wanted.
## Design Overview
### The match set is already single-sourced
`AccessPath.matchValues()` returns exactly `lexical aliases canonical` — the set the `path` surface should now match.
The resolver already unwraps an `access-path` intent through `matchValues()`.
So the change is: make both `path` producers emit `access-path` instead of their lexical-only forms.
No manager change is needed.
### Factory: generalize `forExternalDirectory` to a surface-neutral `forPath`, add `forLiteral`
`forExternalDirectory(pathValue, cwd)` is no longer external-directory-specific.
Generalize and rename it to a surface-neutral factory that also supports a cd-resolved base for bash candidates:
```typescript
// access-path.ts
static forPath(
pathValue: string,
options: { cwd: string; resolveBase?: string },
): AccessPath {
const { cwd, resolveBase = cwd } = options;
return new AccessPath(
normalizePathForComparison(pathValue, resolveBase),
getPathPolicyValues(pathValue, { cwd, resolveBase }),
canonicalNormalizePathForComparison(pathValue, resolveBase),
);
}
// literal-only: the #393 unknown-base case — no absolute, no canonical
static forLiteral(literal: string): AccessPath {
return new AccessPath(literal, literal ? [literal] : [], "");
}
```
`forPath(p, { cwd })` (resolveBase defaults to cwd) is behavior-identical to the old `forExternalDirectory(p, cwd)`: `getPathPolicyValues(p, { cwd, resolveBase: cwd })` equals `getPathPolicyValues(p, { cwd })` because `resolveBase` already defaults to `cwd` inside `getAbsolutePathPolicyValues`.
So renaming the external-directory callers preserves their behavior.
`forLiteral` produces `matchValues() === [literal]`, `boundaryValue() === ""`, `value() === literal` — exactly the conservative unknown-base shape (`matchValues()` already collapses to the aliases when `canonical` is `""`).
### Tool path gate (`path.ts`)
Build an `AccessPath` and emit an `access-path` intent on the `path` surface; derive the approval pattern from `accessPath.value()` (the lexical absolute, identical to today's `normalizePathForComparison(filePath, tcc.cwd)`):
```typescript
const accessPath = AccessPath.forPath(filePath, { cwd: tcc.cwd });
const check = resolver.resolve({
kind: "access-path",
surface: "path",
path: accessPath,
agentName: tcc.agentName ?? undefined,
});
if (check.state === "allow") return null;
if (check.matchedPattern === undefined) return null; // #58 backward-compat guard, unchanged
const pattern = deriveApprovalPattern(accessPath.value());
```
The [#58] guard (skip when only the universal default fired) is preserved — the resolved check still carries `matchedPattern`.
Prompt/log/decision/denialContext keep using the raw `filePath`.
### Bash path candidates (`cwd-projection.ts` → `bash-path.ts`)
`BashPathRuleCandidate` carries an `AccessPath` instead of precomputed lexical `policyValues`, keeping the raw `token` for prompts/logs/approvals:
```typescript
export interface BashPathRuleCandidate {
readonly token: string;
readonly path: AccessPath;
}
```
`projectRuleCandidates` builds each candidate's `AccessPath`, preserving the [#393] unknown-base branch via `forLiteral`:
```typescript
const path =
base.kind === "unknown" && isRelativeCandidate(candidate)
? AccessPath.forLiteral(normalizePathPolicyLiteral(candidate))
: AccessPath.forPath(candidate, {
cwd,
resolveBase: base.kind === "known" ? resolve(cwd, base.offset) : cwd,
});
if (path.matchValues().length === 0) continue;
const key = path.matchValues().join("\0"); // dedup identity preserved
```
`getPolicyValuesForRuleCandidate` is dissolved into `projectRuleCandidates` (its sole caller) — its lexical/literal branching moves into the `AccessPath` construction above.
`bash-path.ts` emits `access-path` per candidate and derives the approval base from `path.value()`:
```typescript
for (const { token, path } of candidates) {
const check = resolver.resolve({
kind: "access-path",
surface: "path",
path,
agentName: tcc.agentName ?? undefined,
});
// ...existing #58 guard, deny short-circuit, ask accumulation unchanged...
}
// approval base:
const approvalBase = worstEntry.path.value();
```
This honors Tell-Don't-Ask (the resolver asks the `AccessPath` for `matchValues()`) and keeps the manager string-based — identical to how the external-directory gates already work.
### Collapse the emitted `path-values` variant
After both producers emit `access-path`, no gate emits `path-values`.
Remove `PathValuesAccessIntent` from the emitted `AccessIntent` union while keeping it in `ResolvedAccessIntent` (the resolver still produces it internally via `toResolvedIntent`, and the manager still consumes it):
```typescript
export type AccessIntent = ToolAccessIntent | AccessPathAccessIntent;
export type ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent;
```
`toResolvedIntent` now maps `access-path → path-values` and `tool → tool`; its prior `path-values` passthrough case is gone.
### Edge cases
- **Not a symlink:** `matchValues()` already collapses to the lexical aliases when canonical equals one of them — no spurious extra value.
- **Unresolvable path (empty / ELOOP / EACCES):** `canonicalNormalizePathForComparison` falls back to the lexical form; `forLiteral` yields `boundaryValue() === ""`.
No new match introduced beyond today's lexical behavior.
- **`#58` no-`path`-key configs:** unchanged — the `matchedPattern === undefined` guard still short-circuits.
- **Dedup / session approvals:** keys derive from `value()` (lexical), unchanged.
## Module-Level Changes
- `src/access-intent/access-path.ts` — rename/generalize `forExternalDirectory(pathValue, cwd)``forPath(pathValue, { cwd, resolveBase? })`; add `forLiteral(literal)`.
Update the class doc comment (it names `forExternalDirectory`).
- `src/access-intent/access-intent.ts` — remove `PathValuesAccessIntent` from the emitted `AccessIntent` union (keep the interface and its place in `ResolvedAccessIntent`); update doc comments that describe `path-values` as an emitted variant.
- `src/permission-resolver.ts``toResolvedIntent` drops the `path-values` passthrough branch (now `tool | access-path` input only).
- `src/handlers/gates/path.ts` — build `AccessPath.forPath`, emit `access-path` on `path`, derive pattern from `accessPath.value()`.
- `src/handlers/gates/bash-path.ts` — consume `{ token, path }` candidates, emit `access-path` per candidate, derive `approvalBase` from `path.value()`.
- `src/access-intent/bash/cwd-projection.ts``BashPathRuleCandidate` becomes `{ token, path: AccessPath }`; `projectRuleCandidates` builds `AccessPath` (via `forPath` / `forLiteral`); inline and remove `getPolicyValuesForRuleCandidate`.
Rename the existing `projectExternalPaths` `forExternalDirectory` call sites (2) to `forPath`.
- `src/access-intent/bash/program.ts` — re-exports `BashPathRuleCandidate` (shape change flows through; verify no other change needed).
- `src/handlers/gates/external-directory.ts` — rename the `forExternalDirectory` call to `forPath`.
Documentation (grep-verified — symbol/behavior is named in prose):
- `docs/configuration.md``path` Surface section: update "matches as the agent references it" framing to state it now also matches the symlink-resolved form; add a `path`-surface symlink note (or generalize the existing `external_directory` "Symlinked paths" note at line ~465 to cover both surfaces).
- `docs/architecture/architecture.md` — update: the `access-path.ts` module entry (factory name/desc, line ~670), the `bash-path.ts` entry (line ~696, now emits `access-path`), the `cwd-projection.ts` entry (`pathRuleCandidates()` returns `AccessPath`-backed candidates), the `access-intent.ts` entry (emitted union no longer carries `path-values`), and the Phase 6 follow-on note (line ~760: #486 implemented, #487 narrowed to config-pattern/prompt-input migration).
Verify the inline `Rule`/`Ruleset` type listings are untouched (they are — no rule-type field changes here).
- `.pi/skills/package-pi-permission-system/SKILL.md` — update the gate-fixtures/intent notes (lines ~150152): the bash path gate now emits `access-path` on `path` (not `path-values`); the `makeHandler` adapter and `makePathDispatchResolver` descriptions; and any "`path` matches lexical only" framing.
- `README.md` — add that a `path` deny now also resists symlink-alias evasion (lines ~20 / ~71 describe `path` denies for sensitive files).
## Test Impact Analysis
1. **New tests the change enables:**
- `AccessPath.forPath` with an explicit `resolveBase` (cd-folded base) and `AccessPath.forLiteral` (literal-only, empty boundary) — unit-testable directly.
- The `path` tool gate denying a symlink whose canonical target matches a `path` deny pattern.
- The bash-path gate matching a `path` rule against a symlinked token's canonical form.
2. **Tests that become redundant:** none removed; existing lexical-match assertions stay valid (lexical aliases are still in `matchValues()`).
3. **Tests that must stay as-is:** the [#393] unknown-base test in `test/access-intent/bash/program.test.ts` (non-literal `cd` → literal only) — it now pins `forLiteral` behavior and must keep asserting no canonical/absolute leakage.
Existing tests to migrate (interface/shape changes, same package, type-level breaks):
- `test/access-intent/access-path.test.ts``forExternalDirectory``forPath`; add `forLiteral` cases.
- `test/permission-resolver.test.ts`, `test/handlers/gates/external-directory-policy.test.ts``forExternalDirectory``forPath`.
- `test/access-intent/bash/program.test.ts``pathRuleCandidates()` shape (`policyValues``path: AccessPath`); assert via `path.matchValues()` / `path.value()`.
- `test/handlers/gates/bash-path*` and any `path.ts` gate tests — assert the emitted `access-path` intent and `path.value()`-derived approval base.
## Invariants at risk
This change touches surfaces Phase 6 refactored.
Documented invariants and their pinning tests:
- **[#418] external-directory matches lexical canonical** — preserved by the `forPath` rename (behavior-identical when `resolveBase` defaults to `cwd`).
Pinned by `test/handlers/gates/external-directory-policy.test.ts` and `test/access-intent/access-path.test.ts`.
- **[#393] unknown-base bash token keeps literal only** — preserved by routing that case through `forLiteral`.
Pinned by the non-literal-`cd` case in `test/access-intent/bash/program.test.ts` (extend it to assert `matchValues()` carries no canonical/absolute).
- **[#382] canonical is win32-lowercased** — `forPath` uses `canonicalNormalizePathForComparison` (unchanged).
Pinned by `access-path.test.ts`.
- **[#478] single `resolve(intent)` entry point** — unchanged; both producers still emit one intent through `resolve`.
## TDD Order
1. **`feat(pi-permission-system): add AccessPath.forPath and forLiteral factories`** Test surface: `test/access-intent/access-path.test.ts`.
Add `forPath(pathValue, { cwd, resolveBase })` (generalized rename of `forExternalDirectory`) and `forLiteral(literal)`; migrate the existing `forExternalDirectory` tests to `forPath` and update the three production call sites in the same commit (`external-directory.ts`, `cwd-projection.ts` ×2) — removing an export breaks all importers at the type level, so fold them together.
Cover `forLiteral` (matchValues `[literal]`, empty boundary) and `forPath` with an explicit `resolveBase`.
Also update `test/permission-resolver.test.ts` and `test/handlers/gates/external-directory-policy.test.ts` (rename) in this commit.
2. **`feat(pi-permission-system)!: match the canonical form on the path tool gate`** Test surface: the `path.ts` gate tests.
Migrate `path.ts` to build `AccessPath.forPath` and emit `access-path` on `path`; derive the approval pattern from `value()`.
Red: a tool reading a symlink whose canonical target matches a `path` deny is now denied.
Breaking — `feat!:` with `BREAKING CHANGE:` footer.
3. **`feat(pi-permission-system)!: match the canonical form on the bash-path gate`** Test surface: `test/access-intent/bash/program.test.ts` + bash-path gate tests.
Change `BashPathRuleCandidate` to `{ token, path: AccessPath }`, rebuild candidates in `projectRuleCandidates` (inline/remove `getPolicyValuesForRuleCandidate`, preserve the `forLiteral` unknown-base branch), and migrate `bash-path.ts` to emit `access-path` and derive the approval base from `path.value()`.
The `projectRuleCandidates` return-type change and its `bash-path.ts` consumer + tests break together — one commit.
Red: a bash token symlinked to a `path`-denied target is denied; the [#393] unknown-base case still yields literal-only matches.
Breaking — `feat!:`.
4. **`refactor(pi-permission-system): drop the unproduced path-values emitted variant`** Test surface: type-level + fixtures.
Remove `PathValuesAccessIntent` from the emitted `AccessIntent` union (keep in `ResolvedAccessIntent`); simplify `toResolvedIntent`; update `gate-fixtures.ts` (`makePathDispatchResolver`, `makeHandler` adapter) to the `tool | access-path` emitted surface.
`tsc` confirms no remaining emitter.
5. **`docs(pi-permission-system): document canonical path-surface matching`** Update `docs/configuration.md`, `docs/architecture/architecture.md`, `.pi/skills/package-pi-permission-system/SKILL.md`, and `README.md` per Module-Level Changes.
No release impact on its own (rides the breaking feat).
## Risks and Mitigations
- **Risk: the rename silently changes external-directory behavior.**
Mitigation: `forPath(p, { cwd })` is behavior-identical (proved above); the external-directory-policy and access-path tests pin it and run unchanged-in-intent.
- **Risk: the [#393] unknown-base case regresses to over-matching (spurious canonical/absolute).**
Mitigation: route it through `forLiteral`; extend the existing program test to assert `matchValues()` carries only the literal.
- **Risk: dedup or session-approval keys shift, invalidating in-flight approvals.**
Mitigation: keys derive from `value()` (lexical), which is unchanged; covered by existing approval/dedup tests.
- **Risk: an existing user config's `path` rule starts matching a previously-unmatched symlinked path on upgrade.**
This is the intended breaking behavior; mitigation is the `BREAKING CHANGE:` note and the docs update describing the new symlink-resistant matching.
## Open Questions
- None blocking.
The residual [#487] scope (config-pattern and prompt-input `AccessPath` migration) is unaffected; this plan narrows it by completing the bash-path migration and the `path-values` collapse it listed.
No new follow-up issue is filed (no new work is deferred — work is pulled forward).
[#58]: https://github.com/gotgenes/pi-packages/issues/58
[#382]: https://github.com/gotgenes/pi-packages/issues/382
[#393]: https://github.com/gotgenes/pi-packages/issues/393
[#418]: https://github.com/gotgenes/pi-packages/issues/418
[#478]: https://github.com/gotgenes/pi-packages/issues/478
[#487]: https://github.com/gotgenes/pi-packages/issues/487