Files
my-pi/pi-permission-system/docs/plans/archive/0053-home-directory-expansion.md

11 KiB

issue, issue_title
issue issue_title
53 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()

// 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.

export function compileWildcardPattern<TState>(
  pattern: string,
  state: TState,
): CompiledWildcardPattern<TState> {
  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 NewexpandHomePath() 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 teststests/expand-home.test.ts: covers ~, ~/path, $HOME, $HOME/path, bare ~, no-op patterns, Windows separator.
  2. feat: implement expandHomePath utilitysrc/expand-home.ts: pure function, green tests from step 1.
  3. test: add wildcard-matcher home expansion teststests/wildcard-matcher.test.ts: wildcardMatch("~/dev/*", "/Users/chris/dev/foo") returns true; compileWildcardPattern("~/dev/*", …).pattern retains ~/dev/*.
  4. feat: integrate expandHomePath into compileWildcardPatternsrc/wildcard-matcher.ts: import and apply expansion. Green tests from step 3.
  5. test: integration test for external_directory with ~ patterntests/permission-manager-unified.test.ts: config with "~/trusted/*": "allow", checkPermission("external_directory", { path: "<homedir>/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 configschemas/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.