mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
306 lines
21 KiB
Markdown
306 lines
21 KiB
Markdown
---
|
||
issue: 382
|
||
issue_title: "pi-permission-system: external_directory base permission doesn't auto-detect or allow overrides for pi docs directory when installed via npm on Windows"
|
||
---
|
||
|
||
# Windows: case-insensitive `external_directory` matching and Pi-install auto-detect
|
||
|
||
## Problem Statement
|
||
|
||
On Windows, a base (null) agent cannot read Pi's own docs even with an explicit `external_directory` allow override, and the built-in infrastructure auto-allow never fires either.
|
||
The reporter's config denies all external directories (`external_directory["*"]: "deny"`) but allows the Pi install path (`~/AppData/Roaming/npm/node_modules/@earendil-works/pi-coding-agent/*: "allow"`); every `read`/`find`/`grep` against Pi's docs is still denied by the `external_directory` policy, contradicting the documented last-match-wins semantics.
|
||
|
||
The root cause is a Windows-only path-comparison asymmetry.
|
||
The path under test is canonicalized **and lowercased** on `win32` (`normalizePathForComparison` / `canonicalNormalizePathForComparison`), but the other side of every comparison keeps native case:
|
||
|
||
- Infrastructure-read containment (`isPathWithinDirectory`) uses a case-sensitive `startsWith`, so Pi's install dir under the discovered `node_modules` root (or `agentDir`) never matches the lowercased path — the auto-allow silently fails.
|
||
- `external_directory` / `path` config patterns compile to case-sensitive regexes (`compileWildcardPattern`), so the user's mixed-case `~/AppData/...` allow pattern never matches the lowercased value and the policy falls through to `*: "deny"` — the override is silently ignored.
|
||
|
||
Windows filesystems are case-insensitive, so both comparisons should fold case.
|
||
Separately, the existing auto-discovery finds the directory where the **extension** is installed, which need not contain Pi's docs; Pi exposes its own install location and we should use it.
|
||
|
||
## Goals
|
||
|
||
- On Windows, match `external_directory` / `path` / path-bearing-tool patterns case-insensitively (and separator-agnostically) so explicit allow/deny overrides work as documented.
|
||
- On Windows, make the Pi infrastructure-read auto-allow case-insensitive so Pi's own files are auto-allowed for read-only tools.
|
||
- Adopt Node's platform-native containment idiom (`path.relative`) for the path-containment checks in `path-utils.ts`, matching how Pi itself decides containment (`getCwdRelativePath`).
|
||
- Auto-detect Pi's install directory via the coding-agent public API (`getPackageDir()`) and add it to the read-only infrastructure dirs, so Pi docs are auto-allowed regardless of install layout.
|
||
- Keep POSIX behavior byte-for-byte unchanged.
|
||
|
||
Compatibility note (not a runtime breaking change): the coding-agent peer-dependency floor rises from `>=0.75.0` to `>=0.79.0` because `getPackageDir()` is only re-exported from the package entry point as of v0.79.0.
|
||
Runtime behavior, config shape, and defaults are unchanged on upgrade; this is a `fix:`, not a `feat!:`/`fix!:`.
|
||
|
||
## Non-Goals
|
||
|
||
- Removing the existing `win32` lowercasing in `normalizePathForComparison`.
|
||
After this change it is redundant for matching (the regex `i` flag and `path.relative` both fold case), but removing it widens the blast radius into `skill-prompt-sanitizer` and `bash-program`; defer it.
|
||
- Dissolving the duplicate containment helper in `subagent-context.ts` (`isPathWithinDirectoryForSubagent`) into the shared `path-utils` helper.
|
||
It serves a different concern (subagent detection) and is not implicated in this bug; track as a follow-up.
|
||
- Switching the wildcard engine to `path.matchesGlob`.
|
||
Its `*` does not cross separators and it is case-sensitive even on `win32`, so it would change the established `*`→`.*` semantics and not fix the case bug.
|
||
- Changing the `bash`, `skill`, or `mcp` matching surfaces — only path surfaces fold case.
|
||
|
||
## Background
|
||
|
||
Relevant modules and how they relate:
|
||
|
||
- `src/path-utils.ts` — `normalizePathForComparison` (resolve + normalize + lowercase on `win32`), `canonicalNormalizePathForComparison` (adds `realpathSync`), `isPathWithinDirectory` (case-sensitive `startsWith`), `isPathOutsideWorkingDirectory`, and `isPiInfrastructureRead` (the read-only auto-allow).
|
||
- `src/handlers/gates/external-directory.ts` — builds the `external_directory` descriptor with `input.path = canonicalNormalizePathForComparison(...)` (lowercased on `win32`) and short-circuits to allow when `isPiInfrastructureRead` returns true.
|
||
- `src/wildcard-matcher.ts` — `compileWildcardPattern` (home-expands, then builds a case-sensitive `RegExp`) and `wildcardMatch`.
|
||
- `src/rule.ts` — `evaluate(surface, value, rules)` calls `wildcardMatch(r.pattern, value)`; this is the single surface-aware matching point.
|
||
- `src/extension-paths.ts` — `computeExtensionPaths(agentDir)` builds `piInfrastructureDirs = [agentDir, agentDir/git, globalNodeModulesRoot?]`.
|
||
- `src/node-modules-discovery.ts` — `discoverGlobalNodeModulesRoot()` walks up from the **extension's** `import.meta.url`; falls back to `npm root -g`.
|
||
- `src/index.ts` — composition root; already imports `getAgentDir` from `@earendil-works/pi-coding-agent` and calls `computeExtensionPaths(agentDir)`.
|
||
|
||
How Node and Pi handle this (verified):
|
||
|
||
- `path.win32.relative('C:\\Users\\FOO\\dir', 'c:\\users\\foo\\dir\\sub\\x.md')` → `'sub\\x.md'`; the `win32` implementation folds case natively, and an outside path yields a `..`-prefixed result.
|
||
- Pi's own containment idiom (`packages/coding-agent/src/utils/paths.ts` `getCwdRelativePath`, and `core/tools/read.ts` `getPiDocsClassification`) is `relative(dir, target)` plus a `..`/absolute-prefix check, with **no** manual lowercasing.
|
||
- Pi locates its own files via `getPackageDir()` / `getDocsPath()` (walk up from `__dirname` to `package.json`, honoring `PI_PACKAGE_DIR`); these are re-exported from the package entry as of v0.79.0 (commit `eb43bd44`, first released in `v0.79.0`; the reporter runs `0.79.1`).
|
||
|
||
Constraints from AGENTS.md that apply:
|
||
|
||
- Keep Pi SDK imports at the composition root — `getPackageDir()` is imported in `index.ts` and the value is passed into `computeExtensionPaths`; `path-utils.ts` / `extension-paths.ts` stay SDK-independent.
|
||
- Do not read `process.platform` inside library functions where avoidable — thread it as a defaulted parameter so tests can simulate `win32` on a POSIX CI (stubbing `process.platform` does not switch Node's `path` implementation).
|
||
- Keep schema, example config, `docs/configuration.md`, `README.md`, and types aligned.
|
||
- A `package.json` dependency change requires `pnpm install` and the updated `pnpm-lock.yaml` in the same commit (CI uses `--frozen-lockfile`).
|
||
- `permission["*"]` last-match-wins ordering and wildcard explicitness must stay tested — silent over-match is a permission bypass.
|
||
|
||
## Design Overview
|
||
|
||
Two comparison sites fail on `win32`; each gets a targeted, platform-correct fix.
|
||
|
||
### 1. Containment — adopt `path.relative` (Pi's idiom)
|
||
|
||
Rewrite `isPathWithinDirectory` to use the platform-native `relative()` instead of a hand-rolled lowercase-one-side `startsWith`.
|
||
Select the path flavor explicitly so tests can simulate Windows:
|
||
|
||
```typescript
|
||
import { win32 as winPath, posix as posixPath } from "node:path";
|
||
|
||
export function isPathWithinDirectory(
|
||
child: string,
|
||
parent: string,
|
||
platform: NodeJS.Platform = process.platform,
|
||
): boolean {
|
||
if (!child || !parent) return false;
|
||
const impl = platform === "win32" ? winPath : posixPath;
|
||
if (child === parent) return true;
|
||
const rel = impl.relative(parent, child);
|
||
return (
|
||
rel !== "" &&
|
||
rel !== ".." &&
|
||
!rel.startsWith(`..${impl.sep}`) &&
|
||
!impl.isAbsolute(rel)
|
||
);
|
||
}
|
||
```
|
||
|
||
`isPathOutsideWorkingDirectory(pathValue, cwd, platform = process.platform)` and `isPiInfrastructureRead(..., platform = process.platform)` thread `platform` into the containment call.
|
||
On `win32`, `winPath.relative` folds case, so a lowercased value matches a mixed-case infra dir.
|
||
|
||
Call-site interaction (verify no Tell-Don't-Ask / output-arg regression): `isPiInfrastructureRead` only *reads* its inputs and returns a boolean; the new `platform` parameter is a defaulted scalar, not a dependency bag.
|
||
The project-local branches (`join(cwd, ".pi", "npm" | "git")`) reuse the same `isPathWithinDirectory(..., platform)` and therefore become case-correct too.
|
||
|
||
### 2. Glob pattern matching — fold case and separators for path surfaces
|
||
|
||
Add optional matching behavior, off by default (pure addition, no call-site breakage):
|
||
|
||
```typescript
|
||
interface WildcardMatchOptions {
|
||
caseInsensitive?: boolean; // adds the "i" RegExp flag
|
||
windowsSeparators?: boolean; // normalizes "/" → "\" in the expanded pattern
|
||
}
|
||
export function compileWildcardPattern<TState>(
|
||
pattern: string,
|
||
state: TState,
|
||
options?: WildcardMatchOptions,
|
||
): CompiledWildcardPattern<TState>;
|
||
export function wildcardMatch(
|
||
pattern: string,
|
||
value: string,
|
||
options?: WildcardMatchOptions,
|
||
): boolean;
|
||
```
|
||
|
||
`evaluate` is the single surface-aware site; it gains a defaulted `platform` and folds only the **pattern→value** match for path surfaces (the surface→surface match stays exact):
|
||
|
||
```typescript
|
||
const PATH_SURFACES = new Set([
|
||
...PATH_BEARING_TOOLS, // read, write, edit, find, grep, ls
|
||
"external_directory",
|
||
"path",
|
||
]);
|
||
|
||
export function evaluate(
|
||
surface: string,
|
||
value: string,
|
||
rules: Ruleset,
|
||
defaultAction?: PermissionState,
|
||
platform: NodeJS.Platform = process.platform,
|
||
): Rule {
|
||
const win = platform === "win32" && PATH_SURFACES.has(surface);
|
||
const opts = win
|
||
? { caseInsensitive: true, windowsSeparators: true }
|
||
: undefined;
|
||
const rule = rules.findLast(
|
||
(r) =>
|
||
wildcardMatch(r.surface, surface) &&
|
||
wildcardMatch(r.pattern, value, opts),
|
||
);
|
||
// …unchanged fallback…
|
||
}
|
||
```
|
||
|
||
`PATH_SURFACES` is exported from `path-utils.ts` (where `PATH_BEARING_TOOLS` already lives) and imported by `rule.ts` (no import cycle: `path-utils` does not import `rule`).
|
||
|
||
Why this fixes the reporter's case: the gate hands `evaluate` a lowercased, backslash value; the allow pattern `~/AppData/Roaming/npm/node_modules/@earendil-works/pi-coding-agent/*` home-expands (via `join`) to a mixed-case backslash absolute path; with `caseInsensitive` it now matches and, being last in config order, wins over `*: "deny"`.
|
||
`windowsSeparators` additionally rescues forward-slash absolute patterns (e.g. `C:/Users/.../*`) that never pass through `join`.
|
||
|
||
`isPiInfrastructureRead`'s glob-dir branch (`wildcardMatch(dir, normalizedPath)`, added in [#122]) passes the same `{ caseInsensitive, windowsSeparators }` on `win32`.
|
||
|
||
### 3. Auto-detect Pi's install directory
|
||
|
||
`computeExtensionPaths` accepts an optional `piPackageDir` and adds it to `piInfrastructureDirs` when non-empty:
|
||
|
||
```typescript
|
||
export function computeExtensionPaths(
|
||
agentDir: string,
|
||
piPackageDir?: string,
|
||
): ExtensionPaths {
|
||
// …existing…
|
||
const piInfrastructureDirs: string[] = [
|
||
agentDir,
|
||
join(agentDir, "git"),
|
||
...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
|
||
...(piPackageDir ? [piPackageDir] : []),
|
||
];
|
||
// …
|
||
}
|
||
```
|
||
|
||
`index.ts` wires it from Pi's public API (composition root keeps the SDK import):
|
||
|
||
```typescript
|
||
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
||
// …
|
||
const paths = computeExtensionPaths(getAgentDir(), getPackageDir());
|
||
```
|
||
|
||
`getPackageDir()` always returns a non-empty string (walks up to `package.json`, falls back to `__dirname`, honors `PI_PACKAGE_DIR`), so the guard is belt-and-suspenders.
|
||
Because `getInfrastructureReadDirs()` (in `permission-session.ts`) already unions `piInfrastructureDirs` with config `piInfrastructureReadPaths`, the new entry flows through without further wiring.
|
||
This entry is strictly narrower than the `node_modules` root already auto-allowed for reads, and read-only tools only.
|
||
|
||
### Edge cases
|
||
|
||
- POSIX: `platform` defaults to `process.platform`; on non-`win32`, `opts` is `undefined` and `isPathWithinDirectory` uses `posixPath` — identical to today.
|
||
- Pin/UNC/drive-relative oddities are delegated to Node's `path.win32` rather than re-implemented.
|
||
- A file target as an infra dir (not applicable here — `getPackageDir()` is a directory) would still work via `relative`, but we add the directory, not individual files.
|
||
|
||
## Module-Level Changes
|
||
|
||
- `src/path-utils.ts`
|
||
- Rewrite `isPathWithinDirectory(child, parent, platform = process.platform)` to use `path.win32`/`path.posix` `relative()` + `..`/absolute check.
|
||
- Thread `platform` through `isPathOutsideWorkingDirectory` and `isPiInfrastructureRead`; pass `{ caseInsensitive, windowsSeparators }` to the glob-dir `wildcardMatch` on `win32`.
|
||
- Add and export `PATH_SURFACES` (`PATH_BEARING_TOOLS` ∪ `{ "external_directory", "path" }`).
|
||
- `src/wildcard-matcher.ts`
|
||
- Add `WildcardMatchOptions` and the optional `options` parameter to `compileWildcardPattern` and `wildcardMatch`; apply the `"i"` flag and `/`→`\` separator normalization on the expanded pattern.
|
||
- `src/rule.ts`
|
||
- Add the defaulted `platform` parameter to `evaluate`; fold the pattern match for `PATH_SURFACES` on `win32`.
|
||
Import `PATH_SURFACES` from `path-utils`.
|
||
- `src/extension-paths.ts`
|
||
- Add optional `piPackageDir` parameter to `computeExtensionPaths`; append to `piInfrastructureDirs`.
|
||
Update the `ExtensionPaths` / `computeExtensionPaths` doc comment.
|
||
- `src/index.ts`
|
||
- Import `getPackageDir`; pass `getPackageDir()` to `computeExtensionPaths`.
|
||
- `package.json`
|
||
- Bump peer `@earendil-works/pi-coding-agent` to `>=0.79.0`; bump devDependency to `0.79.1`.
|
||
Bump `@earendil-works/pi-tui` only if `pnpm install` reports a peer mismatch.
|
||
Run `pnpm install`, commit `pnpm-lock.yaml`.
|
||
- Docs
|
||
- `docs/configuration.md` — add Pi's install directory to the infrastructure list; add a "Windows path matching is case-insensitive" note under the `external_directory` / Home Directory Expansion sections.
|
||
- `schemas/permissions.schema.json` — update the `piInfrastructureReadPaths` `markdownDescription` (mention Pi's package dir auto-discovery and `win32` case-insensitivity).
|
||
- `docs/architecture/architecture.md` — refresh the `path-utils.ts` and `extension-paths.ts` line descriptions (lines ~538/545) to mention `path.relative` containment and `piPackageDir`.
|
||
- `README.md` — no change required (does not enumerate infra dirs); confirm during the docs step.
|
||
|
||
Files in Module-Level Changes do not appear in Non-Goals; the two `path-utils` items (containment rewrite vs. lowercasing) are distinct concerns.
|
||
|
||
## Test Impact Analysis
|
||
|
||
This is primarily a bug fix; the only refactor is `isPathWithinDirectory`.
|
||
|
||
1. New tests enabled
|
||
- `path-utils.test.ts`: `isPathWithinDirectory(child, parent, "win32")` is now directly testable for case-insensitive containment on a POSIX CI by injecting the platform and `C:\…` paths — previously impossible because the function read `process.platform` implicitly and lowercased only one side.
|
||
- `wildcard-matcher.test.ts`: `caseInsensitive` and `windowsSeparators` options.
|
||
- `rule.test.ts`: surface-scoped case folding (path surfaces fold on `win32`; `bash`/`skill` stay exact).
|
||
- `extension-paths.test.ts`: `piPackageDir` inclusion.
|
||
2. Tests that become redundant — none.
|
||
The existing POSIX assertions for `isPathWithinDirectory` / `isPiInfrastructureRead` keep their meaning (default `platform` → POSIX path) and act as regression guards.
|
||
3. Tests that must stay as-is
|
||
- The POSIX `pi-infrastructure-read.test.ts` and `path-utils.test.ts` cases continue to exercise the default-platform path and must remain green unchanged.
|
||
|
||
## TDD Order
|
||
|
||
1. `fix` — containment via `path.relative` in `path-utils.ts`.
|
||
Test surface: `test/path-utils.test.ts`.
|
||
Red: `isPathWithinDirectory` with `platform: "win32"` returns true for case-different child/parent and false for a sibling/`..` path; `platform: "linux"` stays case-sensitive; `isPathOutsideWorkingDirectory` honors the injected platform.
|
||
Green: rewrite using `win32`/`posix` `relative()`; thread `platform` (defaulted) through `isPathOutsideWorkingDirectory`.
|
||
Run `pnpm run check` (signature change with defaults — no call-site edits required).
|
||
Commit: `fix(pi-permission-system): make path containment case-insensitive on Windows via path.relative`.
|
||
|
||
2. `fix` — infrastructure-read auto-allow folds case on Windows.
|
||
Test surface: `test/pi-infrastructure-read.test.ts` (and `test/path-utils.test.ts`).
|
||
Red: with `platform: "win32"`, a lowercased path inside a mixed-case infra dir is allowed; a `win32` glob infra dir matches case-insensitively; POSIX cases unchanged.
|
||
Green: thread `platform` into `isPiInfrastructureRead`; pass `{ caseInsensitive, windowsSeparators }` to the glob-dir `wildcardMatch`; export `PATH_SURFACES`.
|
||
Commit: `fix(pi-permission-system): auto-allow infrastructure reads case-insensitively on Windows`.
|
||
|
||
3. `fix` — case-insensitive, separator-normalized path-surface pattern matching.
|
||
Test surface: `test/wildcard-matcher.test.ts`, then `test/rule.test.ts`, then `test/handlers/gates/external-directory.test.ts`.
|
||
Red A: `compileWildcardPattern` / `wildcardMatch` with `caseInsensitive` match mixed-case input; `windowsSeparators` make a `/`-pattern match a `\`-value.
|
||
Green A: add `WildcardMatchOptions` and apply the flag + separator normalization.
|
||
Red B: `evaluate("external_directory", <lowercased win path>, rules, undefined, "win32")` selects a mixed-case `~`-expanded allow rule over a preceding `*: deny` (last-match-wins); the same surfaces stay exact under `platform: "linux"`; `bash`/`skill` stay case-sensitive on `win32`.
|
||
Green B: add the defaulted `platform` to `evaluate`; fold the pattern match for `PATH_SURFACES`.
|
||
Red C (integration): the external-directory gate allows a read of a mixed-case Pi-install path under a `win32` allow override.
|
||
Green C: covered by A+B (no new production code expected).
|
||
Run `pnpm run check`.
|
||
Commit: `fix(pi-permission-system): match external_directory/path patterns case-insensitively on Windows`.
|
||
|
||
4. `fix` — add optional `piPackageDir` to `computeExtensionPaths`.
|
||
Test surface: `test/extension-paths.test.ts`.
|
||
Red: `computeExtensionPaths(agentDir, "/pi/install")` includes `/pi/install` in `piInfrastructureDirs`; omitting it preserves the current list.
|
||
Green: add the parameter and append guarded.
|
||
Commit: `fix(pi-permission-system): include an optional Pi package dir in infrastructure reads`.
|
||
|
||
5. `fix` — bump the coding-agent dependency and wire `getPackageDir()`.
|
||
Test surface: `test/composition-root.test.ts` (smoke), real `@earendil-works/pi-coding-agent`.
|
||
Steps: bump peer to `>=0.79.0` and devDependency to `0.79.1` (and `@earendil-works/pi-tui` if `pnpm install` flags a peer mismatch); run `pnpm install`; update `index.ts` to import and pass `getPackageDir()`.
|
||
This step must carry the dependency bump and the `index.ts` import together — the import only type-checks once the floor moves to v0.79.x (the installed `0.75.4` does not re-export `getPackageDir`).
|
||
Commit (single, with `pnpm-lock.yaml`): `fix(pi-permission-system): auto-detect Pi's install directory for infrastructure reads (#382)`.
|
||
|
||
6. `docs` — align documentation and schema.
|
||
Update `docs/configuration.md`, `schemas/permissions.schema.json`, and `docs/architecture/architecture.md` (and confirm `README.md` needs nothing).
|
||
Commit: `docs(pi-permission-system): document Windows case-insensitive matching and Pi-install auto-allow`.
|
||
|
||
## Risks and Mitigations
|
||
|
||
- Peer-floor bump excludes Pi `<0.79.0`.
|
||
Mitigation: pi-permission-system tracks Pi closely via peers; the reporter is on `0.79.1`; call it out in the changelog-facing commit body and `Goals`.
|
||
Not a runtime breaking change.
|
||
- Simulating `win32` on a POSIX CI: stubbing `process.platform` does **not** switch Node's top-level `path` functions to `win32`.
|
||
Mitigation: the production code selects `path.win32`/`path.posix` from an injected `platform`, and tests pass `"win32"` plus `C:\…`-style absolute paths.
|
||
- `getPackageDir()` resolution under jiti per-extension isolation could differ from expectations or (in exotic setups) point at an unexpected dir.
|
||
Mitigation: it is additive (does not remove the existing `node_modules` discovery), read-only, and guarded for non-empty; `PI_PACKAGE_DIR` provides an escape hatch.
|
||
- Bumping the coding-agent devDependency may force a matching `@earendil-works/pi-tui` bump for peer consistency.
|
||
Mitigation: run `pnpm install` and bump `pi-tui` in lockstep only if peer resolution complains; keep both in the same commit as the lockfile.
|
||
- Folding case could make a `deny` pattern match more paths on Windows.
|
||
Mitigation: this is the correct semantics for a case-insensitive filesystem and is Windows-only; covered by explicit over-match tests.
|
||
|
||
## Open Questions
|
||
|
||
- Should the redundant `win32` lowercasing in `normalizePathForComparison` be removed in a follow-up now that matching folds case independently?
|
||
(Deferred — non-goal.)
|
||
- Should `subagent-context.ts`'s `isPathWithinDirectoryForSubagent` be dissolved into the shared `path-utils` containment helper?
|
||
(Deferred — separate concern.)
|
||
|
||
[#122]: https://github.com/gotgenes/pi-packages/issues/122
|