19 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 147 | Per-tool path patterns for path-bearing tools |
Per-tool path patterns for path-bearing tools
Problem Statement
Path-bearing Pi tools (read, write, edit, find, grep, ls) currently match permissions against the tool name only.
normalizeInput returns values: ["*"] for all of them, so the actual file path is never evaluated against permission patterns.
Rules like "read": { "*.env": "deny" } have no effect — evaluate() only ever sees the literal string "*" as the match value.
There is no way to express path-level restrictions such as "allow reads except .env files," "allow writes only inside src/," or "deny edits to *.lock files."
Goals
- Make path-bearing tool permission rules match against the actual file path.
- Maintain full backward compatibility:
"read": "allow"(shorthand for{ "*": "allow" }) behaves identically because"*"matches any path. - Subsume the motivating use case from #144 (allow external reads, prompt for external writes) without special-casing
external_directory. - Update schema, example config, README, and architecture docs.
Non-Goals
- Changing bash permission semantics — bash path-aware rules are tracked in #148.
- Adding a universal
pathsurface — deferred pending experience with per-tool patterns. - Changing
external_directorybehavior or semantics. - Classifying bash commands as read/write operations.
- Changing the
getToolPermissionevaluation used for tool injection decisions — that must remain tool-level (no path) to avoid hiding tools that are only path-restricted.
Background
Permission surfaces involved
tools — specifically the subset of built-in tools in PATH_BEARING_TOOLS: read, write, edit, find, grep, ls.
How checkPermission works today for tool surfaces
normalizeInput(toolName, input, mcpServerNames)returns{ surface: toolName, values: ["*"], resultExtras: {} }for all non-special, non-bash, non-mcp, non-skill surfaces.evaluateFirst(surface, values, fullRules)callsevaluate(surface, "*", rules)— last-match-wins against the composed ruleset.- Because the value is always
"*", only the surface-level catch-all (e.g.,"read": "allow") ever fires. Path-specific patterns like"*.env": "deny"are in the ruleset but never match because"*.env"does not wildcard-match"*".
How checkPermission works for bash (the model to follow)
normalizeInput("bash", input, ...)returns{ surface: "bash", values: [command], ... }.evaluateFirst("bash", [command], rules)evaluates the actual command string against patterns like"git *"and"rm *".- Per-pattern matching works because the match value is the real input, not a placeholder.
Key constraint: getToolPermission
getToolPermission() is called at agent start to decide whether to inject each tool.
It evaluates evaluate(toolName, "*", composedRules) — deliberately using "*" to get the surface-level catch-all.
This must remain unchanged: a tool with "read": { "*": "allow", "*.env": "deny" } should still be injected (the tool is not blanket-denied).
Existing path extraction
getPathBearingToolPath(toolName, input) in src/path-utils.ts already extracts input.path for tools in PATH_BEARING_TOOLS.
The external-directory gate uses it.
normalizeInput can use the same function.
Design Overview
Change summary
Change normalizeInput so that path-bearing tools return the file path as the match value instead of "*".
Before (current)
// Tool surfaces (read, write, edit, grep, find, ls, extension tools)
return { surface: toolName, values: ["*"], resultExtras: {} };
After (proposed)
// Path-bearing tools: use the file path as the match value.
if (PATH_BEARING_TOOLS.has(toolName)) {
const path = getPathBearingToolPath(toolName, input);
return {
surface: toolName,
values: [path ?? "*"],
resultExtras: {},
};
}
// Extension tools (non-path-bearing): unchanged.
return { surface: toolName, values: ["*"], resultExtras: {} };
When a tool call has no path (e.g., read with missing input), the value falls back to "*", matching the surface-level catch-all — same as today.
Evaluation flow
With config:
"read": { "*": "allow", "*.env": "deny" }
readwithinput.path = "src/main.ts"→evaluate("read", "src/main.ts", rules)→"*"matches (allow),"*.env"does not → allowreadwithinput.path = ".env"→evaluate("read", ".env", rules)→"*"matches (allow),"*.env"matches (deny) → deny (last-match-wins)
Backward compatibility
"read": "allow"→ shorthand for{ "*": "allow" }→evaluate("read", anyPath, rules)→"*"matches → allow. Identical to today."read": "deny"→ shorthand for{ "*": "deny" }→ same as today (andgetToolPermissionhides the tool at injection time).- Existing configs without path patterns behave identically because all their rules use
"*"as the pattern, which matches any path value.
Merge precedence
Unchanged.
Global → project → per-agent frontmatter, deep-shallow merge on the permission object.
A project config can override a global "read" rule with path-specific patterns:
// global: "read": "allow"
// project: "read": { "*": "allow", "*.env": "deny" }
// merged: "read": { "*": "allow", "*.env": "deny" }
How this addresses #144
With per-tool path patterns:
"read": "allow"— permits reads everywhere, including external paths that pass theexternal_directorygate."write": "ask"— restricts writes everywhere, including external paths.external_directoryremains as a separate safety gate for "is this path outside CWD?"- No tool-type keys needed in
external_directory.
Edge cases
- Missing
input.path:getPathBearingToolPathreturnsnull→normalizeInputfalls back to"*"→ surface-level catch-all applies. - Empty
input.path:getPathBearingToolPathcallsgetNonEmptyString, which returnsnullfor empty strings → same fallback. - Extension tools (non-path-bearing): unchanged —
values: ["*"]as today. getToolPermission: unchanged — evaluates"*"pattern, not file paths. A config like"read": { "*": "allow", "*.env": "deny" }still returns"allow"for tool injection, which is correct — the tool should be available; only specific paths are restricted.- Session approvals: the
suggestSessionPatternfunction inpattern-suggest.tscurrently returns"*"for non-bash, non-mcp tools. With path-based evaluation, the session approval pattern should include the path for path-bearing tools so that "approve for session" grants a path-scoped approval rather than a blanket tool approval.
Module-Level Changes
Changed files
| File | Change |
|---|---|
src/input-normalizer.ts |
Add PATH_BEARING_TOOLS import; return input.path as the match value for path-bearing tools instead of "*". |
src/pattern-suggest.ts |
For path-bearing tools, derive a session approval pattern from the file path (e.g., directory prefix) instead of returning "*". |
src/handlers/gates/helpers.ts |
Update deriveDecisionValue to return the file path for path-bearing tools (currently returns toolName). |
schemas/permissions.schema.json |
Update the examples array and the "read" example to show path patterns. Add markdownDescription noting path-pattern support for path-bearing tools. |
config/config.example.json |
Add a "read" entry with path patterns (e.g., "*.env": "deny") alongside the existing "read": "allow". |
README.md |
Document per-tool path patterns, show examples, note backward compatibility. |
docs/architecture/architecture.md |
Update the input normalization section to reflect path-bearing tool changes. |
Changed test files
| File | Change |
|---|---|
tests/input-normalizer.test.ts |
Update "tool surfaces" tests: path-bearing tools now return file path from input.path instead of "*". Add tests for missing/empty path fallback. |
tests/permission-manager-unified.test.ts |
Add integration tests: path-pattern matching for read/write/edit tools (allow, deny, ask by path). |
tests/handlers/gates/tool.test.ts |
Verify describeToolGate produces correct decision values when the check result includes path-specific patterns. |
tests/pattern-suggest.test.ts |
Add tests for path-bearing tool session approval patterns. |
Unchanged files
| File | Reason |
|---|---|
src/permission-manager.ts |
checkPermission and getToolPermission are unchanged — the path-bearing logic is fully contained in normalizeInput. |
src/rule.ts |
evaluate and evaluateFirst are unchanged — they already support arbitrary pattern matching. |
src/normalize.ts |
normalizeFlatConfig already converts { "*.env": "deny" } into rules correctly. |
src/wildcard-matcher.ts |
Wildcard matching already handles path patterns. |
src/handlers/gates/external-directory.ts |
External directory gate is unaffected — it has its own path evaluation. |
src/handlers/gates/bash-external-directory.ts |
Bash path extraction is unaffected. |
Test Impact Analysis
-
New tests enabled:
tests/input-normalizer.test.ts: path-bearing tools return file paths — the core behavioral change.tests/permission-manager-unified.test.ts: end-to-end path-pattern matching (e.g.,"read": { "*.env": "deny" }blocksreadof.env).tests/pattern-suggest.test.ts: session approval patterns include file paths for path-bearing tools.
-
Existing tests that need updating:
tests/input-normalizer.test.ts: the "uses'*'as the lookup value for built-in tools" test currently assertsvalues: ["*"]forread,write,edit,grep,find,ls. With the change, these tools return the file path wheninput.pathis present, and"*"only when it's missing. The test must split into "returns file path when input.path is present" and "falls back to'*'when input.path is missing."
-
Existing tests that stay as-is:
tests/rule.test.ts—evaluateandevaluateFirstare unchanged.tests/normalize.test.ts— config normalization is unchanged.tests/handlers/gates/external-directory.test.ts— external directory gate is unaffected.tests/permission-manager-unified.test.ts— existing tests remain valid; new tests are additive.
TDD Order
Step 1 — Red: normalizeInput returns file path for path-bearing tools
- In
tests/input-normalizer.test.ts, update the "tool surfaces" describe block:- Change existing tests for path-bearing tools (
read,write,edit,grep,find,ls) to expectvalues: [inputPath]wheninput.pathis present. - Add tests for missing
input.path(expectvalues: ["*"]fallback). - Add tests for empty
input.path(expectvalues: ["*"]fallback). - Keep extension tool tests unchanged (still expect
["*"]).
- Change existing tests for path-bearing tools (
- Run tests — they fail (red) because
normalizeInputstill returns["*"].
Commit: test: expect normalizeInput to return file path for path-bearing tools (#147)
Step 2 — Green: implement the normalizeInput change
- In
src/input-normalizer.ts, add aPATH_BEARING_TOOLSimport frompath-utilsandgetPathBearingToolPath. - Before the final tool-surfaces return, add a branch: if the tool is path-bearing, extract the path and return it as the value.
- Run tests — step 1 tests pass (green).
Commit: feat: normalizeInput returns file path for path-bearing tools (#147)
Step 3 — Integration tests: path-pattern matching in checkPermission
- In
tests/permission-manager-unified.test.ts, add a new describe block for path-bearing tool path patterns:"read": { "*": "allow", "*.env": "deny" }deniesreadof.env."write": { "*": "deny", "src/*": "allow" }allowswriteofsrc/main.ts."read": "allow"still allowsreadof any path (backward compatibility)."read": "deny"still deniesreadof any path (backward compatibility).- Session rule for a specific path overrides config deny.
- Run tests — they pass (already green from step 2).
Commit: test: add integration tests for per-tool path patterns (#147)
Step 4 — Update session approval patterns for path-bearing tools
- In
tests/pattern-suggest.test.ts, add tests: path-bearing tools produce a path-scoped session approval pattern (e.g., directory-prefixed wildcard) instead of"*". - In
src/pattern-suggest.ts, update the suggestion logic for path-bearing tools to derive a pattern from the file path. - Run tests — pass.
Commit: feat: path-scoped session approvals for path-bearing tools (#147)
Step 5 — Update deriveDecisionValue for path-bearing tools
- In
tests/handlers/gates/tool.test.ts(ortests/handlers/gates/helpers.test.ts), add tests:deriveDecisionValuereturns the file path for path-bearing tools. - In
src/handlers/gates/helpers.ts, updatederiveDecisionValueto checkPATH_BEARING_TOOLSand return the path from the check result. - Run tests — pass.
Commit: feat: decision events include file path for path-bearing tools (#147)
Step 6 — Update schema, example config, and docs
- Update
schemas/permissions.schema.json: add path-pattern examples forread/write/edit. - Update
config/config.example.json: show a"read"entry with path patterns. - Update
README.md: document per-tool path patterns with examples. - Update
docs/architecture/architecture.md: update the input normalization section. - Run
pnpm run buildto verify no type errors.
Commit: docs: document per-tool path patterns (#147)
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Could this silently weaken a permission? | No — existing configs use "*" patterns (surface-level catch-alls) which match any path value. The change only makes previously-inert path patterns active; it cannot make a deny rule stop matching. |
getToolPermission returns wrong state |
getToolPermission evaluates with "*" pattern (unchanged), so "read": { "*": "allow", "*.env": "deny" } still returns "allow" for tool injection. This is correct — the tool is available; only specific paths are restricted. |
| Session approvals become too narrow | Step 4 updates session approval patterns to be path-scoped. A "for this session" approval on read /outside/file.txt should approve that path, not all reads. The deriveApprovalPattern function already handles path-based patterns for external_directory. |
| Path normalization inconsistency | getPathBearingToolPath returns the raw input.path string. Wildcard matching is case-sensitive on Unix. This is consistent with how external_directory path patterns work — no new normalization is introduced. |
| Extension tools break | Extension tools are not in PATH_BEARING_TOOLS and continue to return values: ["*"]. No change. |
Open Questions
-
Should
normalizePathForComparisonbe applied before matching? The external-directory gate normalizes paths (resolve relative, expand~). For per-tool path patterns, shouldnormalizeInputalso normalize, or match against the rawinput.path? Recommendation: start with rawinput.pathto keep the change minimal. Normalization can be added in a follow-up if users report that~/filepatterns don't matchinput.pathvalues. If normalization is added, it must usecwd— whichnormalizeInputdoes not currently receive. The external-directory gate hascwdavailable inToolCallContext, so passing it through is feasible but increases the change scope. -
Should
findandgrepmatch againstinput.pathor a different field? Both tools accept apathfield in their input.getPathBearingToolPathalready handles them uniformly. This plan treats them the same asread/write/edit.