20 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 509 | Bash bare-filename arguments bypass the path permission surface |
Bash bare-filename arguments — rule-driven promotion into the path surface
Release Recommendation
Release: ship independently
This issue is a standalone bug fix.
It is not a member of any architecture-roadmap release batch (the roadmap's remaining batch is "symlink-resistant-path-matching", which #509 is not part of), so it ships on its own once landed.
Problem Statement
A path permission rule (e.g. "id_rsa": "deny", "*.pem": "deny") gates a file when it is read through the read tool or through a bash command that uses a prefixed path (cat ./id_rsa, cat ~/.ssh/id_rsa), but not when the same file is referenced by a bare filename (cat id_rsa).
The broad bash classifier classifyTokenAsRuleCandidate accepts a token only if it starts with ., contains /, contains .., or is a Windows drive-letter absolute path.
A bare filename has none of these shapes, so it is dropped before rule evaluation and bypasses the path surface — an inconsistency with the read/write tools, which evaluate input.path directly.
The bare-token exclusion is deliberate: in bash, most argument tokens are not file paths (git status, npm run build, grep id_rsa secrets.txt), so promoting all bare tokens would produce pattern false positives and — under a broad "*" rule — turn every bash argument into a prompt.
The fix must close the bypass without reintroducing that blow-up.
Goals
- Gate a bash bare-filename argument by a
pathrule when the token matches an active, specific (non-*)pathdeny/ask pattern, so the permission decision for a file is the same across thereadtool and bash. - Preserve the current behavior exactly when no
pathrules are configured (the default), and never promote against the universal"*"fallback. - Keep bare tokens that do not match a specific
pathrule dropped, as today (status,main,build,podsstay untouched).
This is not a breaking change: it only tightens gating for configs that already declare specific path deny/ask rules, and it never loosens an existing decision.
No config field, default, or output shape changes.
Non-Goals
- Argument-position / per-command awareness (knowing that
grep PATTERN FILE's first argument is a search pattern, or thatgit checkout BRANCHnames a branch). Rule-driven promotion still matchesgit grep id_rsaagainst anid_rsarule and produces a spurious prompt; this is accepted as a fail-safe (it prompts, never silently allows). Closing that gap needs per-command file-argument knowledge and is out of scope. - Backslash-relative Windows tokens (
dir\file, no/, no leading., not a drive-letter absolute) — a shape-recognition gap rather than a promotion gap. Deferred to #520. - Session-rule-driven promotion: promotion is decided from the composed config ruleset only (session approvals are allow-shaped and do not gate).
- The strict
external_directoryclassifier (classifyTokenAsPathCandidate) is unchanged — this issue concerns thepathsurface only.
Background
The relevant modules and their current relationships:
src/access-intent/bash/token-classification.ts— pure, synchronous classifiers.classifyTokenAsRuleCandidateis the broadpath-rule shape gate; it shares the privaterejectNonPathTokenprelude (flags, env assignments, URLs,@scopepackages, regex metachars) with the strict classifier.src/access-intent/bash/bash-path-resolver.ts—BashPathResolverwalks the AST once, tags each token with its cd-folded effective base, and projects two slices:projectExternalPaths(strict) andprojectRuleCandidates(broad). It holds aPathNormalizer(platform + cwd baked in) as its sole collaborator.src/access-intent/bash/program.ts—BashProgram.parse(command, normalizer)constructs the resolver and eagerly resolves both slices so the three bash gates share one parse.src/handlers/gates/bash-path.ts—describeBashPathGatereadsbashProgram.pathRuleCandidates(), resolves each against thepathsurface, and gates on the most restrictive. It already treats a token whose only match is the universal default (matchedPattern === undefined) as unrestricted (#58), but a real"*"config pattern would still fire — which is why promotion must exclude"*"before evaluation.src/handlers/gates/tool-call-gate-pipeline.ts—ToolCallGatePipeline.evaluateperforms the singleBashProgram.parseand holdsScopedPermissionResolver+ToolCallGateInputs(satisfied byPermissionSession).src/permission-manager.ts—PermissionManagerowns the composed ruleset and the injectedplatform;getComposedConfigRules(agentName?)already exposes config-layer rules, andPATH_SURFACES/pathMatchOptionsencode the Windows case-and-separator fold for path matching.
Constraint from AGENTS.md / the package skill: do not read process.platform inside src/ — the Windows case fold must be decided where the platform already lives (the manager), not re-derived in the bash layer.
The manager must stay string-based and must not import AccessPath (guarded by a no-restricted-imports lint rule); this change adds only a wildcardMatch-based query and does not touch that boundary.
Design Overview
Decision model
Promotion is a two-part decision, kept in layers that already own each concern:
- Which patterns can promote (policy) — the composed config ruleset, filtered to
path-surface rules whose pattern is not"*"and whose action isdenyorask. This filtering, and the platform-correct wildcard match, live inPermissionManager, which already holds the ruleset and the injectedplatform. - Which tokens are shape-eligible (shape) — a token that survives
rejectNonPathToken(not a flag, env assignment, URL,@scope, or regex pattern). This stays in the pure classifier.
The manager hands the bash layer a ready predicate (Tell-Don't-Ask): "is this bare token promotable?" The bash layer never sees the patterns or re-implements matching, so the Windows fold has a single home.
// New shared predicate type (src/types.ts)
export type PathRuleTokenMatcher = (token: string) => boolean;
Manager: build the promotion predicate
// PermissionManager (implements ScopedPermissionManager)
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
const { composedRules } = this.resolvePermissions(agentName);
const patterns = composedRules
.filter(
(r) =>
r.layer === "config" &&
r.surface === "path" &&
r.pattern !== "*" &&
r.action !== "allow",
)
.map((r) => r.pattern);
if (patterns.length === 0) return NO_PROMOTION; // module const: () => false
const options =
this.platform === "win32"
? { caseInsensitive: true as const, windowsSeparators: true as const }
: undefined;
return (token) => patterns.some((p) => wildcardMatch(p, token, options));
}
A pattern containing / (e.g. secrets/config) can never match a bare token (no separator), so no extra filtering is needed — such patterns simply never fire during promotion, and prefixed-path tokens continue to be handled by the existing shape gate.
The Windows fold mirrors pathMatchOptions so promotion agrees with the later path-surface evaluation (cat ID_RSA matches an id_rsa deny rule on win32).
Threading the predicate to the bash layer
The predicate flows manager → session → pipeline → BashProgram.parse → BashPathResolver, mirroring how getPathNormalizer already threads:
// ToolCallGatePipeline.evaluate
const isPromotable = this.inputs.getPromotablePathTokenMatcher(
tcc.agentName ?? undefined,
);
const bashProgram =
tcc.toolName === "bash" && command
? await BashProgram.parse(command, normalizer, isPromotable)
: null;
BashProgram.parse(command, normalizer, isPromotable?) gains an optional third parameter defaulting to a no-op matcher, so the other caller (bash-path-extractor.ts, which only reads externalPaths()) is unaffected — promotion touches only the rule-candidate slice.
Resolver: promote at projection time
BashPathResolver gains the matcher as an injected collaborator (constructor DI, default no-op):
constructor(
private readonly normalizer: PathNormalizer,
private readonly isPromotablePathToken: PathRuleTokenMatcher = () => false,
) {}
projectRuleCandidates falls back to a promoted classification when the broad shape gate rejects a token:
const candidate =
classifyTokenAsRuleCandidate(token) ??
classifyPromotedRuleCandidate(token, this.isPromotablePathToken);
if (!candidate) continue;
// unchanged: buildRuleCandidatePath(candidate, base), dedup, push
A promoted token then flows through the existing buildRuleCandidatePath → normalizer.forPath("id_rsa", { resolveBase }), producing an AccessPath whose matchValues() include the raw id_rsa alias — so describeBashPathGate resolves it against the path surface, matches id_rsa: deny, and gates it, using the raw token in prompts/logs exactly as for a prefixed path.
The #393 unknown-base rule (a token after a non-literal cd stays literal-only) applies to promoted tokens too, since it lives in buildRuleCandidatePath.
Classifier: the promoted shape gate
// token-classification.ts — reuses the private rejectNonPathToken prelude
export function classifyPromotedRuleCandidate(
token: string,
isPromotable: PathRuleTokenMatcher,
): string | null {
if (rejectNonPathToken(token)) return null;
return isPromotable(token) ? token : null;
}
Keeping the reject prelude here means a flag or regex-shaped token that happens to match a pattern (e.g. -id_rsa, or a pattern with metachars) is still refused, and the predicate is pure (patterns are captured in the closure passed in).
Consumer call-site verification (Law of Demeter / Tell-Don't-Ask)
- Pipeline → session:
this.inputs.getPromotablePathTokenMatcher(agentName)— one call, no reach-through into the ruleset. - Session → manager:
this.permissionManager.getPromotablePathTokenMatcher(agentName)— a straight delegate, matching the existinggetInfrastructureReadDirs/getPathNormalizershape onPermissionSession. - Resolver → predicate:
this.isPromotablePathToken(token)— invokes an injected function; the resolver never learns of patterns, platform, or the manager.
Module-Level Changes
src/types.ts— addexport type PathRuleTokenMatcher = (token: string) => boolean;(neutral shared home; no import cycle).src/permission-manager.ts— addgetPromotablePathTokenMatcher(agentName?)to theScopedPermissionManagerinterface and implement it onPermissionManager; importwildcardMatchandPathRuleTokenMatcher; add theNO_PROMOTIONmodule constant.src/permission-session.ts— addgetPromotablePathTokenMatcher(agentName?)delegating tothis.permissionManager(satisfies the widenedToolCallGateInputs).src/handlers/gates/tool-call-gate-pipeline.ts— widenToolCallGateInputswithgetPromotablePathTokenMatcher(agentName?); fetch the matcher inevaluateand pass it toBashProgram.parse.src/access-intent/bash/program.ts— add the optionalisPromotablethird parameter toparseand forward it tonew BashPathResolver.src/access-intent/bash/bash-path-resolver.ts— inject the matcher (default no-op); use the promoted fallback inprojectRuleCandidates.src/access-intent/bash/token-classification.ts— addclassifyPromotedRuleCandidate; update the module header comment to describe the new promoted classifier alongside the two existing ones.- Test fixtures:
test/helpers/session-fixtures.ts— addgetPromotablePathTokenMatcher: vi.fn(() => () => false)tomakeFakePermissionManager.test/helpers/gate-fixtures.ts— add agetPromotablePathTokenMatcheroverride + default (() => () => false) tomakeGateInputs.
- Docs:
packages/pi-permission-system/docs/architecture/architecture.md— update thetoken-classification.tsmodule-tree line (currently naming the two classifiers) to mentionclassifyPromotedRuleCandidate, and add a short note on the manager'sgetPromotablePathTokenMatcherpredicate feeding the bashpathgate..pi/skills/package-pi-permission-system/SKILL.md— the "bashexternal_directorygate only sees tokens thatclassifyTokenAsPathCandidateaccepts … gated by the broaderpathsurface (classifyTokenAsRuleCandidate)" prose now understates thepathsurface: add that a bare filename is promoted into thepathsurface when it matches a specific (non-*)pathdeny/ask rule, and that promotion is decided by the manager's platform-aware matcher.packages/pi-permission-system/docs/configuration.md— add a sentence to thepath-surface documentation noting that a specific (non-*)pathdeny/ask rule also gates bare-filename bash arguments (cat id_rsa), so the rule behaves the same across thereadtool and bash.
No file listed here is claimed as unchanged in Non-Goals; the external_directory classifier and config schema are genuinely untouched.
Test Impact Analysis
- New tests enabled by this change:
classifyPromotedRuleCandidate(pure): promotes a shape-eligible token when the predicate returns true; returnsnullwhen it returns false; still rejects flags/URLs/env/regex tokens regardless of the predicate.PermissionManager.getPromotablePathTokenMatcher: matchesid_rsaagainst"id_rsa": "deny"; matcheskey.pemagainst"*.pem": "ask"; does not match against a"*"rule; does not match against an allow-only rule; returns a no-op when nopathrules exist; folds case on an injectedwin32platform (ID_RSA→ matchesid_rsa).BashPathResolver/BashProgram.parse: with a promoting matcher,cat id_rsayields anid_rsarule candidate; with the default no-op matcher, it yields none (regression guard for the no-config default).
- Redundant tests: none.
Existing
classifyTokenAsRuleCandidatetests keep asserting bare tokens returnnull— that shape behavior is unchanged; promotion is an additive second layer. - Tests that must stay as-is: the existing
token-classification,program, andbash-pathgate tests that exercise prefixed/relative/absolute tokens and the#393/#418resolution invariants — they pin the unchanged path.
Invariants at risk
This change touches token-classification.ts (refactored in #475, extended in #508) and bash-path-resolver.ts (cd-projection #475, canonical matching #418, #393 unknown-base rule).
The invariants that must not regress, and their pins:
- No-config default is behavior-preserving — with no
pathrules, no bare token is promoted. Pinned by a newBashProgram.parsetest using the default no-op matcher, plus the existing default-config gate tests. #393unknown-base literal-only — a promoted token after a non-literalcdkeeps only its literal value. Preserved structurally (promotion feeds the unchangedbuildRuleCandidatePath); add a resolver test asserting a promoted token under an unknown base is literal-only.#418canonical/lexical alias matching — promoted tokens resolve through the sameforPath/matchValuespath as any relative token. Covered by the end-to-end gate test resolving a promoted token against apathdeny rule."*"never storms — a"path": { "*": "ask" }config does not promote every bare bash argument. Pinned by agetPromotablePathTokenMatchertest asserting no match against"*".
TDD Order
Numbered red→green→commit cycles.
The ScopedPermissionManager / ToolCallGateInputs interface widenings break their fakes at the type level, so each interface change lands with its fake update and the real implementation in one commit.
-
Pure promoted classifier. Test
classifyPromotedRuleCandidate(promote when predicate true, reject when false, still reject flags/URLs/env/regex). Add thePathRuleTokenMatchertype intypes.tsand the classifier intoken-classification.ts. Commit:feat(pi-permission-system): add rule-driven bare-token classifier. -
Manager promotion predicate. Test
PermissionManager.getPromotablePathTokenMatcher(specific deny/ask match,"*"excluded, allow-only excluded, empty when nopathrules, win32 case-fold via injected platform). Add the method to theScopedPermissionManagerinterface, implement it onPermissionManager, and updatemakeFakePermissionManagerin the same commit (interface widening breaks the fake's type). Commit:feat(pi-permission-system): derive promotable path-token matcher from config. -
Resolver + BashProgram promotion. Test
BashProgram.parse/BashPathResolver: a promoting matcher turnscat id_rsainto a rule candidate; the default no-op matcher yields none; a promoted token under an unknown base stays literal-only (#393). Inject the matcher intoBashPathResolver(default no-op), add the optional third parameter toBashProgram.parse, and wire the promoted fallback intoprojectRuleCandidates. Commit:feat(pi-permission-system): promote bare tokens in bash path projection. -
Pipeline wiring. Test
ToolCallGatePipelinepasses the session matcher into the parse (a configid_rsa: denymakescat id_rsaresolve to deny). WidenToolCallGateInputs, implementgetPromotablePathTokenMatcheronPermissionSession, fetch-and-pass it inevaluate, and updatemakeGateInputsin the same commit (interface widening breaks the fixture). Commit:feat(pi-permission-system): gate bash bare filenames via path rules. -
End-to-end composition-root repro. Test (in
composition-root.test.ts, filesystem-backed) that withpath: { "id_rsa": "deny" }, a bashcat id_rsatool call is blocked, andcat key.pemunder"*.pem": "deny"is blocked, whilegit status(bare non-matching token) is unaffected — the literal repro from the issue. Commit:test(pi-permission-system): cover bash bare-filename path gating end to end. -
Docs. Update
architecture.md, the packageSKILL.md, andconfiguration.mdper Module-Level Changes. Commit:docs(pi-permission-system): document bash bare-filename path promotion.
Risks and Mitigations
- Spurious prompts for search patterns / branch names (
git grep id_rsaunderid_rsa: deny). Accepted per the chosen scope: it fails safe (prompts, never silently allows). Argument-position awareness is deferred (Non-Goals); the end-to-end test documents the fail-safe direction. - Windows fold divergence — promotion matching disagreeing with path-surface evaluation.
Mitigated by deciding the fold in the manager with the same
caseInsensitive/windowsSeparatorsoptionspathMatchOptionsuses, and by an injected-win32unit test. - Interface-widening breakage — adding methods to
ScopedPermissionManager/ToolCallGateInputsbreaks fakes. Mitigated by folding each fake update into the same commit as its interface change (TDD steps 2 and 4). - Performance —
getPromotablePathTokenMatcherfilters the composed ruleset per bash tool call. Bounded: it reuses the cachedresolvePermissionsresult and returns a fast no-op closure when nopathrules exist (the common case).
Open Questions
- Argument-position / per-command file-argument awareness to eliminate the accepted search-pattern false positives — deferred; no issue filed (no concrete design yet).
The principled successor is the
ModelTriageAuthorizerindocs/architecture/architecture.md("Discriminating delegation: a modelAuthorizer"): promotion here produces theaskon the ask-producing side ofevaluate(), and a modelAuthorizerdismisses the false positive on the ask-consuming side. This plan is compatible with that target by construction — a promoted token emits the same structured descriptor a prefixed path does, so the authority layer needs no promotion-specific knowledge. - Backslash-relative Windows tokens (
dir\file) — deferred and tracked in #520.