Files
my-pi/pi-permission-system/docs/plans/0583-bare-slash-root-external-directory.md
T

12 KiB

issue, issue_title
issue issue_title
583 pi-permission-system: bare-slash `find /` bypasses the external_directory gate

Treat a bare / as a filesystem-root path candidate

Release Recommendation

Release: ship independently

This is a standalone gate-tightening bug fix, not a member of any roadmap batch. The architecture roadmap has no step referencing #583, so it releases on its own as a fix: — the same class as the sibling gate-tightening fixes #481 (floor opaque wrappers) and #490 (floor find/fd exec wrappers), both shipped as independent fix: commits.

Problem Statement

find / scans the entire filesystem from root, but its / argument is dropped before any path gate sees it, so a whole-filesystem search runs with no external_directory prompt. The only external-directory access in that command rides on the single / token, and every bash token classifier runs a shared rejection prelude, rejectNonPathToken, whose /^\/+$/ branch drops any all-slashes token as "never meaningful path arguments in practice." That premise is wrong for search and list tools: find /, ls /, grep -r pat /, and rg x / all pass a bare / as a deliberate filesystem-root argument — exactly the external-directory access the gate exists to catch. The rejection came from the upstream fork's #68, which over-corrected to suppress a spurious prompt on echo // and in doing so silenced a legitimate high-risk access.

