From 3a344d13197eb8f6637e5516f780996d6077cf7a Mon Sep 17 00:00:00 2001 From: cyre Date: Wed, 29 Jul 2026 13:04:05 +0000 Subject: [PATCH 1/6] fix(git): make commit-clean merge-aware for MERGE_HEAD lineage --- scripts/git/commit-clean.sh | 60 ++++++++++++----- scripts/git/commit_clean_test.sh | 108 +++++++++++++++++++++++++++++-- 2 files changed, 146 insertions(+), 22 deletions(-) diff --git a/scripts/git/commit-clean.sh b/scripts/git/commit-clean.sh index 6f51b4ff6..8937ddf86 100755 --- a/scripts/git/commit-clean.sh +++ b/scripts/git/commit-clean.sh @@ -14,6 +14,10 @@ MANDATORY sequence (agents — never skip or reorder): Unstaged edits are preserved but are NOT included in the commit. Uses git commit-tree so Cursor commit-msg hooks never run. +During an in-progress merge (MERGE_HEAD present), parent lineage is preserved +automatically: HEAD is the first parent and every SHA listed in MERGE_HEAD is +added as an additional parent (two-parent merges and N-way octopus merges). + Options: --amend Replace HEAD (parent becomes HEAD^) --allow-empty Allow intentional empty commits (rare) @@ -104,37 +108,54 @@ esac verify_args=() [[ "$allow_empty" -eq 1 ]] && verify_args+=(--allow-empty) [[ "$amend" -eq 1 ]] && verify_args+=(--amend) -if ((${#verify_args[@]} > 0)); then - bash "$SCRIPT_DIR/verify-staged-for-commit.sh" "${verify_args[@]}" -else - bash "$SCRIPT_DIR/verify-staged-for-commit.sh" -fi +bash "$SCRIPT_DIR/verify-staged-for-commit.sh" "${verify_args[@]}" tree="$(git write-tree)" + +merge_head_path="$(git rev-parse --git-path MERGE_HEAD)" +in_merge=0 +if [[ "$amend" -eq 0 && -f "$merge_head_path" ]]; then + in_merge=1 +fi + +parents=() if [[ "$amend" -eq 1 ]]; then - parent="$(git rev-parse HEAD^)" -else - if git rev-parse HEAD >/dev/null 2>&1; then - parent="$(git rev-parse HEAD)" - else - parent="" - fi + parents+=("$(git rev-parse HEAD^)") +elif git rev-parse HEAD >/dev/null 2>&1; then + parents+=("$(git rev-parse HEAD)") +fi + +if [[ "$in_merge" -eq 1 ]]; then + while IFS= read -r merge_parent || [[ -n "$merge_parent" ]]; do + merge_parent="${merge_parent%%#*}" + merge_parent="${merge_parent//[[:space:]]/}" + [[ -n "$merge_parent" ]] || continue + parents+=("$merge_parent") + done <"$merge_head_path" fi if [[ "$dry_run" -eq 1 ]]; then echo "commit-clean: dry-run — would create commit on tree ${tree}" >&2 - if [[ -n "$parent" ]]; then - echo "commit-clean: dry-run — parent ${parent}" >&2 + if ((${#parents[@]} > 0)); then + echo "commit-clean: dry-run — parents ${parents[*]}" >&2 + fi + if [[ "$in_merge" -eq 1 ]]; then + echo "commit-clean: dry-run — merge in progress; MERGE_HEAD parents retained" >&2 fi printf '%s\n' "$message" exit 0 fi -if [[ -n "$parent" ]]; then +commit_tree_args=("$tree") +for parent_sha in "${parents[@]}"; do + commit_tree_args+=(-p "$parent_sha") +done + +if ((${#commit_tree_args[@]} > 1)); then new_sha="$( printf '%s\n' "$message" | GIT_AUTHOR_NAME="$author_name" GIT_AUTHOR_EMAIL="$author_email" \ - git commit-tree "$tree" -p "$parent" -F - + git commit-tree "${commit_tree_args[@]}" -F - )" else new_sha="$( @@ -151,4 +172,11 @@ else git update-ref HEAD "$new_sha" fi +if [[ "$in_merge" -eq 1 ]]; then + rm -f \ + "$merge_head_path" \ + "$(git rev-parse --git-path MERGE_MODE)" \ + "$(git rev-parse --git-path MERGE_MSG)" +fi + echo "$new_sha" diff --git a/scripts/git/commit_clean_test.sh b/scripts/git/commit_clean_test.sh index 475d4414a..ed1c32006 100755 --- a/scripts/git/commit_clean_test.sh +++ b/scripts/git/commit_clean_test.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Regression tests for commit-clean empty-commit guards. +# Regression tests for commit-clean empty-commit guards and merge parent lineage. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -35,30 +35,30 @@ for helper in commit-clean.sh verify-staged-for-commit.sh banned_attribution_lib done printf 'base\n' >"$tmp/README.md" git -C "$tmp" add README.md -run_in_repo "$tmp" bash "$tmp/scripts/git/commit-clean.sh" -m "init" >/dev/null +git -C "$tmp" commit -q -m "init" printf 'unstaged only\n' >"$tmp/README.md" -if run_in_repo "$tmp" bash "$tmp/scripts/git/commit-clean.sh" -m "should fail unstaged" >/dev/null 2>&1; then +if run_in_repo "$tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "should fail unstaged" >/dev/null 2>&1; then fail "commit-clean must reject unstaged-only working tree" else pass "blocks commit when edits are unstaged" fi -if run_in_repo "$tmp" bash "$tmp/scripts/git/verify-staged-for-commit.sh" >/dev/null 2>&1; then +if run_in_repo "$tmp" bash "$SCRIPT_DIR/verify-staged-for-commit.sh" >/dev/null 2>&1; then fail "verify-staged must reject empty index" else pass "verify-staged rejects empty index" fi git -C "$tmp" add README.md -if ! run_in_repo "$tmp" bash "$tmp/scripts/git/verify-staged-for-commit.sh" >/dev/null 2>&1; then +if ! run_in_repo "$tmp" bash "$SCRIPT_DIR/verify-staged-for-commit.sh" >/dev/null 2>&1; then fail "verify-staged must accept staged delta" else pass "verify-staged accepts staged delta" fi -sha="$(run_in_repo "$tmp" bash "$tmp/scripts/git/commit-clean.sh" -m "staged commit")" +sha="$(run_in_repo "$tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "staged commit")" if [[ -z "$sha" ]]; then fail "commit-clean must return a sha for staged commit" else @@ -77,6 +77,102 @@ else pass "committed tree includes staged content" fi +merge_tmp="$(mktemp -d)" +git -C "$merge_tmp" init -q +git -C "$merge_tmp" config user.name "Test User" +git -C "$merge_tmp" config user.email "codex@openai.com" +mkdir -p "$merge_tmp/scripts/git" +for helper in commit-clean.sh verify-staged-for-commit.sh banned_attribution_lib.sh; do + install -m 0755 "$SCRIPT_DIR/$helper" "$merge_tmp/scripts/git/$helper" +done +printf 'main\n' >"$merge_tmp/README.md" +git -C "$merge_tmp" add README.md +git -C "$merge_tmp" commit -q -m "main init" +git -C "$merge_tmp" branch -M main +main_sha="$(git -C "$merge_tmp" rev-parse HEAD)" + +git -C "$merge_tmp" checkout -q -b feature +printf 'feature\n' >"$merge_tmp/README.md" +git -C "$merge_tmp" add README.md +git -C "$merge_tmp" commit -q -m "feature change" +feature_sha="$(git -C "$merge_tmp" rev-parse HEAD)" + +git -C "$merge_tmp" checkout -q main +git -C "$merge_tmp" merge --no-commit --no-ff feature >/dev/null 2>&1 || true +printf 'merged\n' >"$merge_tmp/README.md" +git -C "$merge_tmp" add README.md + +merge_sha="$(run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "merge: feature into main")" +parent_count="$(git -C "$merge_tmp" show -s --format=%P "$merge_sha" | wc -w | tr -d ' ')" +if [[ "$parent_count" -ne 2 ]]; then + fail "merge commit must retain two parents (got ${parent_count})" +else + pass "merge commit retains two parents" +fi + +first_parent="$(git -C "$merge_tmp" rev-parse "${merge_sha}^1")" +second_parent="$(git -C "$merge_tmp" rev-parse "${merge_sha}^2")" +if [[ "$first_parent" != "$main_sha" || "$second_parent" != "$feature_sha" ]]; then + fail "merge parents must be main (${main_sha}) and feature (${feature_sha})" +else + pass "merge parents match HEAD and MERGE_HEAD lineage" +fi + +if [[ -f "$merge_tmp/.git/MERGE_HEAD" ]]; then + fail "MERGE_HEAD must be cleared after commit-clean merge finalization" +else + pass "MERGE_HEAD cleared after merge commit" +fi + +octopus_tmp="$(mktemp -d)" +git -C "$octopus_tmp" init -q +git -C "$octopus_tmp" config user.name "Test User" +git -C "$octopus_tmp" config user.email "codex@openai.com" +mkdir -p "$octopus_tmp/scripts/git" +for helper in commit-clean.sh verify-staged-for-commit.sh banned_attribution_lib.sh; do + install -m 0755 "$SCRIPT_DIR/$helper" "$octopus_tmp/scripts/git/$helper" +done +printf 'base\n' >"$octopus_tmp/README.md" +git -C "$octopus_tmp" add README.md +git -C "$octopus_tmp" commit -q -m "base" +git -C "$octopus_tmp" branch -M main +octopus_main="$(git -C "$octopus_tmp" rev-parse HEAD)" + +git -C "$octopus_tmp" checkout -q -b branch-a +printf 'a\n' >"$octopus_tmp/a.txt" +git -C "$octopus_tmp" add a.txt +git -C "$octopus_tmp" commit -q -m "branch a" +branch_a="$(git -C "$octopus_tmp" rev-parse HEAD)" + +git -C "$octopus_tmp" checkout -q -b branch-b +printf 'b\n' >"$octopus_tmp/b.txt" +git -C "$octopus_tmp" add b.txt +git -C "$octopus_tmp" commit -q -m "branch b" +branch_b="$(git -C "$octopus_tmp" rev-parse HEAD)" + +git -C "$octopus_tmp" checkout -q main +printf 'ab\n' >"$octopus_tmp/a.txt" +printf 'ab\n' >"$octopus_tmp/b.txt" +git -C "$octopus_tmp" add a.txt b.txt +{ + printf '%s\n' "$branch_a" + printf '%s\n' "$branch_b" +} >"$octopus_tmp/.git/MERGE_HEAD" + +octopus_sha="$(run_in_repo "$octopus_tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "merge: octopus")" +octopus_parent_count="$(git -C "$octopus_tmp" show -s --format=%P "$octopus_sha" | wc -w | tr -d ' ')" +if [[ "$octopus_parent_count" -ne 3 ]]; then + fail "octopus merge commit must retain three parents (got ${octopus_parent_count})" +else + pass "octopus merge retains three parents" +fi + +if [[ "$(git -C "$octopus_tmp" rev-parse "${octopus_sha}^1")" != "$octopus_main" ]]; then + fail "octopus first parent must be pre-merge HEAD" +else + pass "octopus first parent is HEAD" +fi + if [[ "$failures" -gt 0 ]]; then echo "commit_clean_test: $failures failure(s)" >&2 exit 1 From 375be392266cd143c3bb1d1de1d0d21d19765f45 Mon Sep 17 00:00:00 2001 From: cyre Date: Wed, 29 Jul 2026 20:13:23 +0000 Subject: [PATCH 2/6] test(git): clean up all temp repos in commit_clean_test EXIT trap --- scripts/git/commit_clean_test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/git/commit_clean_test.sh b/scripts/git/commit_clean_test.sh index ed1c32006..5ed66e304 100755 --- a/scripts/git/commit_clean_test.sh +++ b/scripts/git/commit_clean_test.sh @@ -24,7 +24,7 @@ run_in_repo() { } tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT +trap 'rm -rf -- "$tmp" "${merge_tmp:-}" "${octopus_tmp:-}"' EXIT git -C "$tmp" init -q git -C "$tmp" config user.name "Test User" From d802f34cb2c0f07b7c43e9c9359551df3b2348d5 Mon Sep 17 00:00:00 2001 From: cyre Date: Wed, 29 Jul 2026 20:28:19 +0000 Subject: [PATCH 3/6] test(git): extend commit-clean merge regression coverage --- scripts/git/commit_clean_test.sh | 74 ++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/scripts/git/commit_clean_test.sh b/scripts/git/commit_clean_test.sh index 5ed66e304..901f6cbb5 100755 --- a/scripts/git/commit_clean_test.sh +++ b/scripts/git/commit_clean_test.sh @@ -102,6 +102,23 @@ git -C "$merge_tmp" merge --no-commit --no-ff feature >/dev/null 2>&1 || true printf 'merged\n' >"$merge_tmp/README.md" git -C "$merge_tmp" add README.md +pre_dry_head="$(git -C "$merge_tmp" rev-parse HEAD)" +dry_out="$(run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" --dry-run -m "dry-run merge" 2>&1)" +if [[ "$(git -C "$merge_tmp" rev-parse HEAD)" != "$pre_dry_head" ]]; then + fail "dry-run must not move branch tip" +else + pass "dry-run leaves branch tip unchanged" +fi +if [[ ! -f "$merge_tmp/.git/MERGE_HEAD" ]]; then + fail "dry-run must preserve MERGE_HEAD during in-progress merge" +else + pass "dry-run preserves MERGE_HEAD" +fi +case "$dry_out" in + *"merge in progress"*) pass "dry-run reports merge-aware parent retention" ;; + *) fail "dry-run must mention merge in progress" ;; +esac + merge_sha="$(run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "merge: feature into main")" parent_count="$(git -C "$merge_tmp" show -s --format=%P "$merge_sha" | wc -w | tr -d ' ')" if [[ "$parent_count" -ne 2 ]]; then @@ -124,6 +141,63 @@ else pass "MERGE_HEAD cleared after merge commit" fi +git -C "$merge_tmp" checkout -q -b mode-cleanup "$main_sha" +git -C "$merge_tmp" checkout -q -b feature2 "$feature_sha" +git -C "$merge_tmp" checkout -q mode-cleanup +git -C "$merge_tmp" merge --no-commit --no-ff feature2 >/dev/null 2>&1 || true +printf 'mode cleanup\n' >"$merge_tmp/README.md" +printf 'merge msg\n' >"$merge_tmp/.git/MERGE_MSG" +git -C "$merge_tmp" add README.md +run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "merge: verify mode cleanup" >/dev/null +for artifact in MERGE_HEAD MERGE_MODE MERGE_MSG; do + if [[ -f "$merge_tmp/.git/$artifact" ]]; then + fail "$artifact must be cleared after commit-clean merge finalization" + else + pass "$artifact cleared after merge commit" + fi +done + +git -C "$merge_tmp" checkout -q -b amend-target "$main_sha" +printf 'amend me\n' >"$merge_tmp/README.md" +git -C "$merge_tmp" add README.md +run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "first" >/dev/null +printf '%s\n' "$feature_sha" >"$merge_tmp/.git/MERGE_HEAD" +printf 'amended\n' >"$merge_tmp/README.md" +git -C "$merge_tmp" add README.md +amend_sha="$(run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" --amend -m "amended")" +amend_parent_count="$(git -C "$merge_tmp" show -s --format=%P "$amend_sha" | wc -w | tr -d ' ')" +if [[ "$amend_parent_count" -ne 1 ]]; then + fail "--amend must ignore MERGE_HEAD and keep a single parent (got ${amend_parent_count})" +else + pass "--amend ignores MERGE_HEAD during merge state" +fi + +git -C "$merge_tmp" checkout -q -b preserve-unstaged "$main_sha" +git -C "$merge_tmp" merge --no-commit --no-ff feature2 >/dev/null 2>&1 || true +printf 'staged merge\n' >"$merge_tmp/README.md" +printf 'leave unstaged\n' >"$merge_tmp/UNSTAGED.txt" +git -C "$merge_tmp" add README.md +run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "merge: preserve unstaged" >/dev/null +if [[ ! -f "$merge_tmp/UNSTAGED.txt" ]]; then + fail "unstaged file must survive commit-clean merge commit" +elif git -C "$merge_tmp" ls-files --error-unmatch UNSTAGED.txt >/dev/null 2>&1; then + fail "commit-clean must not stage unstaged files during merge commit" +else + pass "unstaged untracked file preserved across merge commit-clean" +fi +if [[ "$(cat "$merge_tmp/UNSTAGED.txt")" != "leave unstaged" ]]; then + fail "unstaged file contents must remain intact" +else + pass "unstaged file contents intact" +fi + +single_parent_count="$(git -C "$tmp" show -s --format=%P "$sha" | wc -w | tr -d ' ')" +if [[ "$single_parent_count" -ne 1 ]]; then + fail "non-merge commit must have exactly one parent (got ${single_parent_count})" +else + pass "non-merge commit retains single parent" +fi + octopus_tmp="$(mktemp -d)" git -C "$octopus_tmp" init -q git -C "$octopus_tmp" config user.name "Test User" From ecc4cf692fa24aa3160a6ef44f597e22c945d518 Mon Sep 17 00:00:00 2001 From: cyre Date: Wed, 29 Jul 2026 20:49:21 +0000 Subject: [PATCH 4/6] test(git): harden commit_clean_test trap and merge setup --- scripts/git/commit-clean.sh | 2 +- scripts/git/commit_clean_test.sh | 28 ++++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/scripts/git/commit-clean.sh b/scripts/git/commit-clean.sh index 8937ddf86..fcbc22399 100755 --- a/scripts/git/commit-clean.sh +++ b/scripts/git/commit-clean.sh @@ -151,7 +151,7 @@ for parent_sha in "${parents[@]}"; do commit_tree_args+=(-p "$parent_sha") done -if ((${#commit_tree_args[@]} > 1)); then +if ((${#parents[@]} > 0)); then new_sha="$( printf '%s\n' "$message" | GIT_AUTHOR_NAME="$author_name" GIT_AUTHOR_EMAIL="$author_email" \ diff --git a/scripts/git/commit_clean_test.sh b/scripts/git/commit_clean_test.sh index 901f6cbb5..462968a4e 100755 --- a/scripts/git/commit_clean_test.sh +++ b/scripts/git/commit_clean_test.sh @@ -24,7 +24,22 @@ run_in_repo() { } tmp="$(mktemp -d)" -trap 'rm -rf -- "$tmp" "${merge_tmp:-}" "${octopus_tmp:-}"' EXIT +trap 'rm -rf -- "$tmp" ${merge_tmp:+"$merge_tmp"} ${octopus_tmp:+"$octopus_tmp"}' EXIT + +require_merge() { + local repo="$1" + local onto="$2" + local branch="$3" + git -C "$repo" checkout -q "$onto" + if ! git -C "$repo" merge --no-commit --no-ff "$branch" >/dev/null 2>&1; then + fail "merge --no-commit --no-ff ${branch} must succeed on ${onto}" + return 1 + fi + if [[ ! -f "$repo/.git/MERGE_HEAD" ]]; then + fail "merge must create MERGE_HEAD for ${branch} onto ${onto}" + return 1 + fi +} git -C "$tmp" init -q git -C "$tmp" config user.name "Test User" @@ -98,7 +113,7 @@ git -C "$merge_tmp" commit -q -m "feature change" feature_sha="$(git -C "$merge_tmp" rev-parse HEAD)" git -C "$merge_tmp" checkout -q main -git -C "$merge_tmp" merge --no-commit --no-ff feature >/dev/null 2>&1 || true +require_merge "$merge_tmp" main feature printf 'merged\n' >"$merge_tmp/README.md" git -C "$merge_tmp" add README.md @@ -144,7 +159,7 @@ fi git -C "$merge_tmp" checkout -q -b mode-cleanup "$main_sha" git -C "$merge_tmp" checkout -q -b feature2 "$feature_sha" git -C "$merge_tmp" checkout -q mode-cleanup -git -C "$merge_tmp" merge --no-commit --no-ff feature2 >/dev/null 2>&1 || true +require_merge "$merge_tmp" mode-cleanup feature2 printf 'mode cleanup\n' >"$merge_tmp/README.md" printf 'merge msg\n' >"$merge_tmp/.git/MERGE_MSG" git -C "$merge_tmp" add README.md @@ -172,8 +187,13 @@ else pass "--amend ignores MERGE_HEAD during merge state" fi +rm -f \ + "$merge_tmp/.git/MERGE_HEAD" \ + "$merge_tmp/.git/MERGE_MODE" \ + "$merge_tmp/.git/MERGE_MSG" + git -C "$merge_tmp" checkout -q -b preserve-unstaged "$main_sha" -git -C "$merge_tmp" merge --no-commit --no-ff feature2 >/dev/null 2>&1 || true +require_merge "$merge_tmp" preserve-unstaged feature2 printf 'staged merge\n' >"$merge_tmp/README.md" printf 'leave unstaged\n' >"$merge_tmp/UNSTAGED.txt" git -C "$merge_tmp" add README.md From 2618333b77f6bd7bea65e026ca93d65428862ae8 Mon Sep 17 00:00:00 2001 From: cyre Date: Wed, 29 Jul 2026 21:03:21 +0000 Subject: [PATCH 5/6] test(git): short-circuit commit_clean_test scenarios on require_merge failure --- scripts/git/commit_clean_test.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/git/commit_clean_test.sh b/scripts/git/commit_clean_test.sh index 462968a4e..a1f42e817 100755 --- a/scripts/git/commit_clean_test.sh +++ b/scripts/git/commit_clean_test.sh @@ -113,7 +113,7 @@ git -C "$merge_tmp" commit -q -m "feature change" feature_sha="$(git -C "$merge_tmp" rev-parse HEAD)" git -C "$merge_tmp" checkout -q main -require_merge "$merge_tmp" main feature +if require_merge "$merge_tmp" main feature; then printf 'merged\n' >"$merge_tmp/README.md" git -C "$merge_tmp" add README.md @@ -155,11 +155,12 @@ if [[ -f "$merge_tmp/.git/MERGE_HEAD" ]]; then else pass "MERGE_HEAD cleared after merge commit" fi +fi git -C "$merge_tmp" checkout -q -b mode-cleanup "$main_sha" git -C "$merge_tmp" checkout -q -b feature2 "$feature_sha" git -C "$merge_tmp" checkout -q mode-cleanup -require_merge "$merge_tmp" mode-cleanup feature2 +if require_merge "$merge_tmp" mode-cleanup feature2; then printf 'mode cleanup\n' >"$merge_tmp/README.md" printf 'merge msg\n' >"$merge_tmp/.git/MERGE_MSG" git -C "$merge_tmp" add README.md @@ -171,6 +172,7 @@ for artifact in MERGE_HEAD MERGE_MODE MERGE_MSG; do pass "$artifact cleared after merge commit" fi done +fi git -C "$merge_tmp" checkout -q -b amend-target "$main_sha" printf 'amend me\n' >"$merge_tmp/README.md" @@ -193,7 +195,7 @@ rm -f \ "$merge_tmp/.git/MERGE_MSG" git -C "$merge_tmp" checkout -q -b preserve-unstaged "$main_sha" -require_merge "$merge_tmp" preserve-unstaged feature2 +if require_merge "$merge_tmp" preserve-unstaged feature2; then printf 'staged merge\n' >"$merge_tmp/README.md" printf 'leave unstaged\n' >"$merge_tmp/UNSTAGED.txt" git -C "$merge_tmp" add README.md @@ -210,6 +212,7 @@ if [[ "$(cat "$merge_tmp/UNSTAGED.txt")" != "leave unstaged" ]]; then else pass "unstaged file contents intact" fi +fi single_parent_count="$(git -C "$tmp" show -s --format=%P "$sha" | wc -w | tr -d ' ')" if [[ "$single_parent_count" -ne 1 ]]; then From c0b1ef7fe0ff4a81acae8a052200b1cc24b92836 Mon Sep 17 00:00:00 2001 From: cyre Date: Wed, 29 Jul 2026 21:57:04 +0000 Subject: [PATCH 6/6] chore(git): remove orama attribution-guard scripts from periscope --- .cursor/rules/banned-attribution-local.mdc | 15 - .../rules/never-undo-attribution-expunge.mdc | 40 -- .cursor/rules/no-commit-attribution.mdc | 20 - .../zero-banned-attribution-everywhere.mdc | 17 - AGENTS.md | 18 +- .../git/apply-attribution-guard-all-repos.sh | 71 -- scripts/git/audit_attribution.sh | 9 - scripts/git/audit_engine.py | 623 ------------------ scripts/git/banned_attribution_lib.sh | 187 ------ scripts/git/check_commit_message.sh | 162 ----- scripts/git/check_identity.sh | 9 - scripts/git/commit-clean.sh | 182 ----- scripts/git/commit_clean_test.sh | 278 -------- scripts/git/cursor-hooks-id.sh | 15 - scripts/git/daily-attribution-guard.sh | 88 --- .../git/disable-cursor-commit-attribution.sh | 46 -- scripts/git/expunge-all-workspace-repos.sh | 118 ---- scripts/git/hooks/commit-msg.strip-coauthor | 39 -- scripts/git/identity-policy.json | 39 -- scripts/git/identity-policy.schema.json | 47 -- .../git/neutralize-cursor-coauthor-hook.sh | 65 -- scripts/git/scan-tracked-banned-tokens.sh | 31 - scripts/git/sync-attribution-guard-scripts.sh | 69 -- scripts/git/sync-banned-patterns-to-repo.sh | 27 - scripts/git/verify-git-guards.sh | 135 ---- scripts/git/verify-guard-parity.sh | 82 --- scripts/git/verify-staged-for-commit.sh | 99 --- 27 files changed, 5 insertions(+), 2526 deletions(-) delete mode 100644 .cursor/rules/banned-attribution-local.mdc delete mode 100644 .cursor/rules/never-undo-attribution-expunge.mdc delete mode 100644 .cursor/rules/no-commit-attribution.mdc delete mode 100644 .cursor/rules/zero-banned-attribution-everywhere.mdc delete mode 100644 scripts/git/apply-attribution-guard-all-repos.sh delete mode 100644 scripts/git/audit_attribution.sh delete mode 100755 scripts/git/audit_engine.py delete mode 100644 scripts/git/banned_attribution_lib.sh delete mode 100644 scripts/git/check_commit_message.sh delete mode 100644 scripts/git/check_identity.sh delete mode 100755 scripts/git/commit-clean.sh delete mode 100755 scripts/git/commit_clean_test.sh delete mode 100644 scripts/git/cursor-hooks-id.sh delete mode 100644 scripts/git/daily-attribution-guard.sh delete mode 100644 scripts/git/disable-cursor-commit-attribution.sh delete mode 100644 scripts/git/expunge-all-workspace-repos.sh delete mode 100644 scripts/git/hooks/commit-msg.strip-coauthor delete mode 100644 scripts/git/identity-policy.json delete mode 100644 scripts/git/identity-policy.schema.json delete mode 100644 scripts/git/neutralize-cursor-coauthor-hook.sh delete mode 100644 scripts/git/scan-tracked-banned-tokens.sh delete mode 100755 scripts/git/sync-attribution-guard-scripts.sh delete mode 100644 scripts/git/sync-banned-patterns-to-repo.sh delete mode 100755 scripts/git/verify-git-guards.sh delete mode 100644 scripts/git/verify-guard-parity.sh delete mode 100755 scripts/git/verify-staged-for-commit.sh diff --git a/.cursor/rules/banned-attribution-local.mdc b/.cursor/rules/banned-attribution-local.mdc deleted file mode 100644 index c8eb655ae..000000000 --- a/.cursor/rules/banned-attribution-local.mdc +++ /dev/null @@ -1,15 +0,0 @@ ---- -description: Load gitignored private attribution lessons before any git commit -alwaysApply: true ---- - -# Private attribution (never publish tokens) - -Before `git commit` or `git push`: - -1. Read **`.cursor/private/agent-lesson-git-attribution.md`** (gitignored — not on GitHub). -2. Read **`.cursor/private/banned-attribution-patterns`** for the live token list. -3. If missing: `bash scripts/cursor/install-user-git-environment.sh` (pulls from orama + sync). -4. **Never** copy banned tokens into tracked files, commit messages, PR text, or LESSONS. - -Public policy (no token examples): `../orama-system/docs/wiki/08-git-hygiene-and-branching.md` diff --git a/.cursor/rules/never-undo-attribution-expunge.mdc b/.cursor/rules/never-undo-attribution-expunge.mdc deleted file mode 100644 index 511336bb1..000000000 --- a/.cursor/rules/never-undo-attribution-expunge.mdc +++ /dev/null @@ -1,40 +0,0 @@ ---- -description: Re-introducing expunged commit attribution forces destructive main rewrites — never do it -alwaysApply: true ---- - -# Attribution expunge is fragile — do not undo it - -## The loop you must break - -Every time an agent adds **forbidden** `Co-authored-by` trailers (listed in `.cursor/private/banned-attribution-patterns`, never on GitHub), the owner must: - -1. Rewrite history (`bash scripts/git/expunge-all-workspace-repos.sh` or per-repo expunge) -2. Force-push **`main` and every branch** again - -That is destructive for all collaborators. **Do not cause another rewrite.** - -## Non-negotiable agent behavior - -| Do | Do not | -|----|--------| -| Read `.cursor/private/agent-lesson-git-attribution.md` before any git write | Copy forbidden tokens into tracked files, commits, PRs, or LESSONS | -| `git add` → `verify-staged-for-commit.sh` → `commit-clean.sh` (in that order) | Run `commit-clean.sh` without staging or verify | -| Push with `bash scripts/git/publish-clean-branch.sh main origin` | Raw `git push` without audit | -| Run `bash scripts/git/daily-attribution-guard.sh` at session start | "Document" forbidden emails in repo docs so you remember | - -```bash -git add -bash scripts/git/verify-staged-for-commit.sh # must print OK + non-empty stat -bash scripts/git/commit-clean.sh -m "type(scope): summary" -git show --stat --oneline HEAD # confirm file delta before push -``` - -## Session checklist (every day) - -```bash -bash scripts/git/daily-attribution-guard.sh -bash scripts/git/install-local-hooks.sh -``` - -If CI or scan fails: fix before any new commits. Never patch by echoing forbidden identities in commit messages. diff --git a/.cursor/rules/no-commit-attribution.mdc b/.cursor/rules/no-commit-attribution.mdc deleted file mode 100644 index 5f4549433..000000000 --- a/.cursor/rules/no-commit-attribution.mdc +++ /dev/null @@ -1,20 +0,0 @@ ---- -description: Never add Cursor or third-party co-author trailers to git commits -alwaysApply: true ---- - -# Git commit attribution (cloud + agent) - -When committing in this repository: - -1. **Never** add `Co-authored-by:`, `Made-with: Cursor`, or any Cursor/agent attribution trailer to commit messages. -2. **Cursor cloud** installs guards automatically: `bash scripts/cursor/install-user-git-environment.sh` (also via `.cursor/environment.json` → `cloud-bootstrap.sh` on VM start). -3. **Every clone** must run `bash scripts/git/install-local-hooks.sh` (enforced by pre-commit + CI). -4. Use only approved **author** identity: `cyre ` or `cyre ` (or `Codex `). Never `Cursor Agent` as author. -5. **Before every push:** `pre-push` runs attribution audit — banned `Co-authored-by` never reaches GitHub. -6. To publish: `bash scripts/git/publish-clean-branch.sh ` (neutralize → verify → audit → push). -7. **Banned identities:** read `.cursor/private/agent-lesson-git-attribution.md` (gitignored). - Do not echo banned tokens in tracked docs. Use `bash scripts/git/commit-clean.sh` if Cursor injects trailers. -8. **Never** `git push` without hooks installed (`bash scripts/git/install-local-hooks.sh`). - -Desktop (optional): Cursor Settings → Agents → Attribution → OFF (IDE/CLI only; cloud may still inject until guards run). diff --git a/.cursor/rules/zero-banned-attribution-everywhere.mdc b/.cursor/rules/zero-banned-attribution-everywhere.mdc deleted file mode 100644 index c1e755bc4..000000000 --- a/.cursor/rules/zero-banned-attribution-everywhere.mdc +++ /dev/null @@ -1,17 +0,0 @@ ---- -description: Banned identities must not appear in code, commits, authors, or messages — ever on GitHub -alwaysApply: true ---- - -# Zero tolerance (four layers) - -Forbidden identities are listed only in `.cursor/private/banned-attribution-patterns` (gitignored). - -They must not appear in: - -1. **Tracked code or docs** — CI runs `bash scripts/git/scan-tracked-banned-tokens.sh` -2. **Commit author or committer** — `check_identity.sh` + `audit_attribution.sh` -3. **Commit message body** (including `Co-authored-by`) — strip hook + `check_commit_message.sh` + `commit-clean.sh` -4. **GitHub at all** — `pre-push` audit; if leaked, run `expunge-all-workspace-repos.sh` and force-push every branch - -Re-adding after an expunge forces another destructive `main` rewrite. Do not document forbidden tokens in the repo to "remember" them — read `.cursor/private/agent-lesson-git-attribution.md` only. diff --git a/AGENTS.md b/AGENTS.md index 5972fc9e2..fa6b753ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -356,19 +356,11 @@ GitHub Actions service container in `.github/workflows/ci.yml`. ## Cursor Cloud: git commits -Run on cloud VM boot: - -```bash -bash scripts/git/apply-attribution-guard-all-repos.sh -``` - -Hook-free commit when needed: - -```bash -bash scripts/git/commit-clean.sh -m "type(scope): summary" -``` - -See orama-system `docs/wiki/09-cursor-cloud-commit-attribution.md` (canonical). +periscope is **excluded** from orama attribution-guard scripts (`commit-clean.sh`, +`sync-attribution-guard-scripts.sh`, etc.). Use standard `git commit` per the Git +Rules section above. PT and AlphaClaw in the same cloud workspace run +`bash ../orama-system/scripts/git/apply-attribution-guard-all-repos.sh` from their +repos (periscope is skipped automatically). **Fork policy:** integration branch is `merged`. Open agent PRs from `cursor/*` branches → `merged` (never → `main`). diff --git a/scripts/git/apply-attribution-guard-all-repos.sh b/scripts/git/apply-attribution-guard-all-repos.sh deleted file mode 100644 index bab2e1a9c..000000000 --- a/scripts/git/apply-attribution-guard-all-repos.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# Apply mandatory git hooks to Perpetua-Tools + sibling repos (Cursor session + manual). -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -DISABLE="$SCRIPT_DIR/disable-cursor-commit-attribution.sh" -INSTALL="$SCRIPT_DIR/install-local-hooks.sh" -SYNC="$SCRIPT_DIR/sync-attribution-guard-scripts.sh" - -OPENCLAW_HOME="${OPENCLAW_HOME:-$HOME/openclaw-v1}" - -resolve_git_repo() { - local r="$1" - [[ -n "$r" ]] || return 1 - [[ "$r" == *'${'* ]] && return 1 - [[ -d "$r/.git" ]] || return 1 - local abs - abs="$(cd "$r" && pwd)" || return 1 - printf '%s' "$abs" -} - -raw_candidates=( - "$PT_ROOT" - "${PERPETUA_TOOLS_PATH:-$PT_ROOT}" - "${PERPETUA_TOOLS_ROOT:-$PT_ROOT}" - "${ORAMA_SYSTEM_PATH:-$OPENCLAW_HOME/orama-system}" - "${ALPHACLAW_INSTALL_DIR:-$OPENCLAW_HOME/AlphaClaw}" - "/agent/repos/Perpetua-Tools" - "/agent/repos/orama-system" - "/agent/repos/AlphaClaw" - "/agent/repos/periscope" -) - -if [[ -d /agent/repos ]]; then - for d in /agent/repos/*; do - raw_candidates+=("$d") - done -fi - -declare -A seen=() -unique=() -for r in "${raw_candidates[@]}"; do - resolved="$(resolve_git_repo "$r" 2>/dev/null || true)" - [[ -n "$resolved" ]] || continue - if [[ -n "${seen[$resolved]+x}" ]]; then - continue - fi - seen[$resolved]=1 - unique+=("$resolved") -done - -if [[ -x "$SYNC" ]]; then - for r in "${unique[@]}"; do - [[ "$r" == "$PT_ROOT" ]] && continue - bash "$SYNC" "$r" 2>/dev/null || true - done -fi - -for r in "${unique[@]}"; do - bash "$DISABLE" "$r" - if [[ -x "$INSTALL" && -x "$r/scripts/git/ensure_hooks_installed.sh" ]]; then - bash "$INSTALL" "$r" || echo "warn: install-local-hooks failed: $r" >&2 - elif [[ -x "$DISABLE" ]]; then - : - fi - git -C "$r" config --local user.name "cyre" 2>/dev/null || true - git -C "$r" config --local user.email "Lawrence@cyre.me" 2>/dev/null || true -done - -echo "OK: mandatory hooks applied for ${#unique[@]} repo(s)" diff --git a/scripts/git/audit_attribution.sh b/scripts/git/audit_attribution.sh deleted file mode 100644 index 4d40ee9bf..000000000 --- a/scripts/git/audit_attribution.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -# Scan commits for Co-authored-by policy, non-approved authors, and banned attribution. -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -N="${1:-79}" -exec python3 "$SCRIPT_DIR/audit_engine.py" audit \ - --repo "$REPO_ROOT" \ - --history-count "$N" diff --git a/scripts/git/audit_engine.py b/scripts/git/audit_engine.py deleted file mode 100755 index 21a82bf63..000000000 --- a/scripts/git/audit_engine.py +++ /dev/null @@ -1,623 +0,0 @@ -#!/usr/bin/env python3 -"""audit_engine.py -- unified identity classification and audit engine. - -Phases 1-2 of docs/plans/2026-07-24-unified-identity-audit-integrated-plan.md. -Single source of truth for approved Git author/committer identities, -consumed by repo_hygiene.py, check_identity.sh, and audit_attribution.sh. -""" -from __future__ import annotations - -import argparse -import json -import os -import re -import subprocess -import sys -import tempfile -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Optional - -_POLICY_FILENAME = "identity-policy.json" -_SUPPORTED_VERSION = 1 - - -class IdentityPolicyError(Exception): - """Policy file missing, unreadable, malformed, or unsupported version.""" - - -class AttributionCheckError(Exception): - """The banned-attribution check itself failed to execute -- a missing - library, a source failure, or an undefined helper function. Must never - be treated as "no banned attribution found": that would make an - execution failure fail open, indistinguishable from a genuine clean - result.""" - - -@dataclass(frozen=True) -class ClassificationResult: - approved: bool - reason: str - matched_kind: str = "" - - -@dataclass(frozen=True) -class IdentityCheckResult: - approved: bool - exit_code: int - messages: tuple[str, ...] - - -def _engine_dir() -> Path: - return Path(__file__).resolve().parent - - -def openclaw_workspace_root(root: Path) -> Path: - for candidate in (root, *root.parents): - if (candidate / "orama-system").is_dir(): - return candidate - return root - - -def private_literal_values(root: Path, key: str) -> list[str]: - configured_path = os.getenv("OPENCLAW_VERBOTEN_LITERALS") - path = Path(configured_path) if configured_path else openclaw_workspace_root(root) / ".verboten-literals.local" - if not path.is_file(): - return [] - values: list[str] = [] - try: - lines = path.read_text(encoding="utf-8").splitlines() - except OSError: - return [] - for raw in lines: - raw = raw.split("#", 1)[0].strip() - if not raw or "=" not in raw: - continue - raw_key, value = raw.split("=", 1) - if raw_key.strip() != key: - continue - value = "".join(value.split()) - if value: - values.append(value) - return values - - -def _validate_policy_data(data: dict) -> None: - # Container-type guards first, before any iteration -- valid JSON can - # still have the wrong SHAPE (e.g. human_identities as an int, - # repo_bot_identities as a list instead of a mapping). Without these - # checks, iterating/`.items()`-ing the wrong type raises a bare - # TypeError/AttributeError that escapes load_policy() uncaught, - # bypassing the IdentityPolicyError fail-closed contract this module - # exists to guarantee. - if not isinstance(data["human_identities"], list): - raise IdentityPolicyError("human_identities must be a list") - if not isinstance(data["agent_identities"], list): - raise IdentityPolicyError("agent_identities must be a list") - if not isinstance(data["repo_bot_identities"], dict): - raise IdentityPolicyError("repo_bot_identities must be an object") - - seen_emails: set[str] = set() - - def _track(email: str, label: str) -> None: - if not isinstance(email, str) or not email: - raise IdentityPolicyError(f"{label} must be a non-empty string") - key = email.casefold() - if key in seen_emails: - raise IdentityPolicyError(f"duplicate identity email after normalization: {label}") - seen_emails.add(key) - - for entry in data["human_identities"]: - if not isinstance(entry, dict): - raise IdentityPolicyError("human_identities entries must be objects") - if "email" not in entry: - raise IdentityPolicyError("human_identities entry missing required 'email' field") - _track(entry["email"], entry["email"]) - aliases = entry.get("aliases", []) - if not isinstance(aliases, list): - raise IdentityPolicyError( - f"human_identities entry's 'aliases' must be a list, got {type(aliases).__name__}" - ) - for alias in aliases: - _track(alias, alias if isinstance(alias, str) else repr(alias)) - - for entry in data["agent_identities"]: - if not isinstance(entry, dict): - raise IdentityPolicyError("agent_identities entries must be objects") - if "email" not in entry: - raise IdentityPolicyError("agent_identities entry missing required 'email' field") - _track(entry["email"], entry["email"]) - - for repo_name, bots in data["repo_bot_identities"].items(): - if not isinstance(bots, list): - raise IdentityPolicyError(f"repo_bot_identities[{repo_name!r}] must be a list") - for bot in bots: - if not isinstance(bot, str) or not bot: - raise IdentityPolicyError( - f"repo_bot_identities[{repo_name!r}] entries must be non-empty strings, " - f"got {bot!r}" - ) - if "*" in bot: - raise IdentityPolicyError( - f"universal bot wildcard patterns are not allowed: {bot!r} (repo {repo_name})" - ) - _track(bot, bot) - - if "vendor_domains" in data: - raise IdentityPolicyError("vendor_domains approval list is not permitted in identity policy") - - -def load_policy(policy_path: Optional[Path] = None) -> dict: - path = policy_path or (_engine_dir() / _POLICY_FILENAME) - if not path.is_file(): - raise IdentityPolicyError(f"identity policy file missing: {path}") - try: - data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise IdentityPolicyError(f"identity policy file unreadable/invalid JSON: {exc}") from exc - if not isinstance(data, dict): - raise IdentityPolicyError("identity policy file must be a JSON object") - version = data.get("version") - if version != _SUPPORTED_VERSION: - raise IdentityPolicyError( - f"unsupported identity policy version {version!r}; " - f"this engine only supports version {_SUPPORTED_VERSION}" - ) - for required in ("human_identities", "agent_identities", "repo_bot_identities"): - if required not in data: - raise IdentityPolicyError(f"identity policy missing required key: {required}") - _validate_policy_data(data) - return data - - -def _env_approved_emails() -> set[str]: - if os.getenv("GITHUB_ACTIONS") == "true" and os.getenv("ORAMA_ALLOW_IDENTITY_ENV_OVERRIDE") != "1": - return set() - raw = os.getenv("ORAMA_APPROVED_EMAILS", "") - if not raw.strip(): - return set() - emails: set[str] = set() - for part in raw.split(","): - part = part.strip() - if not part or "@" not in part or "*" in part: - raise IdentityPolicyError(f"invalid ORAMA_APPROVED_EMAILS entry: {part!r}") - emails.add(part.casefold()) - return emails - - -def _private_identity_ok( - name: str, - email: str, - root: Path, - private_literal_values_fn: Callable[[Path, str], list[str]], - *, - email_only: bool, -) -> bool: - private_emails = {v.casefold() for v in private_literal_values_fn(root, "owner_gmail")} - if email.casefold() not in private_emails: - return False - if email_only: - return True - private_names = [v.casefold() for v in private_literal_values_fn(root, "owner_name")] - name_tokens = private_names or ["cyre"] - return any(token in name.casefold() for token in name_tokens) - - -_GITHUB_NUMERIC_BOT_PREFIX = re.compile(r"^\d+\+") - - -def _normalize_github_noreply_email(email: str) -> str: - """Strip GitHub's numeric ID prefix from noreply bot addresses. - - Commits may use ``206951365+cursor[bot]@users.noreply.github.com`` while - policy lists ``cursor[bot]@users.noreply.github.com``. Only normalizes - ``@users.noreply.github.com`` addresses; does not broaden to other domains. - """ - email_lc = email.strip().casefold() - local, sep, domain = email_lc.partition("@") - if sep != "@" or domain != "users.noreply.github.com": - return email_lc - return f"{_GITHUB_NUMERIC_BOT_PREFIX.sub('', local)}@{domain}" - - -def _repo_bot_approved(email: str, repo_bots: list[str]) -> bool: - normalized = _normalize_github_noreply_email(email) - return normalized in {_normalize_github_noreply_email(b) for b in repo_bots} - - -def is_approved_identity( - name: str, - email: str, - *, - root: Path, - repo_name: str = "", - policy_path: Optional[Path] = None, - private_literal_values_fn: Optional[Callable[[Path, str], list[str]]] = None, - profile: str = "strict", -) -> ClassificationResult: - """Classify (name, email) against the unified policy (fail-closed on policy errors).""" - policy = load_policy(policy_path) - name_lc, email_lc = name.strip().casefold(), email.strip().casefold() - literals_fn = private_literal_values_fn or private_literal_values - - if email_lc in _env_approved_emails(): - return ClassificationResult(True, "approved via ORAMA_APPROVED_EMAILS override", "env_override") - - if profile == "configured": - for entry in policy["human_identities"]: - if email_lc == entry["email"].casefold(): - return ClassificationResult(True, "approved human identity", "human") - for alias in entry.get("aliases", []): - if email_lc == alias.casefold(): - return ClassificationResult(True, "approved human identity (alias)", "human_alias") - for entry in policy["agent_identities"]: - if entry["email"].casefold() == email_lc: - allowed = [n.casefold() for n in entry.get("allowed_names", [])] - if not allowed or name_lc in allowed: - return ClassificationResult(True, "approved agent identity", "agent") - return ClassificationResult( - False, - f"email {email!r} is an approved agent identity, but name {name!r} " - f"is not in its allowed_names", - ) - if _private_identity_ok(name, email, root, literals_fn, email_only=True): - return ClassificationResult(True, "approved private owner identity", "private") - return ClassificationResult(False, f"identity {name!r} <{email!r}> not found in policy") - - if profile == "audit_relaxed": - if email_lc == "cursoragent@cursor.com" or ("cursor" in name_lc and "agent" in name_lc): - return ClassificationResult(True, "approved Cursor Agent identity", "agent") - for entry in policy["human_identities"]: - if email_lc == entry["email"].casefold(): - return ClassificationResult(True, "approved human identity", "human") - for alias in entry.get("aliases", []): - if email_lc == alias.casefold(): - return ClassificationResult(True, "approved human identity (alias)", "human_alias") - for entry in policy["agent_identities"]: - if entry["email"].casefold() == email_lc: - return ClassificationResult(True, "approved agent identity", "agent") - repo_bots = policy["repo_bot_identities"].get(repo_name, []) - if _repo_bot_approved(email, repo_bots): - return ClassificationResult(True, f"approved bot identity for {repo_name}", "repo_bot") - if _private_identity_ok(name, email, root, literals_fn, email_only=True): - return ClassificationResult(True, "approved private owner identity", "private") - return ClassificationResult(False, f"identity {name!r} <{email!r}> not found in policy") - - for entry in policy["human_identities"]: - if entry["email"].casefold() == email_lc and entry["name"].casefold() == name_lc: - return ClassificationResult(True, "approved human identity", "human") - for alias in entry.get("aliases", []): - if alias.casefold() == email_lc and entry["name"].casefold() == name_lc: - return ClassificationResult(True, "approved human identity (alias)", "human_alias") - - for entry in policy["agent_identities"]: - if entry["email"].casefold() == email_lc: - allowed = [n.casefold() for n in entry.get("allowed_names", [])] - if not allowed or name_lc in allowed: - return ClassificationResult(True, "approved agent identity", "agent") - return ClassificationResult( - False, - f"email {email!r} is an approved agent identity, but name {name!r} " - f"is not in its allowed_names {entry.get('allowed_names', [])!r}", - ) - - repo_bots = policy["repo_bot_identities"].get(repo_name, []) - if _repo_bot_approved(email, repo_bots): - return ClassificationResult(True, f"approved bot identity for {repo_name}", "repo_bot") - - if _private_identity_ok(name, email, root, literals_fn, email_only=False): - return ClassificationResult(True, "approved private owner identity", "private") - - return ClassificationResult(False, f"identity {name!r} <{email!r}> not found in policy") - - -def is_cursor_agent_context(name: str, email: str) -> bool: - for var in ("CURSOR_AGENT", "CURSOR_TRACE_ID", "CURSOR_SESSION_ID"): - if os.getenv(var): - return True - name_lc = name.casefold() - email_lc = email.casefold() - if "cursor" in name_lc: - return True - return email_lc.endswith("@cursor.com") or email_lc.endswith("@cursor.sh") - - -def check_configured_identity( - repo_root: Path, - *, - cursor_scoped: bool = True, - policy_path: Optional[Path] = None, - private_literal_values_fn: Optional[Callable[[Path, str], list[str]]] = None, -) -> IdentityCheckResult: - proc = subprocess.run( - ["git", "-C", str(repo_root), "config", "user.name"], - capture_output=True, - text=True, - check=False, - ) - name = (proc.stdout or "").strip() - proc = subprocess.run( - ["git", "-C", str(repo_root), "config", "user.email"], - capture_output=True, - text=True, - check=False, - ) - email = (proc.stdout or "").strip() - lines = ( - f"git user.name={name or ''}", - f"git user.email={email or ''}", - ) - - if cursor_scoped and not is_cursor_agent_context(name, email): - return IdentityCheckResult(True, 0, lines) - - if not name or not email: - return IdentityCheckResult( - False, - 1, - lines + ("ERROR: set user.name and user.email before committing",), - ) - - try: - result = is_approved_identity( - name, - email, - root=repo_root, - repo_name=repo_root.name, - policy_path=policy_path, - private_literal_values_fn=private_literal_values_fn, - profile="configured", - ) - except IdentityPolicyError as exc: - return IdentityCheckResult(False, 1, lines + (f"ERROR: {exc}",)) - - if result.approved: - return IdentityCheckResult(True, 0, lines + ("OK: approved git identity",)) - - error_lines = ( - "ERROR: git identity must match scripts/git/identity-policy.json " - "(human, agent, private owner, or approved alias).", - f" found: {name} <{email}>", - f" reason: {result.reason}", - ) - return IdentityCheckResult(False, 1, lines + error_lines) - - -def _run_git(repo_root: Path, *args: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", "-C", str(repo_root), *args], - capture_output=True, - text=True, - check=False, - ) - - -def _bash_banned_attribution_hit( - repo_root: Path, - ae_lc: str, - an_lc: str, - ce_lc: str, - cn_lc: str, - body_lc: str, -) -> bool: - lib = _engine_dir() / "banned_attribution_lib.sh" - # lib and repo_root passed as positional args ($6/$7), same as the - # other 5 values -- never interpolated into the script text itself. - # An f-string-interpolated path containing a shell metacharacter - # (unlikely in practice, but not something to rely on) would - # otherwise be a real injection point. - script = ( - 'set -u\n' - 'source "$6" || exit 2\n' - 'declare -f banned_attribution_hit >/dev/null 2>&1 || exit 2\n' - 'root="$7"\n' - 'if banned_attribution_hit "$1" "$2" "$3" "$4" "$5" "$root"; then exit 0; else exit 1; fi' - ) - proc = subprocess.run( - [ - "bash", "-c", script, "banned_attribution_hit", - ae_lc, an_lc, ce_lc, cn_lc, body_lc, str(lib), str(repo_root), - ], - cwd=repo_root, - capture_output=True, - text=True, - check=False, - ) - # 0 = hit, 1 = no hit -- both legitimate results of the check actually - # running. Any other code (2 = source/helper-definition failure - # explicitly, anything else = an unexpected internal error) must NOT - # be silently treated as "no hit": that would make a missing library - # or a broken helper function indistinguishable from a clean pass, - # exactly the fail-open behavior audit_engine.py exists to prevent. - if proc.returncode not in (0, 1): - raise AttributionCheckError( - f"banned_attribution_hit execution failed (exit {proc.returncode}): " - f"{proc.stderr.strip() or proc.stdout.strip() or 'no output'}" - ) - return proc.returncode == 0 - - -def _coauthor_policy_ok(repo_root: Path, body: str) -> bool: - hook = repo_root / "scripts/git/check_commit_message.sh" - if not hook.is_file() or not os.access(hook, os.X_OK): - return True - with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as tmp: - tmp.write(body) - tmp_path = tmp.name - try: - proc = subprocess.run( - [str(hook), tmp_path], - cwd=repo_root, - capture_output=True, - text=True, - check=False, - ) - return proc.returncode == 0 - finally: - Path(tmp_path).unlink(missing_ok=True) - - -def _preferred_bot_label(repo_root: Path, policy: dict) -> str: - bots = policy.get("repo_bot_identities", {}).get(repo_root.name, []) - if not bots: - return "any" - return bots[0] - - -def _audit_ref( - repo_root: Path, - ref: str, - history_count: int, - policy_path: Optional[Path], - private_literal_values_fn: Optional[Callable[[Path, str], list[str]]], -) -> str: - sha_proc = _run_git(repo_root, "rev-parse", "-q", "--verify", ref) - if sha_proc.returncode != 0: - return f"{ref}\tMISSING\t-\t-\t-\t-\n" - sha = (sha_proc.stdout or "").strip() - policy = load_policy(policy_path) - preferred_bot = _preferred_bot_label(repo_root, policy) - banned = bad_author = bad_co = count = 0 - log_proc = _run_git(repo_root, "log", f"-{history_count}", "--format=%H", ref) - for commit_hash in (log_proc.stdout or "").splitlines(): - commit_hash = commit_hash.strip() - if not commit_hash: - continue - count += 1 - body = (_run_git(repo_root, "log", "-1", "--format=%B", commit_hash).stdout or "") - ae = (_run_git(repo_root, "log", "-1", "--format=%ae", commit_hash).stdout or "").strip() - an = (_run_git(repo_root, "log", "-1", "--format=%an", commit_hash).stdout or "").strip() - ae_lc, an_lc = ae.casefold(), an.casefold() - ce = (_run_git(repo_root, "log", "-1", "--format=%ce", commit_hash).stdout or "").strip() - cn = (_run_git(repo_root, "log", "-1", "--format=%cn", commit_hash).stdout or "").strip() - ce_lc, cn_lc = ce.casefold(), cn.casefold() - body_lc = body.casefold() - if _bash_banned_attribution_hit(repo_root, ae_lc, an_lc, ce_lc, cn_lc, body_lc): - banned += 1 - author = is_approved_identity( - an, - ae, - root=repo_root, - repo_name=repo_root.name, - policy_path=policy_path, - private_literal_values_fn=private_literal_values_fn, - profile="audit_relaxed", - ) - if not author.approved: - bad_author += 1 - if not _coauthor_policy_ok(repo_root, body): - bad_co += 1 - clean = "yes" if banned == 0 and bad_author == 0 and bad_co == 0 else "no" - return ( - f"{ref}\t{sha[:12]}\tbanned={banned}\tbad_author={bad_author}\t" - f"bad_coauthor={bad_co}\tcommits={count}\tclean={clean}\trepo_bot={preferred_bot}\n" - ) - - -def run_attribution_audit( - repo_root: Path, - *, - history_count: int = 79, - audit_range: str = "", - strict: bool = False, - policy_path: Optional[Path] = None, - private_literal_values_fn: Optional[Callable[[Path, str], list[str]]] = None, -) -> int: - repo_root = repo_root.resolve() - os.chdir(repo_root) - lines: list[str] = [] - for ref in ("HEAD", "main", "origin/main"): - lines.append( - _audit_ref( - repo_root, - ref, - history_count, - policy_path, - private_literal_values_fn, - ) - ) - sys.stdout.write("".join(lines)) - - range_spec = audit_range or os.getenv("GIT_AUDIT_RANGE", "") - if not range_spec: - return 0 - - range_banned = range_bad_author = range_bad_co = range_count = 0 - rev_proc = _run_git(repo_root, "rev-list", range_spec) - for commit_hash in (rev_proc.stdout or "").splitlines(): - commit_hash = commit_hash.strip() - if not commit_hash: - continue - range_count += 1 - body = (_run_git(repo_root, "log", "-1", "--format=%B", commit_hash).stdout or "") - ae = (_run_git(repo_root, "log", "-1", "--format=%ae", commit_hash).stdout or "").strip() - an = (_run_git(repo_root, "log", "-1", "--format=%an", commit_hash).stdout or "").strip() - ae_lc, an_lc = ae.casefold(), an.casefold() - ce = (_run_git(repo_root, "log", "-1", "--format=%ce", commit_hash).stdout or "").strip() - cn = (_run_git(repo_root, "log", "-1", "--format=%cn", commit_hash).stdout or "").strip() - ce_lc, cn_lc = ce.casefold(), cn.casefold() - body_lc = body.casefold() - oneline = (_run_git(repo_root, "log", "-1", "--oneline", commit_hash).stdout or "").strip() - if _bash_banned_attribution_hit(repo_root, ae_lc, an_lc, ce_lc, cn_lc, body_lc): - range_banned += 1 - print(f"banned_attribution: {commit_hash} {oneline}", file=sys.stderr) - author = is_approved_identity( - an, - ae, - root=repo_root, - repo_name=repo_root.name, - policy_path=policy_path, - private_literal_values_fn=private_literal_values_fn, - profile="audit_relaxed", - ) - if not author.approved: - range_bad_author += 1 - print(f"bad_author: {commit_hash} {an} <{ae}>", file=sys.stderr) - if not _coauthor_policy_ok(repo_root, body): - range_bad_co += 1 - print(f"bad_coauthor: {commit_hash} {oneline}", file=sys.stderr) - - range_clean = "yes" if range_banned == 0 and range_bad_author == 0 and range_bad_co == 0 else "no" - sys.stdout.write( - f"RANGE\t{range_spec}\tbanned={range_banned}\tbad_author={range_bad_author}\t" - f"bad_coauthor={range_bad_co}\tcommits={range_count}\tclean={range_clean}\n" - ) - strict_flag = strict or os.getenv("GIT_AUDIT_STRICT") == "1" - if strict_flag and range_clean != "yes": - return 1 - return 0 - - -def main(argv: Optional[list[str]] = None) -> int: - parser = argparse.ArgumentParser(description="Unified git identity policy engine") - sub = parser.add_subparsers(dest="command", required=True) - - cfg = sub.add_parser("configured-identity", help="Check git config user identity") - cfg.add_argument("--repo", type=Path, required=True) - cfg.add_argument("--cursor-scoped", action="store_true", default=False) - - audit = sub.add_parser("audit", help="Audit commit attribution") - audit.add_argument("--repo", type=Path, required=True) - audit.add_argument("--history-count", type=int, default=79) - audit.add_argument("--range", default="") - audit.add_argument("--strict", action="store_true", default=False) - - args = parser.parse_args(argv) - if args.command == "configured-identity": - result = check_configured_identity(args.repo, cursor_scoped=args.cursor_scoped) - for line in result.messages: - stream = sys.stderr if line.startswith("ERROR:") else sys.stdout - print(line, file=stream) - return result.exit_code - if args.command == "audit": - return run_attribution_audit( - args.repo, - history_count=args.history_count, - audit_range=args.range, - strict=args.strict, - ) - return 2 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/git/banned_attribution_lib.sh b/scripts/git/banned_attribution_lib.sh deleted file mode 100644 index 928526514..000000000 --- a/scripts/git/banned_attribution_lib.sh +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env bash -# Shared helpers for gitignored banned-attribution patterns (no literals in callers). -set -euo pipefail - -banned_patterns_file() { - local root="${1:-}" - if [[ -z "$root" ]]; then - root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" - fi - local private="${root}/.cursor/private/banned-attribution-patterns" - if [[ -f "$private" && -s "$private" ]]; then - printf '%s' "$private" - return 0 - fi - local openclaw="${OPENCLAW_ATTRIBUTION_PATTERNS:-${HOME:-}/.cursor/openclaw/banned-attribution-patterns}" - if [[ -f "$openclaw" && -s "$openclaw" ]]; then - printf '%s' "$openclaw" - return 0 - fi - printf '%s' "$private" -} - -# banned_patterns_ready reports whether a valid banned-attribution patterns file exists and is non-empty. -# banned_patterns_ready accepts an optional root directory argument used to resolve the patterns file; it exits with status 0 if the resolved file exists and has size > 0, non-zero otherwise. -banned_patterns_ready() { - local f - f="$(banned_patterns_file "${1:-}")" - [[ -f "$f" && -s "$f" ]] -} - -# list_banned_pattern_tokens streams banned-attribution pattern tokens (one per line) from the repository or user patterns file. -# It resolves the patterns file (optional `root` argument), reads it line-by-line, removes inline comments (`#`) and all whitespace, skips empty tokens, and writes each remaining token to stdout on its own line. -# Usage: while read -r token; do ...; done < <(list_banned_pattern_tokens "$root") -# Parameters: -# root (optional) — repository root directory to use when resolving the patterns file; if omitted, the script determines the root automatically. -# Exit: -# Returns non-zero if the resolved patterns file does not exist or cannot be read. -list_banned_pattern_tokens() { - local f token - f="$(banned_patterns_file "${1:-}")" - if [[ ! -f "$f" ]]; then - return 1 - fi - while IFS= read -r token || [[ -n "$token" ]]; do - token="${token%%#*}" - token="$(printf '%s' "$token" | tr -d '[:space:]')" - [[ -n "$token" ]] || continue - printf '%s\n' "$token" - done <"$f" -} - -# first_banned_pattern_token outputs the first non-empty, non-comment banned-attribution pattern token from the resolved patterns file (takes an optional root directory argument). -# It prints the token to stdout and returns success; if no file or no token is found it returns a non-zero status. -first_banned_pattern_token() { - local f token - f="$(banned_patterns_file "${1:-}")" - if [[ ! -f "$f" ]]; then - return 1 - fi - while IFS= read -r token || [[ -n "$token" ]]; do - token="${token%%#*}" - token="$(printf '%s' "$token" | tr -d '[:space:]')" - [[ -n "$token" ]] || continue - printf '%s' "$token" - return 0 - done <"$f" - return 1 -} - -# line_matches_banned_pattern checks whether a lowercased line contains any banned-attribution pattern token; tokens are lowercased before matching and are read from the resolved patterns file. -line_matches_banned_pattern() { - local line_lc="$1" - local root="${2:-}" - local token token_lc - while IFS= read -r token; do - token_lc="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')" - if [[ "$line_lc" == *"$token_lc"* ]]; then - return 0 - fi - done < <(list_banned_pattern_tokens "$root" 2>/dev/null || true) - return 1 -} - -openclaw_workspace_root() { - local root="${1:-}" - if [[ -z "$root" ]]; then - root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" - fi - local cur="$root" - while [[ "$cur" != "/" && -n "$cur" ]]; do - if [[ -d "$cur/orama-system" ]]; then - printf '%s' "$cur" - return 0 - fi - cur="$(dirname "$cur")" - done - printf '%s' "$root" -} - -verboten_literals_file() { - local root="${1:-}" - if [[ -n "${OPENCLAW_VERBOTEN_LITERALS:-}" ]]; then - printf '%s' "$OPENCLAW_VERBOTEN_LITERALS" - return 0 - fi - printf '%s/.verboten-literals.local' "$(openclaw_workspace_root "$root")" -} - -list_private_literal_values() { - local root="${1:-}" selector="${2:-}" f raw key value - f="$(verboten_literals_file "$root")" - [[ -f "$f" ]] || return 1 - while IFS= read -r raw || [[ -n "$raw" ]]; do - raw="${raw%%#*}" - raw="$(printf '%s' "$raw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" - [[ -n "$raw" ]] || continue - case "$raw" in - *=*) - key="${raw%%=*}" - value="${raw#"$key="}" - value="$(printf '%s' "$value" | tr -d '[:space:]')" - [[ -n "$value" ]] || continue - [[ -z "$selector" || "$key" == "$selector" ]] || continue - printf '%s\n' "$value" - ;; - esac - done <"$f" -} - -private_owner_email_ok() { - local email_lc="$1" root="${2:-}" token token_lc - while IFS= read -r token; do - token_lc="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')" - [[ "$email_lc" == "$token_lc" ]] && return 0 - done < <(list_private_literal_values "$root" owner_gmail 2>/dev/null || true) - return 1 -} - -private_owner_name_ok() { - local name_lc="$1" root="${2:-}" token token_lc - while IFS= read -r token; do - token_lc="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')" - [[ "$name_lc" == *"$token_lc"* ]] && return 0 - done < <(list_private_literal_values "$root" owner_name 2>/dev/null || true) - return 1 -} - -line_matches_private_forbidden_literal() { - local line_lc="$1" root="${2:-}" token token_lc - while IFS= read -r token; do - token_lc="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')" - [[ "$line_lc" == *"$token_lc"* ]] && return 0 - done < <(list_private_literal_values "$root" forbidden_attribution 2>/dev/null || true) - return 1 -} - -# banned_attribution_hit returns 0 when author/committer/body metadata matches a banned pattern. -banned_attribution_hit() { - local ae_lc="$1" an_lc="$2" ce_lc="$3" cn_lc="$4" body_lc="$5" - local root="${6:-}" - if ! banned_patterns_ready "$root"; then - return 1 - fi - line_matches_banned_pattern "$ae_lc" "$root" && return 0 - line_matches_private_forbidden_literal "$ae_lc" "$root" && return 0 - line_matches_banned_pattern "$an_lc" "$root" && return 0 - line_matches_private_forbidden_literal "$an_lc" "$root" && return 0 - line_matches_banned_pattern "$ce_lc" "$root" && return 0 - line_matches_private_forbidden_literal "$ce_lc" "$root" && return 0 - line_matches_banned_pattern "$cn_lc" "$root" && return 0 - line_matches_private_forbidden_literal "$cn_lc" "$root" && return 0 - local line line_lc - while IFS= read -r line; do - line_lc="$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')" - case "$line_lc" in - co-authored-by:*) - if line_matches_banned_pattern "$line_lc" "$root"; then - return 0 - fi - if line_matches_private_forbidden_literal "$line_lc" "$root"; then - return 0 - fi - ;; - esac - done <<< "$body_lc" - return 1 -} diff --git a/scripts/git/check_commit_message.sh b/scripts/git/check_commit_message.sh deleted file mode 100644 index c4c12e128..000000000 --- a/scripts/git/check_commit_message.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env bash -# Co-authored-by policy: allow well-known public AI/helper attribution; block -# unattributable random @gmail.com co-authors (see docs/wiki/08-git-hygiene-and-branching.md). -set -euo pipefail - -msg_file="${1:?commit message file required}" -[[ -f "$msg_file" ]] || { echo "ERROR: missing commit message file: $msg_file" >&2; exit 1; } -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -# shellcheck source=banned_attribution_lib.sh -source "$SCRIPT_DIR/banned_attribution_lib.sh" - -ALLOWED_EXACT_COAUTHOR_EMAILS=( - cursoragent@cursor.com - lawrence@bettermind.ph - lawrence@cyre.me - noreply@anthropic.com - claude@anthropic.com - kimi-agent@kimi.ai - cloud-kimi-agent@kimi.ai -) - -ALLOWED_GMAIL_COAUTHORS=( - diazmelgarejo@gmail.com -) - -WELL_KNOWN_COAUTHOR_DOMAIN_SUFFIXES=( - openai.com - anthropic.com - cursor.com - cursor.sh - google.com - google.dev - github.com - microsoft.com - azure.com - perplexity.ai - x.ai - coderabbit.ai - mistral.ai - deepseek.com - cohere.com - meta.com - sourcegraph.com - devin.ai - codeium.com - nousresearch.com - kimi.ai -) - -WELL_KNOWN_COAUTHOR_NAME_MARKERS=( - codex - claude - opus - fable - anthropic - cursor - cursoragent - gemini - google - copilot - openai - github - microsoft - perplexity - grok - coderabbit - coderabbitai - mistral - deepseek - cohere - llama - devin - cody - codeium - windsurf - qwen - hermes - nousresearch - kimi -) - -email_domain_ok() { - local email_lc="$1" - local domain="${email_lc#*@}" - [[ -z "$domain" ]] && return 1 - local suffix - for suffix in "${WELL_KNOWN_COAUTHOR_DOMAIN_SUFFIXES[@]}"; do - if [[ "$domain" == "$suffix" || "$domain" == *."$suffix" ]]; then - return 0 - fi - done - return 1 -} - -gmail_allowed() { - local email_lc="$1" - local allowed - for allowed in "${ALLOWED_GMAIL_COAUTHORS[@]}"; do - if [[ "$email_lc" == "$allowed" ]]; then - return 0 - fi - done - private_owner_email_ok "$email_lc" "$REPO_ROOT" && return 0 - return 1 -} - -coauthor_line_ok() { - local line_lc="$1" - local email_lc="" - if [[ "$line_lc" =~ \<([^>]+)\> ]]; then - email_lc="$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]')" - fi - - if [[ -n "$email_lc" ]]; then - local exact - for exact in "${ALLOWED_EXACT_COAUTHOR_EMAILS[@]}"; do - if [[ "$email_lc" == "$exact" ]]; then - return 0 - fi - done - if [[ "$email_lc" == *@gmail.com || "$email_lc" == *@googlemail.com ]]; then - gmail_allowed "$email_lc" - return $? - fi - if email_domain_ok "$email_lc"; then - return 0 - fi - return 1 - fi - - local marker - for marker in "${WELL_KNOWN_COAUTHOR_NAME_MARKERS[@]}"; do - if [[ "$line_lc" == *"$marker"* ]]; then - return 0 - fi - done - private_owner_name_ok "$line_lc" "$REPO_ROOT" && return 0 - - return 1 -} - -while IFS= read -r line || [[ -n "$line" ]]; do - case "$line" in - [Cc]o-[Aa]uthor*) - line_lc="$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')" - if line_matches_private_forbidden_literal "$line_lc" "$REPO_ROOT"; then - echo "ERROR: Co-authored-by contains forbidden private attribution" >&2 - echo " $line" >&2 - exit 1 - fi - if ! coauthor_line_ok "$line_lc"; then - echo "ERROR: Co-authored-by not on approved co-author policy:" >&2 - echo " $line" >&2 - echo "Allowed: explicit allowlist, well-known public AI/vendor domains, or allowlisted gmail." >&2 - exit 1 - fi - ;; - esac -done < "$msg_file" - -exit 0 diff --git a/scripts/git/check_identity.sh b/scripts/git/check_identity.sh deleted file mode 100644 index 8b3f6713a..000000000 --- a/scripts/git/check_identity.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" - -exec python3 "$SCRIPT_DIR/audit_engine.py" configured-identity \ - --repo "$REPO_ROOT" \ - --cursor-scoped diff --git a/scripts/git/commit-clean.sh b/scripts/git/commit-clean.sh deleted file mode 100755 index fcbc22399..000000000 --- a/scripts/git/commit-clean.sh +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env bash -# Create a commit without running git commit hooks (avoids Cursor co-author injection). -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: scripts/git/commit-clean.sh -m "message" [--amend] [--allow-empty] [--dry-run] - -MANDATORY sequence (agents — never skip or reorder): - 1. git add # this script NEVER stages for you - 2. bash scripts/git/verify-staged-for-commit.sh - 3. bash scripts/git/commit-clean.sh -m "type(scope): summary" - -Unstaged edits are preserved but are NOT included in the commit. -Uses git commit-tree so Cursor commit-msg hooks never run. - -During an in-progress merge (MERGE_HEAD present), parent lineage is preserved -automatically: HEAD is the first parent and every SHA listed in MERGE_HEAD is -added as an additional parent (two-parent merges and N-way octopus merges). - -Options: - --amend Replace HEAD (parent becomes HEAD^) - --allow-empty Allow intentional empty commits (rare) - --dry-run Verify and print summary; do not update the branch - -Environment overrides: GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, COMMIT_CLEAN_ALLOW_EMPTY=1 -EOF -} - -repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || { - echo "error: not inside a git repository" >&2 - exit 1 -} -cd "$repo_root" -SCRIPT_DIR="$repo_root/scripts/git" -# shellcheck source=banned_attribution_lib.sh -source "$SCRIPT_DIR/banned_attribution_lib.sh" - -message="" -amend=0 -allow_empty="${COMMIT_CLEAN_ALLOW_EMPTY:-0}" -dry_run=0 -while [[ $# -gt 0 ]]; do - case "$1" in - -m) - message="${2:-}" - shift 2 - ;; - --amend) - amend=1 - shift - ;; - --allow-empty) - allow_empty=1 - shift - ;; - --dry-run) - dry_run=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown argument: $1" >&2 - usage - exit 1 - ;; - esac -done - -[[ -n "$message" ]] || { - echo "error: -m message is required" >&2 - usage - exit 1 -} - -author_name="${GIT_AUTHOR_NAME:-$(git config user.name)}" -author_email="${GIT_AUTHOR_EMAIL:-$(git config user.email)}" -[[ -n "$author_name" && -n "$author_email" ]] || { - echo "error: configure user.name and user.email or set GIT_AUTHOR_*" >&2 - exit 1 -} -author_email_lc="$(printf '%s' "$author_email" | tr '[:upper:]' '[:lower:]')" -author_domain_ok() { - local email_lc="$1" - local domain="${email_lc#*@}" - [[ -z "$domain" || "$domain" == "$email_lc" ]] && return 1 - case "$domain" in - openai.com|*.openai.com|anthropic.com|*.anthropic.com|cursor.com|*.cursor.com|cursor.sh|*.cursor.sh|google.com|*.google.com|google.dev|*.google.dev|github.com|*.github.com|microsoft.com|*.microsoft.com|azure.com|*.azure.com|perplexity.ai|*.perplexity.ai|x.ai|*.x.ai) - return 0 - ;; - esac - return 1 -} -case "$author_email_lc" in - diazmelgarejo@gmail.com|lawrence@cyre.me|codex@openai.com) - ;; - *) - if ! private_owner_email_ok "$author_email_lc" "$repo_root" && ! author_domain_ok "$author_email_lc"; then - echo "error: commit author email must be one of the approved owner emails, codex@openai.com, or a well-known AI/vendor domain" >&2 - exit 1 - fi - ;; -esac - -verify_args=() -[[ "$allow_empty" -eq 1 ]] && verify_args+=(--allow-empty) -[[ "$amend" -eq 1 ]] && verify_args+=(--amend) -bash "$SCRIPT_DIR/verify-staged-for-commit.sh" "${verify_args[@]}" - -tree="$(git write-tree)" - -merge_head_path="$(git rev-parse --git-path MERGE_HEAD)" -in_merge=0 -if [[ "$amend" -eq 0 && -f "$merge_head_path" ]]; then - in_merge=1 -fi - -parents=() -if [[ "$amend" -eq 1 ]]; then - parents+=("$(git rev-parse HEAD^)") -elif git rev-parse HEAD >/dev/null 2>&1; then - parents+=("$(git rev-parse HEAD)") -fi - -if [[ "$in_merge" -eq 1 ]]; then - while IFS= read -r merge_parent || [[ -n "$merge_parent" ]]; do - merge_parent="${merge_parent%%#*}" - merge_parent="${merge_parent//[[:space:]]/}" - [[ -n "$merge_parent" ]] || continue - parents+=("$merge_parent") - done <"$merge_head_path" -fi - -if [[ "$dry_run" -eq 1 ]]; then - echo "commit-clean: dry-run — would create commit on tree ${tree}" >&2 - if ((${#parents[@]} > 0)); then - echo "commit-clean: dry-run — parents ${parents[*]}" >&2 - fi - if [[ "$in_merge" -eq 1 ]]; then - echo "commit-clean: dry-run — merge in progress; MERGE_HEAD parents retained" >&2 - fi - printf '%s\n' "$message" - exit 0 -fi - -commit_tree_args=("$tree") -for parent_sha in "${parents[@]}"; do - commit_tree_args+=(-p "$parent_sha") -done - -if ((${#parents[@]} > 0)); then - new_sha="$( - printf '%s\n' "$message" | - GIT_AUTHOR_NAME="$author_name" GIT_AUTHOR_EMAIL="$author_email" \ - git commit-tree "${commit_tree_args[@]}" -F - - )" -else - new_sha="$( - printf '%s\n' "$message" | - GIT_AUTHOR_NAME="$author_name" GIT_AUTHOR_EMAIL="$author_email" \ - git commit-tree "$tree" -F - - )" -fi - -branch="$(git symbolic-ref --short HEAD 2>/dev/null || true)" -if [[ -n "$branch" ]]; then - git update-ref "refs/heads/${branch}" "$new_sha" -else - git update-ref HEAD "$new_sha" -fi - -if [[ "$in_merge" -eq 1 ]]; then - rm -f \ - "$merge_head_path" \ - "$(git rev-parse --git-path MERGE_MODE)" \ - "$(git rev-parse --git-path MERGE_MSG)" -fi - -echo "$new_sha" diff --git a/scripts/git/commit_clean_test.sh b/scripts/git/commit_clean_test.sh deleted file mode 100755 index a1f42e817..000000000 --- a/scripts/git/commit_clean_test.sh +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env bash -# Regression tests for commit-clean empty-commit guards and merge parent lineage. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -failures=0 - -fail() { - echo "FAIL: $*" >&2 - failures=$((failures + 1)) -} - -pass() { - echo "OK: $*" -} - -run_in_repo() { - local work="$1" - shift - ( - cd "$work" - "$@" - ) -} - -tmp="$(mktemp -d)" -trap 'rm -rf -- "$tmp" ${merge_tmp:+"$merge_tmp"} ${octopus_tmp:+"$octopus_tmp"}' EXIT - -require_merge() { - local repo="$1" - local onto="$2" - local branch="$3" - git -C "$repo" checkout -q "$onto" - if ! git -C "$repo" merge --no-commit --no-ff "$branch" >/dev/null 2>&1; then - fail "merge --no-commit --no-ff ${branch} must succeed on ${onto}" - return 1 - fi - if [[ ! -f "$repo/.git/MERGE_HEAD" ]]; then - fail "merge must create MERGE_HEAD for ${branch} onto ${onto}" - return 1 - fi -} - -git -C "$tmp" init -q -git -C "$tmp" config user.name "Test User" -git -C "$tmp" config user.email "codex@openai.com" -mkdir -p "$tmp/scripts/git" -for helper in commit-clean.sh verify-staged-for-commit.sh banned_attribution_lib.sh; do - install -m 0755 "$SCRIPT_DIR/$helper" "$tmp/scripts/git/$helper" -done -printf 'base\n' >"$tmp/README.md" -git -C "$tmp" add README.md -git -C "$tmp" commit -q -m "init" - -printf 'unstaged only\n' >"$tmp/README.md" - -if run_in_repo "$tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "should fail unstaged" >/dev/null 2>&1; then - fail "commit-clean must reject unstaged-only working tree" -else - pass "blocks commit when edits are unstaged" -fi - -if run_in_repo "$tmp" bash "$SCRIPT_DIR/verify-staged-for-commit.sh" >/dev/null 2>&1; then - fail "verify-staged must reject empty index" -else - pass "verify-staged rejects empty index" -fi - -git -C "$tmp" add README.md -if ! run_in_repo "$tmp" bash "$SCRIPT_DIR/verify-staged-for-commit.sh" >/dev/null 2>&1; then - fail "verify-staged must accept staged delta" -else - pass "verify-staged accepts staged delta" -fi - -sha="$(run_in_repo "$tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "staged commit")" -if [[ -z "$sha" ]]; then - fail "commit-clean must return a sha for staged commit" -else - pass "commit-clean succeeds with staged changes" -fi - -if [[ "$(git -C "$tmp" rev-parse HEAD)" != "$sha" ]]; then - fail "branch tip must advance after commit-clean" -else - pass "branch tip advances" -fi - -if ! git -C "$tmp" diff --quiet HEAD -- README.md; then - fail "committed file must match staged content" -else - pass "committed tree includes staged content" -fi - -merge_tmp="$(mktemp -d)" -git -C "$merge_tmp" init -q -git -C "$merge_tmp" config user.name "Test User" -git -C "$merge_tmp" config user.email "codex@openai.com" -mkdir -p "$merge_tmp/scripts/git" -for helper in commit-clean.sh verify-staged-for-commit.sh banned_attribution_lib.sh; do - install -m 0755 "$SCRIPT_DIR/$helper" "$merge_tmp/scripts/git/$helper" -done -printf 'main\n' >"$merge_tmp/README.md" -git -C "$merge_tmp" add README.md -git -C "$merge_tmp" commit -q -m "main init" -git -C "$merge_tmp" branch -M main -main_sha="$(git -C "$merge_tmp" rev-parse HEAD)" - -git -C "$merge_tmp" checkout -q -b feature -printf 'feature\n' >"$merge_tmp/README.md" -git -C "$merge_tmp" add README.md -git -C "$merge_tmp" commit -q -m "feature change" -feature_sha="$(git -C "$merge_tmp" rev-parse HEAD)" - -git -C "$merge_tmp" checkout -q main -if require_merge "$merge_tmp" main feature; then -printf 'merged\n' >"$merge_tmp/README.md" -git -C "$merge_tmp" add README.md - -pre_dry_head="$(git -C "$merge_tmp" rev-parse HEAD)" -dry_out="$(run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" --dry-run -m "dry-run merge" 2>&1)" -if [[ "$(git -C "$merge_tmp" rev-parse HEAD)" != "$pre_dry_head" ]]; then - fail "dry-run must not move branch tip" -else - pass "dry-run leaves branch tip unchanged" -fi -if [[ ! -f "$merge_tmp/.git/MERGE_HEAD" ]]; then - fail "dry-run must preserve MERGE_HEAD during in-progress merge" -else - pass "dry-run preserves MERGE_HEAD" -fi -case "$dry_out" in - *"merge in progress"*) pass "dry-run reports merge-aware parent retention" ;; - *) fail "dry-run must mention merge in progress" ;; -esac - -merge_sha="$(run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "merge: feature into main")" -parent_count="$(git -C "$merge_tmp" show -s --format=%P "$merge_sha" | wc -w | tr -d ' ')" -if [[ "$parent_count" -ne 2 ]]; then - fail "merge commit must retain two parents (got ${parent_count})" -else - pass "merge commit retains two parents" -fi - -first_parent="$(git -C "$merge_tmp" rev-parse "${merge_sha}^1")" -second_parent="$(git -C "$merge_tmp" rev-parse "${merge_sha}^2")" -if [[ "$first_parent" != "$main_sha" || "$second_parent" != "$feature_sha" ]]; then - fail "merge parents must be main (${main_sha}) and feature (${feature_sha})" -else - pass "merge parents match HEAD and MERGE_HEAD lineage" -fi - -if [[ -f "$merge_tmp/.git/MERGE_HEAD" ]]; then - fail "MERGE_HEAD must be cleared after commit-clean merge finalization" -else - pass "MERGE_HEAD cleared after merge commit" -fi -fi - -git -C "$merge_tmp" checkout -q -b mode-cleanup "$main_sha" -git -C "$merge_tmp" checkout -q -b feature2 "$feature_sha" -git -C "$merge_tmp" checkout -q mode-cleanup -if require_merge "$merge_tmp" mode-cleanup feature2; then -printf 'mode cleanup\n' >"$merge_tmp/README.md" -printf 'merge msg\n' >"$merge_tmp/.git/MERGE_MSG" -git -C "$merge_tmp" add README.md -run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "merge: verify mode cleanup" >/dev/null -for artifact in MERGE_HEAD MERGE_MODE MERGE_MSG; do - if [[ -f "$merge_tmp/.git/$artifact" ]]; then - fail "$artifact must be cleared after commit-clean merge finalization" - else - pass "$artifact cleared after merge commit" - fi -done -fi - -git -C "$merge_tmp" checkout -q -b amend-target "$main_sha" -printf 'amend me\n' >"$merge_tmp/README.md" -git -C "$merge_tmp" add README.md -run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "first" >/dev/null -printf '%s\n' "$feature_sha" >"$merge_tmp/.git/MERGE_HEAD" -printf 'amended\n' >"$merge_tmp/README.md" -git -C "$merge_tmp" add README.md -amend_sha="$(run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" --amend -m "amended")" -amend_parent_count="$(git -C "$merge_tmp" show -s --format=%P "$amend_sha" | wc -w | tr -d ' ')" -if [[ "$amend_parent_count" -ne 1 ]]; then - fail "--amend must ignore MERGE_HEAD and keep a single parent (got ${amend_parent_count})" -else - pass "--amend ignores MERGE_HEAD during merge state" -fi - -rm -f \ - "$merge_tmp/.git/MERGE_HEAD" \ - "$merge_tmp/.git/MERGE_MODE" \ - "$merge_tmp/.git/MERGE_MSG" - -git -C "$merge_tmp" checkout -q -b preserve-unstaged "$main_sha" -if require_merge "$merge_tmp" preserve-unstaged feature2; then -printf 'staged merge\n' >"$merge_tmp/README.md" -printf 'leave unstaged\n' >"$merge_tmp/UNSTAGED.txt" -git -C "$merge_tmp" add README.md -run_in_repo "$merge_tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "merge: preserve unstaged" >/dev/null -if [[ ! -f "$merge_tmp/UNSTAGED.txt" ]]; then - fail "unstaged file must survive commit-clean merge commit" -elif git -C "$merge_tmp" ls-files --error-unmatch UNSTAGED.txt >/dev/null 2>&1; then - fail "commit-clean must not stage unstaged files during merge commit" -else - pass "unstaged untracked file preserved across merge commit-clean" -fi -if [[ "$(cat "$merge_tmp/UNSTAGED.txt")" != "leave unstaged" ]]; then - fail "unstaged file contents must remain intact" -else - pass "unstaged file contents intact" -fi -fi - -single_parent_count="$(git -C "$tmp" show -s --format=%P "$sha" | wc -w | tr -d ' ')" -if [[ "$single_parent_count" -ne 1 ]]; then - fail "non-merge commit must have exactly one parent (got ${single_parent_count})" -else - pass "non-merge commit retains single parent" -fi - -octopus_tmp="$(mktemp -d)" -git -C "$octopus_tmp" init -q -git -C "$octopus_tmp" config user.name "Test User" -git -C "$octopus_tmp" config user.email "codex@openai.com" -mkdir -p "$octopus_tmp/scripts/git" -for helper in commit-clean.sh verify-staged-for-commit.sh banned_attribution_lib.sh; do - install -m 0755 "$SCRIPT_DIR/$helper" "$octopus_tmp/scripts/git/$helper" -done -printf 'base\n' >"$octopus_tmp/README.md" -git -C "$octopus_tmp" add README.md -git -C "$octopus_tmp" commit -q -m "base" -git -C "$octopus_tmp" branch -M main -octopus_main="$(git -C "$octopus_tmp" rev-parse HEAD)" - -git -C "$octopus_tmp" checkout -q -b branch-a -printf 'a\n' >"$octopus_tmp/a.txt" -git -C "$octopus_tmp" add a.txt -git -C "$octopus_tmp" commit -q -m "branch a" -branch_a="$(git -C "$octopus_tmp" rev-parse HEAD)" - -git -C "$octopus_tmp" checkout -q -b branch-b -printf 'b\n' >"$octopus_tmp/b.txt" -git -C "$octopus_tmp" add b.txt -git -C "$octopus_tmp" commit -q -m "branch b" -branch_b="$(git -C "$octopus_tmp" rev-parse HEAD)" - -git -C "$octopus_tmp" checkout -q main -printf 'ab\n' >"$octopus_tmp/a.txt" -printf 'ab\n' >"$octopus_tmp/b.txt" -git -C "$octopus_tmp" add a.txt b.txt -{ - printf '%s\n' "$branch_a" - printf '%s\n' "$branch_b" -} >"$octopus_tmp/.git/MERGE_HEAD" - -octopus_sha="$(run_in_repo "$octopus_tmp" bash "$SCRIPT_DIR/commit-clean.sh" -m "merge: octopus")" -octopus_parent_count="$(git -C "$octopus_tmp" show -s --format=%P "$octopus_sha" | wc -w | tr -d ' ')" -if [[ "$octopus_parent_count" -ne 3 ]]; then - fail "octopus merge commit must retain three parents (got ${octopus_parent_count})" -else - pass "octopus merge retains three parents" -fi - -if [[ "$(git -C "$octopus_tmp" rev-parse "${octopus_sha}^1")" != "$octopus_main" ]]; then - fail "octopus first parent must be pre-merge HEAD" -else - pass "octopus first parent is HEAD" -fi - -if [[ "$failures" -gt 0 ]]; then - echo "commit_clean_test: $failures failure(s)" >&2 - exit 1 -fi - -echo "commit_clean_test: all checks passed" diff --git a/scripts/git/cursor-hooks-id.sh b/scripts/git/cursor-hooks-id.sh deleted file mode 100644 index 37d0f54a9..000000000 --- a/scripts/git/cursor-hooks-id.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Resolve Cursor cloud-agent git hooks directory id (base64 of absolute repo path). -set -euo pipefail - -cursor_hooks_id() { - local repo_path="${1:?repo path required}" - local abs - abs="$(cd "$repo_path" && pwd)" - python3 - "$abs" <<'PY' -import base64 -import sys - -print(base64.b64encode(sys.argv[1].encode()).decode().rstrip("=")) -PY -} diff --git a/scripts/git/daily-attribution-guard.sh b/scripts/git/daily-attribution-guard.sh deleted file mode 100644 index c702fab76..000000000 --- a/scripts/git/daily-attribution-guard.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env bash -# Run at session start (and optionally cron): neutralize injection, scan, verify hooks. -# History rewrite + force-push never run unless ATTRIBUTION_EXPUNGE_AUTO=1 (explicit opt-in). -# -# CANONICAL, SELF-CONTAINED, IDENTICAL ACROSS ALL REPOS. Edit orama's copy, then -# `scripts/git/sync-attribution-guard-scripts.sh ` to redistribute. Do NOT -# replace this with a thin wrapper to another repo — that hardcodes a path and, run -# against the wrapper's own target, execs itself (infinite recursion). It scans the -# whole workspace from whichever repo invokes it, so every entrypoint is equivalent. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -WORKSPACE_ROOT="${WORKSPACE_ROOT:-/agent/repos}" -LOG="${HOME:-}/.cursor/openclaw/attribution-guard.log" - -mkdir -p "${HOME:-}/.cursor/openclaw" -{ - echo "=== $(date -u +%Y-%m-%dT%H:%M:%SZ) daily-attribution-guard ===" -} >>"$LOG" - -if [[ -x "$REPO_ROOT/scripts/cursor/install-user-git-environment.sh" ]]; then - bash "$REPO_ROOT/scripts/cursor/install-user-git-environment.sh" >>"$LOG" 2>&1 || true -fi - -if [[ -x "$SCRIPT_DIR/neutralize-cursor-coauthor-hook.sh" ]]; then - bash "$SCRIPT_DIR/neutralize-cursor-coauthor-hook.sh" --all-agent-hooks >>"$LOG" 2>&1 || true -fi - -# shellcheck source=banned_attribution_lib.sh -source "$REPO_ROOT/scripts/git/banned_attribution_lib.sh" -total_hits=0 -for repo in "$WORKSPACE_ROOT"/*; do - [[ -d "$repo/.git" ]] || continue - bash "$SCRIPT_DIR/sync-banned-patterns-to-repo.sh" "$repo" >>"$LOG" 2>&1 || true - if [[ -x "$repo/scripts/git/install-local-hooks.sh" ]]; then - bash "$repo/scripts/git/install-local-hooks.sh" >>"$LOG" 2>&1 || true - elif [[ "$repo" != "$REPO_ROOT" && -x "$REPO_ROOT/scripts/git/install-local-hooks.sh" ]]; then - (cd "$repo" && git config --local core.hooksPath .githooks 2>/dev/null) || true - fi - hits=0 - h_line="" - while IFS= read -r h; do - while IFS= read -r line; do - line_lc="$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')" - case "$line_lc" in - co-authored-by:*) - if line_matches_banned_pattern "$line_lc" "$repo"; then - hits=$((hits + 1)) - fi - ;; - esac - done < <(git -C "$repo" log -1 --format=%B "$h" 2>/dev/null) - done < <(git -C "$repo" rev-list --all 2>/dev/null) - total_hits=$((total_hits + hits)) - echo "scan $(basename "$repo") hits=$hits" >>"$LOG" -done - -if [[ "$total_hits" -gt 0 ]]; then - echo "ALERT: banned co-author hits=$total_hits — run: bash $SCRIPT_DIR/expunge-all-workspace-repos.sh" >>"$LOG" - if [[ "${ATTRIBUTION_EXPUNGE_AUTO:-}" == "1" ]]; then - echo "ATTRIBUTION_EXPUNGE_AUTO=1 — running workspace expunge" >>"$LOG" - bash "$SCRIPT_DIR/expunge-all-workspace-repos.sh" >>"$LOG" 2>&1 - else - echo "expunge skipped (set ATTRIBUTION_EXPUNGE_AUTO=1 to enable automatic rewrite)" >>"$LOG" - fi -else - echo "scan clean — no expunge required" >>"$LOG" -fi - -if [[ -x "$REPO_ROOT/scripts/git/verify-git-guards.sh" ]]; then - bash "$REPO_ROOT/scripts/git/verify-git-guards.sh" >>"$LOG" 2>&1 || true -fi - -# Zero-fragmentation enforcement (docs/v2/27): assert the canonical guard scripts -# in every workspace repo are byte-identical to orama's. Warn-only here (the daily -# guard never blocks); CI runs the same script as a hard gate. Catches a downstream -# hand-edit before it silently diverges policy. -if [[ -x "$REPO_ROOT/scripts/git/verify-guard-parity.sh" ]]; then - if bash "$REPO_ROOT/scripts/git/verify-guard-parity.sh" --workspace >>"$LOG" 2>&1; then - echo "guard-parity: PASS (all workspace repos byte-identical to canonical)" >>"$LOG" - else - echo "ALERT: guard-parity FAIL — a repo's guard scripts drifted from orama canonical." >>"$LOG" - echo " Re-sync: bash /scripts/git/sync-attribution-guard-scripts.sh " >>"$LOG" - fi -fi - -echo "daily-attribution-guard complete (log: $LOG)" diff --git a/scripts/git/disable-cursor-commit-attribution.sh b/scripts/git/disable-cursor-commit-attribution.sh deleted file mode 100644 index 0534f23c0..000000000 --- a/scripts/git/disable-cursor-commit-attribution.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# Disable Cursor cloud-agent automatic Co-authored-by injection for one git repo. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=cursor-hooks-id.sh -source "$SCRIPT_DIR/cursor-hooks-id.sh" - -repo="${1:-.}" -repo="$(cd "$repo" && pwd)" - -if ! git -C "$repo" rev-parse --git-dir >/dev/null 2>&1; then - echo "skip: not a git repo: $repo" >&2 - exit 0 -fi - -git_dir="$(git -C "$repo" rev-parse --git-dir)" -hooks_dir="$(cd "$git_dir" && pwd)/hooks" -mkdir -p "$hooks_dir" - -# 1) Neutralize Cursor-managed co-author hook (overwrite; chmod -x alone is insufficient). -NEUTRALIZE="$SCRIPT_DIR/neutralize-cursor-coauthor-hook.sh" -if [[ -x "$NEUTRALIZE" ]]; then - bash "$NEUTRALIZE" --repo "$repo" -else - ws_id="$(cursor_hooks_id "$repo")" - coauthor_hook="${HOME}/.cursor/agent-hooks/${ws_id}/commit-msg.cursor.co-author" - if [[ -f "$coauthor_hook" ]]; then - printf '%s\n' '#!/usr/bin/env bash' '# Neutralized — no Co-authored-by injection.' 'exit 0' >"$coauthor_hook" - chmod -x "$coauthor_hook" 2>/dev/null || true - echo "neutralized: $coauthor_hook" - fi -fi - -# 2) Keep mandatory hooks on .githooks (strip runs inside .githooks/commit-msg). -git -C "$repo" config --local core.hooksPath .githooks - -# 3) Prefer approved cyre identity when unset locally. -if [[ -z "$(git -C "$repo" config --local user.name 2>/dev/null || true)" ]]; then - git -C "$repo" config --local user.name "cyre" -fi -if [[ -z "$(git -C "$repo" config --local user.email 2>/dev/null || true)" ]]; then - git -C "$repo" config --local user.email "diazMelgarejo@gmail.com" -fi - -echo "OK: attribution guards applied in $repo" diff --git a/scripts/git/expunge-all-workspace-repos.sh b/scripts/git/expunge-all-workspace-repos.sh deleted file mode 100644 index 6f8eebff1..000000000 --- a/scripts/git/expunge-all-workspace-repos.sh +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env bash -# Expunge banned Co-authored-by trailers from every repo under a workspace root, then force-push all branches. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -WORKSPACE_ROOT="${WORKSPACE_ROOT:-/agent/repos}" -EXPUNGE="$SCRIPT_DIR/expunge-banned-attribution-history.sh" -SYNC="$SCRIPT_DIR/sync-banned-patterns-to-repo.sh" -PUSH_ALL="${PUSH_ALL:-1}" - -HOME="${HOME:-/home/ubuntu}" -export HOME PERPETUA_TOOLS_GUARD="$PT_ROOT" - -if [[ -x "${ORAMA_SYSTEM_PATH:-}/scripts/cursor/write-openclaw-private-attribution.sh" ]]; then - bash "${ORAMA_SYSTEM_PATH}/scripts/cursor/write-openclaw-private-attribution.sh" -elif [[ -x /agent/repos/orama-system/scripts/cursor/write-openclaw-private-attribution.sh ]]; then - bash /agent/repos/orama-system/scripts/cursor/write-openclaw-private-attribution.sh -fi - -if [[ -x "$PT_ROOT/scripts/git/neutralize-cursor-coauthor-hook.sh" ]]; then - bash "$PT_ROOT/scripts/git/neutralize-cursor-coauthor-hook.sh" --all-agent-hooks -fi - -scan_repo_hits() { - local repo="$1" - # shellcheck source=banned_attribution_lib.sh - source "$PT_ROOT/scripts/git/banned_attribution_lib.sh" - local hits=0 h line line_lc - while IFS= read -r h; do - while IFS= read -r line; do - line_lc="$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')" - case "$line_lc" in - co-authored-by:*) - line_matches_banned_pattern "$line_lc" "$repo" && hits=$((hits + 1)) - ;; - esac - done < <(git -C "$repo" log -1 --format=%B "$h") - done < <(git -C "$repo" rev-list --all 2>/dev/null) - printf '%s' "$hits" -} - -force_push_repo() { - local repo="$1" - git -C "$repo" remote get-url origin >/dev/null 2>&1 || return 0 - local branch - while IFS= read -r branch; do - [[ -n "$branch" ]] || continue - git -C "$repo" push --force-with-lease origin "${branch}:${branch}" 2>/dev/null \ - || git -C "$repo" push --force origin "${branch}:${branch}" 2>/dev/null \ - || echo "warn: push failed ${branch} in $(basename "$repo")" >&2 - done < <(git -C "$repo" for-each-ref refs/heads --format='%(refname:short)') -} - -expunge_repo() { - local repo="$1" - local name - name="$(basename "$repo")" - [[ -d "${repo}/.git" ]] || return 0 - - echo ">>> [$name] fetch" - git -C "$repo" fetch origin --prune 2>/dev/null || true - - bash "$SYNC" "$repo" - - local before after - before="$(scan_repo_hits "$repo")" - echo ">>> [$name] banned co-author hits before expunge: $before" - if [[ "$before" -eq 0 ]]; then - echo ">>> [$name] clean — skip history rewrite and force-push" - return 0 - fi - - echo ">>> [$name] filter-branch (all refs)" - if ! git -C "$repo" diff-index --quiet HEAD -- 2>/dev/null \ - || ! git -C "$repo" diff-index --quiet --cached HEAD -- 2>/dev/null; then - git -C "$repo" stash push -u -m "attribution-expunge-autostash" >/dev/null 2>&1 || true - stashed=1 - else - stashed=0 - fi - bash "$EXPUNGE" "$repo" - if [[ "${stashed:-0}" == "1" ]]; then - git -C "$repo" stash pop >/dev/null 2>&1 || true - fi - - after="$(scan_repo_hits "$repo")" - echo ">>> [$name] banned co-author hits after expunge: $after" - if [[ "$after" -ne 0 ]]; then - echo "ERROR: [$name] still has banned trailers after expunge" >&2 - return 1 - fi - - if [[ "$PUSH_ALL" == "1" ]]; then - echo ">>> [$name] force-push all local branches" - force_push_repo "$repo" - fi - echo ">>> [$name] OK" -} - -shopt -s nullglob -repos=("$WORKSPACE_ROOT"/*) -if [[ ! -d "$WORKSPACE_ROOT" ]]; then - echo "ERROR: workspace root not found: $WORKSPACE_ROOT" >&2 - exit 1 -fi - -failed=0 -for repo in "${repos[@]}"; do - [[ -d "$repo/.git" ]] || continue - expunge_repo "$repo" || failed=$((failed + 1)) -done - -if [[ "$failed" -gt 0 ]]; then - echo "expunge-all-workspace-repos: $failed repo(s) failed" >&2 - exit 1 -fi -echo "OK: workspace expunge complete (${#repos[@]} roots scanned)" diff --git a/scripts/git/hooks/commit-msg.strip-coauthor b/scripts/git/hooks/commit-msg.strip-coauthor deleted file mode 100644 index 79b555445..000000000 --- a/scripts/git/hooks/commit-msg.strip-coauthor +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# Strip Cursor-injected commit attribution trailers (commit-msg hook). -set -euo pipefail - -msg_file="${1:?commit message file required}" -[[ -f "$msg_file" ]] || exit 0 - -# Cross-platform in-place sed: BSD sed (macOS) requires `sed -i ''` while -# GNU sed (Linux) uses `sed -i` without an extension argument. -_sed_i() { - if sed --version 2>/dev/null | grep -q 'GNU sed'; then - sed -i "$@" - else - sed -i '' "$@" - fi -} - -ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -LIB="${ROOT}/scripts/git/banned_attribution_lib.sh" -if [[ -f "$LIB" ]]; then - # shellcheck source=banned_attribution_lib.sh - source "$LIB" - if banned_patterns_ready "$ROOT"; then - while IFS= read -r token; do - token_esc="$(printf '%s' "$token" | sed 's/[.[\*^$()+?{|]/\\&/g')" - _sed_i "/${token_esc}/Id" "$msg_file" 2>/dev/null || true - done < <(list_banned_pattern_tokens "$ROOT") - fi -fi - -_sed_i \ - -e '/^Co-authored-by:.*cursoragent@cursor\.com/Id' \ - -e '/^Co-authored-by:.*@bettermind\.ph/Id' \ - -e '/^Co-authored-by:.*[Cc]ursor[[:space:]]* - neutralize-cursor-coauthor-hook.sh --all-agent-hooks - neutralize-cursor-coauthor-hook.sh --repo -EOF -} - -noop_body() { - cat <<'EOF' -#!/usr/bin/env bash -# Neutralized by Perpetua-Tools git guards — never inject Co-authored-by trailers. -exit 0 -EOF -} - -neutralize_file() { - local hook="$1" - [[ -n "$hook" ]] || return 0 - [[ -f "$hook" ]] || return 0 - noop_body >"$hook" - chmod -x "$hook" 2>/dev/null || true - echo "neutralized: $hook" -} - -neutralize_all_agent_hooks() { - local root="${HOME:-}/.cursor/agent-hooks" - [[ -d "$root" ]] || return 0 - local f - while IFS= read -r -d '' f; do - neutralize_file "$f" - done < <(find "$root" -name 'commit-msg.cursor.co-author' -type f -print0 2>/dev/null) -} - -neutralize_repo() { - local repo="$1" - local script_dir hook_id - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - # shellcheck source=cursor-hooks-id.sh - source "$script_dir/cursor-hooks-id.sh" - hook_id="$(cursor_hooks_id "$repo")" - neutralize_file "${HOME:-}/.cursor/agent-hooks/${hook_id}/commit-msg.cursor.co-author" -} - -case "${1:-}" in - --all-agent-hooks) - neutralize_all_agent_hooks - ;; - --repo) - [[ -n "${2:-}" ]] || { usage; exit 1; } - neutralize_repo "$2" - ;; - -h|--help|'') - usage - exit 0 - ;; - *) - neutralize_file "$1" - ;; -esac diff --git a/scripts/git/scan-tracked-banned-tokens.sh b/scripts/git/scan-tracked-banned-tokens.sh deleted file mode 100644 index d2eb0142c..000000000 --- a/scripts/git/scan-tracked-banned-tokens.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -# Fail if any gitignored banned token appears in tracked files (GitHub hygiene). -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -# shellcheck source=banned_attribution_lib.sh -source "$SCRIPT_DIR/banned_attribution_lib.sh" - -cd "$REPO_ROOT" - -if ! banned_patterns_ready "$REPO_ROOT"; then - bash "$REPO_ROOT/scripts/cursor/sync-private-attribution-from-home.sh" -fi - -errors=0 -while IFS= read -r token; do - [[ -n "$token" ]] || continue - while IFS= read -r rel; do - [[ -f "$rel" ]] || continue - if rg -F -i -q "$token" "$rel" 2>/dev/null; then - echo "ERROR: banned token in tracked file: $rel" >&2 - errors=$((errors + 1)) - fi - done < <(git ls-files) -done < <(list_banned_pattern_tokens "$REPO_ROOT") - -if [[ "$errors" -gt 0 ]]; then - exit 1 -fi -echo "OK: no banned tokens in tracked files" diff --git a/scripts/git/sync-attribution-guard-scripts.sh b/scripts/git/sync-attribution-guard-scripts.sh deleted file mode 100755 index ffde85f8f..000000000 --- a/scripts/git/sync-attribution-guard-scripts.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -# Copy attribution-guard scripts from orama-system into a sibling repo checkout. -set -euo pipefail - -target="${1:?target repo path required}" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source_root="$(cd "$SCRIPT_DIR/../.." && pwd)" - -if [[ "$(git -C "$target" rev-parse --is-inside-work-tree 2>/dev/null)" != "true" ]]; then - echo "skip: not a git repo: $target" >&2 - exit 0 -fi - -target="$(cd "$target" && pwd)" -mkdir -p "$target/scripts/git/hooks" - -for rel in \ - cursor-hooks-id.sh \ - hooks/commit-msg.strip-coauthor \ - disable-cursor-commit-attribution.sh \ - commit-clean.sh \ - verify-staged-for-commit.sh \ - commit_clean_test.sh \ - apply-attribution-guard-all-repos.sh \ - sync-attribution-guard-scripts.sh \ - sync-banned-patterns-to-repo.sh \ - banned_attribution_lib.sh \ - audit_attribution.sh \ - check_commit_message.sh \ - check_identity.sh \ - daily-attribution-guard.sh \ - neutralize-cursor-coauthor-hook.sh \ - expunge-all-workspace-repos.sh \ - verify-git-guards.sh \ - verify-guard-parity.sh \ - scan-tracked-banned-tokens.sh; do - [[ -f "$SCRIPT_DIR/$rel" ]] || continue - install -m 0755 "$SCRIPT_DIR/$rel" "$target/scripts/git/$rel" -done - -# daily-attribution-guard.sh is now a normal synced file (canonical full impl in the -# copy list above) — self-contained, byte-identical in every repo, derives its own -# REPO_ROOT. No thin wrapper: a wrapper hardcodes a path and, on its own target, would -# exec itself (infinite recursion). Single source of truth, zero fragmentation. - -# Repo-local agent rules (Cursor Cloud) — no forbidden tokens in these files. -mkdir -p "$target/.cursor/rules" -for rule in no-commit-attribution.mdc never-undo-attribution-expunge.mdc banned-attribution-local.mdc zero-banned-attribution-everywhere.mdc; do - [[ -f "$source_root/.cursor/rules/$rule" ]] || continue - install -m 0644 "$source_root/.cursor/rules/$rule" "$target/.cursor/rules/$rule" -done - -echo "synced guard scripts → $target" - -snippet="$source_root/scripts/git/snippets/AGENTS-cursor-cloud-git.md" -if [[ -f "$snippet" ]]; then - if [[ ! -f "$target/AGENTS.md" ]]; then - { - echo "# Agent instructions" - echo - cat "$snippet" - } >"$target/AGENTS.md" - elif ! grep -q 'apply-attribution-guard-all-repos' "$target/AGENTS.md" 2>/dev/null; then - { - echo - cat "$snippet" - } >>"$target/AGENTS.md" - fi -fi diff --git a/scripts/git/sync-banned-patterns-to-repo.sh b/scripts/git/sync-banned-patterns-to-repo.sh deleted file mode 100644 index cd9e8971c..000000000 --- a/scripts/git/sync-banned-patterns-to-repo.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -# Copy gitignored attribution patterns into any repo (never commits them). -set -euo pipefail - -repo="${1:-.}" -repo="$(cd "$repo" && pwd)" -HOME="${HOME:-/home/ubuntu}" -OPENCLAW="${HOME}/.cursor/openclaw" -src="${OPENCLAW}/banned-attribution-patterns" -dst_dir="${repo}/.cursor/private" - -if [[ ! -f "$src" ]]; then - ORAMA="${ORAMA_SYSTEM_PATH:-/agent/repos/orama-system}" - if [[ -x "${ORAMA}/scripts/cursor/write-openclaw-private-attribution.sh" ]]; then - bash "${ORAMA}/scripts/cursor/write-openclaw-private-attribution.sh" - else - echo "ERROR: missing ${src}" >&2 - exit 1 - fi -fi - -mkdir -p "$dst_dir" -chmod 700 "$dst_dir" 2>/dev/null || true -install -m 0600 "$src" "${dst_dir}/banned-attribution-patterns" -lesson="${OPENCLAW}/private-lessons/perpetua-tools-git-attribution.md" -[[ -f "$lesson" ]] && install -m 0600 "$lesson" "${dst_dir}/agent-lesson-git-attribution.md" || true -printf 'OK: patterns → %s\n' "$dst_dir" diff --git a/scripts/git/verify-git-guards.sh b/scripts/git/verify-git-guards.sh deleted file mode 100755 index a24e6920c..000000000 --- a/scripts/git/verify-git-guards.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env bash -# Verify mandatory repo git guards (hooks, identity, commit-msg policy). -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -# shellcheck source=banned_attribution_lib.sh -source "$SCRIPT_DIR/banned_attribution_lib.sh" -HOME="${HOME:-/home/ubuntu}" -errors=0 - -fail() { - echo "FAIL: $*" >&2 - errors=$((errors + 1)) -} - -ok() { - echo "OK: $*" -} - -cd "$REPO_ROOT" - -if [[ -x scripts/cursor/sync-private-attribution-from-home.sh ]]; then - bash scripts/cursor/sync-private-attribution-from-home.sh >/dev/null -fi - -if ! bash "$REPO_ROOT/scripts/git/ensure_hooks_installed.sh" >/dev/null 2>&1; then - fail "repo hooks not installed (bash scripts/git/install-local-hooks.sh)" -else - ok "repo hooks installed (.githooks)" -fi - -if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then - ok "GitHub Actions — skip user-level Cursor session hook checks" -else - session_hook="${HOME}/.cursor/openclaw/hooks/session-apply-git-guards.sh" - if [[ ! -x "$session_hook" ]]; then - fail "Cursor sessionStart hook missing (bash scripts/cursor/install-user-git-environment.sh)" - else - ok "Cursor sessionStart hook installed" - fi - - if [[ ! -f "${HOME}/.cursor/hooks.json" ]]; then - fail "missing ${HOME}/.cursor/hooks.json" - else - ok "Cursor hooks.json present" - fi -fi - -email_lc="$(git config --local user.email 2>/dev/null | tr '[:upper:]' '[:lower:]' || true)" -case "$email_lc" in - diazmelgarejo@gmail.com | lawrence@cyre.me | codex@openai.com) - ok "user.email=${email_lc}" - ;; - *) - if private_owner_email_ok "$email_lc" "$REPO_ROOT"; then - ok "user.email=" - else - fail "user.email=${email_lc:-} — expected diazmelgarejo@gmail.com, lawrence@cyre.me, configured private owner email, or codex@openai.com" - fi - ;; -esac - -if ! bash "$REPO_ROOT/scripts/git/check_identity.sh" >/dev/null 2>&1; then - fail "check_identity.sh rejected current git identity" -else - ok "check_identity.sh passed" -fi - -if [[ -f "$REPO_ROOT/scripts/git/cursor-hooks-id.sh" ]]; then - # shellcheck disable=SC1091 - source "$REPO_ROOT/scripts/git/cursor-hooks-id.sh" - ws_id="$(cursor_hooks_id "$REPO_ROOT")" - if [[ "${GITHUB_ACTIONS:-}" != "true" ]]; then - coauthor="${HOME}/.cursor/agent-hooks/${ws_id}/commit-msg.cursor.co-author" - if [[ ! -f "$coauthor" ]]; then - ok "Cursor co-author injection hook absent" - elif [[ -x "$coauthor" ]]; then - fail "Cursor co-author hook still executable: $coauthor" - elif ! grep -q 'Neutralized by Perpetua-Tools git guards' "$coauthor" 2>/dev/null; then - fail "Cursor co-author hook not neutralized (run neutralize-cursor-coauthor-hook.sh): $coauthor" - else - ok "Cursor co-author injection hook neutralized" - fi - fi -fi - -fixture_token="$(first_banned_pattern_token "$REPO_ROOT" || true)" -if [[ -z "$fixture_token" ]]; then - fail "banned pattern file empty" -else - tmp="$(mktemp)" - trap 'rm -f "$tmp"' EXIT - printf 'test: verify guards\n\nCo-authored-by: X <%s@example.invalid>\n' "$fixture_token" >"$tmp" - if bash "$REPO_ROOT/scripts/git/check_commit_message.sh" "$tmp" 2>/dev/null; then - fail "check_commit_message.sh should reject banned co-author fixture" - else - ok "commit-msg policy blocks banned co-author fixture" - fi -fi - -tmp="$(mktemp)" -trap 'rm -f "$tmp"' EXIT -cat >"$tmp" <<'MSG' -test: verify guards - -Co-authored-by: Random -MSG -if bash "$REPO_ROOT/scripts/git/check_commit_message.sh" "$tmp" 2>/dev/null; then - fail "check_commit_message.sh should reject unlisted co-author" -else - ok "commit-msg policy blocks unlisted co-author" -fi - -if [[ -x scripts/git/scan-tracked-banned-tokens.sh ]]; then - if bash scripts/git/scan-tracked-banned-tokens.sh >/dev/null 2>&1; then - ok "tracked files contain no banned tokens" - else - fail "banned token found in tracked files" - fi -fi - -if [[ -x "$REPO_ROOT/scripts/git/commit_clean_test.sh" ]]; then - if bash "$REPO_ROOT/scripts/git/commit_clean_test.sh" >/dev/null 2>&1; then - ok "commit-clean empty-commit guards" - else - fail "commit_clean_test.sh failed (run scripts/git/commit_clean_test.sh)" - fi -fi - -if [[ "$errors" -gt 0 ]]; then - echo "verify-git-guards: $errors failure(s)" >&2 - exit 1 -fi -echo "verify-git-guards: all checks passed" diff --git a/scripts/git/verify-guard-parity.sh b/scripts/git/verify-guard-parity.sh deleted file mode 100644 index 4a7f13cfb..000000000 --- a/scripts/git/verify-guard-parity.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -# verify-guard-parity.sh — enforce the zero-fragmentation invariant (docs/v2/27). -# -# Two checks, both fail-closed (non-zero exit on violation): -# 1. COMPLETENESS — every canonical guard script is listed in the sync tool's -# copy loop. Catches the 2026-06-05 bug where check_commit_message.sh and -# check_identity.sh silently drifted because the sync omitted them. -# 2. PARITY (optional, when target repos are given or WORKSPACE_ROOT is set) — -# each downstream repo's guard copies are byte-identical to orama's canonical. -# -# Canonical source is THIS repo (orama-system). Run in CI (orama: completeness -# always; parity when siblings are checked out) and from daily-attribution-guard. -# -# Usage: -# verify-guard-parity.sh # completeness only (single-repo CI) -# verify-guard-parity.sh [...] # + parity against each target -# WORKSPACE_ROOT=/agent/repos verify-guard-parity.sh --workspace -set -u - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CANON="$SCRIPT_DIR" -SYNC="$SCRIPT_DIR/sync-attribution-guard-scripts.sh" -rc=0 - -# The canonical guard set every repo must carry byte-identical. -CANONICAL_GUARDS=( - banned_attribution_lib.sh - audit_attribution.sh - check_commit_message.sh - check_identity.sh - daily-attribution-guard.sh - neutralize-cursor-coauthor-hook.sh - expunge-all-workspace-repos.sh - verify-git-guards.sh - reanchor_scan.sh -) - -echo "== COMPLETENESS: every canonical guard is in the sync copy list ==" -if [[ ! -f "$SYNC" ]]; then - echo " ERR: sync tool not found at $SYNC"; rc=1 -else - for g in "${CANONICAL_GUARDS[@]}"; do - # reanchor_scan.sh is a guard but not distributed by the attribution sync; - # skip it from the sync-list assertion (it ships via the repo checkout). - [[ "$g" == "reanchor_scan.sh" ]] && continue - if grep -qE "^\s*$g\b|/$g\b" "$SYNC"; then - echo " OK $g in sync list" - else - echo " FAIL $g MISSING from sync copy list (would drift silently)"; rc=1 - fi - done -fi - -# Parity: compare each target repo's copies to canonical. -targets=() -if [[ "${1:-}" == "--workspace" ]]; then - for d in "${WORKSPACE_ROOT:-/agent/repos}"/*; do [[ -d "$d/.git" ]] && targets+=("$d"); done -else - targets=("$@") -fi - -if [[ ${#targets[@]} -gt 0 ]]; then - echo "== PARITY: downstream guard copies byte-identical to canonical ==" - for repo in "${targets[@]}"; do - [[ "$(cd "$repo" 2>/dev/null && pwd)" == "$(cd "$CANON/../.." && pwd)" ]] && continue # skip self - for g in "${CANONICAL_GUARDS[@]}"; do - src="$CANON/$g"; dst="$repo/scripts/git/$g" - [[ -f "$src" ]] || continue - if [[ ! -f "$dst" ]]; then - echo " WARN $(basename "$repo")/$g absent"; continue - fi - if cmp -s "$src" "$dst"; then - echo " OK $(basename "$repo")/$g" - else - echo " FAIL $(basename "$repo")/$g DRIFTED — re-sync: bash scripts/git/sync-attribution-guard-scripts.sh $repo"; rc=1 - fi - done - done -fi - -[[ $rc -eq 0 ]] && echo "guard-parity: PASS" || echo "guard-parity: FAIL" -exit $rc diff --git a/scripts/git/verify-staged-for-commit.sh b/scripts/git/verify-staged-for-commit.sh deleted file mode 100755 index 0d31324d0..000000000 --- a/scripts/git/verify-staged-for-commit.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -# Mandatory pre-commit gate: fail unless the index has a real delta vs HEAD. -# Run AFTER git add and BEFORE commit-clean.sh. -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: scripts/git/verify-staged-for-commit.sh [--amend] [--allow-empty] - -Mandatory agent sequence (never skip steps): - 1. git add # commit-clean NEVER stages for you - 2. bash scripts/git/verify-staged-for-commit.sh - 3. bash scripts/git/commit-clean.sh -m "type(scope): summary" - -Fails when: - - nothing is staged (unless --amend for message-only amend) - - staged index would produce the same tree as HEAD (empty commit) - -Options: - --amend Allow message-only amend (staged tree may match HEAD) - --allow-empty Rare escape hatch for intentional empty commits - -Prints git diff --cached --stat on success. -EOF -} - -allow_empty="${COMMIT_CLEAN_ALLOW_EMPTY:-0}" -amend=0 -while [[ $# -gt 0 ]]; do - case "$1" in - --allow-empty) - allow_empty=1 - shift - ;; - --amend) - amend=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown argument: $1" >&2 - usage - exit 1 - ;; - esac -done - -repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || { - echo "error: not inside a git repository" >&2 - exit 1 -} -cd "$repo_root" - -mandatory_sequence() { - cat >&2 <<'EOF' - -MANDATORY sequence (agents — do not skip or reorder): - 1. git add # commit-clean NEVER stages for you - 2. bash scripts/git/verify-staged-for-commit.sh - 3. bash scripts/git/commit-clean.sh -m "type(scope): summary" - -Before step 3, confirm: git diff --cached --stat is non-empty. -EOF -} - -head_tree="" -if git rev-parse HEAD >/dev/null 2>&1; then - head_tree="$(git rev-parse HEAD^{tree})" -fi - -if git diff --cached --quiet 2>/dev/null; then - if [[ "$amend" -eq 1 ]]; then - echo "verify-staged-for-commit: amend with no staged file changes (message-only)" >&2 - exit 0 - fi - echo "error: nothing staged to commit" >&2 - mandatory_sequence - if ! git diff --quiet 2>/dev/null; then - echo "" >&2 - echo "Unstaged edits exist (NOT included unless you git add them):" >&2 - git status --short >&2 - fi - exit 1 -fi - -index_tree="$(git write-tree)" -if [[ "$allow_empty" -eq 0 && "$amend" -eq 0 && -n "$head_tree" && "$index_tree" == "$head_tree" ]]; then - echo "error: staged index matches HEAD tree — would create an empty commit" >&2 - echo "hint: run git add on the paths you intend to commit" >&2 - mandatory_sequence - git diff --cached --stat >&2 - exit 1 -fi - -echo "verify-staged-for-commit: OK — staged changes:" >&2 -git diff --cached --stat >&2