30 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 148 | Cross-cutting path permission surface |
Cross-cutting path permission surface
Problem Statement
Path-level permission rules are fragmented across surfaces:
- Per-tool path patterns (#147, shipped) deny
*.envforread, but the user must repeat the rule forwrite,edit,grep, etc. external_directorygates paths outside CWD, but cannot deny specific in-CWD paths like.env.- Bash has no path-level restriction at all —
cat .envbypasses anyread-level.envdeny.
There is no single place to say "no tool — built-in or bash — may access .env files."
Goals
- Add a
pathpermission surface whose rules apply to all file access (Pi tools and bash). - Use the existing
evaluate()function — same last-match-wins semantics, no new evaluation model. - Broaden the bash token extraction filter to accept relative paths (dot-files,
/-containing tokens). - Compose cleanly with
external_directoryand per-tool path patterns: most restrictive wins. - Update schema, example config, README, and architecture docs.
Non-Goals
- Replacing
external_directory— it remains the CWD-boundary check.pathis the pattern-level policy. - Replacing per-tool path patterns (#147) — they remain for tool-specific overrides (e.g., allow reads of
.envbut deny writes). - Additive or nested command × path evaluation — the original #148 design, deferred due to complexity.
- Extending
PATTERN_FIRST_COMMANDSwith additional commands — follow-up refinement. - Path normalization/resolution for rule matching (see Open Questions).
Background
Permission surfaces involved
path (new) — a cross-cutting surface evaluated for every file access.
How permission evaluation works today
evaluate(surface, value, rules) is the universal primitive.
It finds the last rule in the composed ruleset whose surface and pattern both wildcard-match, returning the action.
All surfaces use this same function.
How per-tool path patterns work (#147)
normalizeInput for path-bearing tools returns input.path as the match value.
evaluate("read", ".env", rules) matches the file path against per-tool patterns.
This is per-tool — a "read": { "*.env": "deny" } rule does not affect write, edit, or bash.
How external_directory works
Two gates extract paths and check whether they resolve outside CWD:
describeExternalDirectoryGate— sync, for path-bearing tools (input.path).describeBashExternalDirectoryGate— async, for bash (tree-sitter extraction).
Both evaluate against external_directory rules via checkPermission.
How bash path extraction works
bash-path-extractor.ts provides:
- A lazy tree-sitter parser (WASM, async init, singleton).
collectPathCandidateTokens(node, tokens)— AST walker that extracts argument tokens, respectsPATTERN_FIRST_COMMANDSto skip pattern arguments.classifyTokenAsPathCandidate(token)— strict filter: accepts/...,~/...,..-containing tokens only.extractExternalPathsFromBashCommand(command, cwd)— combines parsing, walking, classification, and CWD-outside filtering.
The AST walker is reusable.
The strict classification filter is the bottleneck — it rejects relative paths like .env and src/.env.
SPECIAL_PERMISSION_KEYS
external_directory is in this set.
normalizeInput handles special keys by extracting input.path as the match value.
Adding "path" to this set gives it the same treatment for free.
Design Overview
Config syntax
{
"permission": {
"path": {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
"~/.ssh/*": "deny"
},
"external_directory": "ask",
"bash": { "*": "ask", "git *": "allow" },
"read": "allow",
"write": "ask"
}
}
One configuration protects .env from read, write, edit, grep, bash, cat — everything.
The path surface is a standard permission map — same format as every other surface.
Composition model
Four orthogonal layers, most restrictive wins:
| Layer | Question | Applies to |
|---|---|---|
path (new) |
Is this specific path pattern allowed? | All tools + bash |
external_directory |
Is accessing outside CWD ok? | All tools + bash |
| Per-tool patterns (#147) | Is this path ok for this specific tool? | Individual tools |
bash command patterns |
Is this command ok? | Bash only |
A path deny cannot be overridden by a per-tool allow.
This is consistent with AGENTS.md's "default to least privilege."
Evaluation for Pi tools
For path-bearing tools (read, write, edit, find, grep, ls), a new gate:
- Extracts
input.pathviagetPathBearingToolPath()(existing helper). - Calls
checkPermission("path", { path: filePath }, agentName, sessionRules). - If the result is
denyorask, returns aGateDescriptor. - If
allow, returnsnull(no restriction from thepathlayer).
This runs alongside the existing external-directory and tool gates. The most restrictive result across all gates determines the outcome.
Evaluation for bash
A new async gate:
- Extracts argument tokens from the bash command using tree-sitter (reuses the shared parser).
- Applies a broader token filter (accepts dot-files and
/-containing tokens). - Evaluates each token:
evaluate("path", token, fullRules). - Returns the most restrictive result across all tokens (deny > ask > allow).
When no tokens are extracted, the gate returns null (no path restriction).
Most-restrictive evaluation across tokens
New helper function — not a new evaluation model, just an aggregation over multiple evaluate() calls:
function evaluateMostRestrictive(
surface: string,
values: string[],
rules: Ruleset,
): { rule: Rule; value: string } | null {
let worst: { rule: Rule; value: string } | null = null;
for (const value of values) {
const rule = evaluate(surface, value, rules);
if (rule.action === "deny") return { rule, value };
if (rule.action === "ask" && worst?.rule.action !== "ask") {
worst = { rule, value };
}
}
return worst;
}
Returns null when all tokens evaluate to allow (no restriction).
Returns the first deny immediately (short-circuit).
Returns the first ask if no deny is found.
Broadened token extraction
A new function extractTokensForPathRules(command) in bash-path-extractor.ts:
- Parses the command with the shared tree-sitter parser.
- Walks the AST with the existing
collectPathCandidateTokenswalker. - Applies a broader filter than
classifyTokenAsPathCandidate:- Same rejections: empty, flags, env assignments, URLs,
@scope/package, bare-slash, regex metacharacters. - Accepts: tokens starting with
.(dot-files:.env,./src) or containing/(paths:src/foo.ts). - Does NOT require the existing "must start with
/or~/or contain.." gate.
- Same rejections: empty, flags, env assignments, URLs,
- Returns the filtered tokens.
The existing extractExternalPathsFromBashCommand and classifyTokenAsPathCandidate remain unchanged.
normalizeInput integration
Add "path" to SPECIAL_PERMISSION_KEYS.
normalizeInput("path", { path: ".env" }) then returns { surface: "path", values: [".env"], resultExtras: {} } — the same treatment external_directory gets.
getToolPermission for path
getToolPermission("path") evaluates evaluate("path", "*", composedRules).
With "path": { "*": "allow", "*.env": "deny" }, the catch-all "*" → allow is at index 0, "*.env" → deny is at index 1.
evaluate("path", "*", rules) matches "*" against both patterns: "*" matches "*" (allow), "*.env" does NOT match "*" → last match is "*" → allow.
Result: allow.
This means path does not cause tool hiding — correct, because path is a cross-cutting restriction, not a tool-level one.
Gate chain
1. Skill-read gate (existing)
2. Path gate (tools) ← NEW: path-bearing tools only
3. External-directory gate (existing)
4. Bash external-directory gate (existing)
5. Bash path gate ← NEW: bash only
6. Tool permission gate (existing)
The path gate for tools (step 2) runs before the external-directory gate.
If the path surface denies, the command is blocked before the external-directory prompt — no wasted prompts.
The bash path gate (step 5) runs before the tool gate. If it denies, the tool gate is not reached — no double prompts.
Session approvals
For the tool path gate: session approval pattern derived from the file path using deriveApprovalPattern() (existing function, returns <parent-dir>/*).
Surface: "path".
For the bash path gate: session approval scoped to the triggering token's directory.
Surface: "path".
Both use the same surface, so a session approval for "path": "/home/user/.ssh/*" applies to both tool and bash access to that directory.
Merge precedence
Unchanged: global → project → per-agent frontmatter, deep-shallow merge on permission.
The path key merges like any other surface: both-objects → shallow-merge patterns; otherwise → override replaces base.
Backward compatibility
- No existing surface changes semantics.
- Configs without a
pathkey behave identically (no path gate fires —evaluate("path", value, rules)returns the universal default, which is not "deny"). external_directoryis unchanged.- Per-tool path patterns (#147) are unchanged.
Module-Level Changes
New files
| File | Purpose |
|---|---|
src/handlers/gates/path.ts |
describePathGate() — sync gate for path-bearing tools against path rules. |
src/handlers/gates/bash-path.ts |
describeBashPathGate() — async gate for bash against path rules. |
tests/handlers/gates/path.test.ts |
Unit tests for the tool path gate. |
tests/handlers/gates/bash-path.test.ts |
Unit tests for the bash path gate. |
Changed files
| File | Change |
|---|---|
src/input-normalizer.ts |
Add "path" to SPECIAL_PERMISSION_KEYS. |
src/rule.ts |
Add evaluateMostRestrictive() helper (aggregates evaluate() over multiple values). |
src/handlers/gates/bash-path-extractor.ts |
Add classifyTokenAsRuleCandidate() (broader filter) and extractTokensForPathRules(). |
src/handlers/gates/index.ts |
Export new gate functions. |
src/handlers/permission-gate-handler.ts |
Insert path gate (tools) and bash path gate into the chain. |
src/permission-prompts.ts |
Add formatPathDenyReason(), formatPathAskPrompt() for the path surface. |
src/permission-manager.ts |
Add "path" to SPECIAL_PERMISSION_KEYS (duplicated from input-normalizer.ts — both sets must agree). |
schemas/permissions.schema.json |
Add path to the examples. Add markdownDescription noting the cross-cutting semantics. |
config/config.example.json |
Add a "path" entry with *.env deny example. |
README.md |
Document the path surface, composition model, and examples. |
docs/architecture/architecture.md |
Add the path surface to the evaluation flow description. |
Changed test files
| File | Change |
|---|---|
tests/input-normalizer.test.ts |
Add tests: "path" is a special key; normalizeInput("path", { path: ".env" }) returns values: [".env"]. |
tests/rule.test.ts |
Add tests for evaluateMostRestrictive(): deny short-circuits, ask accumulates, all-allow returns null. |
tests/bash-external-directory.test.ts |
Add tests for extractTokensForPathRules(): broader filter accepts .env, src/foo.ts, rejects flags/URLs. |
tests/permission-manager-unified.test.ts |
Add integration tests: path surface denies .env for tool calls; path + per-tool compose (most restrictive wins); session approval on path surface. |
tests/handlers/permission-gate-handler.test.ts |
Add tests for path gate and bash path gate integration in the chain. |
Unchanged files
| File | Reason |
|---|---|
src/rule.ts (evaluate, evaluateFirst) |
Core evaluation unchanged — evaluateMostRestrictive is additive, not a replacement. |
src/types.ts |
FlatPermissionConfig type unchanged — path is a regular surface with a standard pattern map. |
src/normalize.ts |
normalizeFlatConfig handles path naturally (standard surface). |
src/permission-merge.ts |
Merge handles path naturally (standard surface). |
src/wildcard-matcher.ts |
Wildcard matching unchanged. |
src/pattern-suggest.ts |
Session patterns for path use existing deriveApprovalPattern(). The suggestSessionPattern function already handles non-bash, non-mcp surfaces via the PATH_BEARING_TOOLS branch and the default "*" branch. |
src/handlers/gates/external-directory.ts |
External-directory gate unchanged. |
src/handlers/gates/bash-external-directory.ts |
Bash external-directory gate unchanged. |
src/handlers/gates/tool.ts |
Tool gate unchanged. |
Test Impact Analysis
-
New tests enabled:
tests/rule.test.ts:evaluateMostRestrictive()— deny short-circuit, ask accumulation, all-allow returns null, empty values returns null.tests/handlers/gates/path.test.ts: tool path gate — returns null when tool is not path-bearing, returns null when nopathrules, returns descriptor when path matches deny/ask, returns null when path matches allow.tests/handlers/gates/bash-path.test.ts: bash path gate — returns null for non-bash, returns null when nopathrules, extracts tokens and evaluates, most-restrictive across tokens, session bypass.tests/bash-external-directory.test.ts:extractTokensForPathRules— broader filter accepts dot-files and slash-containing tokens.tests/permission-manager-unified.test.ts: end-to-endpathsurface evaluation.
-
Existing tests that become redundant: None — the
pathsurface is purely additive. -
Existing tests that stay as-is: All existing tests for
external_directory, per-tool path patterns, bash command patterns, and session rules.
TDD Order
Step 1 — Red: normalizeInput recognizes path as a special key
- In
tests/input-normalizer.test.ts, add tests:normalizeInput("path", { path: ".env" }, [])returns{ surface: "path", values: [".env"], resultExtras: {} }.normalizeInput("path", {}, [])returnsvalues: ["*"](missing path fallback).
- Tests fail (path not in
SPECIAL_PERMISSION_KEYS).
Commit: test: expect normalizeInput to handle path as special key (#148)
Step 2 — Green: add path to SPECIAL_PERMISSION_KEYS
- In
src/input-normalizer.ts, add"path"toSPECIAL_PERMISSION_KEYS. - In
src/permission-manager.ts, add"path"toSPECIAL_PERMISSION_KEYS. - Tests pass.
Commit: feat: register path as a special permission surface (#148)
Step 3 — Red: evaluateMostRestrictive helper
- In
tests/rule.test.ts, add tests:- Deny short-circuits:
["a", "b"]wherea → denyreturns{ rule, value: "a" }without evaluatingb. - Ask accumulates:
["a", "b"]wherea → ask,b → allowreturns{ rule, value: "a" }. - All allow: returns
null. - Empty values: returns
null. - Deny + ask: deny wins.
- Deny short-circuits:
- Tests fail (function does not exist).
Commit: test: expect evaluateMostRestrictive aggregation (#148)
Step 4 — Green: implement evaluateMostRestrictive
- In
src/rule.ts, addevaluateMostRestrictive(). - Tests pass.
Commit: feat: evaluateMostRestrictive helper for cross-cutting path evaluation (#148)
Step 5 — Red: broader token extraction
- In
tests/bash-external-directory.test.ts, add tests forextractTokensForPathRules:cat .env→ extracts.env.git add src/.env→ extractssrc/.env.echo hello→ extracts nothing (no dot prefix, no slash).rm -rf ./src→ extracts./src, skips-rf.- Heredoc content not extracted.
cat /etc/hosts→ extracts/etc/hosts.- URLs skipped.
- Tests fail (function does not exist).
Commit: test: expect extractTokensForPathRules to capture relative paths (#148)
Step 6 — Green: implement broader token extraction
- In
src/handlers/gates/bash-path-extractor.ts, addclassifyTokenAsRuleCandidate()andextractTokensForPathRules(). - Tests pass.
Commit: feat: broader token extraction for path rules (#148)
Step 7 — Red: tool path gate
- In
tests/handlers/gates/path.test.ts, add tests fordescribePathGate:- Returns
nullfor non-path-bearing tools. - Returns
nullwhenpathcheck result isallow. - Returns
GateDescriptorwhenpathcheck result isdeny. - Returns
GateDescriptorwhenpathcheck result isask. - Descriptor has correct session approval (surface
"path", pattern fromderiveApprovalPattern).
- Returns
- Tests fail (function does not exist).
Commit: test: expect describePathGate for tool path restrictions (#148)
Step 8 — Green: implement tool path gate
- In
src/handlers/gates/path.ts, implementdescribePathGate(). - Add
formatPathDenyReason()andformatPathAskPrompt()insrc/permission-prompts.ts. - Export from
src/handlers/gates/index.ts. - Tests pass.
Commit: feat: path gate for tool path restrictions (#148)
Step 9 — Red: bash path gate
- In
tests/handlers/gates/bash-path.test.ts, add tests fordescribeBashPathGate:- Returns
nullfor non-bash tools. - Returns
nullwhen no tokens extracted. - Returns
nullwhen all tokens evaluate toallow. - Returns
GateDescriptorwhen a token evaluates todeny. - Returns
GateDescriptorwhen a token evaluates toask(most restrictive). - Session bypass: returns
GateBypasswhen session rule covers the path. - Descriptor includes triggering token in prompt message.
- Returns
- Tests fail (function does not exist).
Commit: test: expect describeBashPathGate for bash path restrictions (#148)
Step 10 — Green: implement bash path gate
- In
src/handlers/gates/bash-path.ts, implementdescribeBashPathGate(). - Tests pass.
Commit: feat: bash path gate with broader token extraction (#148)
Step 11 — Integrate into gate chain
- In
src/handlers/permission-gate-handler.ts:- Insert tool path gate (step 2 in chain) before external-directory gate.
- Insert bash path gate (step 5 in chain) before tool gate.
- Add integration tests in
tests/handlers/permission-gate-handler.test.ts. - Add integration tests in
tests/permission-manager-unified.test.ts:path: { "*.env": "deny" }deniesreadof.env.path: { "*.env": "deny" }composes withread: "allow"(path deny wins).path: { "*": "allow" }does not interfere with existing behavior.- Session approval on
pathsurface bypasses the gate. getToolPermission("path")returns catch-all action.
- Run full test suite.
Commit: feat: integrate path gates into permission pipeline (#148)
Step 12 — Schema, example config, and docs
- In
schemas/permissions.schema.json: addpathto examples, addmarkdownDescription. - In
config/config.example.json: add"path"entry with*.envdeny. - In
README.md: document thepathsurface, composition model, examples. - In
docs/architecture/architecture.md: addpathto evaluation flow. - Run
pnpm run build.
Commit: docs: document cross-cutting path permission surface (#148)
Prompt UX Scenarios
Scenario A: path deny for in-CWD file (tool)
Config: "path": { "*": "allow", "*.env": "deny" }, "read": "allow"
Tool: read { path: ".env" }
Gate chain:
1. Path gate (tools) → .env matches "*.env" → deny → BLOCKED
2. Ext-dir gate → not reached
3. Tool gate → not reached
One gate fires, clean deny. The deny message names the path and the matched pattern.
Scenario B: path ask for in-CWD file (bash)
Config: "path": { "*": "allow", ".scratch": "ask" }
Command: git add .scratch
Gate chain:
1. Bash ext-dir gate → null (no external paths)
2. Bash path gate → .scratch matches ".scratch" → ask → PROMPT
3. Tool gate → skipped (bash path gate handled)
One prompt.
Session approval: "path": "<cwd>/.scratch" (or directory pattern).
Scenario C: path deny + external-directory ask (no wasted prompt)
Config: "path": { "*": "allow", "~/.ssh/*": "deny" }, "external_directory": "ask"
Command: cat ~/.ssh/id_rsa
Gate chain:
1. Bash ext-dir gate → ~/.ssh/id_rsa outside CWD → ask → PROMPT
2. Bash path gate → ~/.ssh/id_rsa matches "~/.ssh/*" → deny → BLOCKED
The user sees the external-directory prompt (step 1) before the path deny (step 2). If the user denies step 1, the command is blocked without reaching step 2. If the user allows step 1, step 2 still blocks — the path deny is absolute.
This is a minor UX imperfection (one potentially wasted prompt), but it is consistent: the external-directory gate does not know about path rules, and path rules do not weaken external_directory denials.
The gate ordering (ext-dir before path) preserves the existing behavior where external-directory is the outermost safety net.
To avoid the wasted prompt, the user should align their configs: if "path": { "~/.ssh/*": "deny" }, also set "external_directory": { "~/.ssh/*": "deny" } to deny at the outer gate too.
Scenario D: path + per-tool composition
Config: "path": { "*": "allow", "*.env": "deny" }
"read": { "*": "allow", "*.env": "allow" } ← per-tool attempts override
Tool: read { path: ".env" }
Gate chain:
1. Path gate (tools) → .env matches "*.env" → deny → BLOCKED
2. Tool gate → not reached
The per-tool allow does NOT override the cross-cutting path deny.
This is by design: path is the safety net, per-tool patterns are the flexibility layer.
Session approval options
The path gate uses the existing prompt UI (4 options):
1. Yes
2. Yes, allow path "<dir>/*" for this session
3. No
4. No, provide reason
The session label is derived from deriveApprovalPattern() — same as external_directory.
A session approval on the path surface applies to both tool and bash access to that directory.
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Could this silently weaken a permission? | No — path is additive. Configs without a path key are unaffected. The path gate only restricts; it cannot override a deny from another gate. |
getToolPermission("path") hides tools |
No — path is in SPECIAL_PERMISSION_KEYS, not a tool name. getToolPermission is called for tool names, not special keys. And evaluate("path", "*", rules) returns the catch-all action (typically "allow"). |
Per-tool path allows override path denies |
By design, they cannot. The path gate runs first. If it denies, the tool gate is not reached. |
| Double prompt for external + path rules | Possible for external paths where both gates ask. User can align configs to avoid it. See Scenario C analysis. |
| Performance: tree-sitter runs twice for bash | Parser is a lazy singleton. Parsing the same command twice is negligible (~1ms). Can be merged in a follow-up if profiling shows impact. |
| Broader token extraction causes false-positive denies | The broader filter only accepts tokens starting with . or containing /. Non-path tokens that slip through match "*" → allow unless the user configured a deny pattern that happens to match. |
Open Questions
-
Should path patterns be normalized before matching? Currently, extracted tokens are matched raw against path patterns using
wildcardMatch..envmatches"*.env"but./src/.envdoes not match".env"(it matches"*.env"though). Normalization (resolve relative, expand~) would make matching more predictable but adds complexity and requirescwd. Recommendation: start with raw matching; add normalization in a follow-up if users report surprises. -
Should
pathrules apply tofind,grep, andls? These tools accept apathparameter but it's a search root, not a file being read/written.find { path: "src" }with"path": { "src": "deny" }would deny searchingsrc/. This might be surprising — the user denied access to files namedsrc, not searching undersrc/. Recommendation: include them (they're inPATH_BEARING_TOOLS), but document the semantics clearly. -
Gate ordering: should the bash path gate run before or after the bash external-directory gate? Current plan: after (ext-dir first, path second). If the path gate ran first, a
pathdeny would prevent the wasted ext-dir prompt in Scenario C. But this changes the existing invariant that ext-dir is the outermost safety net. Recommendation: keep ext-dir first for now; revisit if double-prompt feedback materializes. -
Should the
pathsurface affect non-path-bearing tools? Extension tools and MCP tools do not have a standardinput.path. Thepathgate only fires forPATH_BEARING_TOOLSandbash. If an extension tool accesses files via a non-standard input field,pathrules do not apply. This is a known limitation, consistent with howexternal_directoryworks today.