Files
my-pi/pi-permission-system/docs/plans/archive/0058-path-gate-universal-default.md
T

14 KiB

issue, issue_title
issue issue_title
58 The permission configuration is invalid on the Windows system

Fix path gate firing for universal default fallback

Problem Statement

The cross-cutting path permission gate (introduced in #148, v5.17.0) fires for every path-bearing tool call when the user configures "*": "ask" without an explicit "path" surface entry. This causes tools like find, ls, read, and grep to prompt for approval even when the user has explicitly set them to "allow".

The reporter's config:

{
  "permission": {
    "*": "ask",
    "read": "allow",
    "find": "allow",
    "ls": "allow",
    "grep": "allow",
    "skill": { "*": "allow" },
    "external_directory": "ask"
  }
}

Expected: find and ls do not require approval. Actual: every path-bearing tool call triggers an approval prompt.

Despite being reported as a Windows-specific issue, the bug is platform-independent — it affects any config with "*": "ask" and no explicit "path" key.

Goals

  • The path gate must not fire when no explicit path rules are configured.
  • The path gate for tools must respect session approvals on the path surface (secondary bug: the current pre-check excludes session rules).
  • Preserve correct behavior when explicit path rules ARE configured.
  • No breaking changes.

Non-Goals

  • Investigating Windows-specific config-loading issues (no evidence of a path-resolution bug; the behavior reproduces from the code on all platforms).
  • Changing external_directory gate behavior (it correctly prompts when configured).
  • Changing the universal default semantics (it should still fall through to "ask" for surfaces that don't have an explicit cross-cutting gate).

Background

Gate chain order

1. Skill-read gate
2. Path gate (tools)        ← BUG HERE
3. External-directory gate
4. Bash external-directory gate
5. Bash path gate           ← SAME BUG
6. Tool permission gate

The path gate runs BEFORE the tool gate. When it fires, the user sees a prompt even though the tool gate would have allowed the call.

How the bug manifests

  1. describePathGate calls checkPermission("path", { path: filePath }).
  2. No explicit path rule exists in the config.
  3. The universal default rule { surface: "*", pattern: "*", action: "ask", layer: "default" } matches.
  4. check.state is "ask" → the gate returns a descriptor (only "allow" causes early return).
  5. The runner prompts the user.

Why matchedPattern is the correct discriminator

PermissionManager.checkPermission() sets matchedPattern only when rule.layer === "config" || rule.layer === "session". For the path surface (no baseline rules exist), matchedPattern === undefined uniquely identifies the universal default fallback. When an explicit path config rule matches (e.g., "path": { "*.env": "deny" }), matchedPattern is set to the pattern string.

Secondary bug: session rules excluded from tool path gate

describeBashPathGate receives getSessionRuleset() and includes session rules in its check. describePathGate does NOT — it calls checkPermission without session rules, then sets preCheck on the descriptor. The runner uses preCheck directly, so session approvals on the path surface are never seen by the tool path gate.

Design Overview

Fix 1: skip path gate for universal default

In describePathGate, after the existing check.state === "allow" early return, add:

if (check.matchedPattern === undefined) return null;

This means: "no explicit path config rule matched this file — the path gate has nothing to enforce."

In describeBashPathGate, tokens whose check has matchedPattern === undefined (and source !== "session") should be treated as unrestricted — skip them without updating worstCheck:

if (check.matchedPattern === undefined && check.source !== "session") {
  allSessionCovered = false;
  continue;
}

Fix 2: include session rules in tool path gate

Change describePathGate to accept a getSessionRuleset parameter (matching describeBashPathGate's signature) and pass session rules to checkPermission:

export function describePathGate(
  tcc: ToolCallContext,
  checkPermission: CheckPermissionFn,
  getSessionRuleset: () => Rule[],
): GateResult {
  // ...
  const sessionRules = getSessionRuleset();
  const check = checkPermission("path", { path: filePath }, tcc.agentName ?? undefined, sessionRules);
  // ...
}

The CheckPermissionFn type already accepts an optional sessionRules parameter.

With session rules included, the pre-check correctly identifies session-approved paths, and the runner's session fast-path works.

Behavior matrix after fix

Config has path key? Universal default Path matches rule? Gate fires?
No "*": "ask" N/A No (fix)
No "*": "allow" N/A No (existing)
Yes: { "*.env": "deny" } "*": "ask" .env file Yes (deny)
Yes: { "*.env": "deny" } "*": "ask" non-.env file No (fix)
Yes: { "*": "ask" } any any file Yes (explicit config)
Yes: { "*": "allow", "*.env": "deny" } any .env file Yes (deny)
Yes: { "*": "allow", "*.env": "deny" } any non-.env file No (allow)
Session approval for path any approved path No (session fast-path)

Module-Level Changes

Changed files

File Change
src/handlers/gates/path.ts Add matchedPattern === undefined early return; add getSessionRuleset parameter; pass session rules to checkPermission.
src/handlers/gates/bash-path.ts Skip tokens with matchedPattern === undefined && source !== "session".
src/handlers/permission-gate-handler.ts Pass getSessionRuleset to describePathGate.

Changed test files

File Change
tests/handlers/gates/path.test.ts Add test: returns null when matchedPattern is undefined (universal default). Add test: respects session approvals. Update existing tests to pass getSessionRuleset.
tests/handlers/gates/bash-path.test.ts Add test: skips tokens where matchedPattern is undefined.
tests/permission-manager-unified.test.ts Add integration test: tool allowed when "*": "ask" + "read": "allow" + no path config.

Test Impact Analysis

  1. New tests enabled by this fix:

    • Unit test confirming describePathGate returns null for universal default fallback.
    • Unit test confirming describePathGate returns descriptor when session rules yield "session" source (session approval works).
    • Unit test confirming describeBashPathGate skips tokens matching only the universal default.
    • Integration test confirming end-to-end: "find": "allow" works without explicit "path" config.
  2. Existing tests that must be updated:

    • tests/handlers/gates/path.test.ts: all calls to describePathGate must pass a third getSessionRuleset argument.
  3. Existing tests that stay as-is:

    • tests/handlers/gates/bash-path.test.ts: existing tests already use matchedPattern set (explicit config rules) — they remain valid.
    • tests/rule.test.ts, tests/permission-manager-unified.test.ts (existing cases) — no change.

TDD Order

Step 1 — Red: describePathGate skips universal default

  1. In tests/handlers/gates/path.test.ts:
    • Update all existing describePathGate calls to pass a third getSessionRuleset argument (returns []).
    • Add test: "returns null when matchedPattern is undefined (universal default)". Mock checkPermission to return { state: "ask", matchedPattern: undefined, source: "special", origin: "builtin" }. Assert describePathGate(tcc, checkPermission, getSessionRuleset) returns null.
    • Add test: "returns descriptor when matchedPattern is defined (explicit path rule)". Mock returns { state: "ask", matchedPattern: "*.env", source: "special", origin: "global" }. Assert result is a GateDescriptor.
  2. Tests fail (signature mismatch + no matchedPattern check).

Commit: test: expect describePathGate to skip universal default fallback (#58)

Step 2 — Green: implement path gate fix

  1. In src/handlers/gates/path.ts:
    • Add getSessionRuleset: () => Rule[] parameter.
    • Call const sessionRules = getSessionRuleset() and pass to checkPermission.
    • After if (check.state === "allow") return null;, add if (check.matchedPattern === undefined) return null;.
  2. In src/handlers/permission-gate-handler.ts:
    • Pass getSessionRuleset to describePathGate.
  3. Tests pass.

Commit: fix: skip path gate when no explicit path rules configured (#58)

Step 3 — Red: session approval respected by tool path gate

  1. In tests/handlers/gates/path.test.ts:
    • Add test: "returns GateDescriptor with session source when session rule matches". getSessionRuleset returns a session rule for path. Mock checkPermission to return { state: "allow", source: "session", matchedPattern: "/project/*", origin: "session" }. Assert the gate returns null (state is "allow" → early return).
    • Add test: "passes session rules to checkPermission". Assert checkPermission was called with the session ruleset as the 4th argument.
  2. Tests pass immediately (already green from step 2 changes).

Commit: test: verify path gate passes session rules to checkPermission (#58)

Step 4 — Red: describeBashPathGate skips universal default tokens

  1. In tests/handlers/gates/bash-path.test.ts:
    • Add test: "returns null when all tokens match only the universal default". Mock checkPermission to return { state: "ask", matchedPattern: undefined } for all tokens. Assert describeBashPathGate returns null.
    • Add test: "ignores tokens matching universal default but fires for explicit rule matches". First token returns { state: "ask", matchedPattern: undefined } (skip). Second token returns { state: "deny", matchedPattern: "*.env" } (fire). Assert result is a GateDescriptor for the second token.
  2. Tests fail (no matchedPattern check in bash path gate).

Commit: test: expect describeBashPathGate to skip universal default tokens (#58)

Step 5 — Green: implement bash path gate fix

  1. In src/handlers/gates/bash-path.ts:

    • After the checkPermission call inside the token loop, add:

      if (check.matchedPattern === undefined && check.source !== "session") {
        allSessionCovered = false;
        continue;
      }
      
  2. Tests pass.

Commit: fix: bash path gate skips tokens matching only universal default (#58)

Step 6 — Integration test

  1. In tests/permission-manager-unified.test.ts:
    • Add test: with config { "*": "ask", "read": "allow" } and no path key, checkPermission("path", { path: "src/main.ts" }) returns { state: "ask", matchedPattern: undefined }.
    • This confirms the underlying evaluation produces the expected shape that the gate uses to skip.
  2. Tests pass (already green — verifying existing behavior shape).
  3. Run full test suite: pnpm vitest run.

Commit: test: integration test confirms path check returns undefined matchedPattern for universal default (#58)

Risks and Mitigations

Risk Mitigation
Could this weaken security for users who expect path to inherit the universal default? No — the path surface was designed as opt-in (#148 plan: "Configs without a path key behave identically"). Users who want path-level gating must configure it explicitly.
Could matchedPattern === undefined trigger for reasons other than the universal default? For the path surface, only the universal default produces undefined matchedPattern (no baseline rules exist for path). The check is safe.
Signature change to describePathGate breaks callers? Only one call site exists (permission-gate-handler.ts). Updated in step 2.
Session rules change alters prompt frequency? Only in the beneficial direction — previously-approved paths now correctly bypass the gate instead of re-prompting.

Open Questions

  1. Should the fix also emit a debug log entry when the gate skips due to universal default? Useful for diagnosing "why wasn't my path rule enforced" but adds noise. Recommendation: omit for now; add if users report confusion.