Files
my-pi/pi-permission-system/docs/plans/0694-bash-shell-expansion-parity.md
T

32 KiB
Raw Blame History

issue, issue_title
issue issue_title
694 pi-permission-system: Bash path gates miss three variable-expanded external path forms

Resolve plain $HOME / $PWD expansions in bash path tokens

Release Recommendation

Release: ship independently

Issue #694 is not a numbered step in docs/architecture/architecture.md's improvement roadmap, so it carries no Release: batch tag. It is a fail-open security fix on the external_directory gate and lands as a breaking change (fix!:), so it cuts a major release on its own.

Problem Statement

Issue #694 (filed by ThreeIce, a third party) reports three bash tokens whose expanded form escapes the external_directory gate that an equivalent literal spelling triggers. Reproduced against main at 2073c0af with a session cwd outside $HOME:

Command externalPaths today pathRuleCandidates today
touch "$HOME/nonexistent" (empty) /Users/chris/nonexistent
ls "$HOME" (target exists) /Users/chris /Users/chris
ls "${HOME}" (empty) (empty)
ls "${HOME}/somewhere" (empty) <cwd>/${HOME}/somewhere
CURRENT="$HOME"; ls "$CURRENT" (empty) (empty)

Three distinct defects are visible in that table.

First, $HOME/… is expanded by the rule-candidate projection but is invisible to the external-path projection. normalizePathPolicyLiteral calls expandHomePath, so AccessPath resolves $HOME/x to the real home path — but classifyTokenAsPathCandidate does not recognize the $HOME shape, so the strict gate never accepts the token. It reaches external_directory only when the #645 existence probe rescues it, which requires the target to already exist. A new output path (touch, mkdir, a download destination, a copy destination) therefore produces no externalPaths entry at all.

Second, ${HOME} is not expanded anywhere. expandHomePath handles ~, ~/, $HOME, and $HOME/ but not the braced form, so ls "${HOME}" yields nothing on either surface and ls "${HOME}/somewhere" yields a fabricated in-project candidate <cwd>/${HOME}/somewhere — a path that names nothing, displays wrongly in prompts and logs, and can match an in-project path rule.

Third, a literal assignment followed by a reference (CURRENT="$HOME"; ls "$CURRENT") is invisible to both projections.

The framing constraint is docs/decisions/0009-bash-path-projection-completeness-contract.md, which lists "Computed paths ($VAR, $(cmd), "$HOME/x")" as an accepted residual, not a bug. Defects one and two sit awkwardly inside that residual: the package already resolves $HOME, just inconsistently across its two projections. Defect three is squarely inside it.

Goals

  • Resolve a plain $HOME / ${HOME} reference in a bash argument token to the OS home directory before token classification, so $HOME/x receives the same path and external_directory decision as ~/x and as the literal absolute spelling — regardless of whether the target exists.
  • Resolve a plain $PWD / ${PWD} reference to the base-relative form (.), so $PWD/x receives the same decision as ./x and resolves against the cd-folded effective base rather than fabricating <base>/$PWD/x.
  • Expand ${HOME} alongside $HOME in expandHomePath, so the braced form works identically everywhere the unbraced one already does: config rule patterns, piInfrastructureReadPaths, and path policy literals.
  • This is a breaking change. On upgrade, a bash command referencing $HOME/${HOME} newly triggers external_directory where it previously did not, and the token shown in prompts, logs, and derived session-approval patterns becomes the expanded path. Commit as fix(pi-permission-system)!: with a BREAKING CHANGE: footer.
  • Amend ADR 0009 (and its ADR 0003 reconciliation sentence) so $HOME and $PWD are named, bounded exceptions to the computed-path residual rather than an undocumented inconsistency.

