Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,29 @@
#
# Enable once per clone: git config core.hooksPath .githooks
# Skip once: git commit --no-verify (or SKIP_HOOKS=1)
# Skip the tests for good: git config cluckwork.hookTests false
# (the main-branch guard below still runs; only --no-verify skips it)
#
# Deliberately excluded: Cluckwork.Api.IntegrationTests (needs Docker, ~30s+)
# — CI runs the full suite on every PR. This is a ~2s tripwire.
# POSIX sh on purpose: git invokes the shebang directly, and sh exists
# everywhere git does (including git-for-windows).
set -eu

# Guard first (#1171). symbolic-ref reads the HEAD of the GIT_DIR git exports
# to hooks, so a linked worktree sees its own branch. Status 1 is a detached
# HEAD; any other failure refuses rather than letting the commit through.
if head=$(git symbolic-ref --quiet HEAD); then :; elif [ $? -ne 1 ]; then
echo "pre-commit: refusing: cannot read the current branch (git symbolic-ref failed)." >&2
exit 1
fi
if [ "$head" = refs/heads/main ]; then
echo "pre-commit: refusing to commit on main: main changes only through a merged PR. Run \`git switch -c <type>/<topic>\`, then commit there." >&2
exit 1
fi

[ "${SKIP_HOOKS:-0}" = "1" ] && exit 0
[ "$(git config --bool cluckwork.hookTests || echo true)" = false ] && exit 0

# Note: checks run against the WORKING TREE, not the staged snapshot — with
# partial staging (git add -p) the hook can pass while the staged content
Expand Down
73 changes: 73 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/bin/sh
# pre-push: refuse pushes that change main, rewrite history, move a pushed tag,
# or land a branch under another name (#1171).
#
# Enable once per clone: git config core.hooksPath .githooks
# Skip once: git push --no-verify
#
# Git passes one line per ref on stdin: <local ref> <local sha> <remote ref>
# <remote sha>, so this reads what will actually be pushed and parses no
# command line. POSIX sh on purpose (see pre-commit).
#
# Not covered: `git push --mirror` deletes remote refs the clone does not have
# without sending their records (git skips a ref with no local peer), so
# deleting main that way never reaches this hook. The agent command guard
# refuses --mirror, and GitHub's protection of main refuses the deletion.
set -eu

remote=$1

refuse() {
printf 'pre-push: refusing %s -> %s on %s: %s\n' "$local_ref" "$remote_ref" "$remote" "$1" >&2
exit 1
}

is_zero() { case $1 in *[!0]*) return 1 ;; esac; }

# `git push origin HEAD` reports the local ref as HEAD; names are compared by the branch it names.
# current is empty on a detached HEAD (status 1); any other lookup failure refuses.
current=$(git symbolic-ref --quiet HEAD) || [ $? -eq 1 ] || { echo "pre-push: refusing: cannot read the current branch." >&2; exit 1; }

