mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
519 lines
31 KiB
Markdown
519 lines
31 KiB
Markdown
---
|
||
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 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<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()`.
|