Skip to content

fix(git-engine): never run the cloned repo's git hooks (core.hooksPath) - #363

Open
orossant wants to merge 1 commit into
aws-samples:mainfrom
orossant:fix/git-engine-skip-repo-hooks
Open

fix(git-engine): never run the cloned repo's git hooks (core.hooksPath)#363
orossant wants to merge 1 commit into
aws-samples:mainfrom
orossant:fix/git-engine-skip-repo-hooks

Conversation

@orossant

@orossant orossant commented Jul 28, 2026

Copy link
Copy Markdown

Problem

A stage that had already produced all its artifacts died with git_commit_failed, losing the work. The only detail captured was npm's warnings:

git_commit_failed: <org>/<repo>: commit_failed —
  npm warn Unknown project config "strict-peer-dependencies". This will stop working in the next major version of npm.
  npm warn Unknown project config "auto-install-peers".
Unit walking-skeleton failed at functional-design: git_commit_failed

The cause is the user repo's own pre-commit hook. A project using husky + lint-staged installs hooks that shell out to npm/npx. The AgentCore runtime deliberately never installs the checkout's dependencies (see the inode-budget comments in workspace.js and run-stage.js), so the hook exits non-zero and the stage's completed work is lost for a reason that has nothing to do with that work.

Observed on a real deployment: the agent successfully created Business Rules, Domain Entities, and Frontend Components, then lost the whole stage on the commit.

Change

Set core.hooksPath to a hook-free path for every engine-owned git invocation, applied in runGit — the single choke point all of them pass through, so operations added later inherit the policy automatically.

Two reasons the engine must not execute the cloned repo's hooks:

  • Correctness — the failure above. Hooks that assume an installed dependency tree cannot work in this runtime.
  • Security — repo hooks are arbitrary untrusted code running inside the agent runtime, and pushes carry a short-lived credential in the environment. A pre-push hook could read AIDLC_GIT_PASSWORD, abort the push, and surface the value in the returned error detail.

--no-verify would not have been sufficient, and an earlier revision of this PR that used it left the same class of bug open: it covers pre-commit and commit-msg only, so prepare-commit-msg can still abort a commit and pre-push runs on every push. -c core.hooksPath=... disables every hook for the invocation without mutating the repository's own configuration.

Testing

Tests drive real git in throwaway repos and pin the hooks via core.hooksPath rather than .git/hooks. That is the stronger assertion: it proves the engine's own -c core.hooksPath overrides a hooksPath the repository configured for itself, not merely the default location.

Case Asserted
Failing pre-commit commit succeeds, hook never ran, work committed
Failing commit-msg commit succeeds, hook never ran
Failing prepare-commit-msg commit succeeds, hook never ran, tree left clean
Credential-bearing pre-push push succeeds, hook never ran, token appears nowhere in the result, commit reached the remote

Each hook writes a marker file, so "never ran" is asserted by the marker's absence rather than inferred. All five cases fail without the change — verified by reverting the one-line runGit edit and re-running.

  • git-engine.test.js, lane.test.js, resolve-conflict.test.js, run-stage.test.js: 222 passed
  • oxlint clean, oxfmt --check clean
  • Verified live on a dev deployment: the previously failing stage now reports Engine committed + pushed work for code-generation [unit walking-skeleton], and the execution ran to completion through ~30 stages and opened its PR with zero git failures.

Follow-up worth its own issue

The platform does no secret scanning of agent-authored content. This change correctly stops depending on customer hooks, but it does not close that gap — the runtime should scan what its agents commit rather than relying on a hook that may not exist, may not run, or may itself be hostile.

Notes

Rebased onto latest main (b9ab0b6).

I confirm the licensing of this contribution under the repository's MIT-0 license.

@JWThewes

Copy link
Copy Markdown
Contributor

Thanks for putting this together. Preventing repository-owned hooks from running during engine-owned Git operations is the right direction, and this fixes the reported pre-commit / commit-msg failure mode.

There is one gap I think we should address before merging: --no-verify is not a complete hook bypass. Git still runs prepare-commit-msg, which can abort the commit, and the current push path still runs pre-push. I reproduced both against this PR head: a failing prepare-commit-msg caused commitAll to return commit_failed with a dirty tree, and a pre-push hook could read the temporary AIDLC_GIT_PASSWORD, abort the push, and expose that value in the returned error detail.