Non-Goals

  • Defect three — assignment dataflow. CURRENT="$HOME"; ls "$CURRENT" stays unresolved. Confirmed with the operator: the bounded same-program literal-assignment tracker was considered and declined. It reaches a measured 1.6% of real commands (45 of 2767 unique bash commands in the permission review log) at the cost of new stateful dataflow in the AST walk, and ADR 0009 already exists to hold exactly this kind of residual. No follow-up issue is filed; the ADR amendment is the durable record.
  • Conservative flooring of unresolved expansions. Flooring any command carrying an unresolved-expansion path operand to ask was considered and declined: it would newly prompt on a measured 7.0% of real commands (194 of 2767), the prompt-firehose outcome ADR 0009 explicitly rejects for the bare-token case.
  • Widening the resolvable variable set beyond HOME and PWD. No configurable environment-variable allowlist. $TMPDIR, $USER, $XDG_*, and every other name keep their literal text.
  • Parameter expansion with operators. ${HOME:-/tmp}, ${#HOME}, ${HOME%/*}, ${HOME/a/b} stay unresolved — only a plain reference is resolved.
  • cd target folding for expansions. cd "$HOME" && cat x keeps yielding an unknown base. This is parity with the already-conservative cd ~, which literalTextOf also rejects, and an unknown base is the fail-closed direction (relative tokens are then flagged conservatively). literalTextOf in bash-path-resolver.ts is untouched.
  • Command substitution. $(cmd) and backticks stay unresolved.
  • Tool-input paths. $PWD handling is bash-token-scoped; a read/write tool input of $PWD/x is unaffected (expandHomePath has no base to resolve $PWD against, and adding one would widen a shared pure helper for no reported need).

Background

The bash path projection walks the tree-sitter AST once (BashPathResolver.resolve) and emits two slices: externalPaths (strict shape gate → external_directory) and ruleCandidates (broad shape gate → the path surface). Tokens reach both from token-collection.ts, which resolves each argument node's "shell value" via resolveNodeText (access-intent/bash/node-text.ts).

resolveNodeText today returns node.text verbatim for simple_expansion ($HOME) and expansion (${HOME}) nodes, so the expansion's literal spelling is what every downstream consumer sees. Expansion resolution then happens — inconsistently — much later, inside normalizePathPolicyLiteralexpandHomePath, which is a string-prefix matcher operating on the already-classified token. That split is the root cause: classification runs on the unexpanded string, resolution runs on the expanded one.

Verified AST shapes (spiked against the real parser):

$HOME       simple_expansion  → [ "$" (anon), variable_name "HOME" ]
${HOME}     expansion         → [ "${" (anon), variable_name "HOME", "}" (anon) ]
${HOME:-/tmp} expansion       → [ "${", variable_name "HOME", ":-" (anon), word "/tmp", "}" ]
${#HOME}    expansion         → [ "${", "#" (anon), variable_name "HOME", "}" ]
$HOME/sub   concatenation     → [ simple_expansion "$HOME", word "/sub" ]
"$HOME/sub" string            → [ '"', simple_expansion "$HOME", string_content "/sub", '"' ]

The plain-reference cases are structurally distinguishable from the operator cases: a plain reference has exactly one variable_name child and every other child is a pure delimiter ($, ${, }). ${HOME:-/tmp} carries a :- and a word; ${#HOME} carries a #. This is a structural test, not a string-prefix test, so it cannot misfire on an operator form.

Constraints from AGENTS.md and the package skill that apply:

  • The four path layers compose most-restrictive-wins; a path allow cannot suppress an external_directory ask. This is why defect one is a real fail-open: the path surface seeing the expanded value does not compensate for external_directory not seeing it.
  • Do not read process.platform inside src/ (ESLint-guarded). The new module reads neither the platform nor the path flavor — homedir() is platform-agnostic and "." is platform-free.
  • docs/decisions/ and docs/architecture/ are not in the package files allowlist. A link to ADR 0009 added from the shipped docs/configuration.md must be an absolute GitHub URL, not a relative path.
  • pnpm fallow dead-code gates CI, so the new module must be wired into a consumer in the same commit that introduces it.

Design Overview

Resolve expansions at collection, not at classification

The fix moves expansion resolution upstream of classification, into the one place that already turns an AST node into its shell value. This is deliberately the same shape as the --opt=value split that ADR 0009 describes as "token preprocessing, not classification": the resolved token is then handed to the ordinary shape classifiers, existence probe, and AccessPath machinery, all unchanged.

The consequence is that neither classifier needs a $HOME branch. By the time classifyTokenAsPathCandidate sees the token it is already /Users/chris/nonexistent, which its existing startsWith("/") branch accepts. token-classification.ts is not edited at all, which keeps its documented "pure shape function, policy-free" contract intact and avoids re-encoding the home-prefix vocabulary in a third place.

The new collaborator

// src/access-intent/bash/shell-variable-expansion.ts

/**
 * The value of a plain `$NAME` / `${NAME}` reference the path projection
 * resolves, or `null` when the node is not a plain reference or names a
 * variable outside the resolvable set.
 */
export function resolvePlainVariableExpansion(node: TSNode): string | null;

The module owns two facts and nothing else: which expansion node shapes count as a plain reference, and which variable names resolve to what.

const RESOLVABLE_VARIABLES: ReadonlyMap<string, () => string> = new Map([
  // The OS home directory, matching what `expandHomePath` already resolves
  // for `~` and `$HOME` in config patterns and path literals.
  ["HOME", homedir],
  // The shell's working directory === the projection's effective base, so the
  // base-relative marker resolves it correctly after any `cd` folding, with no
  // base parameter and no platform branch.
  ["PWD", () => "."],
]);

const PLAIN_REFERENCE_DELIMITERS: ReadonlySet<string> = new Set(["$", "${", "}"]);

Call site (node-text.ts), the only consumer:

case "simple_expansion":
case "expansion":
  return resolvePlainVariableExpansion(node) ?? node.text;

Tell-Don't-Ask holds: the caller hands over the node and receives the answer, never walking children itself or asking "is this a $HOME?" and then acting. Law of Demeter holds: node-text.ts gains no node.child(1).text reach-through. Parameter type is TSNode, the package's own minimal AST projection (six members); the module reads type, childCount, child(), and a child's type/text. Narrowing further would fragment the one AST abstraction the bash modules share, so TSNode is the right ISP granularity here.

Why $PWD resolves to "." and not to a directory

$PWD in a bash program is the shell's current directory at that point, which after a current-shell cd is not the session cwd. The projection already models exactly this as EffectiveBase, and already resolves relative tokens against it via forBashToken(token, { resolveBase }). Rewriting $PWD to . therefore lands the token in the existing machinery with the correct semantics for free, keeps the resolver a pure function of the node (no base parameter threaded into resolveNodeText), and inherits the #393 unknown-base conservatism when the base is unresolvable. $PWD/sub./sub, bare $PWD., and cd /etc && ls "$PWD/x"/etc/x.

This also means $PWD/… is correctly not a strict external candidate (it is base-relative, like ./x), reaching external_directory through the same route ./x does.

${HOME} in expand-home.ts

expandHomePath is a separate, string-prefix concern serving config rule patterns (wildcard-matcher.ts), piInfrastructureReadPaths (path/pi-infrastructure-read.ts), and path policy literals (access-intent/path-normalization.ts). Adding the ${HOME} prefix there is pure widening — the braced form previously matched nothing — and is what makes "${HOME}/.cargo/*": "allow" work as a config pattern.

// added forms
// `${HOME}`       → homedir()
// `${HOME}/path`  → homedir()/path
// `${HOME}\path`  → homedir()\path (Windows)

$PWD is deliberately not added there: expandHomePath has no base to resolve it against, and widening a shared pure helper beyond its name is the kind of drift this plan is fixing.

Windows

homedir() on win32 returns a native path (C:\Users\x), which classifyTokenAsPathCandidate accepts via WINDOWS_DRIVE_PATH_PATTERN and PathNormalizer.forBashToken routes through the plain branch. Git Bash's own $HOME is the MSYS spelling (/c/Users/x), which the drive-mount branch translates to the same C:\Users\x, so the two agree on the location. $PWD. is platform-free. No new process.platform read, no PathFlavor parameter.

Determinism

ADR 0009 states the invariant as same policy + same filesystem state + same command → same decision, and its closing paragraph excludes ambient host state including environment variables, per ADR 0003. This change makes $HOME and $PWD named, bounded exceptions, which the ADR amendment must state explicitly:

  • $HOME resolves via os.homedir(), which the package already treats as a resolvable input in expandHomePath for ~ and $HOME in config patterns and path literals. This change removes an inconsistency rather than adding a concession.
  • $PWD resolves to the projection's own effective base and reads no environment at all, so it is strictly more deterministic than $HOME.

Every other variable keeps its literal text, so ADR 0003's rejection of cygpath shell-outs and MSYS environment detection is untouched.

Edge cases

  • ${HOME:-/tmp}, ${#HOME}, ${HOME%/*} — operator forms, structurally rejected, keep literal text.
  • $HOMEDIR, $CURRENT, $PATH — outside the resolvable set, keep literal text.
  • FOO=$HOME/bar cmd — a variable_assignment node, already skipped at collection and by the command enumerator's prefix stripping.
  • $HOME inside a heredoc or comment — SKIP_SUBTREE_TYPES already prunes the subtree.
  • grep "$HOME" filegrep is a PATTERN_FIRST_COMMANDS entry, so the first positional is skipped as a pattern.
  • --prefix=$HOME/.localcollectEmbeddedOptionValues reads through resolveNodeText, so the embedded value is the expanded path.
  • A project whose cwd is under $HOME — the expanded path is inside the working directory, so no external_directory prompt fires at all. The measured blast radius below assumes the worse case of a cwd outside home.

Predicted effect

Measured on main at 2073c0af with cwd outside $HOME; the "after" column is the predicted post-change projection.

Command externalPaths before externalPaths after
touch "$HOME/nonexistent" (empty) /Users/chris/nonexistent
ls "$HOME" /Users/chris /Users/chris (unchanged, no longer existence-dependent)
ls "${HOME}" (empty) /Users/chris
ls "${HOME}/somewhere" (empty) /Users/chris/somewhere
cat $HOME/.ssh/id_rsa (absent) (empty) /Users/chris/.ssh/id_rsa
echo hi > $HOME/out.txt (empty) /Users/chris/out.txt
CURRENT="$HOME"; ls "$CURRENT" (empty) (empty) (declined)

Upgrade blast radius, measured over 2767 unique real bash commands from the permission review log (~/.pi/agent/extensions/pi-permission-system/logs/pi-permission-system-permission-review.jsonl):

  • 15 commands (0.5%) contain $HOME or ${HOME} — the upper bound on newly-prompting commands, before subtracting those whose cwd is under home.
  • 1 command (0.04%) contains the braced ${HOME}.
  • 194 commands (7.0%) contain some $VAR/${VAR}; all but the 15 keep today's behavior exactly.

Cost: the change adds no filesystem call and no parse. It adds one map lookup and a bounded child scan per expansion node, on a tree the walk already visits.

Module-Level Changes

Added

  • packages/pi-permission-system/src/access-intent/bash/shell-variable-expansion.ts — new. Exports resolvePlainVariableExpansion(node: TSNode): string | null. Owns the plain-reference structural test and the HOME/PWD resolvable-variable map.
  • packages/pi-permission-system/test/access-intent/bash/shell-variable-expansion.test.ts — new unit tests for the pure module.

Changed — source

  • packages/pi-permission-system/src/access-intent/bash/node-text.ts — the simple_expansion / expansion arm of resolveNodeText delegates to resolvePlainVariableExpansion, falling back to node.text. Update the function's doc comment, which currently documents expansion nodes as returning .text.
  • packages/pi-permission-system/src/expand-home.tsexpandHomePath gains ${HOME}, ${HOME}/…, and ${HOME}\…. Update the supported-forms doc comment.

Verified by grep that no export is removed or renamed, so no consumer sweep is required beyond the two files above. resolveNodeText's only importer is token-collection.ts (unchanged signature); expandHomePath's importers are wildcard-matcher.ts, access-intent/path-normalization.ts, and path/pi-infrastructure-read.ts (unchanged signature). token-classification.ts and bash-path-resolver.ts are not edited.

Changed — tests

  • packages/pi-permission-system/test/access-intent/bash/node-text.test.ts — the existing assertion resolveNodeText(makeNode("simple_expansion", "$HOME")) === "$HOME" inverts. False-green hazard: makeNode defaults to zero children, and a childless node fails the plain-reference test and falls back to node.text, so the existing assertion would keep passing while testing nothing. Rebuild these cases with realistic children (makeNode("simple_expansion", "$HOME", [makeNode("$", "$"), makeNode("variable_name", "HOME")])) and add an operator-form case.
  • packages/pi-permission-system/test/expand-home.test.ts — add ${HOME} cases mirroring the existing $HOME ones, plus a negative for ${HOMEDIR}.
  • packages/pi-permission-system/test/access-intent/bash/program.test.ts — add the issue's three repro commands as end-to-end projection assertions (two fixed, one recorded as the declined residual).
  • packages/pi-permission-system/test/handlers/gates/bash-external-directory.test.ts — add the touch "$HOME/<nonexistent>" case asserting the gate prompts under external_directory: { "*": "ask" }.

Changed — docs

  • packages/pi-permission-system/docs/decisions/0009-bash-path-projection-completeness-contract.md — amend three sections:
    1. "What the projection guarantees" gains a bullet for a resolved plain $HOME/${HOME}/$PWD/${PWD} reference.
    2. The "Computed paths" residual (line 77) narrows to $VAR outside the resolvable set, $(cmd), and assignment-then-reference, and states the declined alternatives with their measured reach (1.6% / 7.0%).
    3. "Determinism and the filesystem" (line 97) gains the two named exceptions and their rationale.
  • packages/pi-permission-system/docs/configuration.md line 592 — the sentence "This is a best-effort heuristic — variable expansion and escaped quotes are not parsed, and relative paths inside subshells are not yet resolved against a per-subshell working directory" is now doubly stale (cd folding shipped in #454/#393). Rewrite to state what is resolved (~, $HOME, ${HOME}, $PWD, ${PWD}, cd-folded relative tokens) and what is not (other variables, command substitution, assignment references). Any ADR 0009 citation added here must be an absolute GitHub URL — docs/decisions/ is not in the package files allowlist.
  • packages/pi-permission-system/docs/configuration.md lines 109, 139, 647, 664 — extend the ~/$HOME pattern-expansion prose to name ${HOME}.
  • packages/pi-permission-system/docs/opencode-compatibility.md line 23 — the "Home directory expansion" row names ~/ and $HOME/; add ${HOME}/.
  • packages/pi-permission-system/docs/architecture/architecture.md:
    • Line 702 — expand-home.ts tree entry: ~/$HOME/${HOME}.
    • Line 727 — node-text.ts tree entry: resolveNodeText is no longer purely lexical; it resolves plain HOME/PWD expansions via the new module.
    • Add a tree entry for shell-variable-expansion.ts alongside its siblings.
    • Line 291 — the ADR 0009 summary sentence gains the resolved-expansion guarantee.
    • Per the architecture-doc convention, cite #694 in a tree entry only on the constraint that must not drift (the resolvable-variable set is bounded to HOME/PWD by ADR 0009); keep provenance out of the tree otherwise.
  • .pi/skills/package-pi-permission-system/SKILL.md lines 268269 — the paragraph enumerating what classifyTokenAsPathCandidate accepts. It describes the mechanism this change reworks and carries no removed symbol, so it will not surface in a src/ grep. Add that a plain $HOME/${HOME} reference is expanded before classification (so it is an absolute-shaped token) and a plain $PWD/${PWD} reference becomes base-relative.

Grep sweep performed: \$HOME|variable expansion|environment variable across packages/pi-permission-system/docs/, README.md, and .pi/skills/package-pi-permission-system/SKILL.md; node-text|token-classification|token-collection|expand-home across docs/architecture/architecture.md. packages/pi-permission-system/README.md names no bash-expansion behavior, so it needs no edit. No file listed here is claimed unchanged in Non-Goals.

Test Impact Analysis

New tests the change enables. shell-variable-expansion.test.ts unit-tests the plain-reference discrimination directly — previously this logic did not exist as a seam, and the operator-form rejection (${HOME:-/tmp}, ${#HOME}) could only have been asserted through a full parse. The module is pure and node-shaped, so each AST form is a one-line case.

Tests that become redundant. None. The change adds behavior to a previously identity-mapped branch; no existing assertion is subsumed.

Tests that must stay as-is.

  • test/path-normalization.test.ts $HOME cases and test/wildcard-matcher.test.ts $HOME-pattern cases exercise expandHomePath's string-prefix path, which serves config patterns and tool-input literals — a surface the AST resolver never touches. They must keep passing unchanged, and they are the regression guard that the ${HOME} addition did not perturb the unbraced forms.
  • test/permission-manager-unified.test.ts $HOME/* pattern-matching cases pin that a rule pattern spelled $HOME/… still matches, which is the opposite direction from token resolution and must not drift.
  • The #645 existence-probe cases in program.test.ts must stay green: the probe remains the mechanism for bare tokens, and $HOME/… merely stops depending on it.

Invariants at Risk

Invariant Source Pinning test Risk
A promoted token matching no explicit rule stays unrestricted (matchedPattern === undefined) ADR 0009 program.test.ts / bash-external-directory.test.ts probe cases Low — the change routes $HOME/… through the strict shape gate rather than the probe, leaving the probe's guard untouched. Verify ls "$HOME" still yields exactly one deduplicated external path, not two.
Classifiers are pure shape functions that consult no ruleset ADR 0009, token-classification.ts header token-classification.test.ts None — the file is not edited. The new module consults no ruleset either.
No process.platform read inside src/ outside index.ts ESLint no-restricted-syntax Lint gate None — no platform read added.
#393 unknown-base conservatism: a relative token after a non-literal cd stays literal-only bash-path-resolver.ts program.test.ts unknown-base cases Low — $HOME/x expands to an absolute and is base-independent; $PWD/x becomes relative and inherits the existing conservatism. Add a case for each under an unknown base.
#533 win32 non-mount POSIX absolutes stay literal-only ADR 0003 path-normalizer.test.ts, program.test.ts win32 cases Low — homedir() on win32 is a drive path, not a POSIX absolute, so the literal-only branch is not entered. Add a win32-flavor case for $HOME/x.
Cost of the projection stays negligible relative to the tree-sitter parse (ADR 0009 measured the probe at ~19% of parse) ADR 0009 none None — the change adds zero filesystem calls and zero parses; it adds a bounded child scan on nodes already visited. No new number is claimed.

TDD Order

  1. ${HOME} in expandHomePath. Red: extend test/expand-home.test.ts with ${HOME}, ${HOME}/dev/project, ${HOME}/dev/*, ${HOME}\dev\project, and a negative for ${HOMEDIR} / ${HOME:-/tmp}. Green: add the three prefix branches to expand-home.ts and update its doc comment. Non-breaking pure widening — the braced form previously matched nothing. Commit: feat(pi-permission-system): expand ${HOME} alongside $HOME in path patterns

  2. Plain-expansion resolution in bash tokens. Red, in one cycle:

    • test/access-intent/bash/shell-variable-expansion.test.ts$HOME / ${HOME}homedir(), $PWD / ${PWD}., $HOMEDIR / $CURRENT / ${HOME:-/tmp} / ${#HOME} / a childless node → null.
    • test/access-intent/bash/node-text.test.ts — rebuild the simple_expansion cases with realistic children (see the false-green hazard above) and add an expansion case.
    • test/access-intent/bash/program.test.ts — the issue's repros: touch "$HOME/<nonexistent>" and ls "${HOME}/somewhere" produce the expanded external path; ls "$HOME" still produces exactly one; CURRENT="$HOME"; ls "$CURRENT" still produces none; cd /etc && ls "$PWD/x" resolves to /etc/x; a win32-flavor $HOME/x case; an unknown-base case for each of $HOME/x and $PWD/x.
    • test/handlers/gates/bash-external-directory.test.tstouch "$HOME/<nonexistent>" prompts under external_directory: { "*": "ask" }.

    Green: add src/access-intent/bash/shell-variable-expansion.ts and delegate from resolveNodeText. The module and its wiring land in one commit: an unwired export would trip the CI pnpm fallow dead-code gate. Commit: fix(pi-permission-system)!: resolve $HOME and $PWD expansions in bash path tokens, with a BREAKING CHANGE: footer naming the remediation — an external_directory allow rule for the home-anchored path (e.g. "~/.cargo/registry/*": "allow"), the mechanism docs/configuration.md already documents.

  3. Docs and ADR amendment. ADR 0009 (three sections), docs/configuration.md (line 592 rewrite plus the four ~/$HOME prose sites), docs/opencode-compatibility.md line 23, docs/architecture/architecture.md (lines 291, 702, 727 plus the new tree entry), and .pi/skills/package-pi-permission-system/SKILL.md lines 268269. Commit: docs(pi-permission-system): record resolved shell expansions in ADR 0009 and user docs

Risks and Mitigations

  • New prompts on upgrade. Measured upper bound is 15 of 2767 real commands (0.5%), and lower still for a project under $HOME. Mitigated by the BREAKING CHANGE: footer naming the external_directory allow-rule remediation, and framed by ADR 0009's "over-suppression is unrecoverable, over-surfacing is recoverable".
  • False green in node-text.test.ts. A childless fake node silently falls back to node.text, so the inverted assertion would pass without exercising the new code. Mitigated by rebuilding those cases with realistic children as an explicit red step, and by the program.test.ts end-to-end cases which go through the real parser.
  • Displayed token changes. Prompts, review-log entries, and derived session-approval patterns for a $HOME token now show the expanded path. This is an improvement — deriveApprovalPattern already derives from AccessPath.value() (the expanded form), so today the prompt says $HOME/x while the rule written says /Users/chris/x*. The change makes the two agree. Call it out in the BREAKING CHANGE: footer.
  • Over-resolving an operator form. Mitigated by the structural plain-reference test (exactly one variable_name child, all others pure delimiters) rather than a string-prefix match, with explicit negative cases for ${HOME:-/tmp} and ${#HOME}.
  • ADR drift. Amending ADR 0009's residual list is load-bearing: without it, the next "the gate missed my $VAR" report has no triage answer and the patch-per-report cycle the ADR was written to end resumes. Step 3 is not optional.
  • Third-party issue, partial resolution. Defect three is declined. The ship comment must say so explicitly and cite the amended ADR, rather than closing #694 as if all three were fixed.

Open Questions

  • None blocking. The scope ladder and the resolvable-variable set were both settled with the operator before planning: home-parity only, HOME + PWD.