Files
my-pi/pi-permission-system/docs/plans/archive/0148-bash-path-rules.md
T

30 KiB
Raw Blame History

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 *.env for read, but the user must repeat the rule for write, edit, grep, etc.
  • external_directory gates paths outside CWD, but cannot deny specific in-CWD paths like .env.
  • Bash has no path-level restriction at all — cat .env bypasses any read-level .env deny.

There is no single place to say "no tool — built-in or bash — may access .env files."

Goals

  • Add a path permission 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_directory and 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. path is the pattern-level policy.
  • Replacing per-tool path patterns (#147) — they remain for tool-specific overrides (e.g., allow reads of .env but deny writes).
  • Additive or nested command × path evaluation — the original #148 design, deferred due to complexity.
  • Extending PATTERN_FIRST_COMMANDS with 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, respects PATTERN_FIRST_COMMANDS to 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:

  1. Extracts input.path via getPathBearingToolPath() (existing helper).
  2. Calls checkPermission("path", { path: filePath }, agentName, sessionRules).
  3. If the result is deny or ask, returns a GateDescriptor.
  4. If allow, returns null (no restriction from the path layer).

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:

  1. Extracts argument tokens from the bash command using tree-sitter (reuses the shared parser).
  2. Applies a broader token filter (accepts dot-files and /-containing tokens).
  3. Evaluates each token: evaluate("path", token, fullRules).
  4. 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:

  1. Parses the command with the shared tree-sitter parser.
  2. Walks the AST with the existing collectPathCandidateTokens walker.
  3. 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.
  4. 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 path key behave identically (no path gate fires — evaluate("path", value, rules) returns the universal default, which is not "deny").
  • external_directory is 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

  1. 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 no path rules, 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 no path rules, 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-end path surface evaluation.
  2. Existing tests that become redundant: None — the path surface is purely additive.

  3. 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

  1. In tests/input-normalizer.test.ts, add tests:
    • normalizeInput("path", { path: ".env" }, []) returns { surface: "path", values: [".env"], resultExtras: {} }.
    • normalizeInput("path", {}, []) returns values: ["*"] (missing path fallback).
  2. 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

  1. In src/input-normalizer.ts, add "path" to SPECIAL_PERMISSION_KEYS.
  2. In src/permission-manager.ts, add "path" to SPECIAL_PERMISSION_KEYS.
  3. Tests pass.

Commit: feat: register path as a special permission surface (#148)

Step 3 — Red: evaluateMostRestrictive helper

  1. In tests/rule.test.ts, add tests:
    • Deny short-circuits: ["a", "b"] where a → deny returns { rule, value: "a" } without evaluating b.
    • Ask accumulates: ["a", "b"] where a → ask, b → allow returns { rule, value: "a" }.
    • All allow: returns null.
    • Empty values: returns null.
    • Deny + ask: deny wins.
  2. Tests fail (function does not exist).

Commit: test: expect evaluateMostRestrictive aggregation (#148)

Step 4 — Green: implement evaluateMostRestrictive

  1. In src/rule.ts, add evaluateMostRestrictive().
  2. Tests pass.

Commit: feat: evaluateMostRestrictive helper for cross-cutting path evaluation (#148)

Step 5 — Red: broader token extraction

  1. In tests/bash-external-directory.test.ts, add tests for extractTokensForPathRules:
    • cat .env → extracts .env.
    • git add src/.env → extracts src/.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.
  2. Tests fail (function does not exist).

Commit: test: expect extractTokensForPathRules to capture relative paths (#148)

Step 6 — Green: implement broader token extraction

  1. In src/handlers/gates/bash-path-extractor.ts, add classifyTokenAsRuleCandidate() and extractTokensForPathRules().
  2. Tests pass.

Commit: feat: broader token extraction for path rules (#148)

Step 7 — Red: tool path gate

  1. In tests/handlers/gates/path.test.ts, add tests for describePathGate:
    • Returns null for non-path-bearing tools.
    • Returns null when path check result is allow.
    • Returns GateDescriptor when path check result is deny.
    • Returns GateDescriptor when path check result is ask.
    • Descriptor has correct session approval (surface "path", pattern from deriveApprovalPattern).
  2. Tests fail (function does not exist).

Commit: test: expect describePathGate for tool path restrictions (#148)

Step 8 — Green: implement tool path gate

  1. In src/handlers/gates/path.ts, implement describePathGate().
  2. Add formatPathDenyReason() and formatPathAskPrompt() in src/permission-prompts.ts.
  3. Export from src/handlers/gates/index.ts.
  4. Tests pass.

Commit: feat: path gate for tool path restrictions (#148)

Step 9 — Red: bash path gate

  1. In tests/handlers/gates/bash-path.test.ts, add tests for describeBashPathGate:
    • Returns null for non-bash tools.
    • Returns null when no tokens extracted.
    • Returns null when all tokens evaluate to allow.
    • Returns GateDescriptor when a token evaluates to deny.
    • Returns GateDescriptor when a token evaluates to ask (most restrictive).
    • Session bypass: returns GateBypass when session rule covers the path.
    • Descriptor includes triggering token in prompt message.
  2. Tests fail (function does not exist).

Commit: test: expect describeBashPathGate for bash path restrictions (#148)

Step 10 — Green: implement bash path gate

  1. In src/handlers/gates/bash-path.ts, implement describeBashPathGate().
  2. Tests pass.

Commit: feat: bash path gate with broader token extraction (#148)

Step 11 — Integrate into gate chain

  1. 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.
  2. Add integration tests in tests/handlers/permission-gate-handler.test.ts.
  3. Add integration tests in tests/permission-manager-unified.test.ts:
    • path: { "*.env": "deny" } denies read of .env.
    • path: { "*.env": "deny" } composes with read: "allow" (path deny wins).
    • path: { "*": "allow" } does not interfere with existing behavior.
    • Session approval on path surface bypasses the gate.
    • getToolPermission("path") returns catch-all action.
  4. Run full test suite.

Commit: feat: integrate path gates into permission pipeline (#148)

Step 12 — Schema, example config, and docs

  1. In schemas/permissions.schema.json: add path to examples, add markdownDescription.
  2. In config/config.example.json: add "path" entry with *.env deny.
  3. In README.md: document the path surface, composition model, examples.
  4. In docs/architecture/architecture.md: add path to evaluation flow.
  5. 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

  1. Should path patterns be normalized before matching? Currently, extracted tokens are matched raw against path patterns using wildcardMatch. .env matches "*.env" but ./src/.env does not match ".env" (it matches "*.env" though). Normalization (resolve relative, expand ~) would make matching more predictable but adds complexity and requires cwd. Recommendation: start with raw matching; add normalization in a follow-up if users report surprises.

  2. Should path rules apply to find, grep, and ls? These tools accept a path parameter but it's a search root, not a file being read/written. find { path: "src" } with "path": { "src": "deny" } would deny searching src/. This might be surprising — the user denied access to files named src, not searching under src/. Recommendation: include them (they're in PATH_BEARING_TOOLS), but document the semantics clearly.

  3. 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 path deny 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.

  4. Should the path surface affect non-path-bearing tools? Extension tools and MCP tools do not have a standard input.path. The path gate only fires for PATH_BEARING_TOOLS and bash. If an extension tool accesses files via a non-standard input field, path rules do not apply. This is a known limitation, consistent with how external_directory works today.