--- issue: 53 issue_title: "Support ~/$HOME expansion in permission config patterns" --- # Support `~`/`$HOME` expansion in permission config patterns ## Problem Statement Permission config patterns that reference home-relative paths (e.g., `external_directory` rules, skill globs) require fully expanded absolute paths today. This makes configs non-portable across machines and users. Users expect to write `~/development/pi/*` or `$HOME/development/pi/*` and have it match the expanded path at evaluation time. ## Goals - Expand `~`, `~/...`, `$HOME`, and `$HOME/...` prefixes in rule patterns at normalization time. - Keep the stored/displayed pattern as the user wrote it (the `Rule.pattern` field retains `~/...` for readability in logs and `matchedPattern`). - Apply expansion only to the compiled/matching form, not to pattern identity. - Add an `expandHomePath()` utility function. - Update schema docs and example config to document the feature. - Cover edge cases: Windows-style `~\`, bare `~`, `$HOME` alone, patterns without home prefix (no-op). ## Non-Goals - Environment variable expansion beyond `$HOME` (e.g., `$USER`, `$XDG_CONFIG_HOME`) — out of scope, file a separate issue if needed. - Expanding `~` in the **value** side of evaluation (tool input paths) — the external-directory handler already does this in `normalizePathForComparison()`. - Changing the config merge semantics or adding new surfaces. ## Background ### Relevant modules | Module | Role | | --------------------------- | -------------------------------------------------------------------------------------------------------- | | `src/normalize.ts` | `normalizeFlatConfig()` converts flat permission config into a `Ruleset`. | | `src/rule.ts` | `evaluate()` calls `wildcardMatch(r.pattern, value)` — patterns must already be in final matchable form. | | `src/wildcard-matcher.ts` | `wildcardMatch()` / `compileWildcardPattern()` compile `*`-glob patterns into regexes. | | `src/external-directory.ts` | `normalizePathForComparison()` already expands `~` on the value side (tool input paths). | | `src/input-normalizer.ts` | For `external_directory`, passes the raw `path` from tool input as the value to evaluate against. | ### Permission surface involved Primarily `external_directory` and `special`, but expansion applies generically to any rule pattern that starts with `~` or `$HOME`. In practice, path-bearing patterns appear in `external_directory` and potentially `bash` command patterns. ### How evaluation works today 1. Config patterns → `normalizeFlatConfig()` → `Rule[]` (pattern stored verbatim). 2. At check time: `evaluate(surface, value, rules)` → `wildcardMatch(rule.pattern, value)`. 3. The value for `external_directory` is the **raw path** from tool input (e.g., `/Users/chris/development/pi/file.ts`). 4. So `rule.pattern` must be an absolute path glob to match — `~/development/pi/*` would fail today. ### Design decision: where to expand Expansion at **`wildcardMatch()` / `compileWildcardPattern()` time** is the cleanest approach: - The `Rule.pattern` field retains the user-written form for display in logs and `matchedPattern`. - The regex used for matching sees the expanded path. - No changes needed to `normalizeFlatConfig()`, `evaluate()`, or any caller. ## Design Overview ### New utility: `expandHomePath()` ```typescript // src/expand-home.ts import { homedir } from "node:os"; import { join } from "node:path"; /** * Expand `~` and `$HOME` prefixes in a pattern to the OS home directory. * Returns the input unchanged if no home prefix is present. */ export function expandHomePath(pattern: string): string { if (pattern === "~" || pattern === "$HOME") { return homedir(); } if (pattern.startsWith("~/") || pattern.startsWith("~\\")) { return join(homedir(), pattern.slice(2)); } if (pattern.startsWith("$HOME/") || pattern.startsWith("$HOME\\")) { return join(homedir(), pattern.slice(6)); } return pattern; } ``` ### Integration point: `compileWildcardPattern()` Apply `expandHomePath()` to the pattern before splitting on `*` and building the regex. The `pattern` field in the returned `CompiledWildcardPattern` retains the original (unexpanded) value. ```typescript export function compileWildcardPattern( pattern: string, state: TState, ): CompiledWildcardPattern { const expanded = expandHomePath(pattern); const escaped = expanded .split("*") .map((part) => escapeRegExp(part)) .join(".*"); return { pattern, // original for display state, regex: new RegExp(`^${escaped}$`, "s"), }; } ``` The standalone `wildcardMatch()` function also calls `compileWildcardPattern`, so it inherits expansion automatically. ### Edge cases - `~` alone → matches exactly `homedir()`. - `$HOME` alone → matches exactly `homedir()`. - `~/` → matches `homedir() + "/"` (trailing slash). - Patterns not starting with `~` or `$HOME` → unchanged (no-op). - Windows: `~\foo` is handled by the `startsWith("~\\")` branch. ## Module-Level Changes | File | Change | | ------------------------------------------ | ------------------------------------------------------------------------------------------ | | `src/expand-home.ts` | **New** — `expandHomePath()` utility. | | `src/wildcard-matcher.ts` | Import `expandHomePath`; apply in `compileWildcardPattern()`. | | `schemas/permissions.schema.json` | Add `markdownDescription` noting `~`/`$HOME` support in pattern keys. | | `config/config.example.json` | Add example using `~/...` in `external_directory`. | | `tests/expand-home.test.ts` | **New** — unit tests for `expandHomePath()`. | | `tests/wildcard-matcher.test.ts` | Add tests for home-expanded patterns via `wildcardMatch()` and `compileWildcardPattern()`. | | `tests/permission-manager-unified.test.ts` | Integration test: `external_directory` rule with `~/...` pattern matches expanded path. | | `docs/architecture/target-architecture.md` | Note home expansion in wildcard-matcher description if applicable. | ## TDD Order 1. **test: add expandHomePath unit tests** — `tests/expand-home.test.ts`: covers `~`, `~/path`, `$HOME`, `$HOME/path`, bare `~`, no-op patterns, Windows separator. 2. **feat: implement expandHomePath utility** — `src/expand-home.ts`: pure function, green tests from step 1. 3. **test: add wildcard-matcher home expansion tests** — `tests/wildcard-matcher.test.ts`: `wildcardMatch("~/dev/*", "/Users/chris/dev/foo")` returns true; `compileWildcardPattern("~/dev/*", …).pattern` retains `~/dev/*`. 4. **feat: integrate expandHomePath into compileWildcardPattern** — `src/wildcard-matcher.ts`: import and apply expansion. Green tests from step 3. 5. **test: integration test for external_directory with ~ pattern** — `tests/permission-manager-unified.test.ts`: config with `"~/trusted/*": "allow"`, `checkPermission("external_directory", { path: "/trusted/repo" })` returns allow. 6. **feat: green integration test (no code change expected)** — Step 4 already makes this pass; confirm and commit together with step 5 if trivial. 7. **docs: update schema and example config** — `schemas/permissions.schema.json`: add note about `~`/`$HOME` expansion in pattern descriptions. `config/config.example.json`: add `~/...` example in `external_directory`. ## Risks and Mitigations | Risk | Mitigation | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Could this silently weaken a permission? No — expansion only makes existing explicit patterns matchable. A pattern that previously failed to match (because `~` was not expanded) now correctly matches, which is the user's intent. No new implicit allows are introduced. | Confirm via integration test that only explicitly written `~`-prefixed rules gain match power. | | `homedir()` returns different values across platforms/users | This is the intended behavior — portability is the goal. Tests mock `homedir()` to a known value. | | Expanding `~` in non-path surfaces (e.g., bash command patterns like `~something`) | Unlikely to cause harm — bash commands starting with `~/` are legitimate path references. Patterns like `~username/` are not supported (documented as non-goal). | | `$HOME` prefix conflicts with literal `$HOME` in a non-path pattern | Extremely unlikely in practice. Document that `$HOME` is expanded; users who need a literal `$HOME` prefix can avoid it. | ## Open Questions - Should `$HOME` expansion also handle the case where the `HOME` env var differs from `os.homedir()`? For now, use `os.homedir()` consistently (matches `normalizePathForComparison` behavior). Revisit if a user reports a mismatch.