Could we centralize this policy in the Git runner by invoking engine Git commands with git -c core.hooksPath=/dev/null ..., rather than adding --no-verify at individual commit and merge sites? That would cover commits, merges, pushes, and other engine-owned Git operations consistently without changing the repository configuration. I would also pin core.hooksPath to the fixture directory in the tests and add regression cases for prepare-commit-msg and credential-bearing pre-push hooks.

The underlying change is useful; I think this adjustment would make the implementation match the intended contract and avoid another hook type causing the same production failure. Thanks again for tackling it.

@orossant
orossant force-pushed the fix/git-engine-skip-repo-hooks branch from 72bebf7 to b9d2e47 Compare July 29, 2026 18:02
@orossant orossant changed the title fix(git-engine): don't run the cloned repo's git hooks (--no-verify) fix(git-engine): honour the repo's git hooks, bypass only when they block Jul 29, 2026
@orossant

Copy link
Copy Markdown
Author

Revised after review of my own approach.

The original version passed --no-verify unconditionally. That was too blunt: some pre-commit hooks are security controls (secretlint, gitleaks, detect-secrets), and since the platform does no secret scanning of its own, bypassing them outright would mean agent-authored content reaching history with no scanning on either side.

It now tries with hooks first, falls back to --no-verify only when the commit is genuinely blocked, and emits a v2.git.hooks_bypassed event so the bypass is auditable rather than silent. A new test asserts that a succeeding hook still runs, so working hooks keep protecting the repo.

One implementation note worth flagging: the fallback is placed after commitAll's retry/backoff loop, not inside it. My first attempt put it inside attemptOnce, which silently absorbed the transient-failure case the existing retries a transient commit failure with backoff test covers, and would have misreported hooks as bypassed when the real cause was transient. The trade is ~6s of extra latency before a bypass, but only on repos whose hooks are already failing.

@orossant

Copy link
Copy Markdown
Author

You're right on all three, and the pre-push finding inverts the reasoning I used to justify this design — so let me concede that explicitly.

I had argued for a conditional bypass (try hooks, fall back only when they block) on the grounds that a repo's hooks might be protective — secretlint, gitleaks — and that silently disabling them would be worse than the bug. That framing was wrong. Repo hooks are arbitrary untrusted code executing inside the agent runtime with a live credential in the environment, which is exactly what your AIDLC_GIT_PASSWORD reproduction demonstrates. Running them isn't a safety net, it's the attack surface. A compromised or malicious hook in a customer repo could exfiltrate the platform's Git token.

Also conceded:

  • --no-verify is not a hook bypass. It covers pre-commit and commit-msg only; prepare-commit-msg still aborts the commit, reproducing the very failure this PR claims to fix.
  • The push path was untouched, so pre-push ran regardless.

Six call-site patches were both incomplete and the wrong layer. I'll rework it as you suggest:

  • centralize in the Git runner so every engine-owned invocation carries -c core.hooksPath=/dev/null, covering commits, merges, pushes and anything added later, without touching repository configuration
  • drop the per-site --no-verify and the try-then-fallback logic entirely
  • pin core.hooksPath to the fixture directory in tests, and add regression cases for prepare-commit-msg and a credential-bearing pre-push that asserts the token never reaches the returned error detail

I'll push the revision shortly. Thanks for reproducing both cases rather than just flagging them — the credential path in particular I would not have found.

A stage that had already produced its artifacts died with
git_commit_failed, and the only detail captured was npm's warnings:

  commit_failed — npm warn Unknown project config "strict-peer-dependencies"
                  npm warn Unknown project config "auto-install-peers"

The cause is the USER repo's own pre-commit hook. A project using husky +
lint-staged installs hooks that shell out to npm/npx, but the runtime
deliberately never installs the checkout's dependencies (see the
inode-budget notes in workspace.js), so the hook exits non-zero and the
stage's completed work is lost for a reason unrelated to that work.

Set `core.hooksPath` to a hook-free path for every engine-owned git
invocation, in runGit — the single choke point all of them pass through,
so operations added later inherit the policy.

Two reasons the engine must not execute repo hooks:

- CORRECTNESS: the failure above. Hooks assuming an installed dependency
  tree cannot work in this runtime.
