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 theexternal_directorygate under the defaultaskpolicy. - The same tokens become
path-surface rule candidates, so apathrule 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/etcand/usralready are, regardless of which command consumes it.
Non-Goals
- No command-awareness.
This fix does not make the gate distinguish
find /(real access) fromecho /(prints a slash); the package gates path-shaped tokens regardless of the consuming command, andecho /etc/passwdalready prompts today.echo /prompting is the consistent consequence, not a separate feature. - No change to any config default.
The
external_directorydefault remainsask; a user withexternal_directory: allowsees no change. - No win32-specific new mechanism.
On win32 a bare
/already resolves through the existingposix-absoluteliteral-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 feedingprojectExternalPaths(theexternal_directorysurface).classifyTokenAsRuleCandidate— the broad gate feedingprojectRuleCandidates(thepathsurface).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 itstoken.startsWith("/")branch.classifyTokenAsRuleCandidate("/", posixPathFlavor)returns/viaflavor.hasPathSeparator("/").classifyPromotedRuleCandidateno longer pre-rejects/, though promotion still requires a matching specificpathrule.
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.@/foois 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 fromrejectNonPathToken. 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 thedescribe("bare-slash tokens are skipped")block todescribe("bare-slash tokens resolve to external root"):echo /,echo //,echo ///each now return["/"].echo // hellonow 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/hostsand/.- Add a regression test for the issue's headline:
find /(and optionallyfind / -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
- New coverage enabled: a direct regression test that
find /resolves to an external/— the exact repro from #583 that produced no prompt. - Redundant tests removed: the two "bare-slash guard is still needed" tests in
test/bash-external-directory.test.tsdocument the removed branch as necessary defense-in-depth; they are deleted, not migrated. - Tests that stay (inverted, not removed): the
token-classification.test.tsbare-slash unit tests and thebash-external-directory.test.tsbare-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
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(bothclassifyTokenAsPathCandidateandclassifyTokenAsRuleCandidatebare-slash cases now expect the token returned), and rewrite thebash-external-directory.test.tsbare-slash block to assertecho //echo ///echo ///resolve to["/"], add thefind /regression test, and updatecat /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 fromrejectNonPathTokeninsrc/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).
- RED — invert the classifier unit tests in
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 defaultaskpolicy, including harmless cases likeecho /. Mitigation / rationale: this is consistent with the package's command-agnostic path model, whereecho /etc/passwdalready 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 afix:, notfix!:, matching how #481 and #490 (both added new prompts) were classified — no config default changes, and the gate's documentedaskcontract 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 asecho /etcprompting. The current tree-sitter parser feeds real argument tokens (notpath.normalizeoutput), 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.