Skip to content

fix(hooks): anchor POSIX user-scope hook commands to $HOME - #2944

Open
lzx (Bronyal-lzx) wants to merge 2 commits into
microsoft:mainfrom
Bronyal-lzx:fix/posix-user-hook-home-anchor
Open

fix(hooks): anchor POSIX user-scope hook commands to $HOME#2944
lzx (Bronyal-lzx) wants to merge 2 commits into
microsoft:mainfrom
Bronyal-lzx:fix/posix-user-hook-home-anchor

Conversation

@Bronyal-lzx

@Bronyal-lzx lzx (Bronyal-lzx) commented Sep 11, 2026

Copy link
Copy Markdown

fix(hooks): anchor POSIX user-scope hook commands to $HOME

TL;DR

User-scope installs (apm install -g) expanded the installing machine's home
directory into merged hook commands, so a ~/.claude/settings.json kept in a
dotfiles repo differed on every host and hand-normalizing it was undone by the
next install. On POSIX hosts the rewritten path is now anchored to $HOME,
which the invoking shell expands, so cwd-independence is unchanged. Windows
keeps absolute paths, and so do single-quoted references and a dynamic target
root outside the home directory.

Note

Fixes #2821. Only the form of the user-scope path changes; the merge,
ownership, and cleanup behavior is untouched.

Problem (WHY)

Why these matter: the absolute rewrite itself is required — hook commands
resolve against the working directory, not against the settings file:

"The absolute rewrite itself is right (#1310 / #1354: hook commands resolve
against the cwd, not the settings file) ... The ask is only about how
user-scope paths are anchored on POSIX targets."
#2821

The repo's own authoring guidance says the same thing about tracked files:

"Script paths. Use ${PLUGIN_ROOT} ... Plain absolute paths break on
consumers' machines."
hooks-and-commands.md

Approach (WHAT)

# Fix (and why, if non-obvious)
1 In the deploy_root branch (populated for user scope only), emit $HOME/<path-relative-to-deploy-root> instead of str((deploy_root / target_rel).resolve()). The deploy root is Path.home() for user scope, so the substitution is exact rather than heuristic.
2 Substitute only when relative_to(deploy_root) succeeds. A target root that resolves outside the deploy root — CLAUDE_CONFIG_DIR pointing elsewhere — keeps the previous absolute path instead of gaining a $HOME prefix that would point at the wrong location.
3 Gate the behavior on one module constant (_POSIX_USER_HOOK_PATHS = os.name != "nt") so Windows keeps the current form and tests can drive both branches deterministically.
4 Keep quoting exactly as it was. The deploy root branch never consulted path_is_quoted before, and it still does not, so no command string changes shape beyond its prefix.
5 Update the three test layers that encoded the old contract, rather than adding a parallel expectation set: the unit hook suite, the in-process install-dispatch suite, and the real-CLI lifecycle contract.
6 Anchor only when the reference is unquoted or double-quoted. A shell expands $HOME outside single quotes only, so a single-quoted reference keeps the previous absolute form instead of handing the target a literal '$HOME/…' path. The existing quote detection now reports the quote character rather than a boolean (_wrapping_quote), which is the whole cost of this guard.

Implementation (HOW)

Diff for line-level evidence:
https://github.com/microsoft/apm/pull/2944/files

Trade-offs

  • Chose $HOME over leaving the absolute path and documenting a dotfiles workaround. Rejected: telling users to post-process a file APM owns — the rewrite would still lose on the next install, as [FEATURE] Anchor user-scope hook commands to $HOME on POSIX targets so ~/.claude/settings.json stays portable across machines #2821 reports.
  • Chose to keep Windows absolute. Rejected: a Windows variable form (%USERPROFILE%) in this PR, because its cmd/PowerShell semantics differ and Project-scope hook path rewriting produces cwd-dependent commands on Windows (Claude Code target) #2408 shows cwd handling is already special there.
  • Chose absolute for a dynamic root outside the home directory. Rejected: writing $HOME/... there, which would resolve to a path that does not contain the deployed script.
  • Chose absolute for a single-quoted reference. Rejected: rewriting the surrounding single quotes into double quotes, which would widen this PR into a quote-normalization change for a shape the repo's own examples do not use — and the failure mode of getting it wrong is a hook that silently never runs.
  • Chose target-agnostic anchoring on POSIX, because _project_scoped_command_path is shared by every merge-based target. Rejected: restricting to claude/codex without maintainer input — it is a one-line condition if you prefer that.
  • Chose no mermaid diagram. Rejected: a decorative flowchart for a one-line prefix substitution; the repo requires every block to be validated with mmdc, which is not available in this environment, so an unvalidated diagram would be worse than none.

