20 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 393 | 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
pathgate use cd-aware policy values for literal current-shellcdcommands, 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
inputobject. - Remove the now-orphaned
pathTokens()/extractTokensForPathRuleschain 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 fromasktoallow. For a least-privilege package that loosening is breaking — usefeat!:with aBREAKING 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
extractExternalPathsFromBashCommandor theexternal_directorytoken classifier — out of scope; only the orphanedpathTokenssurface 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 };normalizePathSurfaceValueextracts and home-expandsinput.path.src/permission-manager.ts—configureForCwdalready records the cwd via the loader;checkPermissioncallsnormalizeInputthenevaluateFirst.ScopedPermissionManageris 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;rawCandidatespairs each path token with itsEffectiveBase(the effective dir after folding literalcdcommands).pathTokens()returns deduplicated rule-candidate tokens (no cwd resolution).src/handlers/gates/bash-path.ts—describeBashPathGateevaluates eachpathTokens()token against thepathsurface and returns the most restrictive result; always setspreCheck.src/handlers/gates/bash-path-extractor.ts—extractTokensForPathRules(thin facade overpathTokens, test-only) andextractExternalPathsFromBashCommand(used by the external-directory gate path; kept).src/handlers/gates/runner.ts—runDescriptorusesdescriptor.preCheckwhen set and only callsresolver.resolve(descriptor.surface, descriptor.input, …)whenpreCheckis 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
pathTokenschain. - 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:
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:
/** 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
currentCwdinconfigureForCwd(trimmed, empty →undefined). - Thread
currentCwdintonormalizeInput. - For
PATH_SURFACES, evaluate withevaluateAnyValue; MCP and all other surfaces keepevaluateFirst. - Add an explicit method so the bash gate can evaluate its own precomputed values without a side-channel:
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)
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:
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-literalcd "$DIR",cd -, barecd) 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):
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
cdbefore a relative token → literal-only policy values (no spurious absolute allow). "*"/ empty path →["*"](surface catch-all), unchanged.
Module-Level Changes
src/path-utils.ts— addPathPolicyValueOptions,normalizePathPolicyLiteral,getPathPolicyValues(+ privategetAbsolutePathPolicyValues,getCwdRelativePathPolicyValues); importrelativefromnode:path.src/rule.ts— addevaluateAnyValue; extract privateruleMatches+pathMatchOptionsfromevaluate.src/input-normalizer.ts— add optionalcwdparam tonormalizeInput;normalizePathSurfaceValue→normalizePathSurfaceValuesreturningstring[]viagetPathPolicyValues. NoINTERNAL_PATH_POLICY_VALUESsymbol; nonormalizeOptionalStringArrayimport.src/permission-manager.ts— addcurrentCwdfield; capture inconfigureForCwd; thread intonormalizeInput; dispatchPATH_SURFACEStoevaluateAnyValue; addcheckPathPolicytoScopedPermissionManager+ class; extract shared result-builder helper.src/permission-resolver.ts— addresolvePathPolicytoScopedPermissionResolver+PermissionResolver.src/handlers/gates/bash-program.ts— addBashPathRuleCandidate,pathRuleCandidates, privategetPolicyValuesForRuleCandidate; removepathTokens().src/handlers/gates/bash-path.ts— consumepathRuleCandidates(tcc.cwd)andresolver.resolvePathPolicy; keep rawtokenfor presentation.src/handlers/gates/bash-path-extractor.ts— removeextractTokensForPathRules(orphaned after the gate migrates); keepextractExternalPathsFromBashCommand.test/helpers/session-fixtures.ts— addcheckPathPolicytomakeFakePermissionManager.test/helpers/gate-fixtures.ts— addresolvePathPolicytomakeResolver,makeGateRunner's resolver, andmakePathDispatchResolver.test/handlers/gates/tool-call-gate-pipeline.test.ts— mockpathRuleCandidatesinstead ofpathTokens.- 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(removeextractTokensForPathRulesblock). - 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
- New tests enabled by the change:
getPathPolicyValues/normalizePathPolicyLiteralunit tests (cwd present/absent, inside/outside cwd,resolveBase,"*", quotes/@/~).evaluateAnyValueunit tests (last-match-wins across aliases; absolute-alias fallback).pathRuleCandidatesunit tests (relative→absolute+relative; literalcd; unknowncd→literal only).normalizeInputcwd-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
pathsurface). resolvePathPolicydelegation test.bash-pathcd-aware policy-value test (resolves against literalcd, preserves raw prompt token) and unknown-cdconservative test.
- Redundant tests removed: the
pathTokensdescribe block (bash-program.test.ts) and theextractTokensForPathRulesdescribe block (bash-external-directory.test.ts) — both exercise the orphaned chain being deleted. - Tests that must stay as-is: the
extractExternalPathsFromBashCommandsuite (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 singlenormalizeInputtest locks the no-side-channel property by asserting an extrapathPolicyValues-like key oninputis ignored.
TDD Order
- path-utils policy values.
Surface:
test/path-utils.test.ts. AddnormalizePathPolicyLiteral+getPathPolicyValues(pure functions); red tests for cwd/resolveBase/outside-cwd/"*"/quotes. Commit:feat: add path-policy value derivation (#393). - Alias-aware evaluation.
Surface:
test/rule.test.ts. AddevaluateAnyValue; refactorruleMatches/pathMatchOptions(no behavior change toevaluate). Commit:feat: add alias-aware evaluateAnyValue (#393). - normalizeInput cwd aliases.
Surface:
test/input-normalizer.test.ts. Add optionalcwd;normalizePathSurfaceValuesreturns aliases viagetPathPolicyValues; 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). - Manager: cwd plumbing, evaluateAnyValue, checkPathPolicy.
Surface:
test/permission-manager-unified.test.ts(+session-fixtures.ts). CapturecurrentCwd; thread intonormalizeInput; dispatchPATH_SURFACES→evaluateAnyValue; addcheckPathPolicyto interface + class + shared result-builder; addcheckPathPolicytomakeFakePermissionManagerand any inlineScopedPermissionManagermock (same commit — interface break). This flips tool/path-surface decisions for relative inputs. Commit:feat!: match relative path inputs against absolute allowlists (#393)with aBREAKING CHANGE:footer. Runpnpm run checkimmediately (shared-interface change). - Resolver: resolvePathPolicy.
Surface:
test/permission-resolver.test.ts(+gate-fixtures.ts). AddresolvePathPolicytoScopedPermissionResolver+PermissionResolver; add it tomakeResolver,makeGateRunner,makePathDispatchResolverand any inline resolver mock (same commit — interface break). Commit:feat: add resolvePathPolicy resolver method (#393). Runpnpm run checkimmediately. - Bash program: pathRuleCandidates (additive).
Surface:
test/handlers/gates/bash-program.test.ts. AddBashPathRuleCandidate+pathRuleCandidates+getPolicyValuesForRuleCandidate; keeppathTokensfor now (lift-and-shift). Commit:feat: add cd-aware pathRuleCandidates to BashProgram (#393). - Bash path gate: migrate to candidates + explicit resolve.
Surface:
test/handlers/gates/bash-path.test.ts(+tool-call-gate-pipeline.test.tsmock →pathRuleCandidates). SwitchdescribeBashPathGatetopathRuleCandidates(tcc.cwd)+resolver.resolvePathPolicy; keep rawtokenfor 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 aBREAKING CHANGE:footer. - Remove the orphaned pathTokens chain.
Surface:
bash-program.ts,bash-path-extractor.ts,bash-program.test.ts,bash-external-directory.test.ts. RemoveBashProgram.pathTokens,extractTokensForPathRules, and their tests; re-check orphaned imports. Commit:refactor: remove orphaned bash pathTokens extraction (#393). - Docs + schema.
Surface:
README.md,docs/configuration.md,schemas/permissions.schema.json. Document cwd-aware matching and the bash literal-cdbehavior. 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 aBREAKING CHANGE:footer and migration note in the README/configuration guide; the close comment thanks@moekyoand 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 apathdeny. - Interface break cascades to mocks.
Mitigation: steps 4 and 5 fold every fixture/inline-mock update into the same commit;
pnpm run checkruns immediately after each. - Removing
pathTokenswhile a caller remains. Mitigation: lift-and-shift — addpathRuleCandidates(step 6), migrate the gate (step 7), deletepathTokens/extractTokensForPathRulesonly after no src caller remains (step 8). - Bash cd resolution over-reaching on non-literal
cd. Mitigation:unknownbase 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_directorytoken classifier should adopt the same alias derivation — out of scope; revisit if a parallel gap surfaces.