--- issue: 56 issue_title: "Unify Rule type and normalize config into flat Ruleset" --- # Unify Rule type and normalize config into flat Ruleset ## Problem Statement After #55 extracted `evaluate()`, the codebase has two representations of permission rules: 1. The `Rule` / `Ruleset` types used by `evaluate()` in `src/rule.ts`. 2. The legacy per-surface types (`ToolPermissions`, `BashPermissions`, `SkillPermissions`, `SpecialPermissions`) — all `Record` — used by config loading, merging, and compiled pattern caches in `PermissionManager`. `PermissionManager` still maintains separate compiled pattern arrays per surface (`compiledBash`, `compiledMcp`, `compiledSkills`, `compiledSpecial`) and a separate `BashFilter` class, even though `evaluate()` handles all surfaces uniformly. The `compiledToRuleset()` bridge in `checkPermission()` converts between these representations on every call — an adapter that exists only because config loading hasn't caught up with the evaluation model. ## Goals - Add `normalizeConfig()` in `src/normalize.ts` that converts the on-disk config shape into a flat `Ruleset` at load time. - Replace `mergePermissions()` (per-category object spread) with array concatenation of `Ruleset` values — later scopes' rules appear last and take priority via last-match-wins. - Extract per-surface default policy into `src/defaults.ts`, kept separate from the `Ruleset` (see Design Overview for rationale). - Remove `BashFilter` class — `evaluate("bash", command, rules)` replaces it. - Remove `ToolPermissions`, `BashPermissions`, `SkillPermissions`, `SpecialPermissions` type aliases from `src/types.ts`. - Remove `GlobalPermissionConfig` and `AgentPermissions` interfaces — each config scope becomes a `Ruleset` at runtime. - Preserve all existing external behavior: on-disk config format, `PermissionCheckResult` return type, `checkPermission()` / `getToolPermission()` public API, MCP baseline auto-allow, and two-phase tool filtering. ## Non-Goals - Changing the on-disk config JSON format (preserved exactly). - Replacing `SessionApprovalCache` with session rules (deferred to #57). - Extracting event handlers (#42) or eliminating module-scope state (#43). - Pre-compiling regex patterns inside normalized `Rule` objects — `wildcardMatch()` per-call is fast enough for current ruleset sizes and this can be optimized in a follow-up. - Changing the JSON schema or example config — no user-facing config changes. ## Background ### Permission surfaces involved All: tools (read, write, edit, bash, grep, find, ls, plus extension tools), bash, mcp, skills, special (external_directory). ### Relevant modules | Module | Role | | ------------------------------------------ | ---------------------------------------------------------------------------- | | `src/permission-manager.ts` | Config loading, merge, `checkPermission()`, `getToolPermission()` | | `src/rule.ts` | `Rule`, `Ruleset`, `evaluate()`, `getDefaultAction()` — introduced in #55 | | `src/wildcard-matcher.ts` | `wildcardMatch()`, compiled pattern infrastructure | | `src/bash-filter.ts` | `BashFilter` class — already unused in `checkPermission()` post-#55 | | `src/config-loader.ts` | `UnifiedPermissionConfig`, `loadUnifiedConfig()`, `normalizeUnifiedConfig()` | | `src/types.ts` | Per-surface type aliases, `AgentPermissions`, `GlobalPermissionConfig` | | `docs/architecture/target-architecture.md` | Target `normalizeConfig()` shape and module layout | ### Current flow (post-#55) 1. `resolvePermissions(agentName)` loads four config scopes (global, project, agent frontmatter, project-agent frontmatter) into per-surface `Record` maps. 2. `mergePermissions()` shallow-spreads maps per category across scopes. 3. Compiled pattern arrays are built per surface (`compiledBash`, `compiledMcp`, etc.). 4. `checkPermission()` converts compiled patterns into temporary `Ruleset` values via `compiledToRuleset()`, then calls `evaluate()`. ### What changes Step 4's on-the-fly conversion moves to step 1 — `normalizeConfig()` produces a `Ruleset` at load time. Steps 2–3 collapse into array concatenation. Step 4 calls `evaluate()` directly against the merged `Ruleset`. ## Design Overview ### Surface naming: tool-name-as-surface Following the issue proposal and target architecture, tool names and special keys become surfaces: ```typescript // tools.read: "allow" → tool name is the surface { surface: "read", pattern: "*", action: "allow" } // bash["git *"]: "ask" → "bash" is the surface, command is the pattern { surface: "bash", pattern: "git *", action: "ask" } // special.external_directory: "ask" → special key is the surface { surface: "external_directory", pattern: "*", action: "ask" } ``` This means `tools.bash: "allow"` normalizes to `{ surface: "bash", pattern: "*", action: "allow" }` — a bash catch-all. This naturally preserves the current dual-purpose behavior where `tools.bash` controls both tool exposure (phase 1) and bash command fallback (phase 2). Similarly, `tools.mcp: "allow"` → `{ surface: "mcp", pattern: "*", action: "allow" }`. ### `normalizeConfig()` (`src/normalize.ts`) ```typescript import type { PermissionState } from "./types"; import type { Rule, Ruleset } from "./rule"; interface NormalizableConfig { tools?: Record; bash?: Record; mcp?: Record; skills?: Record; special?: Record; } /** * Convert the on-disk config shape into a flat Ruleset. * * Ordering within a scope: * 1. tools entries (tool-name-as-surface, pattern "*") * 2. bash entries (surface "bash", pattern = command glob) * 3. mcp entries (surface "mcp", pattern = target glob) * 4. skills entries (surface "skill", pattern = skill glob) * 5. special entries (key-as-surface, pattern "*") * * defaultPolicy is NOT included — handled separately (see below). */ export function normalizeConfig(config: NormalizableConfig): Ruleset { const rules: Ruleset = []; for (const [name, action] of Object.entries(config.tools ?? {})) rules.push({ surface: name, pattern: "*", action }); for (const [pattern, action] of Object.entries(config.bash ?? {})) rules.push({ surface: "bash", pattern, action }); for (const [pattern, action] of Object.entries(config.mcp ?? {})) rules.push({ surface: "mcp", pattern, action }); for (const [pattern, action] of Object.entries(config.skills ?? {})) rules.push({ surface: "skill", pattern, action }); for (const [name, action] of Object.entries(config.special ?? {})) rules.push({ surface: name, pattern: "*", action }); return rules; } ``` ### Why `defaultPolicy` stays separate from the Ruleset `defaultPolicy` cannot be fully represented as catch-all rules in the `Ruleset` because: 1. `defaultPolicy.tools` would need to match ALL tool-name surfaces (read, write, edit, bash, grep, find, ls, plus unknown extension tools). No single rule surface pattern can match exactly "all tool names" without also matching bash/mcp/skill surfaces. 2. `defaultPolicy.special` has the same problem — special keys (e.g., `external_directory`) become their own surfaces. 3. MCP baseline auto-allow depends on distinguishing "no rule matched" from "a defaultPolicy catch-all matched." If `defaultPolicy.mcp` were a catch-all in the ruleset, it would always match and prevent the baseline heuristic from firing. `defaultPolicy` is loaded and merged separately as `PermissionDefaultPolicy` (shallow spread across scopes, same as today). When `evaluate()` returns a synthetic default (no rule matched), `checkPermission()` consults the merged `defaultPolicy` for the appropriate surface fallback. Note: `defaultPolicy.bash`, `defaultPolicy.mcp`, and `defaultPolicy.skills` COULD be represented as catch-all rules (they have fixed surface names), but excluding them preserves MCP baseline auto-allow and keeps all defaults in one place. Once MCP baseline auto-allow is formalized as explicit rules (#57), defaults can optionally move into the ruleset. ### `src/defaults.ts` ```typescript import type { PermissionDefaultPolicy, PermissionState } from "./types"; export const DEFAULT_POLICY: PermissionDefaultPolicy = { tools: "ask", bash: "ask", mcp: "ask", skills: "ask", special: "ask", }; /** * Map a surface name used in evaluate() to the corresponding * defaultPolicy key. Returns undefined for unknown surfaces. */ const SURFACE_TO_DEFAULT_KEY: Record = { bash: "bash", mcp: "mcp", skill: "skill", // tool-name surfaces (read, write, edit, etc.) and special-key surfaces // (external_directory) do not have dedicated keys — they fall back to // "tools" or "special" respectively via getSurfaceDefault(). }; /** * Resolve the default action for a surface, consulting merged defaults. */ export function getSurfaceDefault( surface: string, defaults: PermissionDefaultPolicy, specialKeys: ReadonlySet, ): PermissionState { const key = SURFACE_TO_DEFAULT_KEY[surface]; if (key) return defaults[key]; if (specialKeys.has(surface)) return defaults.special; return defaults.tools; } ``` ### Merge ```typescript // Config normalization produces a Ruleset per scope const globalRules = normalizeConfig(globalConfig); const projectRules = normalizeConfig(projectConfig); const agentRules = normalizeConfig(agentFrontmatter); const projectAgentRules = normalizeConfig(projectAgentFrontmatter); // Concatenation — later scopes appear last → higher priority via last-match-wins const mergedRules = [ ...globalRules, ...projectRules, ...agentRules, ...projectAgentRules, ]; // Defaults merged separately (shallow spread, same as today) const mergedDefaults = { ...DEFAULT_POLICY, ...globalDefaults, ...projectDefaults, ...agentDefaults, ...projectAgentDefaults, }; ``` ### Simplified `ResolvedPermissions` ```typescript type ResolvedPermissions = { rules: Ruleset; defaults: PermissionDefaultPolicy; configuredMcpServerNames: readonly string[]; // merged.mcp is still needed for MCP baseline auto-allow heuristic // (checks whether ANY mcp rule has action "allow") hasAnyMcpAllowRule: boolean; }; ``` ### Simplified `checkPermission()` Each surface branch reduces to `evaluate()` + default fallback: ```typescript // Tools (read, write, edit, grep, find, ls, extension tools) const rule = evaluate(normalizedToolName, "*", rules); const explicit = rules.includes(rule); return { state: explicit ? rule.action : defaults.tools, source: explicit ? "tool" : "default" }; // Bash const rule = evaluate("bash", command, rules); const explicit = rules.includes(rule); return { state: explicit ? rule.action : defaults.bash, source: explicit ? "bash" : "default" }; // Skills const rule = evaluate("skill", skillName, rules); const explicit = rules.includes(rule); return { state: explicit ? rule.action : defaults.skills, source: explicit ? "skill" : "default" }; // Special (external_directory) const rule = evaluate(normalizedToolName, "*", rules); const explicit = rules.includes(rule); return { state: explicit ? rule.action : defaults.special, source: explicit ? "special" : "default" }; // MCP — multi-name loop preserved, baseline auto-allow preserved for (const target of mcpTargets) { const rule = evaluate("mcp", target, rules); if (rules.includes(rule)) return { state: rule.action, source: "mcp" }; } // ... baseline auto-allow heuristic ... return { state: defaults.mcp, source: "default" }; ``` ### Simplified `getToolPermission()` ```typescript getToolPermission(toolName: string, agentName?: string): PermissionState { const { rules, defaults } = this.resolvePermissions(agentName); const rule = evaluate(normalizedToolName, "*", rules); if (rules.includes(rule)) return rule.action; if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) return defaults.special; return defaults.tools; } ``` ### Edge cases 1. **MCP baseline auto-allow**: preserved. `hasAnyMcpAllowRule` is derived from the merged `Ruleset` by checking if any rule with `surface: "mcp"` has `action: "allow"`. The heuristic fires only when no explicit rule matched AND no catch-all (from `tools.mcp`) matched. 2. **`tools.bash` dual-purpose**: preserved naturally. `tools.bash: "allow"` → `{ surface: "bash", pattern: "*", action: "allow" }` is a bash catch-all. Phase 1 (`getToolPermission("bash")`) matches it → tool exposed. Phase 2 (`evaluate("bash", command, ...)`) uses it as fallback for unmatched commands. 3. **`getBashPermissions()` removal**: the method returns `merged.bash` which no longer exists in the simplified model. It has no callers outside `permission-manager.ts` and can be removed. 4. **`normalizeRawPermission()` (frontmatter parsing)**: updated to return a shape compatible with `normalizeConfig()` input. Its deprecated-key detection and top-level shorthand logic are preserved. 5. **Compiled regex caching**: deferred. `evaluate()` uses `wildcardMatch()` which compiles a regex per call. This is fast enough for current ruleset sizes (< 1μs per pattern). Pre-compiled rules can be introduced in a follow-up if profiling shows a need. 6. **`source` field in `PermissionCheckResult`**: preserved with the same values. `"tool"` for tool-surface matches, `"bash"` for bash matches, `"mcp"` for MCP matches, `"skill"` for skill matches, `"special"` for special matches, `"default"` when no rule matched. ### Behavioral difference: `getToolPermission()` now sees command-level catch-alls With tool-name-as-surface, `getToolPermission("bash")` calls `evaluate("bash", "*", rules)`. If the user has `bash: { "*": "allow" }` (a command-level catch-all), this matches and returns `"allow"` — exposing the bash tool. Previously, `getToolPermission("bash")` only checked `tools.bash` and `defaultPolicy.bash`, ignoring command-level patterns. The new behavior is more consistent: if every bash command is allowed via a catch-all, the tool should be exposed. Conversely, if `bash: { "*": "deny" }`, the tool is hidden — which is better UX than showing a tool that always fails. ## Module-Level Changes ### `src/normalize.ts` (new) - Export `normalizeConfig(config): Ruleset`. - Export `NormalizableConfig` interface (subset of `UnifiedPermissionConfig` covering only policy fields). - Pure module — no IO, imports only `./rule` and `./types`. ### `src/defaults.ts` (new) - Export `DEFAULT_POLICY: PermissionDefaultPolicy`. - Export `getSurfaceDefault(surface, defaults, specialKeys): PermissionState`. - Export `mergeDefaults(...partials): PermissionDefaultPolicy`. - Move `DEFAULT_POLICY` constant from `permission-manager.ts`. - Move `normalizePolicy()` / `normalizePartialPolicy()` here as `mergeDefaults()`. ### `src/rule.ts` (modified) - Remove `SURFACE_DEFAULTS` and `getDefaultAction()` — moved to `src/defaults.ts` as `getSurfaceDefault()`. - `evaluate()` accepts an optional `defaultAction` parameter (defaults to `"ask"`) instead of calling `getDefaultAction()`. This keeps `evaluate()` pure without depending on `defaults.ts`. ### `src/permission-manager.ts` (modified — major) - Remove `compiledToRuleset()` helper. - Remove `compilePermissionPatternsFromSources()` helper. - Remove `findCompiledPermissionMatch()` / `findCompiledPermissionMatchForNames()` helpers. - Remove `mergePermissions()` — replaced by array concatenation. - Remove `normalizePolicy()` / `normalizePartialPolicy()` — moved to `defaults.ts`. - Remove `normalizePermissionRecord()` — `normalizeConfig()` handles this. - Remove `BashFilter` import and usage. - Simplify `ResolvedPermissions` to `{ rules: Ruleset, defaults: PermissionDefaultPolicy, hasAnyMcpAllowRule: boolean }`. - Simplify `resolvePermissions()`: call `normalizeConfig()` per scope, concatenate, merge defaults. - Simplify `checkPermission()`: direct `evaluate()` calls against merged ruleset, no more per-surface compiled pattern intermediary. - Simplify `getToolPermission()`: single `evaluate()` call + default fallback. - Remove `getBashPermissions()` (dead method, no external callers). - Update `normalizeRawPermission()` to return a shape compatible with `normalizeConfig()` input. - Update config caches to store `Ruleset` + defaults per scope instead of `GlobalPermissionConfig` / `AgentPermissions`. ### `src/bash-filter.ts` (removed) - `BashFilter` class is already dead code in `checkPermission()` (destructured as `_bashFilter` since #55). - All functionality replaced by `evaluate("bash", command, rules)`. ### `src/types.ts` (modified) - Remove `ToolPermissions`, `BashPermissions`, `SkillPermissions`, `SpecialPermissions` type aliases. - Remove `AgentPermissions` interface. - Remove `GlobalPermissionConfig` interface. - Keep `PermissionState`, `BuiltInToolName`, `PermissionDefaultPolicy`, `PermissionCheckResult`. - Keep `SpecialPermissionName` (used for type-level documentation). ### `src/wildcard-matcher.ts` (no change) - `wildcardMatch()`, `compileWildcardPattern()`, etc. remain as-is. - `compileWildcardPatternEntries()` and `findCompiledWildcardMatch()` may become unused after this change — removal deferred to a cleanup pass. ### `src/config-loader.ts` (no change) - `UnifiedPermissionConfig` and loading functions unchanged. - `normalizeConfig()` in `src/normalize.ts` takes the loaded config as input. ### `schemas/permissions.schema.json` (no change) - On-disk format unchanged. ### `config/config.example.json` (no change) - Example config unchanged. ### Tests | File | Change | | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `tests/normalize.test.ts` (new) | Unit tests for `normalizeConfig()`: per-surface conversion, ordering, edge cases | | `tests/defaults.test.ts` (new) | Unit tests for `getSurfaceDefault()`, `mergeDefaults()` | | `tests/rule.test.ts` (modified) | Update `evaluate()` tests for optional `defaultAction` parameter; remove `getDefaultAction` tests (moved to defaults.test.ts) | | `tests/permission-system.test.ts` (modified) | Update to remove `GlobalPermissionConfig`/`AgentPermissions` references; remove `BashFilter` test; adapt helper functions | | `tests/bash-filter.test.ts` (removed) | Covered by `normalize.test.ts` + `rule.test.ts` + `permission-system.test.ts` | | `tests/session-start.test.ts` (modified) | Update `GlobalPermissionConfig` references | ## TDD Order ### Phase 1: New modules (additive, no existing code changes) 1. **test: normalizeConfig converts tools entries to tool-name-as-surface rules** Red: test that `normalizeConfig({ tools: { read: "allow", write: "deny" } })` produces `[{ surface: "read", pattern: "*", action: "allow" }, { surface: "write", pattern: "*", action: "deny" }]`. Green: implement `normalizeConfig()` tools path in `src/normalize.ts`. `test: normalizeConfig tools entries` 2. **test: normalizeConfig converts bash entries to surface "bash" rules** Red: test `normalizeConfig({ bash: { "git *": "allow", "rm -rf *": "deny" } })` produces bash rules. Green: implement bash path. `test: normalizeConfig bash entries` 3. **test: normalizeConfig converts mcp, skills, special entries** Red: test all remaining surfaces. Verify special keys become their own surface (`external_directory`). Green: implement remaining paths. `test: normalizeConfig mcp, skills, special entries` 4. **test: normalizeConfig ordering — tools before bash/mcp/skills/special** Red: test that with `{ tools: { bash: "allow" }, bash: { "git *": "ask" } }`, the tools catch-all appears before the bash-specific rule. Green: already passes from implementation order. `test: normalizeConfig rule ordering` 5. **test: normalizeConfig empty/missing sections produce empty ruleset** Red: test `normalizeConfig({})` returns `[]`. Green: already passes. `test: normalizeConfig empty config` 6. **test: getSurfaceDefault returns correct defaults for each surface category** Red: test that `getSurfaceDefault("bash", defaults, specialKeys)` returns `defaults.bash`, tool surfaces return `defaults.tools`, special surfaces return `defaults.special`. Green: implement `getSurfaceDefault()` in `src/defaults.ts`. `test: getSurfaceDefault per-surface dispatch` 7. **test: mergeDefaults shallow-merges partial policies** Red: test that `mergeDefaults(globalDefaults, projectDefaults)` produces correct merged result. Green: implement `mergeDefaults()` in `src/defaults.ts`. `test: mergeDefaults shallow merge` 8. **feat: add normalizeConfig and defaults modules** Commit the new modules (`src/normalize.ts`, `src/defaults.ts`) and their tests. `feat: add normalizeConfig and defaults modules` ### Phase 2: Update evaluate() signature 1. **test: update evaluate() tests for optional defaultAction parameter** Red→Green: update `tests/rule.test.ts` — `evaluate()` now accepts an optional `defaultAction` instead of calling `getDefaultAction()`. Move `getDefaultAction()` tests to `tests/defaults.test.ts`. `test: evaluate with optional defaultAction parameter` 2. **feat: evaluate() accepts optional defaultAction** Change `evaluate()` signature to accept `defaultAction?: PermissionState`. When no rule matches, use `defaultAction ?? "ask"` instead of `getDefaultAction(surface)`. Remove `getDefaultAction()` and `SURFACE_DEFAULTS` from `src/rule.ts`. `feat: evaluate accepts optional defaultAction parameter` ### Phase 3: Refactor PermissionManager internals 1. **refactor: update permission-system.test.ts helpers for new types** Update test helper functions that construct `GlobalPermissionConfig` / `AgentPermissions` to use `UnifiedPermissionConfig` or inline `Record`. Remove the `BashFilter` test from `permission-system.test.ts`. All tests should still pass (helpers produce equivalent data). `test: update permission-system test helpers for new types` 2. **refactor: resolvePermissions uses normalizeConfig and array concat** Replace per-surface compiled pattern arrays with `normalizeConfig()` per scope. Replace `mergePermissions()` with array concatenation. Replace per-scope `GlobalPermissionConfig` / `AgentPermissions` caches with `Ruleset` + defaults. Simplify `ResolvedPermissions` type. Run full test suite. `refactor: resolvePermissions uses normalizeConfig and array concat` 3. **refactor: checkPermission uses merged Ruleset directly** Remove `compiledToRuleset()`. Each surface branch calls `evaluate()` against the merged ruleset. Fallback uses `getSurfaceDefault()`. MCP baseline auto-allow logic preserved (uses `hasAnyMcpAllowRule`). Run full test suite. `refactor: checkPermission uses merged Ruleset directly` 4. **refactor: getToolPermission uses evaluate** Replace the per-surface `if/else if` chain with a single `evaluate()` call + `getSurfaceDefault()` fallback. Run full test suite. `refactor: getToolPermission uses evaluate` ### Phase 4: Remove dead code 1. **refactor: remove BashFilter class** Delete `src/bash-filter.ts`. Delete `tests/bash-filter.test.ts`. Remove import from `permission-manager.ts`. Run full test suite. `refactor: remove BashFilter class` 2. **refactor: remove per-surface type aliases** Remove `ToolPermissions`, `BashPermissions`, `SkillPermissions`, `SpecialPermissions` from `src/types.ts`. Remove `AgentPermissions`, `GlobalPermissionConfig` from `src/types.ts`. Update all remaining imports (tests, other modules). Run `pnpm run build` (typecheck). `refactor: remove per-surface type aliases and wrapper interfaces` 3. **refactor: remove getBashPermissions dead method** Remove `getBashPermissions()` from `PermissionManager` (no callers). Run full test suite. `refactor: remove getBashPermissions dead method` 4. **refactor: remove unused compiled-pattern helpers** If `compilePermissionPatternsFromSources()`, `findCompiledPermissionMatch()`, `findCompiledPermissionMatchForNames()` are now unused, remove them. Check whether `compileWildcardPatternEntries()`, `compileWildcardPatterns()`, `findCompiledWildcardMatch()`, `findCompiledWildcardMatchForNames()` in `wildcard-matcher.ts` still have callers. Remove any that are dead. Run full test suite + `pnpm run build`. `refactor: remove unused compiled-pattern helpers` ### Phase 5: Docs and verification 1. **docs: update target architecture to mark #56 complete** Update `docs/architecture/target-architecture.md` refactoring sequence to mark #56 as done. `docs: mark #56 complete in target architecture` 2. **verify: full build and test suite** Run `pnpm run build` and `npx vitest run`. Confirm no regressions. `chore: verify clean build after config normalization refactor` ## Risks and Mitigations | Risk | Mitigation | | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Could this silently weaken a permission? | No new `"allow"` path is introduced. When no rule matches, `getSurfaceDefault()` consults the merged `defaultPolicy` which defaults to `"ask"` (least privilege). The `evaluate()` fallback is `"ask"`. Each refactor step runs the full test suite. | | Semantic change: `getToolPermission()` now considers command-level catch-alls | With tool-name-as-surface, `getToolPermission("bash")` can match `bash: { "*": "allow" }`. This is more consistent (don't expose a tool if all commands are denied) and strictly tighter or equivalent. Covered by existing tests + new normalize tests. | | MCP baseline auto-allow could be bypassed | `defaultPolicy` is NOT included in the ruleset, so `evaluate("mcp", target, rules)` returns synthetic default when no explicit/tools.mcp rule matches — baseline heuristic fires as before. Explicit test coverage for this path. | | `tools.bash` dual-purpose semantic drift | `tools.bash: "allow"` normalizes to `{ surface: "bash", pattern: "*", action: "allow" }` — a bash catch-all. This naturally preserves both tool exposure and command fallback. Edge case: `tools.bash: "deny"` + `bash: { "*": "allow" }` now exposes the tool (bash catch-all overrides tools entry). Previously, `tools.bash: "deny"` always hid the tool. This contradictory config is likely a user error, and the new behavior is arguable. Add explicit test. | | Large test file churn in permission-system.test.ts | Step 11 updates test helpers BEFORE refactoring production code. Changes are mechanical (type alias replacement). Intermediate commits keep the suite green. | | Performance regression from losing compiled regex cache | `wildcardMatch()` compiles a regex per call (~1μs). For typical rulesets (< 50 rules), total overhead is < 50μs per permission check. Acceptable. Pre-compiled rules deferred to follow-up if needed. | | `normalizeRawPermission()` (frontmatter) diverges from `normalizeConfig()` | Both share the same input shape (`NormalizableConfig`). `normalizeRawPermission()` handles raw YAML parsing + deprecated keys, then passes the validated shape to `normalizeConfig()`. Tested explicitly. | ## Open Questions - **Should `evaluate()` accept `defaultAction` as a parameter or keep calling `getDefaultAction()`?** Plan proposes parameter — cleaner for testing and keeps `evaluate()` independent of `defaults.ts`. Final decision during implementation. - **Should unused `compileWildcardPatternEntries` / `findCompiledWildcardMatch` exports be removed in this PR?** They may be used by other modules not yet migrated to `evaluate()`. Removal is in step 18 but gated on checking callers. - **Should `normalizeConfig()` accept raw `Record` or the already-validated `Record` sub-objects?** Plan uses the validated shape (`NormalizableConfig`). Raw validation stays in `config-loader.ts` / `normalizeRawPermission()`.