20 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 511 | pi-permission-system: retire the residual getPlatform() threading (infra-read + skill-prompt sanitization) |
Retire the residual getPlatform() threading (infra-read + skill-prompt sanitization)
Release Recommendation
Release: ship independently
This is the follow-up tracked in the architecture roadmap's "Residual getPlatform() threading (follow-up [#511])" subsection — not one of the five numbered Phase 7 steps and not a member of the symlink-resistant-path-matching batch.
It is a behavior-preserving refactor, so its commits are refactor(pi-permission-system): (a hidden: true changelog type): it does not cut a release on its own.
It lands on main and auto-batches into the next feat:/fix: release.
Problem Statement
510 introduced PathNormalizer as the single home for platform-aware path interpretation and added PermissionSession.getPlatform() (backing ToolCallGateInputs.getPlatform()) as a temporary escape hatch for call sites that still call raw path-utils functions which are not AccessPath operations
Five such sites thread platform directly rather than through PathNormalizer.
Three fold away under Phase 7 (#502, #503/#504) and retire their platform thread as a side effect.
Two are not covered by any Phase 7 step and are this issue's scope:
- Infra-read containment —
handlers/gates/external-directory.tscallsisPiInfrastructureRead(..., platform)directly. - Skill-prompt sanitization —
skill-prompt-sanitizer.ts(createResolvedSkillEntry→normalizePathForComparison,findSkillPathMatch→isPathWithinDirectory), reached frombefore-agent-start.tsandhandlers/gates/skill-read.ts.
Routing both through the PathNormalizer the gates already hold removes their direct platform threading and moves the containment/normalization behavior onto the collaborator that already owns cwd + platform.
Goals
- Route the external-directory infra-read containment check through
PathNormalizersodescribeExternalDirectoryGateno longer takes aplatformparameter. - Route skill-prompt sanitization (entry normalization, read-path normalization, base-dir containment) through
PathNormalizersocreateResolvedSkillEntry,findSkillPathMatch,resolveSkillPromptEntries, anddescribeSkillReadGateno longer take aplatformparameter. - Keep the change behavior-preserving — same decisions, same normalized values, no new filesystem access.
Non-Goals
- Removing
getPlatform().ToolCallGatePipeline.evaluatereadsgetPlatform()once and threads it to three gates (skill-read, external-directory, tool); after this issue the tool gate (describeToolGate) still consumes it, so the pipeline read — andPermissionSession.getPlatform()/ToolCallGateInputs.getPlatform()— must stay until #502 also lands. That final removal is tracked in #513 (fold into this issue only if #502 has already merged at implementation time). - The Phase 7 steps themselves (#502, #503, #504, #505, #506) — they fold the other three reads and dissolve
path-utils.ts; out of scope here. - Removing the leaf
platformparameters inpath-utils.ts(isPiInfrastructureRead,isPathWithinDirectory,normalizePathForComparison). They persist as platform-parameterized predicates; this issue removes the consumers' directplatformthreading, not the leaves. - Changing skill matching from lexical to canonical (symlink-resolved). Skill matching is lexical today and stays lexical (see Design Overview).
Background
Relevant modules and how they relate:
src/path-normalizer.ts— thePathNormalizerclass, constructed at the session edge withplatform+cwdbaked in. Today it exposesforPath/forLiteral(buildAccessPaths),isAbsolute/resolveBase/joinBase, andisWithinDirectory/isOutsideWorkingDirectory. It already importsisPathWithinDirectory/isPathOutsideWorkingDirectoryfrompath-utilsandAccessPathfromaccess-intent/access-path.src/handlers/gates/external-directory.ts—describeExternalDirectoryGate(tcc, infraDirs, resolver, normalizer, platform, extractors). Already holds thenormalizer; buildsaccessPath = normalizer.forPath(externalDirectoryPath)for the boundary decision and pattern matching, then computescanonicalExtPath = accessPath.boundaryValue()and callsisPiInfrastructureRead(tcc.toolName, canonicalExtPath, infraDirs, tcc.cwd, platform).src/skill-prompt-sanitizer.ts—createResolvedSkillEntrynormalizesentry.locationanddirname(entry.location)vianormalizePathForComparison(_, cwd, platform)(lexical only) and caches them asnormalizedLocation/normalizedBaseDirstrings onSkillPromptEntry;findSkillPathMatch(normalizedPath, entries, platform)does an exact-string match onnormalizedLocationand anisPathWithinDirectory(_, entry.normalizedBaseDir, platform)match on the base dir;resolveSkillPromptEntries(prompt, manager, agentName, cwd, platform)drives both.src/handlers/before-agent-start.ts— callsresolveSkillPromptEntries(..., ctx.cwd, this.session.getPlatform()).src/handlers/gates/skill-read.ts—describeSkillReadGate(tcc, platform, getActiveSkillEntries)normalizes the read path vianormalizePathForComparison(path, tcc.cwd, platform)then callsfindSkillPathMatch(normalizedReadPath, entries, platform).src/handlers/gates/tool-call-gate-pipeline.ts—ToolCallGatePipeline.evaluatebuildsnormalizer = getPathNormalizer()andplatform = getPlatform(), then threadsplatformintodescribeSkillReadGate,describeExternalDirectoryGate, anddescribeToolGate.
Constraint (AGENTS.md / package-pi-permission-system skill): no src/ module may read process.platform (ESLint no-restricted-syntax guard, exempting index.ts); every leaf takes an injected platform.
This change does not add any process.platform read — it consolidates injected-platform reads onto PathNormalizer.
tcc.cwd is set from ctx.cwd in permission-gate-handler.ts, and PathNormalizer is rebuilt from ctx.cwd on session.activate(ctx) (called before every gate evaluate).
So the normalizer's baked cwd equals tcc.cwd and the before-agent-start ctx.cwd — confirming that moving the cwd argument onto the normalizer is behavior-preserving.
Design Overview
Decision: route through PathNormalizer methods, do not carry AccessPaths on skill entries
The issue raises a fork for skill-prompt sanitization: carry AccessPaths on SkillPromptEntry, or resolve through the normalizer.
Carrying AccessPaths is rejected because AccessPath.forPath eagerly computes the canonical (symlink-resolved) alias via canonicalNormalizePathForComparison → canonicalizePath → realpathSync (src/canonicalize-path.ts).
Skill matching is purely lexical today (both sides use normalizePathForComparison, which never touches the filesystem).
Building an AccessPath per skill entry on every before_agent_start (every turn) and per read tool call would introduce repeated realpathSync filesystem access and switch matching toward canonical form — both behavior changes against a behavior-preserving refactor.
Instead, add a lexical normalize method to PathNormalizer and reuse its existing isWithinDirectory.
PathNormalizer gains two methods
/** Lexical (not symlink-resolved) comparison value against the baked cwd. */
comparableValue(pathValue: string): string {
return normalizePathForComparison(pathValue, this.cwd, this.platform);
}
/** Pi infrastructure-read containment against the baked cwd/platform. */
isInfrastructureRead(
toolName: string,
accessPath: AccessPath,
infraDirs: readonly string[],
): boolean {
return isPiInfrastructureRead(
toolName,
accessPath.boundaryValue(),
infraDirs,
this.cwd,
this.platform,
);
}
comparableValue returns a plain string (like resolveBase/joinBase), distinct from the for* methods that build AccessPaths — the lexical absolute form used for skill comparison.
isInfrastructureRead takes the already-built AccessPath (the gate constructs it for the boundary decision and pattern matching) and extracts boundaryValue() internally — Tell-Don't-Ask, and it avoids a second forPath (which would re-run realpathSync).
PathNormalizer adds imports for normalizePathForComparison and isPiInfrastructureRead from path-utils (it already imports the two containment predicates).
External-directory gate call site
// before: isPiInfrastructureRead(tcc.toolName, accessPath.boundaryValue(), infraDirs, tcc.cwd, platform)
if (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
return { action: "allow", /* … infrastructure_auto_allowed … */ };
}
The platform parameter and the isPiInfrastructureRead + (now-unused) canonicalExtPath local are removed; the getToolInputPath import stays.
The accessPath.boundaryValue() comment about the canonical form moves into PathNormalizer.isInfrastructureRead.
Skill sanitizer call sites
// createResolvedSkillEntry(entry, state, normalizer)
normalizedLocation: normalizer.comparableValue(entry.location),
normalizedBaseDir: normalizer.comparableValue(dirname(entry.location)),
// findSkillPathMatch(normalizedPath, entries, normalizer)
if (!entry.normalizedBaseDir || !normalizer.isWithinDirectory(normalizedPath, entry.normalizedBaseDir)) continue;
// describeSkillReadGate(tcc, normalizer, getActiveSkillEntries)
const normalizedReadPath = normalizer.comparableValue(path);
const matchedSkill = findSkillPathMatch(normalizedReadPath, activeSkillEntries, normalizer);
SkillPromptEntry.normalizedLocation / normalizedBaseDir stay string — only how they are computed changes.
resolveSkillPromptEntries, createResolvedSkillEntry, and findSkillPathMatch swap their cwd/platform parameters for a single normalizer: PathNormalizer.
before-agent-start.ts passes this.session.getPathNormalizer() instead of ctx.cwd, this.session.getPlatform().
skill-prompt-sanitizer.ts drops its normalizePathForComparison and isPathWithinDirectory imports (replaced by normalizer calls) and adds a PathNormalizer type import.
skill-read.ts drops its normalizePathForComparison import.
Pipeline call sites
ToolCallGatePipeline.evaluate drops platform from the describeSkillReadGate and describeExternalDirectoryGate calls.
It keeps const platform = this.inputs.getPlatform(); for describeToolGate (the #502 site), so getPlatform() is unchanged — see Non-Goals and #513.
Behavior-preservation argument
- Infra-read:
tcc.cwd === normalizer.cwdand the platform passed today equals the normalizer's platform in production (both from the same composition-root injection), sonormalizer.isInfrastructureRead(toolName, accessPath, infraDirs)computes the identical predicate. - Skill:
normalizer.comparableValue(x) === normalizePathForComparison(x, normalizer.cwd, normalizer.platform)by definition, andnormalizer.cwd/platformequal thecwd/platformthreaded today;normalizer.isWithinDirectory(a, b) === isPathWithinDirectory(a, b, normalizer.platform). No filesystem access is introduced (lexical only).
Module-Level Changes
src/path-normalizer.ts— addcomparableValue(pathValue)andisInfrastructureRead(toolName, accessPath, infraDirs); add importsnormalizePathForComparison,isPiInfrastructureReadfrom#src/path-utils.src/handlers/gates/external-directory.ts— remove theplatform: NodeJS.Platformparameter; replace the directisPiInfrastructureRead(...)call (and thecanonicalExtPathlocal) withnormalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs); drop theisPiInfrastructureReadimport (keepgetToolInputPath).src/skill-prompt-sanitizer.ts—createResolvedSkillEntry,findSkillPathMatch,resolveSkillPromptEntriesswapcwd/platformparams fornormalizer: PathNormalizer; usenormalizer.comparableValue/normalizer.isWithinDirectory; dropnormalizePathForComparison+isPathWithinDirectoryimports; addPathNormalizertype import.src/handlers/gates/skill-read.ts—describeSkillReadGateswapsplatformparam fornormalizer: PathNormalizer; usenormalizer.comparableValue(path)and passnormalizertofindSkillPathMatch; dropnormalizePathForComparisonimport.src/handlers/before-agent-start.ts— passthis.session.getPathNormalizer()toresolveSkillPromptEntries(replacingctx.cwd, this.session.getPlatform()).src/handlers/gates/tool-call-gate-pipeline.ts— dropplatformfrom thedescribeSkillReadGateanddescribeExternalDirectoryGatecalls; keep thegetPlatform()read fordescribeToolGate.
Tests:
test/handlers/gates/external-directory.test.ts—gateUnderTestdrops the"linux"platform arg.test/handlers/gates/skill-read.test.ts— pass aPathNormalizer(e.g.new PathNormalizer("linux", tcc.cwd)) instead of"linux".test/skill-prompt-sanitizer.test.ts— passnew PathNormalizer("linux", CWD)instead ofCWD, "linux"toresolveSkillPromptEntriesandfindSkillPathMatch.test/path-normalizer.test.ts— add coverage forcomparableValue(lexical, no FS) andisInfrastructureRead(read-only tool in/out of infra dirs; write tool not bypassed), posix and win32 flavors.
Docs:
docs/architecture/architecture.md— extend thepath-normalizer.tsmethod list (line ~676) withcomparableValueandisInfrastructureRead; update the "ResidualgetPlatform()threading (follow-up [#511])" subsection so the infra-read and skill-sanitizer bullets read as routed throughPathNormalizer(done), leaving only the three Phase 7-step reads outstanding, and note that thegetPlatform()accessor itself persists until #502 (tracked in #513)..pi/skills/package-pi-permission-system/SKILL.md— addcomparableValue/isInfrastructureReadto the normalizer method list in the Debugging section (line ~175).
No README change: this is internal threading, not a user-facing command or feature.
Test Impact Analysis
- New tests enabled.
comparableValueandisInfrastructureReadbecome directly unit-testable onPathNormalizer(with the bakedcwd/platform), instead of only through the gate.isInfrastructureReadgets focused coverage (read-only tool inside an infra dir → true; write tool inside → false; outside → false) on both platform flavors via injectedPathNormalizer. - Redundant tests.
None become redundant — the existing gate-level and
pi-infrastructure-read.test.tstests still exercise the predicate through the full path and remain valuable as integration coverage. The leafisPiInfrastructureRead/normalizePathForComparison/isPathWithinDirectorytests intest/path-utils.test.tsandtest/pi-infrastructure-read.test.tsstay as-is (the leaves are unchanged). - Tests that must stay.
external-directory.test.ts,skill-read.test.ts, andskill-prompt-sanitizer.test.tscontinue to exercise the gates/sanitizer end-to-end; they only shed theplatformargument in favor of aPathNormalizer.
Invariants at risk
The #510 seam (the precursor refactor) established that no interior src/ module reads process.platform and that PathNormalizer is the single platform home.
This change preserves both — it consolidates injected-platform reads onto PathNormalizer and adds no process.platform read (the ESLint no-restricted-syntax guard pins this; pnpm run lint fails on a violation).
The #418/#486 external-directory matching invariant (config patterns match the typed and symlink-resolved aliases) is untouched: the accessPath/matchValues()/boundaryValue() usage in the gate is unchanged — only the infra-read call moves onto the normalizer.
external-directory.test.ts's "resolves the typed and symlink-resolved aliases (#418)" test and the infra-bypass tests pin these.
TDD Order
Each cycle is behavior-preserving; suggested commit type refactor: (with test: where a step is test-only).
Because removing the platform parameter from an exported function breaks every caller and its tests at the type level in the same commit, each site's production change, its call-site updates, and its test updates land together.
-
Add
PathNormalizer.comparableValue+isInfrastructureRead. Surface:test/path-normalizer.test.ts. Red: assertcomparableValuereturns the lexical absolute form (posix + win32, no FS) andisInfrastructureReadmatchesisPiInfrastructureReadfor read-only-in-infra (true), write-in-infra (false), outside (false). Green: add both methods + imports. Commit:refactor(pi-permission-system): add PathNormalizer comparableValue + isInfrastructureRead. -
Route the external-directory infra-read through
PathNormalizer. Surface:src/handlers/gates/external-directory.ts,src/handlers/gates/tool-call-gate-pipeline.ts(call site),test/handlers/gates/external-directory.test.ts. Red/Green: drop theplatformparameter; callnormalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs); update the pipeline call andgateUnderTest(drop"linux"). Commit:refactor(pi-permission-system): route external-directory infra-read through PathNormalizer. -
Route skill-prompt sanitization through
PathNormalizer. Surface:src/skill-prompt-sanitizer.ts,src/handlers/gates/skill-read.ts,src/handlers/before-agent-start.ts,src/handlers/gates/tool-call-gate-pipeline.ts(skill-read call site),test/skill-prompt-sanitizer.test.ts,test/handlers/gates/skill-read.test.ts. Red/Green: swapcwd/platformparams fornormalizeracrosscreateResolvedSkillEntry/findSkillPathMatch/resolveSkillPromptEntries/describeSkillReadGate; usecomparableValue/isWithinDirectory; pass the normalizer frombefore-agent-startand the pipeline; migrate tests to construct aPathNormalizer. Commit:refactor(pi-permission-system): route skill-prompt sanitization through PathNormalizer. -
Docs. Surface:
docs/architecture/architecture.md,.pi/skills/package-pi-permission-system/SKILL.md. Update the normalizer method lists and the residual-threading subsection (infra-read + skill routed throughPathNormalizer;getPlatform()persists until #502, tracked in #513). Commit:docs(pi-permission-system): record PathNormalizer infra-read/comparable-value routing.
Risks and Mitigations
- Risk: introducing filesystem access via
AccessPathin the skill path. Mitigation: use the lexicalcomparableValue(norealpathSync), notforPath; verified in step 1's test (default identityrealpathSyncmock + lexical assertions). - Risk: a
cwd/platformmismatch between the threaded values and the normalizer's baked values changing a decision. Mitigation: confirmedtcc.cwd === ctx.cwd === normalizer.cwdand a single composition-rootplatforminjection; the existing gate/sanitizer tests pin the decisions. - Risk: a dropped edit in a multi-site
Editbatch silently leaving a staleplatformarg. Mitigation: each step removes a parameter, sotsc(pnpm run check) fails immediately on any missed caller; run it per step. - Risk: stale docs/skill prose referencing the old threading.
Mitigation: step 4 updates
architecture.mdand the package SKILL; the pre-completion reviewer greps for residual references.
Open Questions
- Final
getPlatform()removal — deferred to #513 (depends on #502 also landing). If #502 has already merged when this issue is implemented, fold thegetPlatform()removal into step 3 and close #513.