Skip to content

feat(hook-manager): add a CaptainHook adapter - #1899

Open
Anurag-S1ngh wants to merge 3 commits into
apache:mainfrom
Anurag-S1ngh:feat/1824-hook-manager-captainhook-adapter
Open

Anurag-S1ngh wants to merge 3 commits into
apache:mainfrom
Anurag-S1ngh:feat/1824-hook-manager-captainhook-adapter

Conversation

@Anurag-S1ngh

@Anurag-S1ngh Anurag-S1ngh commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Add the captainhook adapter to the hook-manager registry: captainhook.json detection, the CaptainHook hook marker, and install through the hooks-path-env strategy.
  • Recognise the install at any non-option argument that prefixes it (captainhook -n install, captainhook --ansi install, captainhook inst), via a new install.match: prefix registry field, so a Symfony Console invocation cannot bypass the rewrite.
  • Fall back to <repository root>/vendor/bin/<tool> only for adapters with install.vendor_bin, so a Composer project's local CaptainHook works without widening PATH; a vendor/bin symlink back to the shim exits 127 instead of exec'ing itself, and canonicalisation no longer depends on readlink -f.
  • Warn on an install through the shim when composer.json requires captainhook/hook-installer or captainhook/plugin-composer and neither extra.captainhook.disable-plugin nor config.allow-plugins turns it off. The keys are read with python3 rather than matched as text, so a disable-plugin under another package's extra cannot silence it.
  • Teach install Step P.3b, verify check 8 and the troubleshooting page about the plugin, and record a real run under Verified runs.

The branch is rebased onto main and carries only the CaptainHook delta; overcommit and lefthook are unchanged from main.

Test plan

  • uv run --project tools/agent-isolation --group dev pytest tools/agent-isolation/tests: pass
  • uv run --project tools/dev --group dev pytest tools/dev/tests: pass
  • uv run --project tools/sandbox-lint --group dev pytest tools/sandbox-lint/tests: pass
  • uv run --project tools/setup-preflight --group dev pytest tools/setup-preflight/tests: pass
  • prek run --files <changed files> --skip workspace-pytest, plus prek run lychee --files <changed .md>: pass
  • Real run in a composer:2 container: CaptainHook 5.29.2, PHP 8.5.12, Composer 2.11.1, git 2.54.0. The install through the shim used the vendor/bin fallback and wrote the repository's own .git/hooks/; the shared dispatcher dir and global core.hooksPath were unchanged. A commit behind the dispatcher ran the repository's captainhook.json and its Exists action rejected it. The control, a bare vendor/bin/captainhook install without the shim, wrote through the shared dispatcher symlinks and replaced the dispatcher.

