--- issue: 58 issue_title: "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: ```json { "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 ```text 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: ```typescript 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`: ```typescript 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`: ```typescript 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: ```typescript 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.