Files
my-pi/pi-permission-system/docs/plans/archive/0056-unify-rule-normalize-config.md
T

519 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<string, PermissionState>` — 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<string, PermissionState>` 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 23 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<string, PermissionState>;
bash?: Record<string, PermissionState>;
mcp?: Record<string, PermissionState>;
skills?: Record<string, PermissionState>;
special?: Record<string, PermissionState>;
}
/**
* 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<string, keyof PermissionDefaultPolicy> = {
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<string>,
): 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<string, PermissionState>`.
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<string, unknown>` or the already-validated `Record<string, PermissionState>` sub-objects?**
Plan uses the validated shape (`NormalizableConfig`).
Raw validation stays in `config-loader.ts` / `normalizeRawPermission()`.