Benefits

  1. A user-scope settings file tracked in a dotfiles repo now holds the same hook entries on macOS and Linux, whatever the username or home path.
  2. Re-running apm install -g on such a repo produces no diff instead of rewriting every hook entry.
  3. Renaming a user or moving a home directory no longer invalidates user-scope hook commands.
  4. Containment is stricter, not looser: a path resolving outside the deploy root can no longer be rewritten at all.
  5. Windows users observe no change in generated commands.

Validation

pytest tests/unit/integration/test_hook_integrator.py -q -n0

188 passed in 2.82s

pytest tests/integration/test_hook_js_sidecar_lifecycle_contract.py -q -n0 — real CLI, locally built apm binary, isolated HOME:

5 passed in 71.30s

ruff check src/ tests/ and ruff format --check src/ tests/ (both must be silent in CI):

All checks passed!
1768 files already formatted

Baseline comparison, because this machine cannot run the full CI matrix:

Full unit suite and lifecycle-smoke selection, branch vs. an untouched baseline checkout using the same binary
tests/unit + tests/test_console.py
  branch:   118 failed, 22183 passed
  baseline: 118 failed, 22181 passed
  -> candidate-only failures: none; failure sets identical. The branch collects
     exactly the 2 tests added here.

pytest -m 'lifecycle_smoke and not lifecycle_merge_group' tests/integration
  branch:   166 passed, 14 failed
  baseline: 165 passed, 15 failed
  -> candidate-only failures: none. The single baseline-only failure is the
     global hook lifecycle contract, which fails on that checkout because it
     still expects an absolute path while the shared binary already writes
     $HOME -- the control that shows this diff is what changes the behavior.

Every failure on both sides is a pre-existing environment limitation here:
no gh CLI, no pwsh, sandboxed pty allocation, and network-dependent
contracts.

Scenario Evidence

# Scenario (user promise) Principle(s) Test(s) proving it Type
1 Install a hook package with apm install -g on a POSIX host — the tracked settings file holds a $HOME-anchored command instead of the installing host's prefix (regression trap for #2821) Portability by manifest tests/unit/integration/test_hook_integrator.py::TestClaudeIntegration::test_user_scope_writes_portable_home_hook_paths unit
2 Install globally, then run the hook from an unrelated working directory — it still executes DevX (pragmatic as npm), Multi-harness support tests/integration/test_hook_js_sidecar_lifecycle_contract.py::test_required_global_copilot_sidecar_lifecycle e2e
3 Reinstall the same package globally — hook entries converge instead of duplicating, and apm uninstall --global removes exactly the package-owned files DevX (pragmatic as npm) tests/integration/test_hook_js_sidecar_lifecycle_contract.py::test_required_global_copilot_sidecar_lifecycle e2e
4 Install the same package at user scope for Copilot and Kiro — both targets get the anchored command Multi-harness support, Portability by manifest tests/integration/test_hook_integrator_copilot_casing_e2e.py::test_copilot_install_scope_controls_script_paths[True], ::test_kiro_install_scope_controls_script_paths[True] integration
5 Install globally on Windows — the generated command keeps its previous absolute form Multi-harness support tests/unit/integration/test_hook_integrator.py::TestIssue1007Fixes::test_rewrite_command_deploy_root_keeps_windows_absolute_path unit
6 Point CLAUDE_CONFIG_DIR outside the home directory — the command is never rewritten into a wrong $HOME path Secure by default, Portability by manifest tests/unit/integration/test_hook_integrator.py::TestIssue1007Fixes::test_rewrite_command_dynamic_root_outside_home_stays_absolute unit
7 Write a hook command with a single-quoted plugin-root reference — the emitted command stays executable instead of containing a literal $HOME DevX (pragmatic as npm), Multi-harness support tests/unit/integration/test_hook_integrator.py::TestIssue1007Fixes::test_rewrite_command_single_quoted_reference_stays_absolute unit

How to test

  • uv run --extra dev pytest tests/unit/integration/test_hook_integrator.py -q -n0188 passed, including the three boundary cases (Windows, single-quoted reference, root outside home).
  • uv run --extra dev pytest tests/integration/test_hook_integrator_copilot_casing_e2e.py -q -n0 → passes for both user_scope parametrizations of Copilot and Kiro.
  • Build the CLI (scripts/build-binary.sh) and run APM_BINARY_PATH=… APM_E2E_TESTS=1 uv run --extra dev pytest tests/integration/test_hook_js_sidecar_lifecycle_contract.py -q -n05 passed.
  • uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/ → both silent.
  • Spot-check the artifact: HOME=/tmp/scratch apm install --global --target claude <pkg> then grep -o '\$HOME[^"]*' /tmp/scratch/.claude/settings.json → prints $HOME/.claude/hooks/… and never /tmp/scratch/....

Review feedback addressed

