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,289 @@
---
issue: 393
issue_title: "fix(pi-permission-system): normalize path policy inputs"
---
# Normalize path policy inputs
## Problem Statement
The path gates already know the current Pi working directory — `PermissionManager.configureForCwd` records it — but the evaluator never used it.
So a relative tool input or bash token such as `src/App.jsx` could never match an absolute allowlist rule such as `/workspace/project/*`, even though the two name the same file.
After extension and MCP tools started flowing through the same path gates ([#352]), this gap became more visible: relative inputs silently miss absolute rules.
The fix is to feed the evaluator a set of equivalent "policy values" for a path (absolute, project-relative, and raw relative), derived from the known working directory, and to match them with last-match-wins preserved across the aliases so a catch-all on one spelling cannot mask a later specific rule on another.
This plan adopts that capability from third-party PR [#393] (`@moekyo`) but plans a simplified design: the PR is reference, not the merge target.
The direction, scope, and breaking classification were confirmed by the operator during the PR Review stage (recorded in the retro) — the `Decide` gate is satisfied and is not re-litigated here.
## Goals
- Derive equivalent path-policy lookup values (absolute, project-relative, raw) for path surfaces when the working directory is known.
- Match those values with last-match-wins preserved **across** aliases of the same path, so an early catch-all match cannot mask a later, more specific rule.
- Make the bash `path` gate use cd-aware policy values for literal current-shell `cd` commands, while keeping prompts, logs, and session approvals on the raw token.
- Pass bash's per-token resolution context **explicitly** through a dedicated resolver/manager method — not as a symbol-keyed side-channel on the tool `input` object.
- Remove the now-orphaned `pathTokens()` / `extractTokensForPathRules` chain rather than suppressing the dead-code flag.
- Document the matching semantics in the README, configuration guide, and JSON schema description.
- **This change is breaking.**
It flips permission decisions on upgrade with no config edit: a relative input under a config like `path: { "*": "ask", "/workspace/project/*": "allow" }` moves from `ask` to `allow`.
For a least-privilege package that loosening is breaking — use `feat!:` with a `BREAKING CHANGE:` footer.
## Non-Goals
- Adding a config flag to opt out of cwd-aware matching — the flat permission model stays as-is; no new config surface.
- Changing MCP target resolution: MCP keeps `evaluateFirst` (its candidates are genuinely different targets, not aliases of one path).
- Per-tool path maps for extension tools (threading the access extractor through `normalizeInput`) — a deferred follow-up noted in [#352].
- Reworking `extractExternalPathsFromBashCommand` or the `external_directory` token classifier — out of scope; only the orphaned `pathTokens` surface is removed.
## Background
Relevant existing modules:
- `src/path-utils.ts``normalizePathForComparison` (lexical cleanup + resolve against cwd), `PATH_SURFACES` (the set of path-matching surfaces), `PATH_BEARING_TOOLS`.
- `src/rule.ts``evaluate` (last-match-wins over one value), `evaluateFirst` (first-non-default across MCP candidates).
- `src/input-normalizer.ts``normalizeInput(toolName, input, mcpServerNames)` maps a tool call to `{ surface, values, resultExtras }`; `normalizePathSurfaceValue` extracts and home-expands `input.path`.
- `src/permission-manager.ts``configureForCwd` already records the cwd via the loader; `checkPermission` calls `normalizeInput` then `evaluateFirst`. `ScopedPermissionManager` is the narrow interface gates depend on.
- `src/permission-resolver.ts``ScopedPermissionResolver.resolve(surface, input, agentName)`; the bash path gate's sole evaluation entry point.
- `src/handlers/gates/bash-program.ts` — parses a command once; `rawCandidates` pairs each path token with its `EffectiveBase` (the effective dir after folding literal `cd` commands). `pathTokens()` returns deduplicated rule-candidate tokens (no cwd resolution).
- `src/handlers/gates/bash-path.ts``describeBashPathGate` evaluates each `pathTokens()` token against the `path` surface and returns the most restrictive result; always sets `preCheck`.
- `src/handlers/gates/bash-path-extractor.ts``extractTokensForPathRules` (thin facade over `pathTokens`, test-only) and `extractExternalPathsFromBashCommand` (used by the external-directory gate path; **kept**).
- `src/handlers/gates/runner.ts``runDescriptor` uses `descriptor.preCheck` when set and **only** calls `resolver.resolve(descriptor.surface, descriptor.input, …)` when `preCheck` is absent.
Constraints from AGENTS.md / package skill that apply:
- Default to least privilege; a loosening behavior change is breaking.
- Keep schema, example config, `docs/configuration.md`, `README.md`, and TS types/loaders aligned.
- "Treat any declared field not read at runtime as a maintenance trap" — drives removing the orphaned `pathTokens` chain.
- Do not smuggle policy through a symbol on raw tool `input`; pass resolution context explicitly.
## Design Overview
### Equivalent path-policy values (path-utils.ts)
Add a pure value-deriver shared by every path surface:
```typescript
export interface PathPolicyValueOptions {
/** Current Pi working directory; enables a project-relative alias. */
cwd?: string;
/** Directory used to resolve into an absolute value. Defaults to cwd; bash
* passes the effective dir after a literal cd. */
resolveBase?: string;
}
/** Lexical cleanup only — trim, strip wrapping quotes, strip leading `@`,
* expand `~`/`$HOME`. No cwd resolution. Preserves `src/*`, `*.env` rules. */
export function normalizePathPolicyLiteral(pathValue: string): string;
/** Equivalent lookup values, most-specific first:
* [ absolute (resolved against resolveBase ?? cwd),
* project-relative (when inside cwd),
* raw literal ] — deduped. `"*"` and empty collapse to themselves. */
export function getPathPolicyValues(
pathValue: string,
options?: PathPolicyValueOptions,
): string[];
```
When no base is available, `getPathPolicyValues` returns just the literal — so behavior with cwd unknown is unchanged.
### Alias-aware evaluation (rule.ts)
Add `evaluateAnyValue`, distinct from `evaluateFirst`:
```typescript
/** Last rule that matches ANY alias wins (last-match-wins across aliases).
* Lets an absolute allowlist and a legacy relative rule coexist without an
* early catch-all masking a later specific rule. */
export function evaluateAnyValue(
surface: string,
values: string[],
rules: Ruleset,
platform?: NodeJS.Platform,
): { rule: Rule; value: string };
```
Refactor the surface/pattern match in `evaluate` into a private `ruleMatches(rule, surface, value, platform)` (and `pathMatchOptions`) so `evaluate` and `evaluateAnyValue` share the Windows case/separator folding.
This is a pure internal refactor — no behavior change to `evaluate`.
### Manager: cwd plumbing + explicit path-policy entry (permission-manager.ts)
- Capture `currentCwd` in `configureForCwd` (trimmed, empty → `undefined`).
- Thread `currentCwd` into `normalizeInput`.
- For `PATH_SURFACES`, evaluate with `evaluateAnyValue`; MCP and all other surfaces keep `evaluateFirst`.
- Add an explicit method so the bash gate can evaluate its own precomputed values without a side-channel:
```typescript
export interface ScopedPermissionManager {
// …existing…
checkPathPolicy(
values: readonly string[],
agentName?: string,
sessionRules?: Ruleset,
): PermissionCheckResult;
}
```
`checkPathPolicy` composes rules + session rules exactly as `checkPermission`, then evaluates `evaluateAnyValue("path", values, fullRules)` and builds a `PermissionCheckResult` with `toolName: "path"`, `source: "special"`.
Extract the shared post-evaluation result-building (rule dispatch + extras + source/pattern derivation) into a private helper used by both `checkPermission` and `checkPathPolicy` — same synchronous lifecycle, genuine duplication, safe to extract.
This method is the explicit replacement for the PR's `INTERNAL_PATH_POLICY_VALUES` symbol.
It is safe by construction: the values come only from the bash gate's own computation, never from a string-keyed field on untrusted tool `input`, so there is no spoofing surface.
### Resolver: narrow delegating method (permission-resolver.ts)
```typescript
export interface ScopedPermissionResolver {
resolve(surface: string, input: unknown, agentName?: string): PermissionCheckResult;
resolvePathPolicy(
values: readonly string[],
agentName?: string,
): PermissionCheckResult;
}
```
`PermissionResolver.resolvePathPolicy` delegates to `manager.checkPathPolicy(values, agentName, this.sessionRules.getRuleset())`, mirroring how `resolve` composes the session ruleset.
### Bash program: cd-aware candidates (bash-program.ts)
Add `pathRuleCandidates(cwd?)` returning the raw token (for prompts) paired with policy values:
```typescript
export interface BashPathRuleCandidate {
readonly token: string; // raw — prompts, logs, approvals
readonly policyValues: readonly string[]; // cd-aware — policy matching
}
pathRuleCandidates(cwd?: string): BashPathRuleCandidate[];
```
A private `getPolicyValuesForRuleCandidate(candidate, base, cwd)` owns the cd semantics:
- No `cwd` → literal only (unchanged behavior).
- `base.kind === "unknown"` (non-literal `cd "$DIR"`, `cd -`, bare `cd`) on a relative token → literal only (conservative — do not invent an absolute alias).
- Otherwise → `getPathPolicyValues(candidate, { cwd, resolveBase: base.kind === "known" ? resolve(cwd, base.offset) : cwd })`.
### Bash path gate: consume candidates, resolve explicitly (bash-path.ts)
Call site (verifies Tell-Don't-Ask / LoD — bash tells the resolver "resolve these values", no reach-through):
```typescript
const candidates = bashProgram.pathRuleCandidates(tcc.cwd);
if (candidates.length === 0) return null;
for (const { token, policyValues } of candidates) {
const check = resolver.resolvePathPolicy(policyValues, tcc.agentName ?? undefined);
// …existing backward-compat (#58), session-cover, deny/ask aggregation…
// descriptor uses `token` for pattern, prompt, log, decision, and input
}
```
The descriptor keeps `input: { path: worstToken }` and `preCheck: worstCheck`.
Because `runDescriptor` uses `preCheck` whenever it is set (and the bash path gate always sets it), `descriptor.input` is never re-resolved — so no symbol on `input` is needed (the PR's stamp was vestigial).
### Edge cases
- cwd unknown → single literal value; identical to current behavior.
- Token outside cwd (e.g. `/etc/hosts`) → absolute + literal, no project-relative alias.
- Non-literal `cd` before a relative token → literal-only policy values (no spurious absolute allow).
- `"*"` / empty path → `["*"]` (surface catch-all), unchanged.
## Module-Level Changes
- `src/path-utils.ts` — add `PathPolicyValueOptions`, `normalizePathPolicyLiteral`, `getPathPolicyValues` (+ private `getAbsolutePathPolicyValues`, `getCwdRelativePathPolicyValues`); import `relative` from `node:path`.
- `src/rule.ts` — add `evaluateAnyValue`; extract private `ruleMatches` + `pathMatchOptions` from `evaluate`.
- `src/input-normalizer.ts` — add optional `cwd` param to `normalizeInput`; `normalizePathSurfaceValue``normalizePathSurfaceValues` returning `string[]` via `getPathPolicyValues`.
**No** `INTERNAL_PATH_POLICY_VALUES` symbol; no `normalizeOptionalStringArray` import.
- `src/permission-manager.ts` — add `currentCwd` field; capture in `configureForCwd`; thread into `normalizeInput`; dispatch `PATH_SURFACES` to `evaluateAnyValue`; add `checkPathPolicy` to `ScopedPermissionManager` + class; extract shared result-builder helper.
- `src/permission-resolver.ts` — add `resolvePathPolicy` to `ScopedPermissionResolver` + `PermissionResolver`.
- `src/handlers/gates/bash-program.ts` — add `BashPathRuleCandidate`, `pathRuleCandidates`, private `getPolicyValuesForRuleCandidate`; **remove** `pathTokens()`.
- `src/handlers/gates/bash-path.ts` — consume `pathRuleCandidates(tcc.cwd)` and `resolver.resolvePathPolicy`; keep raw `token` for presentation.
- `src/handlers/gates/bash-path-extractor.ts`**remove** `extractTokensForPathRules` (orphaned after the gate migrates); keep `extractExternalPathsFromBashCommand`.
- `test/helpers/session-fixtures.ts` — add `checkPathPolicy` to `makeFakePermissionManager`.
- `test/helpers/gate-fixtures.ts` — add `resolvePathPolicy` to `makeResolver`, `makeGateRunner`'s resolver, and `makePathDispatchResolver`.
- `test/handlers/gates/tool-call-gate-pipeline.test.ts` — mock `pathRuleCandidates` instead of `pathTokens`.
- Tests: `test/path-utils.test.ts`, `test/rule.test.ts`, `test/input-normalizer.test.ts`, `test/permission-manager-unified.test.ts`, `test/permission-resolver.test.ts`, `test/handlers/gates/bash-program.test.ts`, `test/handlers/gates/bash-path.test.ts`, `test/bash-external-directory.test.ts` (remove `extractTokensForPathRules` block).
- Docs/schema: `README.md`, `docs/configuration.md`, `schemas/permissions.schema.json` (markdownDescription).
Before finalizing, grep `src/`, `test/`, and `.pi/skills/package-pi-permission-system/SKILL.md` for `pathTokens` and `extractTokensForPathRules` to confirm every reference is removed or migrated.
Any test file that constructs a `ScopedPermissionManager` or `ScopedPermissionResolver` mock inline (not via the shared fixtures) must gain the new method in the same step the interface changes — grep both interface names across `test/`.
## Test Impact Analysis
1. **New tests enabled by the change:**
- `getPathPolicyValues` / `normalizePathPolicyLiteral` unit tests (cwd present/absent, inside/outside cwd, `resolveBase`, `"*"`, quotes/`@`/`~`).
- `evaluateAnyValue` unit tests (last-match-wins across aliases; absolute-alias fallback).
- `pathRuleCandidates` unit tests (relative→absolute+relative; literal `cd`; unknown `cd`→literal only).
- `normalizeInput` cwd-alias tests for path surfaces.
- Manager cwd-aware path-policy tests (relative input vs. absolute allowlist; legacy relative still works; last-match-wins; cross-cutting `path` surface).
- `resolvePathPolicy` delegation test.
- `bash-path` cd-aware policy-value test (resolves against literal `cd`, preserves raw prompt token) and unknown-`cd` conservative test.
2. **Redundant tests removed:** the `pathTokens` describe block (`bash-program.test.ts`) and the `extractTokensForPathRules` describe block (`bash-external-directory.test.ts`) — both exercise the orphaned chain being deleted.
3. **Tests that must stay as-is:** the `extractExternalPathsFromBashCommand` suite (`bash-external-directory.test.ts`) — it exercises the kept external-directory extraction, untouched by this change.
The PR's symbol-spoofing tests are **not** ported (the symbol is not implemented); instead a single `normalizeInput` test locks the no-side-channel property by asserting an extra `pathPolicyValues`-like key on `input` is ignored.
## TDD Order
1. **path-utils policy values.**
Surface: `test/path-utils.test.ts`.
Add `normalizePathPolicyLiteral` + `getPathPolicyValues` (pure functions); red tests for cwd/resolveBase/outside-cwd/`"*"`/quotes.
Commit: `feat: add path-policy value derivation (#393)`.
2. **Alias-aware evaluation.**
Surface: `test/rule.test.ts`.
Add `evaluateAnyValue`; refactor `ruleMatches`/`pathMatchOptions` (no behavior change to `evaluate`).
Commit: `feat: add alias-aware evaluateAnyValue (#393)`.
3. **normalizeInput cwd aliases.**
Surface: `test/input-normalizer.test.ts`.
Add optional `cwd`; `normalizePathSurfaceValues` returns aliases via `getPathPolicyValues`; add the no-side-channel test.
The single call site (`permission-manager.ts`) compiles unchanged (optional param).
Commit: `feat: normalize path inputs to cwd-aware policy values (#393)`.
4. **Manager: cwd plumbing, evaluateAnyValue, checkPathPolicy.**
Surface: `test/permission-manager-unified.test.ts` (+ `session-fixtures.ts`).
Capture `currentCwd`; thread into `normalizeInput`; dispatch `PATH_SURFACES``evaluateAnyValue`; add `checkPathPolicy` to interface + class + shared result-builder; add `checkPathPolicy` to `makeFakePermissionManager` and any inline `ScopedPermissionManager` mock (same commit — interface break).
This flips tool/path-surface decisions for relative inputs.
Commit: `feat!: match relative path inputs against absolute allowlists (#393)` with a `BREAKING CHANGE:` footer.
Run `pnpm run check` immediately (shared-interface change).
5. **Resolver: resolvePathPolicy.**
Surface: `test/permission-resolver.test.ts` (+ `gate-fixtures.ts`).
Add `resolvePathPolicy` to `ScopedPermissionResolver` + `PermissionResolver`; add it to `makeResolver`, `makeGateRunner`, `makePathDispatchResolver` and any inline resolver mock (same commit — interface break).
Commit: `feat: add resolvePathPolicy resolver method (#393)`.
Run `pnpm run check` immediately.
6. **Bash program: pathRuleCandidates (additive).**
Surface: `test/handlers/gates/bash-program.test.ts`.
Add `BashPathRuleCandidate` + `pathRuleCandidates` + `getPolicyValuesForRuleCandidate`; keep `pathTokens` for now (lift-and-shift).
Commit: `feat: add cd-aware pathRuleCandidates to BashProgram (#393)`.
7. **Bash path gate: migrate to candidates + explicit resolve.**
Surface: `test/handlers/gates/bash-path.test.ts` (+ `tool-call-gate-pipeline.test.ts` mock → `pathRuleCandidates`).
Switch `describeBashPathGate` to `pathRuleCandidates(tcc.cwd)` + `resolver.resolvePathPolicy`; keep raw `token` for prompt/log/approval/`input`.
This makes bash tokens cd-aware against absolute rules.
Commit: `feat!: resolve bash path tokens with cd-aware policy values (#393)` with a `BREAKING CHANGE:` footer.
8. **Remove the orphaned pathTokens chain.**
Surface: `bash-program.ts`, `bash-path-extractor.ts`, `bash-program.test.ts`, `bash-external-directory.test.ts`.
Remove `BashProgram.pathTokens`, `extractTokensForPathRules`, and their tests; re-check orphaned imports.
Commit: `refactor: remove orphaned bash pathTokens extraction (#393)`.
9. **Docs + schema.**
Surface: `README.md`, `docs/configuration.md`, `schemas/permissions.schema.json`.
Document cwd-aware matching and the bash literal-`cd` behavior.
Commit: `docs: document cwd-aware path policy matching (#393)`.
Every implementation/docs commit carries `Co-authored-by: moekyo <shigotods@outlook.com>` and references the PR as `(#393)` / `Refs #393` — never `Closes #393`.
## Risks and Mitigations
- **Silent gate loosening on upgrade (security).**
Mitigation: classify as breaking (`feat!:`), with a `BREAKING CHANGE:` footer and migration note in the README/configuration guide; the close comment thanks `@moekyo` and links the implementing SHAs.
Do not name a config opt-out — none exists; the note explains that absolute allowlists now also cover their relative spellings and that tighter control needs narrower patterns or a `path` deny.
- **Interface break cascades to mocks.**
Mitigation: steps 4 and 5 fold every fixture/inline-mock update into the same commit; `pnpm run check` runs immediately after each.
- **Removing `pathTokens` while a caller remains.**
Mitigation: lift-and-shift — add `pathRuleCandidates` (step 6), migrate the gate (step 7), delete `pathTokens`/`extractTokensForPathRules` only after no src caller remains (step 8).
- **Bash cd resolution over-reaching on non-literal `cd`.**
Mitigation: `unknown` base yields literal-only policy values; covered by an explicit conservative test in steps 67.
## Open Questions
- Whether to later expose cwd-aware matching as an opt-out config flag if users report unexpected loosening — deferred until there is a concrete request.
- Whether the `external_directory` token classifier should adopt the same alias derivation — out of scope; revisit if a parallel gap surfaces.
[#352]: https://github.com/gotgenes/pi-packages/issues/352
[#393]: https://github.com/gotgenes/pi-packages/issues/393