mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
231 lines
20 KiB
Markdown
231 lines
20 KiB
Markdown
---
|
|
issue: 511
|
|
issue_title: "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:
|
|
|
|
1. **Infra-read containment** — `handlers/gates/external-directory.ts` calls `isPiInfrastructureRead(..., platform)` directly.
|
|
2. **Skill-prompt sanitization** — `skill-prompt-sanitizer.ts` (`createResolvedSkillEntry` → `normalizePathForComparison`, `findSkillPathMatch` → `isPathWithinDirectory`), reached from `before-agent-start.ts` and `handlers/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 `PathNormalizer` so `describeExternalDirectoryGate` no longer takes a `platform` parameter.
|
|
- Route skill-prompt sanitization (entry normalization, read-path normalization, base-dir containment) through `PathNormalizer` so `createResolvedSkillEntry`, `findSkillPathMatch`, `resolveSkillPromptEntries`, and `describeSkillReadGate` no longer take a `platform` parameter.
|
|
- Keep the change behavior-preserving — same decisions, same normalized values, no new filesystem access.
|
|
|
|
### Non-Goals
|
|
|
|
- **Removing `getPlatform()`.**
|
|
`ToolCallGatePipeline.evaluate` reads `getPlatform()` 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 — and `PermissionSession.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 `platform` parameters in `path-utils.ts`** (`isPiInfrastructureRead`, `isPathWithinDirectory`, `normalizePathForComparison`).
|
|
They persist as platform-parameterized predicates; this issue removes the *consumers'* direct `platform` threading, 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` — the `PathNormalizer` class, constructed at the session edge with `platform` + `cwd` baked in.
|
|
Today it exposes `forPath`/`forLiteral` (build `AccessPath`s), `isAbsolute`/`resolveBase`/`joinBase`, and `isWithinDirectory`/`isOutsideWorkingDirectory`.
|
|
It already imports `isPathWithinDirectory`/`isPathOutsideWorkingDirectory` from `path-utils` and `AccessPath` from `access-intent/access-path`.
|
|
- `src/handlers/gates/external-directory.ts` — `describeExternalDirectoryGate(tcc, infraDirs, resolver, normalizer, platform, extractors)`.
|
|
Already holds the `normalizer`; builds `accessPath = normalizer.forPath(externalDirectoryPath)` for the boundary decision and pattern matching, then computes `canonicalExtPath = accessPath.boundaryValue()` and calls `isPiInfrastructureRead(tcc.toolName, canonicalExtPath, infraDirs, tcc.cwd, platform)`.
|
|
- `src/skill-prompt-sanitizer.ts` — `createResolvedSkillEntry` normalizes `entry.location` and `dirname(entry.location)` via `normalizePathForComparison(_, cwd, platform)` (lexical only) and caches them as `normalizedLocation` / `normalizedBaseDir` strings on `SkillPromptEntry`; `findSkillPathMatch(normalizedPath, entries, platform)` does an exact-string match on `normalizedLocation` and an `isPathWithinDirectory(_, entry.normalizedBaseDir, platform)` match on the base dir; `resolveSkillPromptEntries(prompt, manager, agentName, cwd, platform)` drives both.
|
|
- `src/handlers/before-agent-start.ts` — calls `resolveSkillPromptEntries(..., ctx.cwd, this.session.getPlatform())`.
|
|
- `src/handlers/gates/skill-read.ts` — `describeSkillReadGate(tcc, platform, getActiveSkillEntries)` normalizes the read path via `normalizePathForComparison(path, tcc.cwd, platform)` then calls `findSkillPathMatch(normalizedReadPath, entries, platform)`.
|
|
- `src/handlers/gates/tool-call-gate-pipeline.ts` — `ToolCallGatePipeline.evaluate` builds `normalizer = getPathNormalizer()` and `platform = getPlatform()`, then threads `platform` into `describeSkillReadGate`, `describeExternalDirectoryGate`, and `describeToolGate`.
|
|
|
|
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 `AccessPath`s on skill entries
|
|
|
|
The issue raises a fork for skill-prompt sanitization: carry `AccessPath`s on `SkillPromptEntry`, or resolve through the normalizer.
|
|
Carrying `AccessPath`s 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
|
|
|
|
```typescript
|
|
/** 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 `AccessPath`s — 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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.cwd` and the platform passed today equals the normalizer's platform in production (both from the same composition-root injection), so `normalizer.isInfrastructureRead(toolName, accessPath, infraDirs)` computes the identical predicate.
|
|
- Skill: `normalizer.comparableValue(x) === normalizePathForComparison(x, normalizer.cwd, normalizer.platform)` by definition, and `normalizer.cwd`/`platform` equal the `cwd`/`platform` threaded today; `normalizer.isWithinDirectory(a, b) === isPathWithinDirectory(a, b, normalizer.platform)`.
|
|
No filesystem access is introduced (lexical only).
|
|
|
|
### Module-Level Changes
|
|
|
|
- `src/path-normalizer.ts` — add `comparableValue(pathValue)` and `isInfrastructureRead(toolName, accessPath, infraDirs)`; add imports `normalizePathForComparison`, `isPiInfrastructureRead` from `#src/path-utils`.
|
|
- `src/handlers/gates/external-directory.ts` — remove the `platform: NodeJS.Platform` parameter; replace the direct `isPiInfrastructureRead(...)` call (and the `canonicalExtPath` local) with `normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)`; drop the `isPiInfrastructureRead` import (keep `getToolInputPath`).
|
|
- `src/skill-prompt-sanitizer.ts` — `createResolvedSkillEntry`, `findSkillPathMatch`, `resolveSkillPromptEntries` swap `cwd`/`platform` params for `normalizer: PathNormalizer`; use `normalizer.comparableValue` / `normalizer.isWithinDirectory`; drop `normalizePathForComparison` + `isPathWithinDirectory` imports; add `PathNormalizer` type import.
|
|
- `src/handlers/gates/skill-read.ts` — `describeSkillReadGate` swaps `platform` param for `normalizer: PathNormalizer`; use `normalizer.comparableValue(path)` and pass `normalizer` to `findSkillPathMatch`; drop `normalizePathForComparison` import.
|
|
- `src/handlers/before-agent-start.ts` — pass `this.session.getPathNormalizer()` to `resolveSkillPromptEntries` (replacing `ctx.cwd, this.session.getPlatform()`).
|
|
- `src/handlers/gates/tool-call-gate-pipeline.ts` — drop `platform` from the `describeSkillReadGate` and `describeExternalDirectoryGate` calls; keep the `getPlatform()` read for `describeToolGate`.
|
|
|
|
Tests:
|
|
|
|
- `test/handlers/gates/external-directory.test.ts` — `gateUnderTest` drops the `"linux"` platform arg.
|
|
- `test/handlers/gates/skill-read.test.ts` — pass a `PathNormalizer` (e.g. `new PathNormalizer("linux", tcc.cwd)`) instead of `"linux"`.
|
|
- `test/skill-prompt-sanitizer.test.ts` — pass `new PathNormalizer("linux", CWD)` instead of `CWD, "linux"` to `resolveSkillPromptEntries` and `findSkillPathMatch`.
|
|
- `test/path-normalizer.test.ts` — add coverage for `comparableValue` (lexical, no FS) and `isInfrastructureRead` (read-only tool in/out of infra dirs; write tool not bypassed), posix and win32 flavors.
|
|
|
|
Docs:
|
|
|
|
- `docs/architecture/architecture.md` — extend the `path-normalizer.ts` method list (line ~676) with `comparableValue` and `isInfrastructureRead`; update the "Residual `getPlatform()` threading (follow-up [#511])" subsection so the infra-read and skill-sanitizer bullets read as *routed through `PathNormalizer`* (done), leaving only the three Phase 7-step reads outstanding, and note that the `getPlatform()` accessor itself persists until #502 (tracked in #513).
|
|
- `.pi/skills/package-pi-permission-system/SKILL.md` — add `comparableValue`/`isInfrastructureRead` to 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
|
|
|
|
1. **New tests enabled.**
|
|
`comparableValue` and `isInfrastructureRead` become directly unit-testable on `PathNormalizer` (with the baked `cwd`/`platform`), instead of only through the gate.
|
|
`isInfrastructureRead` gets focused coverage (read-only tool inside an infra dir → true; write tool inside → false; outside → false) on both platform flavors via injected `PathNormalizer`.
|
|
2. **Redundant tests.**
|
|
None become redundant — the existing gate-level and `pi-infrastructure-read.test.ts` tests still exercise the predicate through the full path and remain valuable as integration coverage.
|
|
The leaf `isPiInfrastructureRead`/`normalizePathForComparison`/`isPathWithinDirectory` tests in `test/path-utils.test.ts` and `test/pi-infrastructure-read.test.ts` stay as-is (the leaves are unchanged).
|
|
3. **Tests that must stay.**
|
|
`external-directory.test.ts`, `skill-read.test.ts`, and `skill-prompt-sanitizer.test.ts` continue to exercise the gates/sanitizer end-to-end; they only shed the `platform` argument in favor of a `PathNormalizer`.
|
|
|
|
### 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.
|
|
|
|
1. **Add `PathNormalizer.comparableValue` + `isInfrastructureRead`.**
|
|
Surface: `test/path-normalizer.test.ts`.
|
|
Red: assert `comparableValue` returns the lexical absolute form (posix + win32, no FS) and `isInfrastructureRead` matches `isPiInfrastructureRead` for 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`.
|
|
|
|
2. **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 the `platform` parameter; call `normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)`; update the pipeline call and `gateUnderTest` (drop `"linux"`).
|
|
Commit: `refactor(pi-permission-system): route external-directory infra-read through PathNormalizer`.
|
|
|
|
3. **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: swap `cwd`/`platform` params for `normalizer` across `createResolvedSkillEntry`/`findSkillPathMatch`/`resolveSkillPromptEntries`/`describeSkillReadGate`; use `comparableValue`/`isWithinDirectory`; pass the normalizer from `before-agent-start` and the pipeline; migrate tests to construct a `PathNormalizer`.
|
|
Commit: `refactor(pi-permission-system): route skill-prompt sanitization through PathNormalizer`.
|
|
|
|
4. **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 through `PathNormalizer`; `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 `AccessPath` in the skill path.**
|
|
Mitigation: use the lexical `comparableValue` (no `realpathSync`), not `forPath`; verified in step 1's test (default identity `realpathSync` mock + lexical assertions).
|
|
- **Risk: a `cwd`/`platform` mismatch between the threaded values and the normalizer's baked values changing a decision.**
|
|
Mitigation: confirmed `tcc.cwd === ctx.cwd === normalizer.cwd` and a single composition-root `platform` injection; the existing gate/sanitizer tests pin the decisions.
|
|
- **Risk: a dropped edit in a multi-site `Edit` batch silently leaving a stale `platform` arg.**
|
|
Mitigation: each step removes a parameter, so `tsc` (`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.md` and 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 the `getPlatform()` removal into step 3 and close #513.
|