Responding to the Copilot review on 6331f22:

  • [critical] Single-quoted references (hook_integrator.py:613) — correct, and this was a regression I introduced, not a pre-existing one: the previous absolute form worked inside any quoting. node '${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs' produced node '$HOME/.claude/hooks/…', where the shell expands nothing. Anchoring now requires an unquoted or double-quoted reference; the quote character is threaded through instead of a boolean, and a regression test asserts the single-quoted form stays absolute and executable.
  • [nit] CHANGELOG entry missing the PR reference — fixed: the entry now ends with (#2944).
  • [nit] packages/apm-guide/.apm/skills/apm-usage/package-authoring.md still documents absolute paths — fixed: that resource now describes the $HOME anchor and its three exceptions.
  • [suppressed] Absolute root outside home + bundle copying (hook_integrator.py:605) — verified as pre-existing rather than introduced here. On an untouched main checkout, the same input produces the same absolute command and the same absolute copy target from this branch, so the ensure_path_within rejection in copy_deployed_hook_bundle is independent of this diff. This PR neither touches hook_bundle.py nor changes that path's behavior; happy to file or take a follow-up that supports an absolute root end to end.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Maintenance / refactor

Testing

  • Tested locally
  • All existing tests pass
  • Added tests for new functionality (if applicable)

Spec conformance (OpenAPM v0.1)

  • N/A — this PR does not change OpenAPM-observable behaviour.

The Mode B detector counts substantive added lines under src/apm_cli/integration/;
this diff is at 22, above the 20-line threshold, so it carries the documented
waiver rather than a spec citation. No existing req-XXX covers user-scope
hook anchoring, and inventing one for a path-spelling change would put
implementation detail into the spec:

apm-spec-waiver: user-scope hook anchoring changes path spelling only, adding no new OpenAPM requirement

If you would rather make user-scope anchoring normative, I am happy to add the
anchor + manifest row + conformance test instead and drop the waiver.

User-scope installs expanded the installing host's home directory into the
merged hook `command` entries, so a `~/.claude/settings.json` kept in a
dotfiles repo differed on every machine it was synced to -- and a manually
normalized file was rewritten back on the next `apm install -g`.

On POSIX hosts, anchor the rewritten path to `$HOME` when the deployed
script lives under the deploy root. Hooks run through a shell, which
expands `$HOME` at invocation time, so the microsoft#1310 / microsoft#1354 cwd-independence
is preserved while the merged file becomes host-independent. Windows
keeps the absolute form, and a dynamic target root outside the home
directory (for example CLAUDE_CONFIG_DIR) stays absolute too.

The idempotent upsert already strips prior entries through their
`_apm_source` ownership marker rather than by comparing command strings,
so a reinstall converges on the anchored form without duplicating hooks.

Fixes microsoft#2821

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical and moderate implementation issues remain, along with documentation and changelog follow-ups.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR anchors POSIX user-scope hook commands to $HOME, preserving absolute paths for Windows and external roots.

Changes:

  • Updates hook rewriting and scope propagation.
  • Adds unit and lifecycle coverage.
  • Updates documentation and changelog entries.
  • Outstanding fixes remain for quoted $HOME expansion and external-root bundle copying.
File summaries
File Summary
tests/unit/integration/test_hook_integrator.py Tests path rewriting across platforms and roots.
tests/integration/test_hook_js_sidecar_lifecycle_contract.py Covers $HOME expansion during lifecycle execution.
tests/integration/test_hook_integrator_copilot_casing_e2e.py Tests scoped Copilot and Kiro paths.
src/apm_cli/integration/hook_integrator.py Implements $HOME-anchored paths; critical (2 votes) single-quote expansion and moderate (1 vote) external-root bundle-copy issues remain.
src/apm_cli/install/services.py Updates user-scope hook handling.
docs/src/content/docs/reference/targets-matrix.md Documents target path behavior.
docs/src/content/docs/producer/author-primitives/hooks-and-commands.md Documents portable hook paths; usage skill documentation also needs updating (nit, 3 votes).
CHANGELOG.md Records the fix; PR number should be appended (nit, 3 votes).
Review details

Suppressed comments (1)

src/apm_cli/integration/hook_integrator.py:605

  • When CLAUDE_CONFIG_DIR is an absolute path outside HOME, root_dir and each target_rel are absolute. This branch returns an absolute command, but copy_deployed_hook_bundle later calls ensure_path_within(target_file, project_root) for that same target (hook_bundle.py:201-203), which rejects the script outside HOME; a real global install containing a hook therefore fails. Support the absolute root through bundle copying and manifest bookkeeping as well, or do not advertise this case as supported.
                    # A dynamic target root such as CLAUDE_CONFIG_DIR may live
                    # outside the deploy root. Keep that explicit absolute path
                    # rather than emitting a misleading $HOME-relative command.
  • Files reviewed: 8/8 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/apm_cli/integration/hook_integrator.py
Comment thread CHANGELOG.md Outdated
Comment on lines +288 to +292
rewrites `${PLUGIN_ROOT}` and relative `./` references so Claude Code
and Copilot CLI can execute scripts regardless of the working
directory. On POSIX hosts the rewritten path is anchored to `$HOME`
(for example `$HOME/.claude/hooks/<pkg>/run.sh`), which the shell
expands at invocation time, so a user-scope config kept in a dotfiles
@Bronyal-lzx lzx (Bronyal-lzx) changed the title fix: anchor POSIX user-scope hook commands to $HOME fix(hooks): anchor POSIX user-scope hook commands to $HOME Sep 11, 2026
@Bronyal-lzx

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Review feedback on microsoft#2944: a shell expands $HOME outside single quotes
only, so anchoring a single-quoted reference turned
`node '${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs'` into a literal
'$HOME/.claude/hooks/...' path the target would never expand. Thread the
quote character through instead of a boolean and anchor only for
unquoted or double-quoted references.

Also sync the shipped apm-guide package-authoring resource and the
CHANGELOG PR reference.
@Bronyal-lzx

Copy link
Copy Markdown
Author

Thanks for the review — all three items are addressed in b8afdbd, and I checked the fourth.

Single-quoted references (critical). Confirmed, and it was a regression I introduced rather than pre-existing behavior: the old absolute form worked under any quoting, while node '${CLAUDE_PLUGIN_ROOT}/hooks/run.mjs' became node '$HOME/.claude/hooks/…', where the shell expands nothing. Anchoring now happens only for unquoted or double-quoted references, so the quote character is threaded through instead of a boolean. Regression test added: test_rewrite_command_single_quoted_reference_stays_absolute.

CHANGELOG PR reference. Fixed — the entry now ends with (#2944).

apm-guide resource. Fixed — packages/apm-guide/.apm/skills/apm-usage/package-authoring.md now documents the $HOME anchor and its three exceptions: Windows, single-quoted references, and a dynamic target root outside the home directory.

Absolute root outside HOME and bundle copying (suppressed comment). Verified as independent of this diff: on an untouched main checkout, the same input produces the same absolute command and the same absolute copy target, so the ensure_path_within rejection in copy_deployed_hook_bundle predates this PR. This change neither touches hook_bundle.py nor alters that path's behavior, so I left it alone rather than widening the scope — happy to open a follow-up for end-to-end support of an absolute root if that is wanted.

Local re-run after the fix: 195 passed (hook unit suite plus the in-process install-dispatch suite), 5 passed (real-CLI lifecycle contract against a rebuilt binary), ruff clean. The Mode B substantive-line count for this diff is now 22, above the 20-line threshold, so the PR body carries the documented apm-spec-waiver instead of a spec citation.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Three unresolved moderate findings remain involving repeated mixed-quote replacements and lifecycle shell-expansion coverage.

Review details

Suppressed comments (3)

src/apm_cli/integration/hook_integrator.py:699

  • The quote policy is computed for the current match, but this global replacement rewrites every identical reference in the command. For example, bash "${CLAUDE_PLUGIN_ROOT}/hooks/run.sh" && bash '${CLAUDE_PLUGIN_ROOT}/hooks/run.sh' makes the second occurrence $HOME/... inside single quotes, where it is literal and the hook fails. Replace only the current occurrence (for example with count=1, while processing matches in order) so each reference keeps its own quote semantics.
                    _wrapping_quote(command, match),
                )
                new_command = new_command.replace(full_var, resolved_cmd)

src/apm_cli/integration/hook_integrator.py:749

  • The same global replacement issue applies to repeated relative references with mixed quoting. If an unquoted/double-quoted ./hooks/run.sh appears before a single-quoted copy, this replaces both with $HOME/..., leaving the latter as a literal path and breaking that hook. Limit the replacement to the current match so the single-quoted occurrence remains absolute.
                    _wrapping_quote(command, match),
                )
                new_command = new_command.replace(rel_ref, resolved_cmd)

tests/integration/test_hook_js_sidecar_lifecycle_contract.py:731

  • This lifecycle test replaces $HOME in the parsed argv before invoking the hook, so it never exercises the shell expansion that makes the new representation executable. A regression that writes a literal $HOME path or invokes this command without a shell would still pass here; run the generated command through the same POSIX shell with the isolated HOME (and keep a platform-appropriate direct invocation for Windows) so this contract is actually covered.
    hook_argv = [arg.replace("$HOME", str(scenario.isolated.home)) for arg in command[1:]]
  • Files reviewed: 9/9 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Anchor user-scope hook commands to $HOME on POSIX targets so ~/.claude/settings.json stays portable across machines

2 participants