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.patternfield retains~/...for readability in logs andmatchedPattern). - 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~,$HOMEalone, 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 innormalizePathForComparison(). - 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
- Config patterns →
normalizeFlatConfig()→Rule[](pattern stored verbatim). - At check time:
evaluate(surface, value, rules)→wildcardMatch(rule.pattern, value). - The value for
external_directoryis the raw path from tool input (e.g.,/Users/chris/development/pi/file.ts). - So
rule.patternmust 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.patternfield retains the user-written form for display in logs andmatchedPattern. - 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 exactlyhomedir().$HOMEalone → matches exactlyhomedir().~/→ matcheshomedir() + "/"(trailing slash).- Patterns not starting with
~or$HOME→ unchanged (no-op). - Windows:
~\foois handled by thestartsWith("~\\")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
- test: add expandHomePath unit tests —
tests/expand-home.test.ts: covers~,~/path,$HOME,$HOME/path, bare~, no-op patterns, Windows separator. - feat: implement expandHomePath utility —
src/expand-home.ts: pure function, green tests from step 1. - test: add wildcard-matcher home expansion tests —
tests/wildcard-matcher.test.ts:wildcardMatch("~/dev/*", "/Users/chris/dev/foo")returns true;compileWildcardPattern("~/dev/*", …).patternretains~/dev/*. - feat: integrate expandHomePath into compileWildcardPattern —
src/wildcard-matcher.ts: import and apply expansion. Green tests from step 3. - test: integration test for external_directory with ~ pattern —
tests/permission-manager-unified.test.ts: config with"~/trusted/*": "allow",checkPermission("external_directory", { path: "<homedir>/trusted/repo" })returns allow. - feat: green integration test (no code change expected) — Step 4 already makes this pass; confirm and commit together with step 5 if trivial.
- docs: update schema and example config —
schemas/permissions.schema.json: add note about~/$HOMEexpansion in pattern descriptions.config/config.example.json: add~/...example inexternal_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
$HOMEexpansion also handle the case where theHOMEenv var differs fromos.homedir()? For now, useos.homedir()consistently (matchesnormalizePathForComparisonbehavior). Revisit if a user reports a mismatch.