Goals

  • A bare / (and //, ///) reaching the bash gate is treated as an absolute path resolving to filesystem root, which is outside any project cwd, so it triggers the external_directory gate under the default ask policy.
  • The same tokens become path-surface rule candidates, so a path rule for / can match them, consistent with every other absolute token.
  • Behavior stays consistent with the package's command-agnostic path model: a bare / is gated the same way /etc and /usr already are, regardless of which command consumes it.

Non-Goals

  • No command-awareness. This fix does not make the gate distinguish find / (real access) from echo / (prints a slash); the package gates path-shaped tokens regardless of the consuming command, and echo /etc/passwd already prompts today. echo / prompting is the consistent consequence, not a separate feature.
  • No change to any config default. The external_directory default remains ask; a user with external_directory: allow sees no change.
  • No win32-specific new mechanism. On win32 a bare / already resolves through the existing posix-absolute literal-only branch (#533) and is foreign to the win32 cwd, so it is external there too — no new code path is needed.

Background

The relevant module is src/access-intent/bash/token-classification.ts, which exports three pure classifiers consumed by src/access-intent/bash/bash-path-resolver.ts:

  • classifyTokenAsPathCandidate — the strict gate feeding projectExternalPaths (the external_directory surface).
  • classifyTokenAsRuleCandidate — the broad gate feeding projectRuleCandidates (the path surface).
  • classifyPromotedRuleCandidate — rule-driven promotion of a bare filename (#509).

All three call the private rejectNonPathToken prelude first. Its bare-slash branch is the single point that drops /:

// Bare-slash tokens (/, //, ///) resolve to filesystem root and are never
// meaningful path arguments in practice.
if (/^\/+$/.test(token)) return true;

Removing this branch flips all three classifiers together, which is the desired, consistent outcome:

  • classifyTokenAsPathCandidate("/") then returns / via its token.startsWith("/") branch.
  • classifyTokenAsRuleCandidate("/", posixPathFlavor) returns / via flavor.hasPathSeparator("/").
  • classifyPromotedRuleCandidate no longer pre-rejects /, though promotion still requires a matching specific path rule.

Downstream resolution needs no change. In projectExternalPaths, a / candidate is absolute (not relative), so it takes the main branch: forBashToken("/") yields lexical value / and boundary value /, and isBoundaryOutsideWorkingDirectory("/") is true because / is an ancestor of any project cwd (path.posix.relative(cwd, "/") starts with ..). // and /// normalize to / (verified: path.posix.normalize("//") === "/"), so all three collapse to the same external root. / is not in SAFE_SYSTEM_PATHS (only the four /dev/* device files are), so the safe-path exclusion in isPathOutsideWorkingDirectory does not suppress it.

AGENTS.md constraints that apply:

  • Default to least privilege; when in doubt, prompt. This fix restores a prompt the gate should already produce.
  • Wildcard/shape matching must be explicit and tested — silent over-matching (or here, silent under-matching) is a permission bypass. The flipped tests pin the corrected shape.

Design Overview

The change is the removal of one predicate branch plus its now-inverted tests.

Decision model, before and after, for a bare / token:

Surface Classifier Before After
external_directory classifyTokenAsPathCandidate null (dropped) / → resolved external → prompt
path classifyTokenAsRuleCandidate null (dropped) / → rule candidate
path (promotion) classifyPromotedRuleCandidate null (pre-rejected) eligible; promoted only if a specific path rule matches

Edge cases:

  • // and /// normalize to / and follow the same external path. The integration tests assert the resolved external set is ["/"] for each.
  • @/foo is unaffected: the @-guard admits @/…, and the removed bare-slash branch never matched a token with content after the slashes.
  • //server/share (a UNC-shaped token, relevant only on win32) is unaffected: it has content after the leading slashes, so /^\/+$/ never matched it — its behavior was already governed by the resolver, not this branch.
  • The echo /// outcome is the deliberate, documented behavior change (see Risks).

No new collaborator, type, or module is introduced; this is a subtraction from an existing pure predicate, so there is no Tell-Don't-Ask or ISP surface to sketch.

Module-Level Changes

  • src/access-intent/bash/token-classification.ts — remove the /^\/+$/ branch from rejectNonPathToken. Update the private predicate's JSDoc (currently lists "bare-slash tokens" among what it rejects) and the module header's rejection-case summary so they no longer claim bare-slash is dropped.
  • test/access-intent/bash/token-classification.test.ts — invert the two "bare-slash token → null" tests: classifyTokenAsPathCandidate("/" | "//" | "///") now returns the token; classifyTokenAsRuleCandidate("/" | "//", posixPathFlavor) now returns the token. Rename the test titles to reflect acceptance (e.g. "bare-slash token → accepted as root path").
  • test/bash-external-directory.test.ts — rewrite the describe("bare-slash tokens are skipped") block to describe("bare-slash tokens resolve to external root"):
    • echo /, echo //, echo /// each now return ["/"].
    • echo // hello now returns ["/"].
    • Delete the two "guard is still needed" defense-in-depth tests (they assert the removed branch is necessary; that premise is now false).
    • cat /etc/hosts; echo // now returns both /etc/hosts and /.
    • Add a regression test for the issue's headline: find / (and optionally find / -path "*/pi-coding-agent/*.d.ts") returns ["/"].

No production symbol is removed or renamed, so no src//test/ import graph, README, or architecture-layout listing needs updating. The historical plan docs/plans/0533-win32-git-bash-posix-paths.md:151 mentions "the bare-slash rejection in rejectNonPathToken" in a parenthetical; it is a completed plan record describing the state at that time and is intentionally left unchanged (its conclusion about //server/share remains correct). Grep confirms the package skill and docs/architecture/ do not reference the bare-slash rejection, so neither needs a doc update.

Test Impact Analysis

  1. New coverage enabled: a direct regression test that find / resolves to an external / — the exact repro from #583 that produced no prompt.
  2. Redundant tests removed: the two "bare-slash guard is still needed" tests in test/bash-external-directory.test.ts document the removed branch as necessary defense-in-depth; they are deleted, not migrated.
  3. Tests that stay (inverted, not removed): the token-classification.test.ts bare-slash unit tests and the bash-external-directory.test.ts bare-slash integration block continue to pin the classifier's treatment of /, //, /// — now asserting acceptance-as-root rather than rejection. They remain the guard against a future re-introduction of the rejection.

Invariants at risk

This surface was last reworked by #533 (win32 Git Bash POSIX path semantics). Its documented outcome — a win32 non-mount POSIX absolute resolves literal-only and external — is unaffected here: / on win32 still routes through the posix-absolute branch and is external. No #533 test changes behavior. The #418 external-directory lexical-vs-canonical invariant is likewise untouched: / has identical lexical and canonical forms. No prior step's Outcome: invariant is regressed; the flipped tests are additive assertions on a previously-dropped token, not a relaxation of an existing gate.

TDD Order

  1. fix: treat a bare / as a filesystem-root path candidate (#583)
    • RED — invert the classifier unit tests in test/access-intent/bash/token-classification.test.ts (both classifyTokenAsPathCandidate and classifyTokenAsRuleCandidate bare-slash cases now expect the token returned), and rewrite the bash-external-directory.test.ts bare-slash block to assert echo / / echo // / echo /// resolve to ["/"], add the find / regression test, and update cat /etc/hosts; echo // to expect both paths. Delete the two "guard is still needed" tests. Run the suite to confirm the new assertions fail against current code.
    • GREEN — remove the /^\/+$/ branch from rejectNonPathToken in src/access-intent/bash/token-classification.ts; update the predicate JSDoc and the module-header rejection summary to drop the bare-slash claim.
    • COMMIT — fix(pi-permission-system): treat bare / as a filesystem-root path candidate (#583).

The whole change is one logical subtraction (one predicate branch) that flips both the classifier and integration surfaces simultaneously; splitting it across commits would leave the integration tests unable to be red after the impl lands, so it is a single red→green→commit cycle.

Risks and Mitigations

  • Risk: broader prompting. After the fix, any bare / token prompts under the default ask policy, including harmless cases like echo /. Mitigation / rationale: this is consistent with the package's command-agnostic path model, where echo /etc/passwd already prompts today; a bare / is no more special than /etc. It is a fail-safe tightening (over-prompt, never under-gate), which the package explicitly prefers (least privilege; #509 accepts an analogous fail-safe false positive). It is a fix:, not fix!:, matching how #481 and #490 (both added new prompts) were classified — no config default changes, and the gate's documented ask contract is merely honored where a token previously escaped it.
  • Risk: reintroducing the original #68 spurious prompt. #68 fixed echo // prompting. Mitigation: that "spurious" prompt was never spurious under the command-agnostic model — it is the same class as echo /etc prompting. The current tree-sitter parser feeds real argument tokens (not path.normalize output), so no manufactured / is involved; the flip is deliberate and covered by the rewritten tests.

Open Questions

None. The direction is unambiguous and the change is a single-branch subtraction with inverted tests.