- SECURITY: repo hooks are arbitrary untrusted code running inside the
  agent runtime, and pushes carry a short-lived credential in the
  environment. A pre-push hook could read AIDLC_GIT_PASSWORD, abort the
  push, and surface the value in the returned error detail.

`--no-verify` is NOT sufficient and would have left the same class of bug
open: it covers pre-commit and commit-msg only, so prepare-commit-msg can
still abort a commit and pre-push runs on every push. `-c core.hooksPath`
disables every hook for the invocation without mutating the repository's
own configuration.

Tests drive REAL git in throwaway repos and pin the hooks via
`core.hooksPath` rather than `.git/hooks`, which is the stronger
assertion: it proves the engine's own `-c core.hooksPath` overrides a
hooksPath the repository configured for itself. Cases cover pre-commit,
commit-msg and prepare-commit-msg (commit succeeds, hook never ran, tree
left clean), and a credential-bearing pre-push (push succeeds, the hook
never ran, and the token appears nowhere in the result). All five fail
without the change.

Rebased onto latest main (b9ab0b6).

I confirm the licensing of this contribution under the repository's MIT-0
license.
@orossant
orossant force-pushed the fix/git-engine-skip-repo-hooks branch from b9d2e47 to 2570211 Compare July 29, 2026 20:02
@orossant orossant changed the title fix(git-engine): honour the repo's git hooks, bypass only when they block fix(git-engine): never run the cloned repo's git hooks (core.hooksPath) Jul 29, 2026
@orossant

Copy link
Copy Markdown
Author

Revision pushed (2570211), implemented as you suggested.

runGit now prepends -c core.hooksPath=... to every engine-owned invocation, so commits, merges, pushes and anything added later are covered from one place. The per-site --no-verify calls and the try-then-fallback logic are gone entirely — the diff is now +125/-1 across two files, versus +211/-17 before.

Tests pin the hooks via core.hooksPath rather than .git/hooks, as you asked. That turned out to be the more valuable form: it asserts the engine's -c core.hooksPath overrides a hooksPath the repository set for itself, which the default-location test would not have caught.

Four regression cases, each hook writing a marker file so "did not run" is asserted rather than inferred:

  • pre-commit, commit-msg, prepare-commit-msg — commit succeeds, hook never ran; the prepare-commit-msg case also asserts the tree is left clean, which is the specific symptom you reproduced
  • credential-bearing pre-push — push succeeds, hook never ran, AIDLC_GIT_PASSWORD appears nowhere in the result, and the commit reached the remote

All five fail without the change; I verified by reverting the one-line runGit edit and re-running rather than assuming. 222 tests pass across git-engine, lane, resolve-conflict and run-stage; oxlint and oxfmt clean.

One thing I did not fold in: the platform still performs no secret scanning of agent-authored content. This PR stops depending on customer hooks, which is right, but the gap remains — happy to open a separate issue for it if that is useful.

@JWThewes

Copy link
Copy Markdown
Contributor

First, a big thank you for contributing this and for being so responsive to the review feedback. The revision is a strong improvement: centralizing the policy in runGit fixes the pre-commit, commit-msg, prepare-commit-msg, and pre-push cases covered by the Git engine, and the focused tests are clear and pass locally.

I found one remaining path that needs the same treatment before approval. workspace.js has a separate run helper and invokes Git directly for clone and checkout, so those commands do not receive -c core.hooksPath=/dev/null.

I reproduced both consequences:

  • On a warm workspace with core.hooksPath pointing to a failing post-checkout, the hook ran and checkoutRepo returned branchOk: false.
  • During a fresh clone, a configured post-checkout ran inside the credential-bearing clone process and could read AIDLC_GIT_PASSWORD.

Could you route the Git invocations in workspace.js through the same hook-disabled runner, or through a shared wrapper that applies the policy consistently? Please also add regression cases for a failing post-checkout during warm reuse and a credential-reading post-checkout during clone.

One small test-strengthening item: the pre-push regression says that the new commit reached the remote, but it only checks that the remote head looks like a SHA. That branch already had a valid SHA before the push, so comparing the remote head directly with res.sha (or the local HEAD) would prove the intended behavior.

Thanks again for the thoughtful turnaround on the first review. This is close, and the updated approach is the right direction.

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.

2 participants