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,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 6–7.
|
||||
|
||||
## 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
|
||||
Reference in New Issue
Block a user