fix(hooks): resolve quoted plugin-root refs and warn on the ones that survive - #2645
Conversation
_rewrite_command_for_target matched a plugin-root reference only when a
path separator followed the closing brace immediately. A command that
closes the quote first -- python3 "${CLAUDE_PLUGIN_ROOT}"/hooks/probe.py
-- matched nothing, so the script was never copied and the command was
never rewritten. Because the "not found" warning lives inside the
finditer loop body, a command the pattern cannot match produced no
diagnostic at all: install exited 0 and the raw variable reached the
deployed config, where the target rejects it on every tool call.
Normalize "${VAR}"/rest to "${VAR}/rest" before the existing matcher
runs, so the unsupported spelling becomes the spelling that is already
matched, already rewritten, and already covered by tests. Widening the
group to accept an optional quote instead would leave the opening quote
dangling and emit an unbalanced command.
Unresolvable references of this shape now reach the existing
"Hook script not found" warning rather than passing silently.
Closes microsoft#2639
The "Hook script not found" warning lived inside the finditer loop, so a
command whose spelling the matcher cannot parse produced no diagnostic at
all: zero matches means zero iterations means zero warnings.
Scan the rewritten command after the loop and report any plugin-root
reference that survived, skipping the ones the loop already handled so a
matched-but-missing script still warns exactly once. This decouples the
diagnostic from the match loop, so a future quoting or whitespace variant
that defeats the matcher degrades to a warning rather than a silent skip.
A reference with no path, such as echo "${CLAUDE_PLUGIN_ROOT}", is also
reported: it is left unrewritten by design, and an unexpanded plugin-root
variable in a deployed settings.json is rejected by the target at runtime.
Refs microsoft#2639
|
@microsoft-github-policy-service agree |
There was a problem hiding this comment.
Pull request overview
Fixes a gap in hook command rewriting where plugin-root variables were not detected when a closing quote appeared between the variable and the path separator (e.g. "${CLAUDE_PLUGIN_ROOT}"/hooks/...), and ensures unresolved plugin-root references always produce a warning.
Changes:
- Normalize
"${...}"/pathand'${...}'/pathinto the already-supported fully-quoted spelling before plugin-root matching/rewrite. - Add a post-rewrite residual scan that warns on any remaining
${*_PLUGIN_ROOT...}references not handled by the main matcher. - Add focused unit tests covering quoted-split spellings, residual warnings, and non-duplication of warnings.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/apm_cli/integration/hook_integrator.py |
Adds pre-normalization for quoted-split plugin-root references and a residual post-pass warning for unresolved plugin-root tokens. |
tests/unit/integration/test_hook_integrator.py |
Adds regression tests covering quoted-split rewrites, residual warnings, and “warn once” behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 1 | 0 | Canonical owner is sound; lifecycle fixture proof is missing. |
| CLI Logging Expert | 0 | 2 | 0 | Add actionable diagnostics and lifecycle evidence. |
| DevX UX Expert | 1 | 2 | 0 | Single-quoted Claude roots do not expand; fix and prove install behavior. |
| Supply Chain Security Expert | 0 | 1 | 0 | Traversal-shaped references need residual visibility. |
| OSS Growth Hacker | 0 | 1 | 0 | Actionable warnings preserve user trust; no release work needed. |
| Test Coverage Expert | 0 | 2 | 0 | Unit tests are sub-tier for hook install; add lifecycle regression proof. |
| Performance Expert | 0 | 0 | 0 | New scans are linear in command length. |
B = blocking-severity findings, R = recommended, N = nits. Counts are signal strength, not gates. The maintainer ships.
Top follow-ups
- [DevX UX Expert] Validate single-quoted project-root expansion through hook installation -- Claude cannot expand a variable inside single quotes.
- [Test Coverage Expert] Add an end-to-end lifecycle scenario for quoted and malformed roots -- unit coverage does not prove installed hook behavior.
- [Supply Chain Security Expert] Warn when traversal-shaped references remain after containment handling -- users need visibility into retained commands.
- [CLI Logging Expert] Make unsupported hook syntax warnings actionable -- users need a repair path.
Recommendation
The listed follow-ups are in scope and are being folded into this PR. Once the corrected rewrite and lifecycle proof are validated, no strategic, docs, auth, or performance expansion is needed.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
Normalize single-quoted roots to an expandable command, retain diagnostics for rejected traversal references, and prove quoted and malformed Claude installs through the lifecycle path.\n\nAddresses shepherd-driver panel follow-ups for microsoft#2645.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Package-provided hook commands can contain terminal control characters in unresolved plugin-root references. Render the residual safely and retain a regression test so install diagnostics remain terminal-safe.\n\nAddresses supply-chain security panel follow-up.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep the new quoted plugin-root lifecycle and terminal-safety regressions compliant with the repository formatter so the hook fix can pass the canonical lint gate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Spec Guardian:
|
| Panel | Stance | Shocked | High-signal | Recommended | Nits |
|---|---|---|---|---|---|
| Swagger / OpenAPI editor | ship_with_followups | 7/10 | 0 | 4 | 1 |
| OCI distribution editor | ship_with_followups | 7/10 | 0 | 3 | 1 |
| Package-manager contract editor | ship_with_followups | 7/10 | 0 | 4 | 1 |
| Web architecture editor | ship_with_followups | 7/10 | 0 | 2 | 1 |
Folded in this pass
req-tg-012now requires one expansion-capable double-quoted span regardless of the source quote character.- The requirement consistently uses
implementation-defined plugin-root placeholderand gives${NAME}/${PLUGIN_ROOT}syntax guidance. - Forward slash and backslash are named explicitly, with a worked split-to-normalized example.
- The manifest note, conformance artifacts, indexes, counts, revision history, and functional conformance test remain aligned at 114 statements (109 MUST, 5 SHOULD).
Linter note
Checks 1-10 passed: printable ASCII, forbidden-token scan, schema and fixture parsing, anchor uniqueness, count consistency, links, fixture citations, and CHANGELOG binding. Check 11 observed runtime Python changes, so the general review panel ran in parallel; terminal CI Lint, Spec conformance, and Test Architecture Ratchets are green.
Not folded in this scope
The OCI lens suggested refusing install or dropping a hook entry when a plugin-root path escapes its package. That changes this issue's warn-only contract and needs a dedicated threat-model and migration design rather than a behavior expansion here.
This review is advisory. Re-apply the spec-review label for another pass.
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python architect | 0 | 0 | 1 | Canonical owner and dual guardrail are clean. |
| CLI logging expert | 0 | 1 | 0 | Cause-specific remediation was folded. |
| DevX UX expert | 0 | 1 | 0 | Bare-token recovery now matches the actual cause. |
| Supply-chain security expert | 0 | 0 | 1 | No new vulnerability; adversarial parser coverage added. |
| OSS growth hacker | 0 | 0 | 2 | CHANGELOG now shows concrete before/after syntax and anchored spec path. |
| Doc writer | 0 | 1 | 1 | Recovery cross-link and duplicate-prose cleanup were folded. |
| Test coverage expert | 0 | 1 | 0 | Exact warning wording and scenario evidence are now guarded. |
Counts show the signals raised during the terminal pass; every in-scope item above was folded before this comment.
Top follow-up
- [Security] Open a dedicated design issue for fail-or-drop handling of escaping plugin-root hook paths -- it changes the current warn-only contract and needs migration framing.
Architecture
classDiagram
direction LR
class hook_command_paths {
<<CanonicalOwner>>
+PLUGIN_ROOT_NAMES tuple
+normalize_quoted_plugin_root(command) str
+iter_plugin_root_paths(command) Iterator
+plugin_root_relative_path(path) str
+unresolved_plugin_root_references(command) tuple
+iter_relative_script_paths(command) Iterator
}
class hook_command_warnings {
<<DiagnosticPolicy>>
+warn_unresolved_plugin_root(command, reference, package_name)
}
class HookIntegrator {
<<Consumer>>
-_rewrite_command_for_target(command, package_path, package_name, target) tuple
}
HookIntegrator ..> hook_command_paths : tokenizes paths
HookIntegrator ..> hook_command_warnings : renders recovery
hook_command_warnings ..> hook_command_paths : classifies residual shape
Folded in this run
- Canonicalized plugin-root and relative hook path parsing in
hook_command_paths.py, with AC15d and an architecture test. - Preserved escaped spaces/operators while stopping unescaped
&&,;, and|from becoming path text. - Added sanitized, package-aware, deduplicated and cause-specific diagnostics.
- Added unit, source lifecycle, spec-conformance, owner, and long-adversarial-input coverage.
- Added and refined OpenAPM
req-tg-012, generated conformance artifacts, author docs, install reference, usage resource, CHANGELOG, and PR Scenario Evidence.
Deferred (scope follow-up)
- Evaluate fail-or-drop traversal handling in a dedicated threat-model/design issue; changing warning behavior to install refusal crosses this PR's stated contract.
Regression-trap evidence
- Removing split-quote normalization failed its behavior and
req-tg-012tests. - Removing AC15d failed the architecture authority test.
- Removing long-input normalization failed the adversarial-input test.
- Forcing either residual-warning cause failed its exact remediation test.
Lint contract
CI Lint job 97202244811 passed on exact head, including ruff, format, file length, duplication, YAML I/O, portable paths, and auth boundaries.
CI
All terminal checks are green at https://github.com/microsoft/apm/actions/runs/32642638389. Spec conformance, Test Architecture Ratchets, Lifecycle Smoke, Windows, CodeQL, docs build, NOTICE, CLA, and the aggregate gate also pass.
Mergeability status
| PR | head SHA | CEO stance | iters | folds | defers | Copilot rounds | CI | mergeable | mergeStateStatus | notes |
|---|---|---|---|---|---|---|---|---|---|---|
| #2645 | 0030839373b5 |
ship_now | 4 | 7 | 1 | 1 | green | MERGEABLE | BLOCKED | GitHub review requirement remains. |
Recommendation
Ship after maintainer review. All current-scope findings are folded, the exact-head owner gate is semantically verified, and terminal CI is green.
This panel is advisory. Re-apply the panel-review label for another pass.
38c4c29
into
microsoft:main
… survive (microsoft#2645) * fix(hooks): rewrite plugin-root refs when a quote precedes the separator _rewrite_command_for_target matched a plugin-root reference only when a path separator followed the closing brace immediately. A command that closes the quote first -- python3 "${CLAUDE_PLUGIN_ROOT}"/hooks/probe.py -- matched nothing, so the script was never copied and the command was never rewritten. Because the "not found" warning lives inside the finditer loop body, a command the pattern cannot match produced no diagnostic at all: install exited 0 and the raw variable reached the deployed config, where the target rejects it on every tool call. Normalize "${VAR}"/rest to "${VAR}/rest" before the existing matcher runs, so the unsupported spelling becomes the spelling that is already matched, already rewritten, and already covered by tests. Widening the group to accept an optional quote instead would leave the opening quote dangling and emit an unbalanced command. Unresolvable references of this shape now reach the existing "Hook script not found" warning rather than passing silently. Closes microsoft#2639 * fix(hooks): warn on plugin-root references the rewriter cannot resolve The "Hook script not found" warning lived inside the finditer loop, so a command whose spelling the matcher cannot parse produced no diagnostic at all: zero matches means zero iterations means zero warnings. Scan the rewritten command after the loop and report any plugin-root reference that survived, skipping the ones the loop already handled so a matched-but-missing script still warns exactly once. This decouples the diagnostic from the match loop, so a future quoting or whitespace variant that defeats the matcher degrades to a warning rather than a silent skip. A reference with no path, such as echo "${CLAUDE_PLUGIN_ROOT}", is also reported: it is left unrewritten by design, and an unexpanded plugin-root variable in a deployed settings.json is rejected by the target at runtime. Refs microsoft#2639 * fix(hooks): harden quoted root rewrites Normalize single-quoted roots to an expandable command, retain diagnostics for rejected traversal references, and prove quoted and malformed Claude installs through the lifecycle path.\n\nAddresses shepherd-driver panel follow-ups for microsoft#2645.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(hooks): sanitize unresolved root warnings Package-provided hook commands can contain terminal control characters in unresolved plugin-root references. Render the residual safely and retain a regression test so install diagnostics remain terminal-safe.\n\nAddresses supply-chain security panel follow-up.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(hooks): format quoted root regressions Keep the new quoted plugin-root lifecycle and terminal-safety regressions compliant with the repository formatter so the hook fix can pass the canonical lint gate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: harden quoted plugin-root hook rewrites Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: refresh instruction content hash Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: centralize hook command path matching Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: tailor unresolved hook remediation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: danielmeppiel <danielmeppiel@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
fix(hooks): resolve quoted plugin-root refs and warn on the ones that survive
TL;DR
_rewrite_command_for_targetrecognised a plugin-root reference only when apath separator followed the closing brace immediately. A hook command that
closes the quote first,
python3 "${CLAUDE_PLUGIN_ROOT}"/hooks/probe.py,matched nothing, so no script was copied, the command was never rewritten, and
no warning was emitted.
This implements both halves of the triage verdict:
"${VAR}"/restto"${VAR}/rest"before thefinditercall, turning the unsupported spelling into the one that isalready matched, already rewritten, and already covered by tests.
reference that survived, so the diagnostic no longer depends on the matcher
succeeding.
Closes #2639.
Problem (WHY)
[\\/]directly after\}. With a quote in thatposition
re.finditeryields nothing.Hook script not foundwarning lived inside thefinditerloopbody. Zero matches means zero iterations means zero warnings, so the failure
was completely silent:
apm installexits 0 and reports success.${CLAUDE_PLUGIN_ROOT}then lands in the deployed config, wherethe target rejects it on every matching tool call. That is the symptom [BUG] apm install --target claude writes ${CLAUDE_PLUGIN_ROOT} into ~/.claude/settings.json where Claude Code refuses to expand it #1310
reported; [BUG] apm install --target claude writes ${CLAUDE_PLUGIN_ROOT} into ~/.claude/settings.json where Claude Code refuses to expand it #1310's fix covers the unquoted spelling, and this is the residual
case it does not reach.
"${VAR}"/pathis valid, idiomatic shell. It quotes the segment that cancontain spaces, so plugin authors write it and get a silently dead hook.
Approach
Normalisation, not a wider group. Widening the matcher to accept an optional
quote is the trap the issue calls out: the matched span would then run past the
closing quote, so substituting the resolved path leaves the opening quote
dangling and emits an unbalanced command. Moving the closing quote first yields
the fully-quoted spelling the existing code already handles, including its
quote-detection for project-scoped rewrites. The matcher, the rewrite and the
warning branch are untouched.
Residual scan. The loop now records the references it handled, and a pass
after it reports any plugin-root reference still present in the rewritten
command that the loop never saw. A matched-but-missing script therefore still
warns exactly once, through the existing
Hook script not foundpath, ratherthan twice.
One judgement call worth your eyes: a reference with no path, such as
echo "${CLAUDE_PLUGIN_ROOT}", is left unrewritten by design and is now alsoreported by the residual scan. I read the verdict's "catches all unresolved
references regardless of quoting" as covering it, and an unexpanded plugin-root
variable in a deployed
settings.jsonis rejected by the target at runtimeeither way. If you would rather that shape stayed quiet, it is a one-line change
to require a path segment.
Validation evidence
Fail-before and pass-after were both executed, per half.
Normalisation, tests present and
hook_integrator.pyatmain:The warning test fails as
assert 'Hook script not found' in '', which is thesilent skip this issue is about.
Residual scan, tests present and only the normalisation applied:
Both fail as
CaptureResult(out='', err=''). The two "must stay quiet" guardtests pass in both arms, as they should.
All eight new tests with both halves applied:
Behaviour, same package, target
claude:python3 ${CLAUDE_PLUGIN_ROOT}/hooks/probe.pypython3 "${CLAUDE_PLUGIN_ROOT}/hooks/probe.py"python3 "${CLAUDE_PLUGIN_ROOT}"/hooks/probe.pypython3 '${CLAUDE_PLUGIN_ROOT}'/hooks/probe.pyecho "${CLAUDE_PLUGIN_ROOT}"python3 "${CLAUDE_PLUGIN_ROOT}'/hooks/probe.py(mismatched)${CLAUDE_PLUGIN_ROOT}/hooks/absent.pynpx prettier --check .Output quoting stays balanced in both the double- and single-quote cases.
Mismatched quotes are deliberately not normalised: the
(?P=quote)backreference requires the pair to match.
Suite,
pytest tests/unit/integration/ -q -p no:randomly:main:6 failed, 1935 passed, 11 skipped, 4 errors6 failed, 1943 passed, 11 skipped, 4 errorsSame failure set,
+8passed being the new tests. Those 10 pre-existingfailures and errors are all symlink tests and are an artifact of my environment,
Windows 11 Home without Developer Mode, where
os.symlinkraisesOSError: [WinError 1314]. They reproduce with identical test names onunmodified
main, so they are unrelated to this change.I did not run the full
tests/unitsuite to completion locally; it exceeded myten-minute cap.
ruff checkandruff format --checkpass on both changedfiles, and
hook_integrator.pyis 2139 lines, under the 2450 guardrail.How to test
pytest tests/unit/integration/test_hook_integrator.py -k "plugin_root or quote_before_separator" -qOr against a real install, using the two-plugin reproduction in #2639: the
probe-brokenplugin now deploys its script and its command is rewritten,matching
probe-fixed.Type of change
Testing
Spec conformance (OpenAPM v0.1)
req-tg-012for split-quoted plugin-rootresolution and default-visible unresolved-reference diagnostics.
revision history, and generated
CONFORMANCE.md/CONFORMANCE.json.req-tg-012.Scenario Evidence
tests/unit/integration/test_hook_integrator.py::TestScriptPathRewriting::test_rewrite_plugin_root_with_quote_before_separator;tests/spec_conformance/test_manifest_reqs.py::test_plugin_root_hook_resolution_preserves_quoting_and_warnstests/unit/integration/test_hook_integrator.py::TestScriptPathRewriting::test_quote_before_separator_preserves_escaped_space;::test_quote_before_separator_stops_before_shell_operatortests/integration/test_hook_event_native_lifecycle.py::test_claude_install_handles_quoted_and_malformed_plugin_rootstests/unit/integration/test_hook_integrator.py::TestScriptPathRewritingwarning regressionstests/integration/test_architecture_contract_guards.py::test_plugin_root_hook_command_vocabulary_has_one_owner; AC15dMaintainer validation after rebasing on current
main: 186 focused hook/lifecycle testspassed; 51 exact-head owner/conformance tests passed; the spec orphan check aligned
all 114 requirements. Ruff check and format check pass. The local full conformance
run passed 161 tests with one unrelated environment-only failure because the system
Python lacks the optional
tomlpackage; CI runs with the locked dev environment.I used an AI coding assistant while preparing this change. I wrote the
reproduction, ran the fail-before and pass-after for each half myself on the
numbers quoted above, and verified the baseline against unmodified
mainbeforeattributing any failure to this change. The repository states no AI policy, so I
am disclosing rather than assuming it does not matter.