full_ref() { case $1 in refs/* | HEAD) echo "$1" ;; *) echo "refs/heads/$1" ;; esac; }

# A remote.<name>.push refspec maps the branch on purpose (Paseo PR checkouts
# push HEAD:refs/heads/<pr-branch> from a suffixed local branch). Short names
# are read as branches; a leading + is ignored here because the history check
# above already refuses a forced update. A HEAD mapping covers only the
# checked-out branch, never a detached HEAD. Wildcard refspecs are not
# supported and never match.
configured() {
specs=$(git config --get-all "remote.$remote.push") || return 1
while IFS= read -r spec; do
spec=${spec#+}
[ "$(full_ref "${spec#*:}")" = "$remote_ref" ] || continue
src=$(full_ref "${spec%%:*}")
case $src in
HEAD) [ "$local_ref" = "$current" ] && return 0 ;; # a detached local ref stays HEAD
"$local_ref") return 0 ;;
esac
done <<EOF
$specs
EOF
return 1
}

while read -r local_ref local_sha remote_ref remote_sha; do
if [ "$remote_ref" = refs/heads/main ]; then
refuse "main changes only through a merged PR. Run \`git switch -c <type>/<topic>\`, then push that branch."
fi
is_zero "$local_sha" && continue # deleting another branch
case $remote_ref in refs/tags/*)
if ! is_zero "$remote_sha" && [ "$remote_sha" != "$local_sha" ]; then
refuse "a pushed tag is never moved or replaced, even when its commit moves forward. Push a new tag instead."
fi
esac
[ "$local_ref" = HEAD ] && [ -n "$current" ] && local_ref=$current
if ! is_zero "$remote_sha" && ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
refuse "not a fast-forward (a force push, or the remote has commits you lack). Fetch, merge, and push a new commit."
fi
if [ "$local_ref" != "$remote_ref" ] && ! configured; then
refuse "the local and remote names differ, a src:dst push (#900). Run \`git branch --show-current\`, then \`git push -u origin <that-branch>\`."
fi
done
exit 0
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ jobs:
# Node and the runner's Python 3, no toolchain setup needed.
- name: Run the classifier self-tests
run: node --test .github/scripts/changed-paths.test.mjs
- name: Run the git hooks table test
run: python3 tools/agent-guard/git_hooks_test.py
- name: Run the image-scan issue logic self-tests
run: node --test .github/scripts/image-scan-issues.test.mjs
- name: Run the release-line decision self-tests
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ A guard must *fail* when a later change violates an invariant, such as the migra

## Pre-commit hook (opt-in)

`git config core.hooksPath .githooks` enables a ~2s pre-commit hook: unit tests (domain + application) when `.cs`/`.csproj`/`.sln`/`.slnx` files are staged, `npm run typecheck` when `web/` files are staged. Integration tests are deliberately excluded (Docker, slow) — CI is the authority. Skip once with `--no-verify`.
`git config core.hooksPath .githooks` enables a ~2s pre-commit hook: unit tests (domain + application) when `.cs`/`.csproj`/`.sln`/`.slnx` files are staged, `npm run typecheck` when `web/` files are staged. Integration tests are deliberately excluded (Docker, slow) — CI is the authority. Skip once with `--no-verify`; `git config cluckwork.hookTests false` turns the tests off but keeps the guard below.

## CI security gates (#146)

Expand All @@ -124,6 +124,7 @@ CI rejects a PR when a **production** dependency has a known **high+** advisory:
- `origin` = GitHub (`github.com/mforce/cluckwork`); `gitea` = backup mirror. Use `gh` for PRs.
- **`main` is protected** — work on a branch, push it, and open a PR; never commit to `main`. Use `feat/…`, `chore/…`, `docs/…`, or `spec/…` branch names. PRs squash-merge.
- **A hook refuses forbidden git commands from agents.** `tools/agent-guard/git_guard.py` runs before every Bash call in Claude Code (`.claude/settings.json`) and Codex (`.codex/hooks.json`). A command that mentions `git push`, `git commit` or a PR merge, or could spell one through `$`, a backtick or a glob, must be one simple command, optionally after `cd <dir> &&`; chains, pipes, subshells, `$(…)`, variables and wrappers are refused, not parsed. In that form it blocks `gh pr merge`, a non-GET `gh api` call to `pulls/<n>/merge`, a literal `mergePullRequest` GraphQL call, `gh alias set`, `src:dst` and `+` refspecs, `--force`/`-f`/`--mirror`/`--all`/`--prune`, pushes whose source or configured destination is `main`, and `git commit` on `main`. It does not see gh or git aliases defined earlier, a merge query read from a file, commits made by merge, rebase, cherry-pick or pull, a push destination the remote resolves as a symbolic ref to `main`, or a Codex `workdir`. `git_guard_test.py` is its table test in CI. Codex skips a new or changed hook until it is trusted in `/hooks`.
- **Git hooks back up the agent guard for every harness and for people (#1171).** With `git config core.hooksPath .githooks`, `pre-commit` refuses a commit on `main` before any test step (linked worktrees included), and `pre-push` refuses a push to `refs/heads/main`, a non-fast-forward update (force, `+refspec` and `--force-with-lease` alike), moving or replacing a pushed tag, and a push whose local and remote branch names differ unless a non-wildcard `remote.<name>.push` refspec maps that source (a `HEAD:` mapping only for the checked-out branch). It cannot see a `--mirror` push delete a remote `main` the clone lacks, because git sends no record for it; the agent command guard refuses `--mirror`, and GitHub's protection of `main` refuses the deletion. `--no-verify` skips both. `tools/agent-guard/git_hooks_test.py` is their table test in CI. Pi (a project `.pi/extensions/` `tool_call` handler, after project trust) and Hermes (a project plugin, behind `HERMES_ENABLE_PROJECT_PLUGINS` and `plugins.enabled`) can block shell commands but carry no guard from this repo yet, so only these hooks cover them, and nothing stops their `gh pr merge`.
- **The PR title is the release note.** It is always the squashed commit subject (`squash_merge_commit_title=PR_TITLE`), which release-please parses for the changelog. A typo or non-conventional prefix silently drops the entry.
- **The version comes from the branch, not from commit types (#351).** Every release from `main` bumps the middle digit (`"versioning": "always-bump-minor"` in `release-please-config.json`); every release from a hotfix line `release/vX.Y.x` bumps the last digit (`always-bump-patch` in that line's own copy). The first digit moves only by hand, with `Release-As: 2.0.0` added **at merge time** (`gh pr merge <N> --squash --body "Release-As: 2.0.0"` or the squash dialog's extended description): squash commits here carry the PR title and an empty body (`squash_merge_commit_message=BLANK`), so a footer in a branch commit or the PR description is dropped. Commit types choose only the changelog section. A `!` in the PR title marks a breaking change there and moves no digit; a `BREAKING CHANGE:` footer is dropped like any other body text.
- **A commit-body parse error drops the whole commit** (no changelog entry, green run). A body reaches `main` only as a description typed at merge time; in one, never start a line with `word(` that has another `(` inside it. `.githooks/commit-msg` checks branch commits, which no longer reach `main`, and no local hook sees a **PR title** or merge-time text.
Expand Down
8 changes: 5 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ links there rather than restating rationale.
git config core.hooksPath .githooks
```

Enables two fast hooks: **pre-commit** (unit tests for staged .NET changes,
`npm run typecheck` for staged `web/` changes) and **commit-msg** (rejects a
message release-please would silently drop from the changelog). Integration tests
Enables three hooks: **pre-commit** (refuses a commit on `main`, then runs unit
tests for staged .NET changes and `npm run typecheck` for staged `web/` changes),
**commit-msg** (rejects a message release-please would silently drop from the
changelog) and **pre-push** (refuses a push to `main`, a force push, and a push
under another branch name). Integration tests
are deliberately excluded — Docker, slow; CI is the authority. Skip once with
`--no-verify`.

Expand Down
Loading