mirror of
https://github.com/actualbudget/actual.git
synced 2026-07-11 11:59:55 -05:00
* [AI] Fix git-guard false positives on heredoc and multi -m commits The [AI]-prefix check extracted the commit message with a greedy ".*-m" sed pattern, which broke in two real-world cases: - "git commit -m \"$(cat <<'EOF' ...)\"" — the pattern matched the command-substitution text on the first line and reported the message as "$(cat <<", blocking a correctly prefixed commit. - Multiple -m flags — the greedy match grabbed the LAST -m (typically a Co-Authored-By trailer), so valid commits were blocked and a missing prefix on the subject could slip through if the last paragraph happened to start with [AI]. Replace the extraction: find the FIRST -m/--message via shortest-prefix stripping (git takes the first -m as the subject), detect the heredoc-in-command-substitution form and read the subject from the heredoc body's first line, and otherwise check the inline string as before. Editor/amend/file-based commits remain untouched, as do the push, hook-skip, git-config and yarn-workspace guards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * [AI] Add release notes * [AI] Fail closed when heredoc detector matches without a body line The heredoc pattern also matches single-line command substitutions containing a here-string (e.g. <<< on one line), where reading the "second line" produced an empty message that the final check treated as "no inline message" and allowed — letting unprefixed subjects through. Take the first non-blank heredoc body line instead (matching git's leading-blank-line cleanup), and when no body line exists fall back to inline parsing so a statically unknowable message is blocked rather than waved through. Found by CodeRabbit review on #8192. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
130 lines
5.2 KiB
Bash
Executable File
130 lines
5.2 KiB
Bash
Executable File
#!/bin/sh
|
|
# Shared agent guard for shell/Bash commands (Claude PreToolUse[Bash],
|
|
# Codex PreToolUse[^Bash$], Cursor beforeShellExecution via adapter).
|
|
#
|
|
# Deterministically enforces the project's git-safety and workspace rules that
|
|
# used to live as prose in AGENTS.md / .github/agents/pr-and-commit-rules.md.
|
|
# Reads the command from `.tool_input.command` on stdin. Exit code 2 + stderr
|
|
# blocks the call and feeds the reason back to the agent.
|
|
#
|
|
# Best-effort by design: it catches the honest mistakes agents actually make
|
|
# (plain push-to-main, a forgotten [AI] commit prefix, --no-verify, yarn in a
|
|
# workspace). It does NOT try to defend against deliberate evasion via unusual
|
|
# shell forms (git global options before the subcommand, `git commit -F`/`-C`/
|
|
# `--amend`, etc.) — CI and branch protection are the real gates for what lands.
|
|
|
|
block() {
|
|
echo "$1" >&2
|
|
exit 2
|
|
}
|
|
|
|
# Fail closed: a malformed payload (jq parse failure) blocks rather than allows.
|
|
cmd=$(jq -r '.tool_input.command // empty' 2>/dev/null) ||
|
|
block "Blocked: could not parse the hook payload (.tool_input.command)."
|
|
[ -z "$cmd" ] && exit 0
|
|
|
|
# Enforce: always run yarn from the repo root, never in a child workspace.
|
|
# Catch cd/pushd into a package dir, or `yarn --cwd packages/...`.
|
|
case "$cmd" in
|
|
*"cd packages/"* | *"cd ./packages/"* | *"cd packages "* | *"cd ./packages "* | \
|
|
*"pushd packages/"* | *"pushd ./packages/"* | *"pushd packages "* | *"pushd ./packages "* | \
|
|
*"yarn --cwd packages/"* | *"yarn --cwd ./packages/"* | *"yarn --cwd packages "* | *"yarn --cwd ./packages "* | \
|
|
*"yarn --cwd=packages/"* | *"yarn --cwd=./packages/"* | *"yarn --cwd=packages "* | *"yarn --cwd=./packages "*)
|
|
case "$cmd" in
|
|
*yarn*)
|
|
block "Blocked: run yarn from the repo root, not a child workspace (AGENTS.md). Use 'yarn workspace <name> <cmd>' from the root instead." ;;
|
|
esac ;;
|
|
esac
|
|
|
|
# Everything below only applies to actual git invocations. Match `git` as a
|
|
# whole token (not a substring) so commands that merely mention git — e.g.
|
|
# `rg "git config" AGENTS.md` — aren't falsely blocked.
|
|
is_git=0
|
|
set -f
|
|
for tok in $cmd; do
|
|
[ "$tok" = "git" ] && {
|
|
is_git=1
|
|
break
|
|
}
|
|
done
|
|
set +f
|
|
[ "$is_git" -eq 1 ] || exit 0
|
|
|
|
# Never skip git hooks.
|
|
case "$cmd" in
|
|
*--no-verify* | *--no-gpg-sign*)
|
|
block "Blocked: skipping git hooks (--no-verify/--no-gpg-sign) is forbidden." ;;
|
|
esac
|
|
|
|
# Never change git config (reads are fine).
|
|
case "$cmd" in
|
|
*"git config"*)
|
|
case "$cmd" in
|
|
*--get* | *--list* | *" -l "* | *" -l") ;;
|
|
*) block "Blocked: 'git config' changes are forbidden." ;;
|
|
esac ;;
|
|
esac
|
|
|
|
# Never force-push or push to main/master.
|
|
case "$cmd" in
|
|
*"git push"*)
|
|
case "$cmd" in
|
|
*--force* | *" -f "* | *" -f")
|
|
block "Blocked: force push only on explicit user request. Use --force-with-lease and confirm with the user first." ;;
|
|
esac
|
|
# Match main/master only as a whole ref/token (padding avoids matching
|
|
# branches like "maintenance" or "main-feature"). Also catch explicit
|
|
# refspecs like `HEAD:refs/heads/main` / `origin refs/heads/master`.
|
|
case " $cmd " in
|
|
*" main "* | *" master "* | *":main "* | *":master "* | \
|
|
*" refs/heads/main "* | *" refs/heads/master "* | \
|
|
*":refs/heads/main "* | *":refs/heads/master "*)
|
|
block "Blocked: never push to main/master. Push the feature branch instead." ;;
|
|
esac ;;
|
|
esac
|
|
|
|
# Commit messages must start with [AI].
|
|
case "$cmd" in
|
|
*"git commit"*)
|
|
# The [AI] rule applies to the commit subject, which git takes from the
|
|
# FIRST -m/--message flag (later -m flags become body paragraphs, e.g. a
|
|
# Co-Authored-By trailer). ${var#pattern} strips the shortest matching
|
|
# prefix, i.e. finds the first occurrence; when both flag spellings are
|
|
# present, the longer remainder is the one whose flag came first.
|
|
rest=''
|
|
case "$cmd" in
|
|
*" --message"*)
|
|
rest=${cmd#*" --message"}
|
|
rest=${rest#=} ;;
|
|
esac
|
|
case "$cmd" in
|
|
*" -m"*)
|
|
m_rest=${cmd#*" -m"}
|
|
[ ${#m_rest} -gt ${#rest} ] && rest=$m_rest ;;
|
|
esac
|
|
# First line of the remainder tells us the message form. An empty result
|
|
# means no inline message (editor/--amend/-F) — left alone.
|
|
first=$(printf '%s\n' "$rest" | sed -n '1s/^[[:space:]]*//p')
|
|
msg=''
|
|
case "$first" in
|
|
*'$('*'<<'*)
|
|
# Heredoc via command substitution: -m "$(cat <<'EOF' ... EOF)".
|
|
# The subject is the first non-blank line of the heredoc body (git's
|
|
# default cleanup strips leading blank lines).
|
|
msg=$(printf '%s\n' "$rest" | sed -n '2,$p' |
|
|
sed -n '/[^[:space:]]/{s/^[[:space:]]*//;p;q;}') ;;
|
|
esac
|
|
# Inline string: strip one leading quote, truncate at the next quote
|
|
# (good enough for a prefix check). Also the fallback when the heredoc
|
|
# pattern matched but no body line followed — e.g. a single-line <<<
|
|
# here-string — so a statically unknowable message fails closed instead
|
|
# of slipping past as empty.
|
|
[ -n "$msg" ] || msg=$(printf '%s\n' "$first" | sed "s/^['\"]//; s/['\"].*//")
|
|
case "$msg" in
|
|
"" | "[AI]"*) ;;
|
|
*) block "Blocked: commit messages must start with '[AI]'. Got: $msg" ;;
|
|
esac ;;
|
|
esac
|
|
|
|
exit 0
|