@github-actions github-actions Bot added family:setup setup-* skills family:tools tools/* family:docs Docs, MISSION.md, READMEs family:ci .github workflows, prek, validators capability:platform Framework / agent substrate skills (install, verify, doctor, override, status, setup bootstrap) substrate:sandbox Tool substrate: agent isolation, egress control, settings audit substrate:framework-dev Tool substrate: build / validate / eval the framework itself contract:hook-manager Tool capability: git-hook managers under the secure setup (install target, sandbox / host homes) labels Oct 30, 2026

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The registry entry and the generated shim and dispatcher blocks are consistent and tested, but the install path doesn't hold up yet: options before the command bypass the rewrite, the shim can't find a vendor/bin CaptainHook, and the Composer plugin path is left to prose while the adapter is marked shipping without a real run. This branch also carries an older copy of #1893's overcommit commit, so once #1893 lands please rebase onto main so this PR is only the CaptainHook delta (that also clears the current conflict). The real CI hasn't run on this branch, so these come from reading the code.

The branch currently conflicts with main and needs a rebase before this can merge.

options before the command, or an abbreviated command, bypass the rewrite (tools/agent-isolation/hook-manager-shim.sh:103)

A manager's install must put its hook in the repository's own $(git rev-parse --git-common-dir)/hooks/, never in the shared core.hooksPath directory

if [ "${1:-}" != "$install_subcommand" ]; then

The rewrite fires only when the first argument is literally install. CaptainHook's CLI is a Symfony Console application, which accepts global options before the command name (captainhook -n install, captainhook --ansi install) and resolves unambiguous abbreviations (captainhook inst); each of those reaches the real binary unchanged and installs into the shared ~/.claude/git-hooks/. This is the same bypass class raised on the overcommit adapter (#1893). For hooks-path-env the simplest robust fix is to export the GIT_CONFIG_* override on every invocation, which is harmless for hook:* runs and needs no argv parsing; otherwise match the first non-option argument by prefix. Please add shim tests for -n install and an abbreviated command.

the documented install exits 127 for a vendor/bin CaptainHook (docs/setup/secure-agent-setup.md:1443)

Composer plugin run outside the shim, so its install is one explicit
`~/.claude/bin/captainhook install` with the plugin disabled in

The shim locates the real binary only by walking PATH for the next captainhook that is not itself (the real_tool loop in hook-manager-shim.sh). CaptainHook is usually a per-project Composer dev dependency in vendor/bin/, which is not on PATH, so ~/.claude/bin/captainhook install exits 127 with "could not locate the real 'captainhook' binary on PATH". Either document PATH="$HOME/.claude/bin:$PWD/vendor/bin:$PATH" captainhook install, or teach the shim a vendor/bin/<tool> fallback for this adapter, with a test. The same text is in tools/hook-manager/README.md.

the Composer plugin path is left to prose, and the adapter has no verified run (tools/hook-manager/README.md:185)

  1. Exercise the adapter against the real manager — the install through the shim and a commit behind the dispatcher, plus the control that fails without it — and record the run under Verified runs.
| `captainhook` | PHP | `hooks-path-env` (`install`); ... | shipping (source-derived; ...

The captainhook/hook-installer Composer plugin installs on every composer install / composer update and, as this row says, does not pass through the shim; under the contract such a manager needs a strategy outside the shim, but here it is covered only by asking the operator to set extra.captainhook.disable-plugin, with nothing detecting the plugin in composer.json or warning at setup or verify time. The PR body also says CaptainHook was never actually run, which step 7 requires before an adapter ships. Please do a real run (a php:8 container is enough) and record it under Verified runs, which would also settle the two points above; and either detect the plugin and warn, or mark the row planned rather than shipping.

Smaller observations

  • See inline comments on plugins/magpie-setup/skills/isolated-setup-install/step-p-sandbox-allowlists.md:337.

This review was drafted by an AI-assisted tool and
confirmed by an Apache Magpie maintainer. After you've
addressed the points above and pushed an update, an Apache Magpie
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one of them is mis-applied, please reply on the
PR and a maintainer will weigh in.

More on how Apache Magpie handles maintainer review:
Contributing guide.

Comment thread docs/setup/secure-agent-setup.md Outdated
CaptainHook's `vendor/bin` command and its `captainhook/hook-installer`
Composer plugin run outside the shim, so its install is one explicit
`~/.claude/bin/captainhook install` with the plugin disabled in
`composer.json`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the documented install exits 127 for a vendor/bin CaptainHook

The shim locates the real binary only by walking PATH for the next captainhook that is not itself (the real_tool loop in hook-manager-shim.sh). CaptainHook is usually a per-project Composer dev dependency in vendor/bin/, which is not on PATH, so ~/.claude/bin/captainhook install exits 127 with "could not locate the real 'captainhook' binary on PATH". Either document PATH="$HOME/.claude/bin:$PWD/vendor/bin:$PATH" captainhook install, or teach the shim a vendor/bin/<tool> fallback for this adapter, with a test. The same text is in tools/hook-manager/README.md.

@@ -172,10 +183,12 @@ What a repository or an operator configures is where each manager's environments

| Adapter | Language | Install strategy | Home | Status |
|---|---|---|---|---|

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the Composer plugin path is left to prose, and the adapter has no verified run

  1. Exercise the adapter against the real manager — the install through the shim and a commit behind the dispatcher, plus the control that fails without it — and record the run under Verified runs.

The captainhook/hook-installer Composer plugin installs on every composer install / composer update and, as this row says, does not pass through the shim; under the contract such a manager needs a strategy outside the shim, but here it is covered only by asking the operator to set extra.captainhook.disable-plugin, with nothing detecting the plugin in composer.json or warning at setup or verify time. The PR body also says CaptainHook was never actually run, which step 7 requires before an adapter ships. Please do a real run (a php:8 container is enough) and record it under Verified runs, which would also settle the two points above; and either detect the plugin and warn, or mark the row planned rather than shipping.

that a repository the operator works in uses — `~/.claude/bin/prek` today.
A manager whose install strategy is `none` needs no install fix
and gets no shim.
that a repository the operator works in uses — `~/.claude/bin/prek` and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the install skill does not know about captainhook

  1. Add a row above, and update the troubleshooting and verify pages if the manager brings a failure mode of its own.

Step P.3b names only prek and overcommit for the shims to install and the files that detect them, so setup-isolated-setup-install never installs ~/.claude/bin/captainhook or tells the operator about the Composer plugin and vendor/bin. Add captainhook to the list and the detection sentence (captainhook.json), and the plugin note to the troubleshooting and verify pages.

@Anurag-S1ngh
Anurag-S1ngh force-pushed the feat/1824-hook-manager-captainhook-adapter branch from 43a7290 to d41af3f Compare October 31, 2026 06:57
@Anurag-S1ngh

Copy link
Copy Markdown
Contributor Author

Addressed the review:

  • hooks-path-env now applies the core.hooksPath override on every invocation, so captainhook -n install, --ansi install and inst all reach the repository's own hooks. The shim tests cover those forms and that caller GIT_CONFIG_* entries are preserved.
  • The shim falls back to <repository root>/vendor/bin/<tool> when PATH holds no real binary, with a test that runs from a subdirectory.
  • An install through the shim warns when composer.json requires captainhook/hook-installer without extra.captainhook.disable-plugin: true or allow-plugins: false. Step P.3b, the troubleshooting page and verify check 8 now cover the plugin.
  • A real run is recorded under Verified runs: CaptainHook 5.29.2, PHP 8.5.12, Composer 2.11.1 and git 2.54.0 in a composer:2 container. The install through the shim used the vendor/bin fallback and wrote the repository's own hooks while the shared dispatcher dir and global core.hooksPath stayed unchanged. A commit behind the dispatcher ran the repository's captainhook.json and was rejected by its Exists action. The bare-install control wrote through the shared dispatcher symlinks and replaced the dispatcher.

The branch is rebased onto main and no longer conflicts. The overcommit commit remains until #1893 lands; after that I will rebase again so this PR carries only the CaptainHook delta.

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks, the three points from the last review are addressed: hooks-path-env now applies the override on every invocation so options and abbreviations before install can no longer slip past, the shim finds a Composer project's vendor/bin/captainhook, and the plugin is covered in Step P.3b, verify and the troubleshooting page, with a real run recorded under Verified runs.

Two things block a merge now:

  • The branch needs a rebase onto main. #1893 (overcommit) and #1895 (lefthook) have both merged, and this branch still carries an older overcommit commit that replaces #1893's reviewed argv matching (bundled -fi, --inst prefixes, the -i alias) with the every-invocation override. Please rebase so this PR carries only the CaptainHook delta, keep overcommit as it is on main (if the always-on override is the better design for overcommit too, that is its own PR), and keep the new hide-global-config strategy working alongside hooks-path-env.
  • The vendor/bin fallback can call the shim itself (inline below), which hangs every captainhook call.

The other two inline comments are smaller and fine in the same push. One more, optional: the shim tests fail when the caller's environment already sets GIT_CONFIG_COUNT, since they expect index 0; stripping GIT_CONFIG_* in _setup would make them independent of the environment they run in.

The branch currently conflicts with main and needs a rebase before this can merge.

the vendor/bin fallback can exec the shim itself (tools/agent-isolation/hook-manager-shim.sh:107)

locate the REAL binary: the next $tool on PATH that is not us

if _root="$(git rev-parse --show-toplevel 2>/dev/null)" && [ -x "$_root/vendor/bin/$tool" ]; then
    real_tool="$_root/vendor/bin/$tool"

The PATH walk skips the shim through the self_canon comparison; the fallback does not. With vendor/bin/captainhook symlinked to ~/.claude/bin/captainhook (an easy workaround for the old exit 127) and no real captainhook on PATH, every captainhook call execs itself forever and hangs with no message. Please run the same readlink -f self check on the candidate, exit 127 with the "must sit ahead of" message when it is the shim, and add a test for it.

Smaller observations

  • See inline comments on tools/agent-isolation/hook-manager-shim.sh:104, tools/agent-isolation/hook-manager-shim.sh:137.

This review was drafted by an AI-assisted tool and
confirmed by an Apache Magpie maintainer. After you've
addressed the points above and pushed an update, an Apache Magpie
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one of them is mis-applied, please reply on the
PR and a maintainer will weigh in.

More on how Apache Magpie handles maintainer review:
Contributing guide.

if [ -z "$real_tool" ]; then
# A Composer project keeps the binary in vendor/bin, which is not on
# PATH; resolve it from the repository root the command runs in.
if _root="$(git rev-parse --show-toplevel 2>/dev/null)" && [ -x "$_root/vendor/bin/$tool" ]; then

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the vendor/bin fallback can exec the shim itself

locate the REAL binary: the next $tool on PATH that is not us

The PATH walk skips the shim through the self_canon comparison; the fallback does not. With vendor/bin/captainhook symlinked to ~/.claude/bin/captainhook (an easy workaround for the old exit 127) and no real captainhook on PATH, every captainhook call execs itself forever and hangs with no message. Please run the same readlink -f self check on the candidate, exit 127 with the "must sit ahead of" message when it is the shim, and add a test for it.

break
done

if [ -z "$real_tool" ]; then

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the vendor/bin fallback applies to every adapter

When PATH holds no real binary, the shim falls back to <repository root>/vendor/bin/<tool>, so a Composer project's local binary works without widening PATH.

Only Composer keeps a binary in vendor/bin, but the fallback runs for every adapter, so prek --version or overcommit in a cloned repository with no real binary on PATH would run whatever that repository ships as vendor/bin/prek instead of exiting 127. Please limit it to the captainhook adapter (a registry flag, or [ "$tool" = captainhook ]), and say in the README that a global captainhook on PATH wins over the project's pinned one.

_root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0
[ -f "$_root/composer.json" ] || return 0
grep -q '"captainhook/hook-installer"' "$_root/composer.json" || return 0
if grep -Eq '"disable-plugin"[[:space:]]*:[[:space:]]*true' "$_root/composer.json"; then

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the Composer plugin check matches the wrong keys

When composer.json requires the plugin and does not set disable-plugin, an install through the shim warns on stderr that the next dependency update will undo it.

disable-plugin: true anywhere in the file silences the warning, including under another package's extra key, and the older auto-installing captainhook/plugin-composer is not checked at all. Please anchor the match on the captainhook block of extra (or parse with php -r / jq when present) and check captainhook/plugin-composer as well.

@Anurag-S1ngh
Anurag-S1ngh force-pushed the feat/1824-hook-manager-captainhook-adapter branch from adc0cb4 to 70681c3 Compare November 1, 2026 21:46
Why: CaptainHook is a common PHP hook manager, and under whole-user scope
its install writes into the shared core.hooksPath directory the same way
overcommit's does, so it needs the same install fix.

What: the registry entry detects captainhook.json and the CaptainHook hook
marker and reuses the hooks-path-env strategy. Two new registry keys carry
the parts of its CLI the shim needs.

install.match: prefix finds the install action at any non-option argument,
because its Symfony Console CLI takes global options before the command and
resolves abbreviations (captainhook -n install, captainhook inst). Matching
a run rewrites only core.hooksPath, which is harmless.

install.vendor_bin lets the shim run a Composer project's
vendor/bin/captainhook when PATH holds no real binary. The candidate gets the
same canonical-path self-check as the PATH walk, so a symlink back to the
shim is refused with exit 127 instead of exec'ing itself forever, and
canonicalisation no longer depends on readlink -f being present. The
fallback is registry-gated, so prek and overcommit still exit 127 rather
than run whatever a repository ships as vendor/bin/prek.

The shim also warns on a CaptainHook install when composer.json requires
captainhook/hook-installer or captainhook/plugin-composer and neither
extra.captainhook.disable-plugin nor config.allow-plugins turns it off,
since that plugin installs behind the shim on every dependency update. The
keys are read with python3 rather than matched as text, so a disable-plugin
under another package's extra cannot silence the warning; without python3
the check is skipped.

Generated-by: DeepSeek V4.1 Flash
Why: the contract page is where an adapter's strategy, detection and
verified run are recorded, and CaptainHook has all three.

What: add the adapter row with its verified run, the source-derived notes
on how it reads core.hooksPath and how its CLI spells the install, the two
new registry fields, and the vendor/bin fallback with the rule that a
global binary on PATH wins over the project's pinned one. The install
rewrite row, the shim row in tools/agent-isolation and the capability map
now name CaptainHook.

Generated-by: DeepSeek V4.1 Flash
Why: an operator who installs the shim for a Composer project needs to know
that the binary comes from vendor/bin, that its Composer plugin installs
outside the shim, and what to do when that plugin has already replaced the
dispatcher.

What: install Step P.3b lists captainhook.json as the detection file, names
the install spellings the shim covers and the vendor/bin fallback, and asks
the operator to disable the plugin before installing the shim. Verify check
8 reports a repository with either plugin enabled, the troubleshooting page
gains the symptom, root cause and fix, and the whole-user section and its
result table name the CaptainHook hook behind the dispatcher.

Generated-by: DeepSeek V4.1 Flash
@Anurag-S1ngh
Anurag-S1ngh force-pushed the feat/1824-hook-manager-captainhook-adapter branch from 70681c3 to ecebd60 Compare November 1, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

capability:platform Framework / agent substrate skills (install, verify, doctor, override, status, setup bootstrap) contract:hook-manager Tool capability: git-hook managers under the secure setup (install target, sandbox / host homes) family:ci .github workflows, prek, validators family:docs Docs, MISSION.md, READMEs family:setup setup-* skills family:tools tools/* substrate:framework-dev Tool substrate: build / validate / eval the framework itself substrate:sandbox Tool substrate: agent isolation, egress control, settings audit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants