@ccoalm/ccl-skills 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/SKILL.md +8 -7
- package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/mobile-quality-release.md +6 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/SKILL.md +16 -17
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/client-routing.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/manual-invocation-and-prompts.md +6 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +195 -7
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/timeout-auth-and-capabilities.md +3 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/claude_review.sh +13 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/codex_review.sh +9 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/kimi_review.sh +9 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/normalize_review_timeout.sh +22 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/opencode_review.sh +9 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +1540 -129
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_claude_review_probe.sh +8 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_client_compat.py +76 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +1858 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_update_review_plan_intent.sh +789 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/update_review_plan_intent.py +513 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/defect-diagnosis/SKILL.md +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/feature-risk-router/SKILL.md +3 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/architecture-playbook.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/multi-tenant-isolation.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/SKILL.md +4 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/references/state-machine-task-patterns.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +2 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/inference-capacity-operations.md +24 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/llm-client-gateway.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/references/model-prompt-evaluation.md +4 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/SKILL.md +11 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/references/contracts-and-state.md +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/SKILL.md +64 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/agents/openai.yaml +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/async-lifecycle-and-performance.md +73 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/runtime-and-project-contract.md +58 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/source-map.md +41 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/verification-diagnostics-and-security.md +63 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/SKILL.md +3 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/metrics-conventions.md +8 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/sli-slo-design.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/canary-and-rollout-strategy.md +16 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/promotion-gate-and-review.md +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +14 -16
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/code-review-checklist.md +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/delivery-lifecycle.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/design-routing-and-readiness.md +10 -14
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/rd-standards-doc-family-checklist.md +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/verify-developer-experience.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/SKILL.md +135 -86
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/behavioral-aesthetic-logic.md +66 -80
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/delivery-contract.md +275 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-execution-checklist.md +88 -214
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-impl-naming-and-versioning.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-intake-and-acceptance.md +10 -8
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-system-source-of-truth.md +6 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/external-ui-ux-quality-benchmarks.md +112 -95
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/frontend-code-evidence-map.md +30 -21
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/interaction-design-patterns.md +22 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/layout-recipes-and-screenshot-acceptance.md +20 -17
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-project-token-consistency.md +7 -9
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-stack-strategy.md +14 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/operational-processing-workflows.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/platform-mobile-patterns.md +3 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-lifecycle-acceptance-and-iteration.md +9 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-surface-patterns.md +3 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/source-map.md +37 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/tokens-and-components.md +8 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-audit.md +16 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-design-development.md +16 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/visual-craft.md +4 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/multi-tenant-isolation.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/SKILL.md +4 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/state-machine-task-patterns.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +8 -8
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/description-authoring.md +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +104 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +11 -9
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/r0-leakage-audit.md +102 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +103 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +20 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/uiux-judgment-extraction.md +6 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +4 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +69 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/extraction_review_gate.sh +22 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +49 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/obligation-ledger.py +2748 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/register-firing-path-resolution.rb +20 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/shared_git_surface_gate.py +1142 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +17 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +41 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ci_checkout_ref_binding.sh +120 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_entrypoint_domain_scan_terms.sh +82 -8
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_extraction_review_gate.sh +336 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_self_adjudication.sh +82 -10
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_obligation_ledger.sh +1416 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_obligation_ledger_repo_audit.sh +57 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_register_firing_path_wiring.sh +141 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_pointer_integrity.sh +3 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_shared_git_surface_gate.sh +1696 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_uiux_delivery_contract.sh +2117 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_uiux_loading_budget.sh +316 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_extraction_review_state.sh +1176 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_skill_cross_refs.sh +31 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate-skill.sh +9 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py +980 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/terminal-cli-dev/SKILL.md +8 -6
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/classical-test-design-techniques.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/tc-review-and-prioritization.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/test-artifact-management/references/update-lifecycle.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/SKILL.md +16 -15
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/ci-fixtures-and-flake-control.md +5 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/client-runtime-test-matrices.md +10 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/e2e-real-flow-testing.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/integration-contract-testing.md +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/test-code-authoring-patterns.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/test-topology-and-commands.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/SKILL.md +2 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/annotation-driven-revision.md +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/figure-and-table-craft.md +8 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/SKILL.md +7 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/complex-workspace-patterns.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/react-architecture.md +3 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/web-quality-release.md +37 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/web-ui-quality.md +10 -1
- package/dist/assets/release.json +215 -105
- package/package.json +1 -1
|
@@ -0,0 +1,1696 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Behavior regression for the shared Git/PR metadata leakage gate.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
6
|
+
GATE="$SCRIPT_DIR/shared_git_surface_gate.py"
|
|
7
|
+
[ -f "$GATE" ] || { echo "FAIL: gate not found: $GATE" >&2; exit 1; }
|
|
8
|
+
|
|
9
|
+
TMP="$(mktemp -d "${TMPDIR:-/tmp}/shared-git-surface.XXXXXX")"
|
|
10
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
11
|
+
|
|
12
|
+
fail() { echo "FAIL: $*" >&2; exit 1; }
|
|
13
|
+
assert_rc() { [ "$1" = "$2" ] || fail "expected rc=$2 got rc=$1 ($3)"; }
|
|
14
|
+
assert_equals() { [ "$1" = "$2" ] || fail "expected '$1' = '$2' ($3)"; }
|
|
15
|
+
assert_contains() { case "$2" in *"$1"*) : ;; *) fail "expected '$1' ($3): $2";; esac; }
|
|
16
|
+
assert_not_contains() { case "$2" in *"$1"*) fail "unexpected '$1' ($3): $2";; *) : ;; esac; }
|
|
17
|
+
|
|
18
|
+
REPO="$TMP/repo"
|
|
19
|
+
mkdir -p "$REPO"
|
|
20
|
+
git init -q -b main "$REPO"
|
|
21
|
+
git -C "$REPO" config user.email tester@example.invalid
|
|
22
|
+
git -C "$REPO" config user.name "Synthetic Tester"
|
|
23
|
+
printf 'baseline\n' >"$REPO/file.txt"
|
|
24
|
+
git -C "$REPO" add file.txt
|
|
25
|
+
|
|
26
|
+
# Construct the provider URL at runtime so the repository test source does not
|
|
27
|
+
# itself carry a clickable session URL.
|
|
28
|
+
session_url="https://claude"'.'"ai/code/"'synthetic_fixture_123456'
|
|
29
|
+
claude_product_url="https://claude"'.'"ai/code"
|
|
30
|
+
kimi_url="https://kimi"'.'"com/share/"'synthetic_fixture_abcdef'
|
|
31
|
+
gemini_url="https://g"'.'"co/gemini/share/"'synthetic_fixture_ghijkl'
|
|
32
|
+
google_product_url="https://g"'.'"co/product/share/"'synthetic_fixture_product'
|
|
33
|
+
conversation_uuid='12345678-1234-4abc-8def-123456789abc'
|
|
34
|
+
chatgpt_conversation_url="https://chatgpt"'.'"com/c/$conversation_uuid"
|
|
35
|
+
claude_chat_url="https://claude"'.'"ai/chat/$conversation_uuid"
|
|
36
|
+
chatgpt_https_default_port_url="https://chatgpt"'.'"com:443/share/$conversation_uuid"
|
|
37
|
+
chatgpt_http_default_port_url="http://chatgpt"'.'"com:80/share/$conversation_uuid"
|
|
38
|
+
chatgpt_wrong_port_url="https://chatgpt"'.'"com:80/share/$conversation_uuid"
|
|
39
|
+
chatgpt_nested_help_url="https://chatgpt"'.'"com/help/chat/$conversation_uuid"
|
|
40
|
+
claude_nonconversation_url="https://claude"'.'"ai/chat/product-guide"
|
|
41
|
+
printf 'baseline debt\n\nClaude-Session: %s\n' "$session_url" >"$TMP/message.txt"
|
|
42
|
+
git -C "$REPO" commit -q -F "$TMP/message.txt"
|
|
43
|
+
BASE="$(git -C "$REPO" rev-parse HEAD)"
|
|
44
|
+
git -C "$REPO" switch -q -c feature/sanitized-capability
|
|
45
|
+
|
|
46
|
+
run_gate() {
|
|
47
|
+
set +e
|
|
48
|
+
out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \
|
|
49
|
+
python3 "$GATE" --repo "$REPO" --base-ref "$BASE" "$@" 2>&1)"
|
|
50
|
+
rc=$?
|
|
51
|
+
set -e
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
run_gate_without_base() {
|
|
55
|
+
set +e
|
|
56
|
+
out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
|
|
57
|
+
python3 "$GATE" --repo "$REPO" "$@" 2>&1)"
|
|
58
|
+
rc=$?
|
|
59
|
+
set -e
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
run_gate_without_base
|
|
63
|
+
assert_rc "$rc" 2 "an unbound candidate base must fail closed"
|
|
64
|
+
assert_contains "candidate base is unresolved" "$out" "unresolved-base reason"
|
|
65
|
+
|
|
66
|
+
set +e
|
|
67
|
+
out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
|
|
68
|
+
python3 "$GATE" --repo "$REPO" --base-ref refs/heads/missing-candidate-base \
|
|
69
|
+
--default-base-ref "$BASE" 2>&1)"
|
|
70
|
+
rc=$?
|
|
71
|
+
set -e
|
|
72
|
+
assert_rc "$rc" 2 "an invalid explicit base must not fall through to a lower-priority default"
|
|
73
|
+
assert_contains "explicit candidate base does not resolve" "$out" "invalid explicit-base reason"
|
|
74
|
+
|
|
75
|
+
# A branch-creation push has an all-zero `before` SHA. The repository-owned
|
|
76
|
+
# fallback can then point at the just-pushed HEAD and silently produce an empty
|
|
77
|
+
# range, skipping the commit message that crossed the merge boundary.
|
|
78
|
+
git -C "$REPO" update-ref refs/remotes/origin/dev "$BASE"
|
|
79
|
+
python3 - "$TMP/zero-before-push.json" <<'PY'
|
|
80
|
+
import json
|
|
81
|
+
import sys
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
|
|
84
|
+
Path(sys.argv[1]).write_text(json.dumps({
|
|
85
|
+
"ref": "refs/heads/dev",
|
|
86
|
+
"before": "0" * 40,
|
|
87
|
+
}), encoding="utf-8")
|
|
88
|
+
PY
|
|
89
|
+
run_gate_without_base \
|
|
90
|
+
--event-json "$TMP/zero-before-push.json" \
|
|
91
|
+
--default-base-ref refs/remotes/origin/dev
|
|
92
|
+
assert_rc "$rc" 2 "a zero-before direct push with an empty candidate range must fail closed"
|
|
93
|
+
assert_contains "direct-push candidate commit range is empty" "$out" "zero-before push reason"
|
|
94
|
+
|
|
95
|
+
# PR text cannot substitute for the unscanned commit surface on a direct push.
|
|
96
|
+
printf 'Neutral proposed PR summary.\n' >"$TMP/zero-before-proposed-pr.txt"
|
|
97
|
+
run_gate_without_base \
|
|
98
|
+
--event-json "$TMP/zero-before-push.json" \
|
|
99
|
+
--default-base-ref refs/remotes/origin/dev \
|
|
100
|
+
--pr-text-file "$TMP/zero-before-proposed-pr.txt"
|
|
101
|
+
assert_rc "$rc" 2 "proposed PR text must not excuse an empty direct-push range"
|
|
102
|
+
assert_contains "direct-push candidate commit range is empty" "$out" "PR text cannot mask the push"
|
|
103
|
+
|
|
104
|
+
# A non-zero but unreachable `before` SHA is already a hard error and must not
|
|
105
|
+
# fall through to the repository-owned fallback.
|
|
106
|
+
python3 - "$TMP/unreachable-before-push.json" <<'PY'
|
|
107
|
+
import json
|
|
108
|
+
import sys
|
|
109
|
+
from pathlib import Path
|
|
110
|
+
|
|
111
|
+
Path(sys.argv[1]).write_text(json.dumps({
|
|
112
|
+
"ref": "refs/heads/dev",
|
|
113
|
+
"before": "f" * 40,
|
|
114
|
+
}), encoding="utf-8")
|
|
115
|
+
PY
|
|
116
|
+
run_gate_without_base \
|
|
117
|
+
--event-json "$TMP/unreachable-before-push.json" \
|
|
118
|
+
--default-base-ref refs/remotes/origin/dev
|
|
119
|
+
assert_rc "$rc" 2 "an unreachable non-zero push base must fail closed"
|
|
120
|
+
assert_contains "trusted event candidate base does not resolve" "$out" "unreachable push-base reason"
|
|
121
|
+
|
|
122
|
+
# Outside a direct-push event, an explicit empty range is valid for local
|
|
123
|
+
# pre-commit validation; historical debt at/before the base stays excluded.
|
|
124
|
+
run_gate
|
|
125
|
+
assert_rc "$rc" 0 "a local explicit empty range must remain valid"
|
|
126
|
+
assert_contains "commits=0" "$out" "local empty range remains explicit"
|
|
127
|
+
|
|
128
|
+
printf 'clean candidate\n' >>"$REPO/file.txt"
|
|
129
|
+
git -C "$REPO" commit -qam "describe bounded capability"
|
|
130
|
+
clean_head="$(git -C "$REPO" rev-parse HEAD)"
|
|
131
|
+
run_gate
|
|
132
|
+
assert_rc "$rc" 0 "sanitized candidate commit should pass while historical debt stays excluded"
|
|
133
|
+
|
|
134
|
+
# A pre-publication PR text check remains useful even when no candidate commit
|
|
135
|
+
# exists yet. The supplied non-empty file is itself an explicit scanned surface.
|
|
136
|
+
printf 'Neutral proposed PR summary.\n' >"$TMP/empty-range-proposed-pr.txt"
|
|
137
|
+
set +e
|
|
138
|
+
out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
|
|
139
|
+
python3 "$GATE" --repo "$REPO" --base-ref "$clean_head" \
|
|
140
|
+
--branch-name feature/sanitized-capability \
|
|
141
|
+
--pr-text-file "$TMP/empty-range-proposed-pr.txt" 2>&1)"
|
|
142
|
+
rc=$?
|
|
143
|
+
set -e
|
|
144
|
+
assert_rc "$rc" 0 "a non-empty proposed PR surface may be checked with no candidate commits"
|
|
145
|
+
assert_contains "commits=0" "$out" "the allowed empty range remains explicit"
|
|
146
|
+
|
|
147
|
+
for empty_pr_case in zero-byte whitespace-only; do
|
|
148
|
+
case "$empty_pr_case" in
|
|
149
|
+
zero-byte) : >"$TMP/empty-range-proposed-pr.txt" ;;
|
|
150
|
+
whitespace-only) printf ' \t\r\n' >"$TMP/empty-range-proposed-pr.txt" ;;
|
|
151
|
+
esac
|
|
152
|
+
set +e
|
|
153
|
+
out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
|
|
154
|
+
python3 "$GATE" --repo "$REPO" --base-ref "$clean_head" \
|
|
155
|
+
--branch-name feature/sanitized-capability \
|
|
156
|
+
--pr-text-file "$TMP/empty-range-proposed-pr.txt" 2>&1)"
|
|
157
|
+
rc=$?
|
|
158
|
+
set -e
|
|
159
|
+
assert_rc "$rc" 2 "$empty_pr_case proposed PR text must fail closed"
|
|
160
|
+
assert_contains \
|
|
161
|
+
"proposed PR text must contain non-whitespace content" "$out" \
|
|
162
|
+
"$empty_pr_case proposed PR diagnostic"
|
|
163
|
+
done
|
|
164
|
+
|
|
165
|
+
printf 'Private session: %s\n' "$session_url" >"$TMP/empty-range-proposed-pr.txt"
|
|
166
|
+
set +e
|
|
167
|
+
out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
|
|
168
|
+
python3 "$GATE" --repo "$REPO" --base-ref "$clean_head" \
|
|
169
|
+
--branch-name feature/sanitized-capability \
|
|
170
|
+
--pr-text-file "$TMP/empty-range-proposed-pr.txt" 2>&1)"
|
|
171
|
+
rc=$?
|
|
172
|
+
set -e
|
|
173
|
+
assert_rc "$rc" 1 "a prohibited proposed PR surface must still block with no candidate commits"
|
|
174
|
+
assert_contains "category=ai_session_url" "$out" "empty-range proposed PR category"
|
|
175
|
+
assert_not_contains "synthetic_fixture_123456" "$out" "empty-range diagnostic stays redacted"
|
|
176
|
+
|
|
177
|
+
python3 - "$TMP/empty-range-pr-event.json" "$clean_head" <<'PY'
|
|
178
|
+
import json
|
|
179
|
+
import sys
|
|
180
|
+
from pathlib import Path
|
|
181
|
+
|
|
182
|
+
Path(sys.argv[1]).write_text(json.dumps({
|
|
183
|
+
"pull_request": {
|
|
184
|
+
"title": "Bound a shared capability",
|
|
185
|
+
"body": "Neutral behavior and verification summary.",
|
|
186
|
+
"head": {"ref": "feature/sanitized-capability"},
|
|
187
|
+
"base": {"sha": sys.argv[2]},
|
|
188
|
+
}
|
|
189
|
+
}), encoding="utf-8")
|
|
190
|
+
PY
|
|
191
|
+
run_gate_without_base --event-json "$TMP/empty-range-pr-event.json"
|
|
192
|
+
assert_rc "$rc" 0 "trusted non-empty PR text may be checked with no candidate commits"
|
|
193
|
+
assert_contains "commits=0" "$out" "the trusted PR exception remains explicit"
|
|
194
|
+
|
|
195
|
+
python3 - "$TMP/empty-range-pr-event.json" "$clean_head" "$session_url" <<'PY'
|
|
196
|
+
import json
|
|
197
|
+
import sys
|
|
198
|
+
from pathlib import Path
|
|
199
|
+
|
|
200
|
+
Path(sys.argv[1]).write_text(json.dumps({
|
|
201
|
+
"pull_request": {
|
|
202
|
+
"title": "Bound a shared capability",
|
|
203
|
+
"body": "Private session: " + sys.argv[3],
|
|
204
|
+
"head": {"ref": "feature/sanitized-capability"},
|
|
205
|
+
"base": {"sha": sys.argv[2]},
|
|
206
|
+
}
|
|
207
|
+
}), encoding="utf-8")
|
|
208
|
+
PY
|
|
209
|
+
run_gate_without_base --event-json "$TMP/empty-range-pr-event.json"
|
|
210
|
+
assert_rc "$rc" 1 "prohibited trusted PR text must block with no candidate commits"
|
|
211
|
+
assert_contains "surface=pull_request_body" "$out" "empty-range trusted PR locator"
|
|
212
|
+
assert_not_contains "synthetic_fixture_123456" "$out" "empty-range trusted PR stays redacted"
|
|
213
|
+
|
|
214
|
+
candidate_case() {
|
|
215
|
+
local branch="$1" message_file="$2"
|
|
216
|
+
git -C "$REPO" switch -q -C "$branch" "$clean_head"
|
|
217
|
+
printf '%s\n' "$branch" >>"$REPO/file.txt"
|
|
218
|
+
git -C "$REPO" add file.txt
|
|
219
|
+
git -C "$REPO" commit -q -F "$message_file"
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
candidate_identity_case() {
|
|
223
|
+
local branch="$1" author_name="$2" author_email="$3"
|
|
224
|
+
local committer_name="$4" committer_email="$5"
|
|
225
|
+
git -C "$REPO" switch -q -C "$branch" "$clean_head"
|
|
226
|
+
printf '%s\n' "$branch" >>"$REPO/file.txt"
|
|
227
|
+
git -C "$REPO" add file.txt
|
|
228
|
+
GIT_AUTHOR_NAME="$author_name" GIT_AUTHOR_EMAIL="$author_email" \
|
|
229
|
+
GIT_COMMITTER_NAME="$committer_name" GIT_COMMITTER_EMAIL="$committer_email" \
|
|
230
|
+
git -C "$REPO" commit -q -m "describe bounded capability"
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
printf 'change summary\n\nClaude-Session: %s\n' "$session_url" >"$TMP/session-message.txt"
|
|
234
|
+
candidate_case case/session-trailer "$TMP/session-message.txt"
|
|
235
|
+
session_commit="$(git -C "$REPO" rev-parse --short=12 HEAD)"
|
|
236
|
+
run_gate
|
|
237
|
+
assert_rc "$rc" 1 "new session trailer must block"
|
|
238
|
+
assert_contains "category=ai_session_url" "$out" "URL category"
|
|
239
|
+
assert_contains "category=ai_session_trailer" "$out" "trailer category"
|
|
240
|
+
assert_not_contains "synthetic_fixture_123456" "$out" "diagnostic must redact the session id"
|
|
241
|
+
run_gate --event-json "$TMP/zero-before-push.json"
|
|
242
|
+
assert_rc "$rc" 1 "an explicit ancestor must recover scanning for a zero-before push"
|
|
243
|
+
assert_contains "category=ai_session_trailer" "$out" "explicit push base scans the candidate commit"
|
|
244
|
+
|
|
245
|
+
# A clean HEAD must not hide a prohibited message in an earlier candidate
|
|
246
|
+
# commit. This kills a tempting `git log -1`/last-record-only regression.
|
|
247
|
+
printf 'clean follow-up\n' >>"$REPO/file.txt"
|
|
248
|
+
git -C "$REPO" commit -qam "follow up with neutral wording"
|
|
249
|
+
run_gate
|
|
250
|
+
assert_rc "$rc" 1 "an earlier candidate violation must remain blocking"
|
|
251
|
+
assert_contains "locator=$session_commit" "$out" "the earlier candidate commit was scanned"
|
|
252
|
+
|
|
253
|
+
# `--repo` is the single repository identity. Ambient Git routing variables
|
|
254
|
+
# must not redirect every subprocess to a clean decoy and certify the wrong
|
|
255
|
+
# object graph. Exercise both the shortest bypass and the full same-shape set.
|
|
256
|
+
DECOY_REPO="$TMP/git-env-decoy"
|
|
257
|
+
git clone -q "$REPO" "$DECOY_REPO"
|
|
258
|
+
git -C "$DECOY_REPO" reset -q --hard "$clean_head"
|
|
259
|
+
git -C "$DECOY_REPO" config user.email tester@example.invalid
|
|
260
|
+
git -C "$DECOY_REPO" config user.name "Synthetic Tester"
|
|
261
|
+
git -C "$DECOY_REPO" commit -q --allow-empty -m "neutral decoy head"
|
|
262
|
+
: >"$TMP/empty-grafts"
|
|
263
|
+
|
|
264
|
+
set +e
|
|
265
|
+
out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \
|
|
266
|
+
GIT_DIR="$DECOY_REPO/.git" \
|
|
267
|
+
python3 "$GATE" --repo "$REPO" --base-ref "$BASE" \
|
|
268
|
+
--branch-name feature/sanitized-capability 2>&1)"
|
|
269
|
+
rc=$?
|
|
270
|
+
set -e
|
|
271
|
+
assert_rc "$rc" 1 "ambient GIT_DIR must not redirect the candidate scan"
|
|
272
|
+
assert_contains "locator=$session_commit" "$out" "GIT_DIR isolation keeps the real candidate"
|
|
273
|
+
|
|
274
|
+
set +e
|
|
275
|
+
out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \
|
|
276
|
+
GIT_DIR="$DECOY_REPO/.git" \
|
|
277
|
+
GIT_WORK_TREE="$DECOY_REPO" \
|
|
278
|
+
GIT_INDEX_FILE="$DECOY_REPO/.git/index" \
|
|
279
|
+
GIT_COMMON_DIR="$DECOY_REPO/.git" \
|
|
280
|
+
GIT_NAMESPACE=synthetic-clean \
|
|
281
|
+
GIT_OBJECT_DIRECTORY="$DECOY_REPO/.git/objects" \
|
|
282
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES="$DECOY_REPO/.git/objects" \
|
|
283
|
+
GIT_CEILING_DIRECTORIES="$DECOY_REPO" \
|
|
284
|
+
GIT_GRAFT_FILE="$TMP/empty-grafts" \
|
|
285
|
+
python3 "$GATE" --repo "$REPO" --base-ref "$BASE" \
|
|
286
|
+
--branch-name feature/sanitized-capability 2>&1)"
|
|
287
|
+
rc=$?
|
|
288
|
+
set -e
|
|
289
|
+
assert_rc "$rc" 1 "ambient Git repository-routing matrix must be ignored"
|
|
290
|
+
assert_contains "locator=$session_commit" "$out" "routing-matrix isolation keeps the real candidate"
|
|
291
|
+
assert_not_contains "$DECOY_REPO" "$out" "routing diagnostics do not echo decoy paths"
|
|
292
|
+
|
|
293
|
+
# Git's runtime config channels are repository-routing channels too. An
|
|
294
|
+
# injected include can supply core.worktree and executable options even though
|
|
295
|
+
# every command also uses `-C <repo>`. Keep the end-to-end range assertion and
|
|
296
|
+
# probe the shared subprocess helper so sanitization cannot regress one key at
|
|
297
|
+
# a time.
|
|
298
|
+
python3 - "$GATE" "$REPO" "$DECOY_REPO" "$TMP" "$BASE" "$session_commit" <<'PY'
|
|
299
|
+
import importlib.util
|
|
300
|
+
import os
|
|
301
|
+
import shlex
|
|
302
|
+
import subprocess
|
|
303
|
+
import sys
|
|
304
|
+
from pathlib import Path
|
|
305
|
+
|
|
306
|
+
gate_path = Path(sys.argv[1])
|
|
307
|
+
repo_path = Path(sys.argv[2])
|
|
308
|
+
decoy_path = Path(sys.argv[3])
|
|
309
|
+
fixture_root = Path(sys.argv[4])
|
|
310
|
+
base = sys.argv[5]
|
|
311
|
+
candidate_locator = sys.argv[6]
|
|
312
|
+
config_path = fixture_root / "ambient-git-config.cfg"
|
|
313
|
+
fsmonitor_path = fixture_root / "ambient-fsmonitor.sh"
|
|
314
|
+
fsmonitor_marker = fixture_root / "ambient-fsmonitor-ran"
|
|
315
|
+
|
|
316
|
+
fsmonitor_path.write_text(
|
|
317
|
+
"#!/bin/sh\n: >" + shlex.quote(str(fsmonitor_marker)) + "\nexit 0\n",
|
|
318
|
+
encoding="utf-8",
|
|
319
|
+
)
|
|
320
|
+
fsmonitor_path.chmod(0o700)
|
|
321
|
+
|
|
322
|
+
clean_setup_env = os.environ.copy()
|
|
323
|
+
for key in tuple(clean_setup_env):
|
|
324
|
+
if key in {
|
|
325
|
+
"GIT_CONFIG",
|
|
326
|
+
"GIT_CONFIG_COUNT",
|
|
327
|
+
"GIT_CONFIG_GLOBAL",
|
|
328
|
+
"GIT_CONFIG_SYSTEM",
|
|
329
|
+
"GIT_CONFIG_NOSYSTEM",
|
|
330
|
+
"GIT_CONFIG_PARAMETERS",
|
|
331
|
+
} or key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")):
|
|
332
|
+
clean_setup_env.pop(key, None)
|
|
333
|
+
subprocess.run(
|
|
334
|
+
["git", "config", "--file", str(config_path), "core.worktree", str(decoy_path)],
|
|
335
|
+
check=True,
|
|
336
|
+
env=clean_setup_env,
|
|
337
|
+
)
|
|
338
|
+
subprocess.run(
|
|
339
|
+
["git", "config", "--file", str(config_path), "core.fsmonitor", str(fsmonitor_path)],
|
|
340
|
+
check=True,
|
|
341
|
+
env=clean_setup_env,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
counted_config = {
|
|
345
|
+
"GIT_CONFIG_COUNT": "1",
|
|
346
|
+
"GIT_CONFIG_KEY_0": "include.path",
|
|
347
|
+
"GIT_CONFIG_VALUE_0": str(config_path),
|
|
348
|
+
}
|
|
349
|
+
ambient_env = clean_setup_env | counted_config
|
|
350
|
+
candidate = subprocess.run(
|
|
351
|
+
[
|
|
352
|
+
sys.executable,
|
|
353
|
+
str(gate_path),
|
|
354
|
+
"--repo",
|
|
355
|
+
str(repo_path),
|
|
356
|
+
"--base-ref",
|
|
357
|
+
base,
|
|
358
|
+
"--branch-name",
|
|
359
|
+
"feature/sanitized-capability",
|
|
360
|
+
],
|
|
361
|
+
stdout=subprocess.PIPE,
|
|
362
|
+
stderr=subprocess.PIPE,
|
|
363
|
+
check=False,
|
|
364
|
+
env=ambient_env,
|
|
365
|
+
text=True,
|
|
366
|
+
)
|
|
367
|
+
assert candidate.returncode == 1, candidate.stderr
|
|
368
|
+
assert f"locator={candidate_locator}" in candidate.stderr
|
|
369
|
+
assert str(decoy_path) not in candidate.stderr
|
|
370
|
+
assert not fsmonitor_marker.exists()
|
|
371
|
+
|
|
372
|
+
spec = importlib.util.spec_from_file_location(
|
|
373
|
+
"shared_git_surface_gate_config_probe", gate_path
|
|
374
|
+
)
|
|
375
|
+
module = importlib.util.module_from_spec(spec)
|
|
376
|
+
sys.modules[spec.name] = module
|
|
377
|
+
spec.loader.exec_module(module)
|
|
378
|
+
|
|
379
|
+
original_environment = os.environ.copy()
|
|
380
|
+
try:
|
|
381
|
+
os.environ.clear()
|
|
382
|
+
os.environ.update(ambient_env)
|
|
383
|
+
try:
|
|
384
|
+
configured_worktree = module.git(
|
|
385
|
+
decoy_path,
|
|
386
|
+
"config",
|
|
387
|
+
"--get",
|
|
388
|
+
"core.worktree",
|
|
389
|
+
operation="ambient core.worktree probe",
|
|
390
|
+
).decode("utf-8").strip()
|
|
391
|
+
except module.GateError as exc:
|
|
392
|
+
assert str(exc) == "ambient core.worktree probe failed"
|
|
393
|
+
configured_worktree = None
|
|
394
|
+
module.git(
|
|
395
|
+
decoy_path,
|
|
396
|
+
"status",
|
|
397
|
+
"--porcelain=v1",
|
|
398
|
+
operation="ambient fsmonitor probe",
|
|
399
|
+
)
|
|
400
|
+
finally:
|
|
401
|
+
os.environ.clear()
|
|
402
|
+
os.environ.update(original_environment)
|
|
403
|
+
|
|
404
|
+
absorbed = []
|
|
405
|
+
if configured_worktree is not None:
|
|
406
|
+
absorbed.append("include.path/core.worktree")
|
|
407
|
+
if fsmonitor_marker.exists():
|
|
408
|
+
absorbed.append("core.fsmonitor command")
|
|
409
|
+
assert not absorbed, "ambient config reached Git subprocess: " + ", ".join(absorbed)
|
|
410
|
+
|
|
411
|
+
blocked_config_keys = {
|
|
412
|
+
"GIT_CONFIG",
|
|
413
|
+
"GIT_CONFIG_COUNT",
|
|
414
|
+
"GIT_CONFIG_GLOBAL",
|
|
415
|
+
"GIT_CONFIG_SYSTEM",
|
|
416
|
+
"GIT_CONFIG_NOSYSTEM",
|
|
417
|
+
"GIT_CONFIG_PARAMETERS",
|
|
418
|
+
}
|
|
419
|
+
blocked_config_prefixes = ("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")
|
|
420
|
+
synthetic_environment = {
|
|
421
|
+
key: f"synthetic-{index}"
|
|
422
|
+
for index, key in enumerate(module.GIT_REPOSITORY_ROUTING_ENV)
|
|
423
|
+
}
|
|
424
|
+
synthetic_environment.update(
|
|
425
|
+
{
|
|
426
|
+
key: str(config_path)
|
|
427
|
+
for key in blocked_config_keys
|
|
428
|
+
}
|
|
429
|
+
)
|
|
430
|
+
synthetic_environment.update(
|
|
431
|
+
{
|
|
432
|
+
"GIT_CONFIG_KEY_0": "include.path",
|
|
433
|
+
"GIT_CONFIG_VALUE_0": str(config_path),
|
|
434
|
+
"GIT_CONFIG_KEY_91": "core.worktree",
|
|
435
|
+
"GIT_CONFIG_VALUE_91": str(decoy_path),
|
|
436
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
437
|
+
"CCL_SKILL_BASE_REF": base,
|
|
438
|
+
"SYNTH_GIT_ENV_CONTROL": "preserved",
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
try:
|
|
442
|
+
os.environ.update(synthetic_environment)
|
|
443
|
+
sanitized = module.git_environment()
|
|
444
|
+
finally:
|
|
445
|
+
os.environ.clear()
|
|
446
|
+
os.environ.update(original_environment)
|
|
447
|
+
|
|
448
|
+
for key in module.GIT_REPOSITORY_ROUTING_ENV:
|
|
449
|
+
assert key not in sanitized, key
|
|
450
|
+
for key in blocked_config_keys:
|
|
451
|
+
assert key not in sanitized, key
|
|
452
|
+
assert not any(key.startswith(blocked_config_prefixes) for key in sanitized)
|
|
453
|
+
for key in (
|
|
454
|
+
"GIT_TERMINAL_PROMPT",
|
|
455
|
+
"CCL_SKILL_BASE_REF",
|
|
456
|
+
"SYNTH_GIT_ENV_CONTROL",
|
|
457
|
+
):
|
|
458
|
+
assert sanitized[key] == synthetic_environment[key], key
|
|
459
|
+
PY
|
|
460
|
+
|
|
461
|
+
printf '%s\n' 'change summary' 'Codex'"-Session-ID: synthetic_fixture_654321" >"$TMP/session-id-message.txt"
|
|
462
|
+
candidate_case case/session-id "$TMP/session-id-message.txt"
|
|
463
|
+
run_gate
|
|
464
|
+
assert_rc "$rc" 1 "new session identifier must block"
|
|
465
|
+
assert_contains "category=ai_session_id" "$out" "session-id category"
|
|
466
|
+
assert_not_contains "synthetic_fixture_654321" "$out" "session-id diagnostic must redact the identifier"
|
|
467
|
+
|
|
468
|
+
printf '%s\n' 'change summary' 'Kimi-Session-ID: synthetic_fixture_777777' >"$TMP/kimi-session-id-message.txt"
|
|
469
|
+
candidate_case case/kimi-session-id "$TMP/kimi-session-id-message.txt"
|
|
470
|
+
run_gate
|
|
471
|
+
assert_rc "$rc" 1 "Kimi session identifier must block"
|
|
472
|
+
assert_contains "category=ai_session_id" "$out" "expanded session-id provider category"
|
|
473
|
+
|
|
474
|
+
codex_task_url="https://chatgpt"'.'"com/codex/tasks/"'task_e_synthetic123456'
|
|
475
|
+
printf 'change summary\n\nTracked in %s\n' "$codex_task_url" >"$TMP/codex-task-url-message.txt"
|
|
476
|
+
candidate_case case/codex-task-url "$TMP/codex-task-url-message.txt"
|
|
477
|
+
run_gate
|
|
478
|
+
assert_rc "$rc" 1 "a Codex task URL on a session origin must block"
|
|
479
|
+
assert_contains "category=ai_session_url" "$out" "Codex task URL category"
|
|
480
|
+
|
|
481
|
+
codex_task_near_miss="https://chatgpt"'.'"com/help/codex-tasks-overview"
|
|
482
|
+
printf 'change summary\n\nDocs at %s\n' "$codex_task_near_miss" >"$TMP/codex-task-near-miss-message.txt"
|
|
483
|
+
candidate_case case/codex-task-near-miss "$TMP/codex-task-near-miss-message.txt"
|
|
484
|
+
run_gate
|
|
485
|
+
assert_rc "$rc" 0 "a product page near a Codex task path must remain valid"
|
|
486
|
+
|
|
487
|
+
printf 'change summary\n\nPrivate review: %s\n' "$kimi_url" >"$TMP/kimi-url-message.txt"
|
|
488
|
+
candidate_case case/kimi-url "$TMP/kimi-url-message.txt"
|
|
489
|
+
run_gate
|
|
490
|
+
assert_rc "$rc" 1 "Kimi session URL must block"
|
|
491
|
+
assert_contains "category=ai_session_url" "$out" "expanded session URL provider category"
|
|
492
|
+
assert_not_contains "synthetic_fixture_abcdef" "$out" "expanded-provider URL must be redacted"
|
|
493
|
+
|
|
494
|
+
printf 'change summary\n\nCo-Authored-By: Claude <noreply@example.invalid>\n' >"$TMP/coauthor-message.txt"
|
|
495
|
+
candidate_case case/coauthor "$TMP/coauthor-message.txt"
|
|
496
|
+
run_gate
|
|
497
|
+
assert_rc "$rc" 1 "AI co-author trailer must block"
|
|
498
|
+
assert_contains "category=ai_coauthor_trailer" "$out" "co-author category"
|
|
499
|
+
|
|
500
|
+
printf 'change summary\n\nCo-Authored-By: Human Reviewer <reviewer@example.invalid>\n' >"$TMP/human-coauthor-message.txt"
|
|
501
|
+
candidate_case case/human-coauthor "$TMP/human-coauthor-message.txt"
|
|
502
|
+
run_gate
|
|
503
|
+
assert_rc "$rc" 0 "a human co-author trailer must remain valid"
|
|
504
|
+
|
|
505
|
+
printf 'change summary\n\nCo-Authored-By: Ai Weiwei <reviewer@example.invalid>\n' >"$TMP/ai-human-coauthor-message.txt"
|
|
506
|
+
candidate_case case/ai-human-coauthor "$TMP/ai-human-coauthor-message.txt"
|
|
507
|
+
run_gate
|
|
508
|
+
assert_rc "$rc" 0 "a human whose name starts with Ai must not be treated as an AI provider"
|
|
509
|
+
|
|
510
|
+
printf 'change summary\n\nCo-Authored-By: Kimi <k@example.invalid>\n' >"$TMP/provider-name-human-message.txt"
|
|
511
|
+
candidate_case case/provider-name-human "$TMP/provider-name-human-message.txt"
|
|
512
|
+
run_gate
|
|
513
|
+
assert_rc "$rc" 0 "a human whose exact name is also a provider alias must remain valid"
|
|
514
|
+
|
|
515
|
+
printf 'change summary\n\nCo-Authored-By: Qwen Li <qwen.li@example.invalid>\n' >"$TMP/provider-prefix-human-message.txt"
|
|
516
|
+
candidate_case case/provider-prefix-human "$TMP/provider-prefix-human-message.txt"
|
|
517
|
+
run_gate
|
|
518
|
+
assert_rc "$rc" 0 "a human surname after a provider-shaped given name must remain valid"
|
|
519
|
+
|
|
520
|
+
printf 'change summary\n\nCo-Authored-By: Gemini Proctor <gemini.proctor@example.invalid>\n' >"$TMP/ambiguous-provider-human-message.txt"
|
|
521
|
+
candidate_case case/ambiguous-provider-human "$TMP/ambiguous-provider-human-message.txt"
|
|
522
|
+
run_gate
|
|
523
|
+
assert_rc "$rc" 0 "an ambiguous provider prefix in a human name must remain valid"
|
|
524
|
+
|
|
525
|
+
printf 'change summary\n\nCo-Authored-By: Claude Code <helper@example.invalid>\n' >"$TMP/unambiguous-provider-coauthor-message.txt"
|
|
526
|
+
candidate_case case/unambiguous-provider-coauthor "$TMP/unambiguous-provider-coauthor-message.txt"
|
|
527
|
+
run_gate
|
|
528
|
+
assert_rc "$rc" 1 "an unambiguous AI display name must block with an ordinary email"
|
|
529
|
+
assert_contains "category=ai_coauthor_trailer" "$out" "unambiguous AI display-name co-author category"
|
|
530
|
+
|
|
531
|
+
printf 'change summary\n\nCo-Authored-By: **Claude Code** <helper@example.invalid>\n' >"$TMP/emphasized-identity-coauthor-message.txt"
|
|
532
|
+
candidate_case case/emphasized-identity-coauthor "$TMP/emphasized-identity-coauthor-message.txt"
|
|
533
|
+
run_gate
|
|
534
|
+
assert_rc "$rc" 1 "emphasis around only the identity must still block"
|
|
535
|
+
assert_contains "category=ai_coauthor_trailer" "$out" \
|
|
536
|
+
"emphasized-identity co-author category"
|
|
537
|
+
|
|
538
|
+
for qualified_identity in \
|
|
539
|
+
"Claude Sonnet" "Claude 3.5 Sonnet" "Claude v3.5 Sonnet" \
|
|
540
|
+
"Claude Fable 5" "Claude Mythos 5" "Claude 5 Fable" \
|
|
541
|
+
"OpenAI Codex" "ChatGPT-5" "Qwen2.5-Coder" "Gemini 2.5 Pro" \
|
|
542
|
+
"GPT-5.6" "OpenAI GPT-5.6" "Gemini 2.5 Flash"; do
|
|
543
|
+
printf 'change summary\n\nCo-Authored-By: %s <helper@example.invalid>\n' \
|
|
544
|
+
"$qualified_identity" >"$TMP/model-qualified-coauthor-message.txt"
|
|
545
|
+
candidate_case "case/model-qualified-coauthor-${qualified_identity//[^A-Za-z0-9]/-}" \
|
|
546
|
+
"$TMP/model-qualified-coauthor-message.txt"
|
|
547
|
+
run_gate
|
|
548
|
+
assert_rc "$rc" 1 "model-qualified AI co-author $qualified_identity must block"
|
|
549
|
+
assert_contains "category=ai_coauthor_trailer" "$out" \
|
|
550
|
+
"model-qualified AI co-author category"
|
|
551
|
+
done
|
|
552
|
+
|
|
553
|
+
printf 'change summary\n\nCo-Authored-By: Claude\tCode <helper@example.invalid>\n' >"$TMP/tab-qualified-coauthor-message.txt"
|
|
554
|
+
candidate_case case/tab-qualified-coauthor "$TMP/tab-qualified-coauthor-message.txt"
|
|
555
|
+
run_gate
|
|
556
|
+
assert_rc "$rc" 1 "a tab-qualified AI co-author identity must block"
|
|
557
|
+
assert_contains "category=ai_coauthor_trailer" "$out" \
|
|
558
|
+
"tab-qualified AI co-author category"
|
|
559
|
+
|
|
560
|
+
printf 'change summary\n\nCo-Authored-By: Codex <helper@example.invalid>\n' >"$TMP/codex-ordinary-email-message.txt"
|
|
561
|
+
candidate_case case/codex-ordinary-email "$TMP/codex-ordinary-email-message.txt"
|
|
562
|
+
run_gate
|
|
563
|
+
assert_rc "$rc" 1 "a non-human provider name must block with an ordinary email"
|
|
564
|
+
assert_contains "category=ai_coauthor_trailer" "$out" "ordinary-email provider co-author category"
|
|
565
|
+
|
|
566
|
+
printf 'change summary\n\nCo-Authored-By: ChatGPT <noreply@example.invalid>\n' >"$TMP/chatgpt-coauthor-message.txt"
|
|
567
|
+
candidate_case case/chatgpt-coauthor "$TMP/chatgpt-coauthor-message.txt"
|
|
568
|
+
run_gate
|
|
569
|
+
assert_rc "$rc" 1 "another AI-family co-author trailer must block"
|
|
570
|
+
assert_contains "category=ai_coauthor_trailer" "$out" "AI-family co-author category"
|
|
571
|
+
|
|
572
|
+
printf 'change summary\n\nCo-Authored-By: OpenCode <noreply@example.invalid>\n' >"$TMP/opencode-coauthor-message.txt"
|
|
573
|
+
candidate_case case/opencode-coauthor "$TMP/opencode-coauthor-message.txt"
|
|
574
|
+
run_gate
|
|
575
|
+
assert_rc "$rc" 1 "OpenCode co-author trailer must block"
|
|
576
|
+
assert_contains "category=ai_coauthor_trailer" "$out" "expanded co-author provider category"
|
|
577
|
+
|
|
578
|
+
printf 'change summary\n\nCo-Authored-By: claude[bot] <123+claude[bot]@users.noreply.github.com>\n' >"$TMP/bracketed-bot-coauthor-message.txt"
|
|
579
|
+
candidate_case case/bracketed-bot-coauthor "$TMP/bracketed-bot-coauthor-message.txt"
|
|
580
|
+
run_gate
|
|
581
|
+
assert_rc "$rc" 1 "a bracketed GitHub App co-author identity must block"
|
|
582
|
+
assert_contains "category=ai_coauthor_trailer" "$out" "bracketed bot co-author category"
|
|
583
|
+
|
|
584
|
+
printf 'change summary\n\nCo-Authored-By: claude-code[bot] <123+claude-code[bot]@users.noreply.github.com>\n' >"$TMP/hyphenated-bot-coauthor-message.txt"
|
|
585
|
+
candidate_case case/hyphenated-bot-coauthor "$TMP/hyphenated-bot-coauthor-message.txt"
|
|
586
|
+
run_gate
|
|
587
|
+
assert_rc "$rc" 1 "a hyphenated provider GitHub App co-author identity must block"
|
|
588
|
+
assert_contains "category=ai_coauthor_trailer" "$out" "hyphenated bot co-author category"
|
|
589
|
+
|
|
590
|
+
candidate_identity_case case/ai-author "Claude Code" helper@example.invalid \
|
|
591
|
+
"Synthetic Tester" tester@example.invalid
|
|
592
|
+
run_gate
|
|
593
|
+
assert_rc "$rc" 1 "an unambiguous AI author identity must block"
|
|
594
|
+
assert_contains "surface=commit_author" "$out" "AI author surface"
|
|
595
|
+
assert_contains "category=ai_commit_identity" "$out" "AI author category"
|
|
596
|
+
|
|
597
|
+
candidate_identity_case case/ai-committer "Synthetic Tester" tester@example.invalid \
|
|
598
|
+
Codex helper@example.invalid
|
|
599
|
+
run_gate
|
|
600
|
+
assert_rc "$rc" 1 "an unambiguous AI committer identity must block"
|
|
601
|
+
assert_contains "surface=commit_committer" "$out" "AI committer surface"
|
|
602
|
+
assert_contains "category=ai_commit_identity" "$out" "AI committer category"
|
|
603
|
+
|
|
604
|
+
candidate_identity_case case/model-qualified-ai-author "OpenAI Codex" \
|
|
605
|
+
helper@example.invalid "Synthetic Tester" tester@example.invalid
|
|
606
|
+
run_gate
|
|
607
|
+
assert_rc "$rc" 1 "a model-qualified AI author identity must block"
|
|
608
|
+
assert_contains "surface=commit_author" "$out" "model-qualified AI author surface"
|
|
609
|
+
assert_contains "category=ai_commit_identity" "$out" "model-qualified AI author category"
|
|
610
|
+
|
|
611
|
+
candidate_identity_case case/model-qualified-ai-committer "Synthetic Tester" \
|
|
612
|
+
tester@example.invalid "Claude Sonnet" helper@example.invalid
|
|
613
|
+
run_gate
|
|
614
|
+
assert_rc "$rc" 1 "a model-qualified AI committer identity must block"
|
|
615
|
+
assert_contains "surface=commit_committer" "$out" "model-qualified AI committer surface"
|
|
616
|
+
assert_contains "category=ai_commit_identity" "$out" "model-qualified AI committer category"
|
|
617
|
+
|
|
618
|
+
candidate_identity_case case/fable-ai-author "Claude Fable 5" \
|
|
619
|
+
helper@example.invalid "Synthetic Tester" tester@example.invalid
|
|
620
|
+
run_gate
|
|
621
|
+
assert_rc "$rc" 1 "a Fable-model AI author identity must block"
|
|
622
|
+
assert_contains "surface=commit_author" "$out" "Fable AI author surface"
|
|
623
|
+
assert_contains "category=ai_commit_identity" "$out" "Fable AI author category"
|
|
624
|
+
|
|
625
|
+
candidate_identity_case case/mythos-ai-committer "Synthetic Tester" \
|
|
626
|
+
tester@example.invalid "Claude Mythos 5" helper@example.invalid
|
|
627
|
+
run_gate
|
|
628
|
+
assert_rc "$rc" 1 "a Mythos-model AI committer identity must block"
|
|
629
|
+
assert_contains "surface=commit_committer" "$out" "Mythos AI committer surface"
|
|
630
|
+
assert_contains "category=ai_commit_identity" "$out" "Mythos AI committer category"
|
|
631
|
+
|
|
632
|
+
# Annotated tag messages are a pushed surface resolve_head peels past; the
|
|
633
|
+
# gate must scan every tag-object layer of the candidate ref.
|
|
634
|
+
printf 'neutral tag base change\n' >"$TMP/tag-base-message.txt"
|
|
635
|
+
candidate_case case/tag-surface "$TMP/tag-base-message.txt"
|
|
636
|
+
git -C "$REPO" tag -a synthetic-annotated-bad -F - HEAD <<EOF_TAG
|
|
637
|
+
release notes
|
|
638
|
+
|
|
639
|
+
Co-Authored-By: Claude Code <helper@example.invalid>
|
|
640
|
+
EOF_TAG
|
|
641
|
+
run_gate --head-ref refs/tags/synthetic-annotated-bad
|
|
642
|
+
assert_rc "$rc" 1 "an annotated tag message with an AI trailer must block"
|
|
643
|
+
assert_contains "surface=tag_message" "$out" "tag-message surface"
|
|
644
|
+
assert_contains "category=ai_coauthor_trailer" "$out" "tag-message co-author category"
|
|
645
|
+
|
|
646
|
+
git -C "$REPO" tag -a synthetic-annotated-clean -m "neutral release notes" HEAD
|
|
647
|
+
run_gate --head-ref refs/tags/synthetic-annotated-clean
|
|
648
|
+
assert_rc "$rc" 0 "a clean annotated tag must pass"
|
|
649
|
+
assert_contains "tag_objects=1" "$out" "annotated tag surface count"
|
|
650
|
+
|
|
651
|
+
git -C "$REPO" tag synthetic-lightweight HEAD
|
|
652
|
+
run_gate --head-ref refs/tags/synthetic-lightweight
|
|
653
|
+
assert_rc "$rc" 0 "a lightweight tag must pass without tag objects"
|
|
654
|
+
assert_contains "tag_objects=0" "$out" "lightweight tag surface count"
|
|
655
|
+
|
|
656
|
+
git -C "$REPO" -c advice.nestedTag=false tag -a synthetic-nested -F - refs/tags/synthetic-annotated-clean <<EOF_TAG
|
|
657
|
+
wrapper notes
|
|
658
|
+
|
|
659
|
+
Claude-Session: $session_url
|
|
660
|
+
EOF_TAG
|
|
661
|
+
run_gate --head-ref refs/tags/synthetic-nested
|
|
662
|
+
assert_rc "$rc" 1 "a nested annotated tag layer must be scanned"
|
|
663
|
+
assert_contains "surface=tag_message" "$out" "nested tag-message surface"
|
|
664
|
+
|
|
665
|
+
GIT_COMMITTER_NAME="Claude Fable 5" GIT_COMMITTER_EMAIL=helper@example.invalid \
|
|
666
|
+
git -C "$REPO" tag -a synthetic-ai-tagger -m "neutral notes" HEAD
|
|
667
|
+
run_gate --head-ref refs/tags/synthetic-ai-tagger
|
|
668
|
+
assert_rc "$rc" 1 "an AI tagger identity must block"
|
|
669
|
+
assert_contains "surface=tag_tagger" "$out" "tag tagger surface"
|
|
670
|
+
assert_contains "category=ai_commit_identity" "$out" "tag tagger category"
|
|
671
|
+
|
|
672
|
+
# A literal tag object can duplicate the object header to point a lenient
|
|
673
|
+
# parser at a clean decoy while Git peels the first target; fail closed.
|
|
674
|
+
dup_target="$(git -C "$REPO" rev-parse HEAD)"
|
|
675
|
+
{
|
|
676
|
+
printf 'object %s\n' "$dup_target"
|
|
677
|
+
printf 'type commit\n'
|
|
678
|
+
printf 'tag synthetic-dup-header\n'
|
|
679
|
+
printf 'object %s\n' "$dup_target"
|
|
680
|
+
printf 'tagger Synthetic Tester <tester@example.invalid> 1700000000 +0000\n'
|
|
681
|
+
printf '\nneutral message\n'
|
|
682
|
+
} >"$TMP/dup-tag-payload.txt"
|
|
683
|
+
dup_tag_oid="$(git -C "$REPO" hash-object -t tag -w --literally "$TMP/dup-tag-payload.txt")"
|
|
684
|
+
git -C "$REPO" update-ref refs/tags/synthetic-dup-header "$dup_tag_oid"
|
|
685
|
+
run_gate --head-ref refs/tags/synthetic-dup-header
|
|
686
|
+
assert_rc "$rc" 2 "a duplicated tag object header must fail closed"
|
|
687
|
+
assert_contains "duplicate headers" "$out" "duplicate tag header reason"
|
|
688
|
+
|
|
689
|
+
candidate_identity_case case/version-infix-ai-author "Claude 3.5 Sonnet" \
|
|
690
|
+
helper@example.invalid "Synthetic Tester" tester@example.invalid
|
|
691
|
+
run_gate
|
|
692
|
+
assert_rc "$rc" 1 "a version-infix AI author identity must block"
|
|
693
|
+
assert_contains "surface=commit_author" "$out" "version-infix AI author surface"
|
|
694
|
+
assert_contains "category=ai_commit_identity" "$out" "version-infix AI author category"
|
|
695
|
+
|
|
696
|
+
candidate_identity_case case/version-infix-human-control "Claude 3.5 Monet" \
|
|
697
|
+
painter@example.invalid "Synthetic Tester" tester@example.invalid
|
|
698
|
+
run_gate
|
|
699
|
+
assert_rc "$rc" 0 "a version-infix human name must remain valid"
|
|
700
|
+
|
|
701
|
+
candidate_identity_case case/non-claude-human-controls "Codex Ramirez" \
|
|
702
|
+
codex.ramirez@example.invalid "OpenAI Smith" openai.smith@example.invalid
|
|
703
|
+
run_gate
|
|
704
|
+
assert_rc "$rc" 0 "non-Claude provider-shaped human names must remain valid"
|
|
705
|
+
|
|
706
|
+
candidate_identity_case case/versioned-provider-human-controls "Qwen Li" \
|
|
707
|
+
qwen.li@example.invalid "Gemini Proctor" gemini.proctor@example.invalid
|
|
708
|
+
run_gate
|
|
709
|
+
assert_rc "$rc" 0 "versioned-provider grammar must preserve nearby human names"
|
|
710
|
+
|
|
711
|
+
for qualified_identity in "Qwen2.5-Coder" "Gemini 2.5 Pro"; do
|
|
712
|
+
identity_slug="${qualified_identity//[^A-Za-z0-9]/-}"
|
|
713
|
+
candidate_identity_case "case/model-qualified-author-$identity_slug" \
|
|
714
|
+
"$qualified_identity" helper@example.invalid \
|
|
715
|
+
"Synthetic Tester" tester@example.invalid
|
|
716
|
+
run_gate
|
|
717
|
+
assert_rc "$rc" 1 "model-qualified AI author $qualified_identity must block"
|
|
718
|
+
assert_contains "surface=commit_author" "$out" \
|
|
719
|
+
"model-qualified AI author $qualified_identity surface"
|
|
720
|
+
|
|
721
|
+
candidate_identity_case "case/model-qualified-committer-$identity_slug" \
|
|
722
|
+
"Synthetic Tester" tester@example.invalid \
|
|
723
|
+
"$qualified_identity" helper@example.invalid
|
|
724
|
+
run_gate
|
|
725
|
+
assert_rc "$rc" 1 "model-qualified AI committer $qualified_identity must block"
|
|
726
|
+
assert_contains "surface=commit_committer" "$out" \
|
|
727
|
+
"model-qualified AI committer $qualified_identity surface"
|
|
728
|
+
done
|
|
729
|
+
|
|
730
|
+
candidate_identity_case case/tab-qualified-ai-author $'Claude\tCode' \
|
|
731
|
+
helper@example.invalid "Synthetic Tester" tester@example.invalid
|
|
732
|
+
run_gate
|
|
733
|
+
assert_rc "$rc" 1 "a tab-qualified AI author identity must block"
|
|
734
|
+
assert_contains "surface=commit_author" "$out" "tab-qualified AI author surface"
|
|
735
|
+
assert_contains "category=ai_commit_identity" "$out" "tab-qualified AI author category"
|
|
736
|
+
|
|
737
|
+
candidate_identity_case case/bracketed-bot-author 'claude[bot]' \
|
|
738
|
+
'123+claude[bot]@users.noreply.github.com' "Synthetic Tester" tester@example.invalid
|
|
739
|
+
run_gate
|
|
740
|
+
assert_rc "$rc" 1 "a bracketed GitHub App author identity must block"
|
|
741
|
+
assert_contains "surface=commit_author" "$out" "bracketed bot author surface"
|
|
742
|
+
assert_contains "category=ai_commit_identity" "$out" "bracketed bot author category"
|
|
743
|
+
|
|
744
|
+
candidate_identity_case case/bracketed-bot-committer "Synthetic Tester" tester@example.invalid \
|
|
745
|
+
'copilot[bot]' '123+copilot[bot]@users.noreply.github.com'
|
|
746
|
+
run_gate
|
|
747
|
+
assert_rc "$rc" 1 "a bracketed GitHub App committer identity must block"
|
|
748
|
+
assert_contains "surface=commit_committer" "$out" "bracketed bot committer surface"
|
|
749
|
+
assert_contains "category=ai_commit_identity" "$out" "bracketed bot committer category"
|
|
750
|
+
|
|
751
|
+
candidate_identity_case case/hyphenated-bot-author 'github-copilot[bot]' \
|
|
752
|
+
'123+github-copilot[bot]@users.noreply.github.com' \
|
|
753
|
+
"Synthetic Tester" tester@example.invalid
|
|
754
|
+
run_gate
|
|
755
|
+
assert_rc "$rc" 1 "a separator-normalized provider GitHub App author must block"
|
|
756
|
+
assert_contains "surface=commit_author" "$out" "hyphenated bot author surface"
|
|
757
|
+
assert_contains "category=ai_commit_identity" "$out" "hyphenated bot author category"
|
|
758
|
+
|
|
759
|
+
candidate_identity_case case/provider-bot-email "Synthetic App" \
|
|
760
|
+
'123+copilot-swe-agent[bot]@users.noreply.github.com' \
|
|
761
|
+
"Synthetic Tester" tester@example.invalid
|
|
762
|
+
run_gate
|
|
763
|
+
assert_rc "$rc" 1 "a known provider GitHub App email must block with a neutral display name"
|
|
764
|
+
assert_contains "surface=commit_author" "$out" "provider bot email author surface"
|
|
765
|
+
assert_contains "category=ai_commit_identity" "$out" "provider bot email author category"
|
|
766
|
+
|
|
767
|
+
candidate_identity_case case/unrelated-bot-email "Synthetic App" \
|
|
768
|
+
'123+release-helper[bot]@users.noreply.github.com' \
|
|
769
|
+
"Synthetic Tester" tester@example.invalid
|
|
770
|
+
run_gate
|
|
771
|
+
assert_rc "$rc" 0 "an unrelated automation bot identity must remain outside the AI gate"
|
|
772
|
+
|
|
773
|
+
for bot_near_miss in release-codex not-claude human-copilot pre-openai; do
|
|
774
|
+
candidate_identity_case "case/bot-near-miss-$bot_near_miss" "Synthetic App" \
|
|
775
|
+
"123+$bot_near_miss[bot]@users.noreply.github.com" \
|
|
776
|
+
"Synthetic Tester" tester@example.invalid
|
|
777
|
+
run_gate
|
|
778
|
+
assert_rc "$rc" 0 "a provider-token suffix inside another bot account must remain allowed"
|
|
779
|
+
done
|
|
780
|
+
|
|
781
|
+
candidate_identity_case case/provider-name-human Kimi k@example.invalid \
|
|
782
|
+
"Synthetic Tester" tester@example.invalid
|
|
783
|
+
run_gate
|
|
784
|
+
assert_rc "$rc" 0 "a human author whose name is also a provider alias must remain valid"
|
|
785
|
+
|
|
786
|
+
for human_case in claude:Claude kimi:Kimi poe:Poe; do
|
|
787
|
+
human_slug="${human_case%%:*}"
|
|
788
|
+
human_name="${human_case#*:}"
|
|
789
|
+
candidate_identity_case "case/noreply-human-$human_slug" "$human_name" \
|
|
790
|
+
"12345+human.noreply@example.invalid" "Synthetic Tester" tester@example.invalid
|
|
791
|
+
run_gate
|
|
792
|
+
assert_rc "$rc" 0 "a human $human_name author using a privacy-style address must remain valid"
|
|
793
|
+
done
|
|
794
|
+
|
|
795
|
+
printf 'change summary\n\n🤖 Claude Code\n' >"$TMP/footer-message.txt"
|
|
796
|
+
candidate_case case/generated-footer "$TMP/footer-message.txt"
|
|
797
|
+
run_gate
|
|
798
|
+
assert_rc "$rc" 1 "a bot footer must block without depending on its prose"
|
|
799
|
+
assert_contains "category=generated_by_footer" "$out" "footer category"
|
|
800
|
+
|
|
801
|
+
printf 'change summary\n\nGenerated with Gemini\n' >"$TMP/plain-footer-message.txt"
|
|
802
|
+
candidate_case case/plain-generated-footer "$TMP/plain-footer-message.txt"
|
|
803
|
+
run_gate
|
|
804
|
+
assert_rc "$rc" 1 "a generated footer without an emoji must block"
|
|
805
|
+
assert_contains "category=generated_by_footer" "$out" "plain footer category"
|
|
806
|
+
|
|
807
|
+
printf 'change summary\n\n🤖 Generated with [Claude Code](%s)\n' "$claude_product_url" >"$TMP/markdown-footer-message.txt"
|
|
808
|
+
candidate_case case/markdown-generated-footer "$TMP/markdown-footer-message.txt"
|
|
809
|
+
run_gate
|
|
810
|
+
assert_rc "$rc" 1 "a markdown-linked host footer must block"
|
|
811
|
+
assert_contains "category=generated_by_footer" "$out" "markdown-linked footer category"
|
|
812
|
+
|
|
813
|
+
printf '%s\n' 'change summary' '会话'"过程:synthetic internal narration" >"$TMP/process-message.txt"
|
|
814
|
+
candidate_case case/conversation-process "$TMP/process-message.txt"
|
|
815
|
+
run_gate
|
|
816
|
+
assert_rc "$rc" 1 "conversation-process narration must block"
|
|
817
|
+
assert_contains "category=conversation_process" "$out" "conversation-process category"
|
|
818
|
+
|
|
819
|
+
# A repository may request a legacy log output encoding even when each commit
|
|
820
|
+
# correctly declares its own message encoding. The gate must override that
|
|
821
|
+
# ambient presentation choice so CJK-only rules cannot disappear during decode.
|
|
822
|
+
python3 - "$TMP/gb18030-process-message.txt" <<'PY'
|
|
823
|
+
import sys
|
|
824
|
+
from pathlib import Path
|
|
825
|
+
|
|
826
|
+
Path(sys.argv[1]).write_bytes(
|
|
827
|
+
"change summary\n\n会话过程:synthetic legacy narration\n".encode("gb18030")
|
|
828
|
+
)
|
|
829
|
+
PY
|
|
830
|
+
git -C "$REPO" switch -q -C case/gb18030-conversation-process "$clean_head"
|
|
831
|
+
printf '%s\n' 'case/gb18030-conversation-process' >>"$REPO/file.txt"
|
|
832
|
+
git -C "$REPO" add file.txt
|
|
833
|
+
git -C "$REPO" -c i18n.commitEncoding=GB18030 commit -q \
|
|
834
|
+
-F "$TMP/gb18030-process-message.txt"
|
|
835
|
+
git -C "$REPO" config i18n.logOutputEncoding GB18030
|
|
836
|
+
run_gate
|
|
837
|
+
git -C "$REPO" config --unset i18n.logOutputEncoding
|
|
838
|
+
assert_rc "$rc" 1 "ambient legacy log encoding must not hide a CJK violation"
|
|
839
|
+
assert_contains "category=conversation_process" "$out" "legacy-encoding category"
|
|
840
|
+
assert_not_contains "synthetic legacy narration" "$out" "legacy diagnostic stays redacted"
|
|
841
|
+
|
|
842
|
+
# Git's pretty formatter silently truncates a malformed raw commit message at
|
|
843
|
+
# an embedded NUL. Validate the raw object before trusting formatted metadata,
|
|
844
|
+
# otherwise a neutral prefix can hide a prohibited tail.
|
|
845
|
+
nul_tree="$(git -C "$REPO" rev-parse "$clean_head^{tree}")"
|
|
846
|
+
python3 - "$TMP/nul-commit-object" "$nul_tree" "$clean_head" <<'PY'
|
|
847
|
+
import sys
|
|
848
|
+
from pathlib import Path
|
|
849
|
+
|
|
850
|
+
tree, parent = sys.argv[2:4]
|
|
851
|
+
headers = (
|
|
852
|
+
f"tree {tree}\n"
|
|
853
|
+
f"parent {parent}\n"
|
|
854
|
+
"author Synthetic Tester <tester@example.invalid> 1700000000 +0000\n"
|
|
855
|
+
"committer Synthetic Tester <tester@example.invalid> 1700000000 +0000\n"
|
|
856
|
+
"\n"
|
|
857
|
+
).encode("ascii")
|
|
858
|
+
Path(sys.argv[1]).write_bytes(
|
|
859
|
+
headers
|
|
860
|
+
+ b"neutral summary\x00Claude-Session: SYNTH_NUL_MESSAGE_SENTINEL\n"
|
|
861
|
+
)
|
|
862
|
+
PY
|
|
863
|
+
nul_commit="$(git -C "$REPO" hash-object --literally -t commit -w \
|
|
864
|
+
"$TMP/nul-commit-object")"
|
|
865
|
+
git -C "$REPO" switch -q -C case/nul-commit-message "$clean_head"
|
|
866
|
+
git -C "$REPO" update-ref refs/heads/case/nul-commit-message "$nul_commit"
|
|
867
|
+
run_gate
|
|
868
|
+
assert_rc "$rc" 2 "an embedded NUL in a raw commit object must fail closed"
|
|
869
|
+
assert_contains "candidate commit object contains NUL" "$out" "raw commit NUL reason"
|
|
870
|
+
assert_not_contains "SYNTH_NUL_MESSAGE_SENTINEL" "$out" "raw NUL diagnostic stays redacted"
|
|
871
|
+
|
|
872
|
+
# Replacement refs are a local view only: a push still sends the original OID
|
|
873
|
+
# and object. Every Git read in the gate must therefore ignore a clean local
|
|
874
|
+
# replacement rather than certifying bytes that will never be published.
|
|
875
|
+
clean_replacement="$(printf 'neutral replacement message\n' | \
|
|
876
|
+
git -C "$REPO" commit-tree "$nul_tree" -p "$clean_head")"
|
|
877
|
+
git -C "$REPO" replace "$nul_commit" "$clean_replacement"
|
|
878
|
+
run_gate
|
|
879
|
+
git -C "$REPO" replace -d "$nul_commit" >/dev/null
|
|
880
|
+
assert_rc "$rc" 2 "a clean replacement ref must not hide the pushed raw object"
|
|
881
|
+
assert_contains "candidate commit object contains NUL" "$out" "replacement ref NUL reason"
|
|
882
|
+
assert_not_contains "SYNTH_NUL_MESSAGE_SENTINEL" "$out" "replacement diagnostic stays redacted"
|
|
883
|
+
|
|
884
|
+
# Legacy grafts rewrite ancestry independently of replacement objects. A clean
|
|
885
|
+
# grafted head can hide a prohibited intermediate commit even though a push
|
|
886
|
+
# publishes the original parent chain.
|
|
887
|
+
graft_bad="$(printf '%s\n' 'Claude-Session-ID: SYNTH_GRAFT_SENTINEL' | \
|
|
888
|
+
git -C "$REPO" commit-tree "$nul_tree" -p "$clean_head")"
|
|
889
|
+
graft_head="$(printf 'neutral graft head\n' | \
|
|
890
|
+
git -C "$REPO" commit-tree "$nul_tree" -p "$graft_bad")"
|
|
891
|
+
git -C "$REPO" switch -q -C case/graft-history "$clean_head"
|
|
892
|
+
git -C "$REPO" update-ref refs/heads/case/graft-history "$graft_head"
|
|
893
|
+
graft_file="$(git -C "$REPO" rev-parse --git-path info/grafts)"
|
|
894
|
+
case "$graft_file" in
|
|
895
|
+
/*) ;;
|
|
896
|
+
*) graft_file="$REPO/$graft_file" ;;
|
|
897
|
+
esac
|
|
898
|
+
printf '%s %s\n' "$graft_head" "$clean_head" >"$graft_file"
|
|
899
|
+
run_gate
|
|
900
|
+
mv "$graft_file" "$graft_file.disabled"
|
|
901
|
+
assert_rc "$rc" 2 "a non-empty graft file must not hide pushed ancestry"
|
|
902
|
+
assert_contains "candidate history uses unsupported grafts" "$out" "graft reason"
|
|
903
|
+
assert_not_contains "SYNTH_GRAFT_SENTINEL" "$out" "graft diagnostic stays redacted"
|
|
904
|
+
|
|
905
|
+
printf 'tighten-doc: 外部写作实践吸收\n' >"$TMP/provenance-message.txt"
|
|
906
|
+
candidate_case case/provenance "$TMP/provenance-message.txt"
|
|
907
|
+
run_gate
|
|
908
|
+
assert_rc "$rc" 1 "provenance wording must block"
|
|
909
|
+
assert_contains "category=provenance_wording" "$out" "provenance category"
|
|
910
|
+
|
|
911
|
+
# Benign use of the verbs is not provenance unless it names an outside source.
|
|
912
|
+
printf 'review loop: 吸收用户反馈经验并收敛规则\n' >"$TMP/benign-message.txt"
|
|
913
|
+
candidate_case case/benign-wording "$TMP/benign-message.txt"
|
|
914
|
+
run_gate
|
|
915
|
+
assert_rc "$rc" 0 "ordinary feedback wording must not be treated as provenance"
|
|
916
|
+
assert_contains "commits=2" "$out" "the full multi-commit candidate range is scanned"
|
|
917
|
+
|
|
918
|
+
printf 'document ordinary product URL https://example.com/session/share-not-ai\n' >"$TMP/product-url-message.txt"
|
|
919
|
+
candidate_case case/product-url "$TMP/product-url-message.txt"
|
|
920
|
+
run_gate
|
|
921
|
+
assert_rc "$rc" 0 "an ordinary product URL must not be treated as an AI session"
|
|
922
|
+
|
|
923
|
+
# Branch names are shared even before a commit exists.
|
|
924
|
+
git -C "$REPO" switch -q -C feature/claude-session_synthetic123 "$clean_head"
|
|
925
|
+
run_gate
|
|
926
|
+
assert_rc "$rc" 1 "session-shaped branch name must block"
|
|
927
|
+
assert_contains "surface=branch" "$out" "branch locator"
|
|
928
|
+
assert_not_contains "synthetic123" "$out" "branch diagnostic must not echo the identifier"
|
|
929
|
+
|
|
930
|
+
assert_branch_block() {
|
|
931
|
+
local branch_name="$1" category="$2" reason="$3"
|
|
932
|
+
set +e
|
|
933
|
+
out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
|
|
934
|
+
python3 "$GATE" --repo "$REPO" --base-ref "$clean_head" \
|
|
935
|
+
--head-ref "$clean_head" --branch-name "$branch_name" 2>&1)"
|
|
936
|
+
rc=$?
|
|
937
|
+
set -e
|
|
938
|
+
assert_rc "$rc" 1 "$reason"
|
|
939
|
+
assert_contains "surface=branch" "$out" "$reason surface"
|
|
940
|
+
assert_contains "category=$category" "$out" "$reason category"
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
assert_branch_clean() {
|
|
944
|
+
local branch_name="$1" reason="$2"
|
|
945
|
+
set +e
|
|
946
|
+
out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
|
|
947
|
+
python3 "$GATE" --repo "$REPO" --base-ref "$clean_head" \
|
|
948
|
+
--head-ref "$clean_head" --branch-name "$branch_name" 2>&1)"
|
|
949
|
+
rc=$?
|
|
950
|
+
set -e
|
|
951
|
+
assert_rc "$rc" 0 "$reason"
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
# Branch refs cannot contain spaces, so prose regexes must not be reused as-is.
|
|
955
|
+
# Exercise each branch-only metadata class with every ordinary slug separator.
|
|
956
|
+
for branch_separator in '-' '_' '/' '.'; do
|
|
957
|
+
assert_branch_block \
|
|
958
|
+
"feature${branch_separator}inspired${branch_separator}by${branch_separator}claude" \
|
|
959
|
+
provenance_wording "branch provenance wording with '$branch_separator' separators"
|
|
960
|
+
assert_branch_block \
|
|
961
|
+
"feature${branch_separator}adapted${branch_separator}from${branch_separator}gemini" \
|
|
962
|
+
provenance_wording "branch adapted-from wording with '$branch_separator' separators"
|
|
963
|
+
assert_branch_block \
|
|
964
|
+
"feature${branch_separator}generated${branch_separator}by${branch_separator}claude" \
|
|
965
|
+
generated_by_footer "branch generated-by wording with '$branch_separator' separators"
|
|
966
|
+
assert_branch_block \
|
|
967
|
+
"feature${branch_separator}co${branch_separator}authored${branch_separator}by${branch_separator}claude" \
|
|
968
|
+
ai_coauthor_trailer "branch co-author wording with '$branch_separator' separators"
|
|
969
|
+
assert_branch_block \
|
|
970
|
+
"feature${branch_separator}claude${branch_separator}analysis${branch_separator}secret" \
|
|
971
|
+
conversation_process "branch conversation label with '$branch_separator' separators"
|
|
972
|
+
done
|
|
973
|
+
|
|
974
|
+
# A forbidden phrase is a branch segment sequence, not necessarily the final
|
|
975
|
+
# text in the ref. Suffixes must not turn shared metadata into an allowed ref.
|
|
976
|
+
assert_branch_block \
|
|
977
|
+
feature/inspired-by-claude-fix provenance_wording \
|
|
978
|
+
"branch provenance phrase before a suffix"
|
|
979
|
+
assert_branch_block \
|
|
980
|
+
feature/generated-by-claude-cleanup generated_by_footer \
|
|
981
|
+
"branch generated-by phrase before a suffix"
|
|
982
|
+
assert_branch_block \
|
|
983
|
+
feature/co-authored-by-claude-change ai_coauthor_trailer \
|
|
984
|
+
"branch co-author phrase before a suffix"
|
|
985
|
+
for conversation_branch in \
|
|
986
|
+
feature/user-prompt-secret \
|
|
987
|
+
feature/conversation-process-export \
|
|
988
|
+
feature/conversation-transcript-export \
|
|
989
|
+
feature/claude-analysis-findings \
|
|
990
|
+
feature/用户原话-secret \
|
|
991
|
+
feature/会话过程-export \
|
|
992
|
+
feature/模型分析-internal \
|
|
993
|
+
feature/claude-分析-findings; do
|
|
994
|
+
assert_branch_block \
|
|
995
|
+
"$conversation_branch" conversation_process \
|
|
996
|
+
"branch conversation-process phrase before a suffix"
|
|
997
|
+
done
|
|
998
|
+
|
|
999
|
+
for benign_branch in \
|
|
1000
|
+
feature/inspired-by-human-review \
|
|
1001
|
+
feature/generated-schema \
|
|
1002
|
+
feature/co-authored-policy \
|
|
1003
|
+
feature/claude-sdk-compatibility \
|
|
1004
|
+
feature/analysis-dashboard \
|
|
1005
|
+
feature/analysis-engine \
|
|
1006
|
+
feature/user-profile-export \
|
|
1007
|
+
feature/conversation-list-export \
|
|
1008
|
+
feature/用户反馈-export \
|
|
1009
|
+
feature/模型能力-internal \
|
|
1010
|
+
feature/claude-分析器-findings; do
|
|
1011
|
+
assert_branch_clean "$benign_branch" "ordinary branch capability wording remains accepted"
|
|
1012
|
+
done
|
|
1013
|
+
|
|
1014
|
+
# PR title/body arrive through the GitHub event payload in CI.
|
|
1015
|
+
git -C "$REPO" switch -q -C feature/sanitized-pr "$clean_head"
|
|
1016
|
+
python3 - "$TMP/pr-event.json" "$session_url" <<'PY'
|
|
1017
|
+
import json
|
|
1018
|
+
import sys
|
|
1019
|
+
from pathlib import Path
|
|
1020
|
+
|
|
1021
|
+
Path(sys.argv[1]).write_text(json.dumps({
|
|
1022
|
+
"pull_request": {
|
|
1023
|
+
"title": "Bound a shared capability",
|
|
1024
|
+
"body": "Details and private session: " + sys.argv[2],
|
|
1025
|
+
"head": {"ref": "feature/sanitized-pr"},
|
|
1026
|
+
"base": {"sha": ""},
|
|
1027
|
+
}
|
|
1028
|
+
}), encoding="utf-8")
|
|
1029
|
+
PY
|
|
1030
|
+
run_gate --event-json "$TMP/pr-event.json"
|
|
1031
|
+
assert_rc "$rc" 1 "PR body session URL must block"
|
|
1032
|
+
assert_contains "surface=pull_request_body" "$out" "PR body locator"
|
|
1033
|
+
assert_not_contains "synthetic_fixture_123456" "$out" "PR diagnostic must redact the session id"
|
|
1034
|
+
|
|
1035
|
+
python3 - "$TMP/pr-event.json" "$BASE" <<'PY'
|
|
1036
|
+
import json
|
|
1037
|
+
import sys
|
|
1038
|
+
from pathlib import Path
|
|
1039
|
+
|
|
1040
|
+
Path(sys.argv[1]).write_text(json.dumps({
|
|
1041
|
+
"pull_request": {
|
|
1042
|
+
"title": "借鉴外部实践的规则",
|
|
1043
|
+
"body": "Neutral behavior and verification summary.",
|
|
1044
|
+
"head": {"ref": "feature/sanitized-pr"},
|
|
1045
|
+
"base": {"sha": sys.argv[2]},
|
|
1046
|
+
}
|
|
1047
|
+
}), encoding="utf-8")
|
|
1048
|
+
PY
|
|
1049
|
+
run_gate --event-json "$TMP/pr-event.json"
|
|
1050
|
+
assert_rc "$rc" 1 "PR title provenance wording must block"
|
|
1051
|
+
assert_contains "surface=pull_request_title" "$out" "PR title locator"
|
|
1052
|
+
|
|
1053
|
+
python3 - "$TMP/pr-event.json" "$BASE" <<'PY'
|
|
1054
|
+
import json
|
|
1055
|
+
import sys
|
|
1056
|
+
from pathlib import Path
|
|
1057
|
+
|
|
1058
|
+
Path(sys.argv[1]).write_text(json.dumps({
|
|
1059
|
+
"pull_request": {
|
|
1060
|
+
"title": "Bound a shared capability",
|
|
1061
|
+
"body": "Neutral behavior and verification summary.",
|
|
1062
|
+
"head": {"ref": "feature/sanitized-pr"},
|
|
1063
|
+
"base": {"sha": sys.argv[2]},
|
|
1064
|
+
}
|
|
1065
|
+
}), encoding="utf-8")
|
|
1066
|
+
PY
|
|
1067
|
+
run_gate --event-json "$TMP/pr-event.json"
|
|
1068
|
+
assert_rc "$rc" 0 "sanitized PR metadata should pass"
|
|
1069
|
+
|
|
1070
|
+
# A trusted PR event defines the candidate range in CI. An ambient local
|
|
1071
|
+
# override must not move the base forward to HEAD and hide a violating commit.
|
|
1072
|
+
candidate_case case/event-base-precedence "$TMP/session-message.txt"
|
|
1073
|
+
event_head="$(git -C "$REPO" rev-parse HEAD)"
|
|
1074
|
+
python3 - "$TMP/pr-event.json" "$BASE" <<'PY'
|
|
1075
|
+
import json
|
|
1076
|
+
import sys
|
|
1077
|
+
from pathlib import Path
|
|
1078
|
+
|
|
1079
|
+
Path(sys.argv[1]).write_text(json.dumps({
|
|
1080
|
+
"pull_request": {
|
|
1081
|
+
"title": "Bound a shared capability",
|
|
1082
|
+
"body": "Neutral behavior and verification summary.",
|
|
1083
|
+
"head": {"ref": "case/event-base-precedence"},
|
|
1084
|
+
"base": {"sha": sys.argv[2]},
|
|
1085
|
+
}
|
|
1086
|
+
}), encoding="utf-8")
|
|
1087
|
+
PY
|
|
1088
|
+
set +e
|
|
1089
|
+
out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \
|
|
1090
|
+
CCL_SKILL_BASE_REF="$event_head" python3 "$GATE" --repo "$REPO" \
|
|
1091
|
+
--event-json "$TMP/pr-event.json" 2>&1)"
|
|
1092
|
+
rc=$?
|
|
1093
|
+
set -e
|
|
1094
|
+
assert_rc "$rc" 1 "trusted PR event base must outrank an ambient environment base"
|
|
1095
|
+
assert_contains "category=ai_session_trailer" "$out" "event-base precedence preserves the candidate range"
|
|
1096
|
+
git -C "$REPO" switch -q -C feature/sanitized-pr "$clean_head"
|
|
1097
|
+
|
|
1098
|
+
printf 'Neutral proposed PR summary.\n' >"$TMP/proposed-pr.txt"
|
|
1099
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1100
|
+
assert_rc "$rc" 0 "sanitized proposed PR text should pass"
|
|
1101
|
+
printf 'Private session: %s\n' "$session_url" >"$TMP/proposed-pr.txt"
|
|
1102
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1103
|
+
assert_rc "$rc" 1 "prohibited proposed PR text must block before publication"
|
|
1104
|
+
assert_contains "surface=proposed_pr_text" "$out" "proposed PR locator"
|
|
1105
|
+
assert_not_contains "synthetic_fixture_123456" "$out" "proposed PR diagnostic must redact the session id"
|
|
1106
|
+
|
|
1107
|
+
# Every provider-shaped surface uses the same provider registry. These cases
|
|
1108
|
+
# kill partial fixes where a name is added to session IDs but not co-authors,
|
|
1109
|
+
# narration/provenance, generated attribution, or share URLs.
|
|
1110
|
+
assert_proposed_block() {
|
|
1111
|
+
local text="$1" category="$2" reason="$3"
|
|
1112
|
+
printf '%s\n' "$text" >"$TMP/proposed-pr.txt"
|
|
1113
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1114
|
+
assert_rc "$rc" 1 "$reason"
|
|
1115
|
+
assert_contains "category=$category" "$out" "$reason category"
|
|
1116
|
+
}
|
|
1117
|
+
assert_proposed_block "Built with Claude Code" generated_by_footer "generated attribution verb"
|
|
1118
|
+
assert_proposed_block "- Generated with Claude Code" generated_by_footer "list-wrapped generated attribution"
|
|
1119
|
+
assert_proposed_block "> 🤖 Generated with Claude Code" generated_by_footer "quote-wrapped generated attribution"
|
|
1120
|
+
assert_proposed_block "1. Generated with Claude Code" generated_by_footer "ordered-list generated attribution"
|
|
1121
|
+
assert_proposed_block "### Generated with Claude Code" generated_by_footer "heading-wrapped generated attribution"
|
|
1122
|
+
|
|
1123
|
+
# Markdown wrappers are presentation only. Exercise every line-oriented
|
|
1124
|
+
# metadata class against the same wrapper matrix so a one-pattern fix cannot
|
|
1125
|
+
# leave a sibling category bypassable.
|
|
1126
|
+
for markdown_prefix in '- ' '> ' '1. ' '### ' '> - ' '- [ ] ' '> 1. [x] '; do
|
|
1127
|
+
assert_proposed_block \
|
|
1128
|
+
"${markdown_prefix}Generated with Claude Code" \
|
|
1129
|
+
generated_by_footer "Markdown-wrapped generated attribution"
|
|
1130
|
+
assert_proposed_block \
|
|
1131
|
+
"${markdown_prefix}Claude-Session: synthetic_markdown_123456" \
|
|
1132
|
+
ai_session_trailer "Markdown-wrapped session trailer"
|
|
1133
|
+
assert_proposed_block \
|
|
1134
|
+
"${markdown_prefix}Co-Authored-By: Claude Code <bot@example.invalid>" \
|
|
1135
|
+
ai_coauthor_trailer "Markdown-wrapped AI co-author trailer"
|
|
1136
|
+
assert_proposed_block \
|
|
1137
|
+
"${markdown_prefix}会话过程:synthetic internal narration" \
|
|
1138
|
+
conversation_process "Markdown-wrapped conversation-process label"
|
|
1139
|
+
done
|
|
1140
|
+
|
|
1141
|
+
# Inline emphasis/code spans are presentation wrappers too. Keep the opener and
|
|
1142
|
+
# closer paired so an ordinary leading marker cannot be consumed accidentally.
|
|
1143
|
+
for inline_pair in '*|*' '_|_' '**|**' '__|__' '~~|~~' '`|`'; do
|
|
1144
|
+
inline_open="${inline_pair%%|*}"
|
|
1145
|
+
inline_close="${inline_pair#*|}"
|
|
1146
|
+
assert_proposed_block \
|
|
1147
|
+
"${inline_open}Generated with Claude Code${inline_close}" \
|
|
1148
|
+
generated_by_footer "inline-Markdown generated attribution"
|
|
1149
|
+
assert_proposed_block \
|
|
1150
|
+
"${inline_open}Claude-Session: synthetic_inline_123456${inline_close}" \
|
|
1151
|
+
ai_session_trailer "inline-Markdown session trailer"
|
|
1152
|
+
assert_proposed_block \
|
|
1153
|
+
"${inline_open}Co-Authored-By: Claude Code <bot@example.invalid>${inline_close}" \
|
|
1154
|
+
ai_coauthor_trailer "inline-Markdown AI co-author trailer"
|
|
1155
|
+
assert_proposed_block \
|
|
1156
|
+
"${inline_open}会话过程:synthetic inline narration${inline_close}" \
|
|
1157
|
+
conversation_process "inline-Markdown conversation-process label"
|
|
1158
|
+
|
|
1159
|
+
printf '%s\n' "${inline_open}Human-authored compatibility note${inline_close}" >"$TMP/proposed-pr.txt"
|
|
1160
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1161
|
+
assert_rc "$rc" 0 "paired inline Markdown around neutral prose remains accepted"
|
|
1162
|
+
done
|
|
1163
|
+
|
|
1164
|
+
# Wrapper depth is input-controlled. Five levels killed the former fixed-depth
|
|
1165
|
+
# loop; 64 levels keeps the regression honest without making the fixture large.
|
|
1166
|
+
for nested_depth in 5 64; do
|
|
1167
|
+
nested_marker=''
|
|
1168
|
+
for ((nested_index = 0; nested_index < nested_depth; nested_index += 1)); do
|
|
1169
|
+
nested_marker="${nested_marker}**"
|
|
1170
|
+
done
|
|
1171
|
+
assert_proposed_block \
|
|
1172
|
+
"${nested_marker}Generated with Claude Code${nested_marker}" \
|
|
1173
|
+
generated_by_footer "deep inline wrapper generated attribution"
|
|
1174
|
+
assert_proposed_block \
|
|
1175
|
+
"${nested_marker}Claude-Session: synthetic_nested_123456${nested_marker}" \
|
|
1176
|
+
ai_session_trailer "deep inline wrapper session trailer"
|
|
1177
|
+
assert_proposed_block \
|
|
1178
|
+
"${nested_marker}Co-Authored-By: Claude Code <bot@example.invalid>${nested_marker}" \
|
|
1179
|
+
ai_coauthor_trailer "deep inline wrapper co-author trailer"
|
|
1180
|
+
assert_proposed_block \
|
|
1181
|
+
"${nested_marker}会话过程:synthetic nested narration${nested_marker}" \
|
|
1182
|
+
conversation_process "deep inline wrapper conversation label"
|
|
1183
|
+
done
|
|
1184
|
+
|
|
1185
|
+
printf '**Generated with Claude Code__\n' >"$TMP/proposed-pr.txt"
|
|
1186
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1187
|
+
assert_rc "$rc" 0 "mismatched inline wrappers are not normalized as a pair"
|
|
1188
|
+
|
|
1189
|
+
assert_proposed_block \
|
|
1190
|
+
'[Generated with Claude Code](https://example.invalid)' \
|
|
1191
|
+
generated_by_footer "outer Markdown-link generated attribution"
|
|
1192
|
+
assert_proposed_block \
|
|
1193
|
+
'[Co-Authored-By: Claude Code <bot@example.invalid>](https://example.invalid)' \
|
|
1194
|
+
ai_coauthor_trailer "outer Markdown-link co-author trailer"
|
|
1195
|
+
assert_proposed_block \
|
|
1196
|
+
'[会话过程:摘要](https://example.invalid)' \
|
|
1197
|
+
conversation_process "outer Markdown-link conversation label"
|
|
1198
|
+
|
|
1199
|
+
printf '[Claude Code integration guide](https://example.invalid)\n' >"$TMP/proposed-pr.txt"
|
|
1200
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1201
|
+
assert_rc "$rc" 0 "an ordinary complete Markdown link remains accepted"
|
|
1202
|
+
printf '[Ordinary review](%s)\n' "$session_url" >"$TMP/proposed-pr.txt"
|
|
1203
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1204
|
+
assert_rc "$rc" 1 "a normalized outer link must retain destination scanning"
|
|
1205
|
+
assert_contains "category=ai_session_url" "$out" "outer-link destination URL category"
|
|
1206
|
+
assert_not_contains "synthetic_fixture_123456" "$out" "outer-link destination remains redacted"
|
|
1207
|
+
|
|
1208
|
+
assert_proposed_block \
|
|
1209
|
+
'> **Generated with Claude Code**' \
|
|
1210
|
+
generated_by_footer "block and inline Markdown wrappers compose"
|
|
1211
|
+
|
|
1212
|
+
for near_miss_prefix in '[x] ' '- [xx] '; do
|
|
1213
|
+
printf '%s\n' "${near_miss_prefix}Generated with Claude Code" >"$TMP/proposed-pr.txt"
|
|
1214
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1215
|
+
assert_rc "$rc" 0 "a non-task-list checkbox shape must not be consumed as Markdown framing"
|
|
1216
|
+
done
|
|
1217
|
+
|
|
1218
|
+
assert_proposed_block "Co-Authored-By: GitHub Copilot <bot@example.invalid>" ai_coauthor_trailer "provider alias co-author"
|
|
1219
|
+
assert_proposed_block "Gemini 分析:synthetic internal narration" conversation_process "provider analysis label"
|
|
1220
|
+
assert_proposed_block "Adapted from Gemini" provenance_wording "provider provenance wording"
|
|
1221
|
+
assert_proposed_block "Cursor-Session-ID: synthetic_provider_123456" ai_session_id "provider session ID"
|
|
1222
|
+
assert_not_contains "synthetic_provider_123456" "$out" "expanded provider ID remains redacted"
|
|
1223
|
+
assert_proposed_block "AI-Session-ID: synthetic_generic_123456" ai_session_id "generic AI session ID"
|
|
1224
|
+
assert_not_contains "synthetic_generic_123456" "$out" "generic AI ID remains redacted"
|
|
1225
|
+
assert_proposed_block "Generated by AI" generated_by_footer "generic AI attribution"
|
|
1226
|
+
assert_proposed_block "Generated by AI model" generated_by_footer "generic AI model attribution"
|
|
1227
|
+
for model_credit in \
|
|
1228
|
+
'Generated by AI Model' \
|
|
1229
|
+
'Generated by Gemini Pro' \
|
|
1230
|
+
'Generated by Claude Sonnet'; do
|
|
1231
|
+
assert_proposed_block \
|
|
1232
|
+
"$model_credit" generated_by_footer \
|
|
1233
|
+
"common model/product continuation remains generated attribution"
|
|
1234
|
+
done
|
|
1235
|
+
assert_proposed_block "Private review: $gemini_url" ai_session_url "provider share URL"
|
|
1236
|
+
assert_not_contains "synthetic_fixture_ghijkl" "$out" "provider URL remains redacted"
|
|
1237
|
+
assert_proposed_block "Private review: $chatgpt_conversation_url" ai_session_url "root conversation URL"
|
|
1238
|
+
assert_not_contains "$conversation_uuid" "$out" "root conversation URL remains redacted"
|
|
1239
|
+
assert_proposed_block "Private review: $claude_chat_url" ai_session_url "root chat URL"
|
|
1240
|
+
assert_not_contains "$conversation_uuid" "$out" "root chat URL remains redacted"
|
|
1241
|
+
assert_proposed_block "Private review: $chatgpt_https_default_port_url" ai_session_url "HTTPS default-port session URL"
|
|
1242
|
+
assert_not_contains "$conversation_uuid" "$out" "HTTPS default-port URL remains redacted"
|
|
1243
|
+
assert_proposed_block "Private review: $chatgpt_http_default_port_url" ai_session_url "HTTP default-port session URL"
|
|
1244
|
+
assert_not_contains "$conversation_uuid" "$out" "HTTP default-port URL remains redacted"
|
|
1245
|
+
|
|
1246
|
+
printf 'Private review: %s\n' "$chatgpt_wrong_port_url" >"$TMP/proposed-pr.txt"
|
|
1247
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1248
|
+
assert_rc "$rc" 0 "a cross-scheme non-default port is not treated as the canonical origin"
|
|
1249
|
+
|
|
1250
|
+
for human_credit in 'Written by Ai Weiwei' 'Created by Ai Weiwei for the exhibition' 'Written by Claude Monet'; do
|
|
1251
|
+
printf '%s\n' "$human_credit" >"$TMP/proposed-pr.txt"
|
|
1252
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1253
|
+
assert_rc "$rc" 0 "a human-name credit must not be treated as generated attribution"
|
|
1254
|
+
done
|
|
1255
|
+
|
|
1256
|
+
assert_proposed_block "Inspired by Claude" provenance_wording "standard English provenance wording"
|
|
1257
|
+
printf 'Inspired by Claude Monet\n' >"$TMP/proposed-pr.txt"
|
|
1258
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1259
|
+
assert_rc "$rc" 0 "a human-name inspiration credit remains accepted"
|
|
1260
|
+
|
|
1261
|
+
printf 'Summary\r\n\r\nCo-Authored-By: Claude Code <helper@example.invalid>\r\n🤖 Claude Code\r\n' >"$TMP/proposed-pr.txt"
|
|
1262
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1263
|
+
assert_rc "$rc" 1 "CRLF proposed PR text must not bypass line-anchored metadata checks"
|
|
1264
|
+
assert_contains "category=ai_coauthor_trailer" "$out" "CRLF co-author trailer category"
|
|
1265
|
+
assert_contains "category=generated_by_footer" "$out" "CRLF generated footer category"
|
|
1266
|
+
printf 'Neutral CRLF proposed PR summary.\r\n' >"$TMP/proposed-pr.txt"
|
|
1267
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1268
|
+
assert_rc "$rc" 0 "neutral CRLF proposed PR text remains accepted"
|
|
1269
|
+
|
|
1270
|
+
printf '🤖 OpenCode compatibility matrix\n' >"$TMP/proposed-pr.txt"
|
|
1271
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1272
|
+
assert_rc "$rc" 0 "a provider compatibility heading is not a generated footer"
|
|
1273
|
+
printf 'Ordinary product link: %s\n' "$google_product_url" >"$TMP/proposed-pr.txt"
|
|
1274
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1275
|
+
assert_rc "$rc" 0 "a non-Gemini g.co share path is not an AI session URL"
|
|
1276
|
+
printf 'Nested help link: %s\n' "$chatgpt_nested_help_url" >"$TMP/proposed-pr.txt"
|
|
1277
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1278
|
+
assert_rc "$rc" 0 "a nested help/chat path is not a root conversation URL"
|
|
1279
|
+
printf 'Product guide: %s\n' "$claude_nonconversation_url" >"$TMP/proposed-pr.txt"
|
|
1280
|
+
run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
|
|
1281
|
+
assert_rc "$rc" 0 "a non-UUID root chat path is not a conversation URL"
|
|
1282
|
+
|
|
1283
|
+
# A detached Actions checkout still obtains both the branch and base from the
|
|
1284
|
+
# event, rather than silently skipping either surface.
|
|
1285
|
+
git -C "$REPO" switch -q --detach "$clean_head"
|
|
1286
|
+
run_gate_without_base --event-json "$TMP/pr-event.json"
|
|
1287
|
+
assert_rc "$rc" 0 "detached checkout with complete PR event should pass"
|
|
1288
|
+
assert_contains "branch=1" "$out" "event head ref supplied the branch surface"
|
|
1289
|
+
git -C "$REPO" switch -q -C feature/sanitized-pr "$clean_head"
|
|
1290
|
+
|
|
1291
|
+
printf '{ malformed' >"$TMP/pr-event.json"
|
|
1292
|
+
run_gate --event-json "$TMP/pr-event.json"
|
|
1293
|
+
assert_rc "$rc" 2 "malformed CI event must fail closed"
|
|
1294
|
+
assert_contains "shared_git_surface_gate_error" "$out" "malformed-event error marker"
|
|
1295
|
+
|
|
1296
|
+
printf '\377' >"$TMP/pr-event.json"
|
|
1297
|
+
run_gate --event-json "$TMP/pr-event.json"
|
|
1298
|
+
assert_rc "$rc" 2 "non-UTF-8 CI event must fail closed"
|
|
1299
|
+
assert_contains "event JSON is not UTF-8" "$out" "non-UTF-8 event reason"
|
|
1300
|
+
|
|
1301
|
+
# Error diagnostics are themselves shared log surfaces. Ref/path/error details
|
|
1302
|
+
# must never repeat the sentinel that made the command fail.
|
|
1303
|
+
orphan_tree="$(git -C "$REPO" mktree </dev/null)"
|
|
1304
|
+
orphan_commit="$(printf 'orphan probe\n' | git -C "$REPO" commit-tree "$orphan_tree")"
|
|
1305
|
+
error_ref='refs/heads/feature/claude-session_SYNTH_ERROR_SENTINEL'
|
|
1306
|
+
git -C "$REPO" update-ref "$error_ref" "$orphan_commit"
|
|
1307
|
+
set +e
|
|
1308
|
+
out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
|
|
1309
|
+
python3 "$GATE" --repo "$REPO" --base-ref "$error_ref" 2>&1)"
|
|
1310
|
+
rc=$?
|
|
1311
|
+
set -e
|
|
1312
|
+
assert_rc "$rc" 2 "a base without a merge-base must fail closed"
|
|
1313
|
+
assert_contains "shared_git_surface_gate_error" "$out" "merge-base error marker"
|
|
1314
|
+
assert_not_contains "SYNTH_ERROR_SENTINEL" "$out" "git errors must not echo a private ref"
|
|
1315
|
+
|
|
1316
|
+
missing_event="$TMP/claude-session_SYNTH_PATH_SENTINEL.json"
|
|
1317
|
+
run_gate --event-json "$missing_event"
|
|
1318
|
+
assert_rc "$rc" 2 "an unreadable event path must fail closed"
|
|
1319
|
+
assert_contains "event JSON unreadable" "$out" "unreadable event reason"
|
|
1320
|
+
assert_not_contains "SYNTH_PATH_SENTINEL" "$out" "file errors must not echo a private path"
|
|
1321
|
+
|
|
1322
|
+
# Pin the secure-open mechanism, not only its happy-path result: opening first
|
|
1323
|
+
# with no-follow/nonblocking flags and then fstat/read on that descriptor closes
|
|
1324
|
+
# the lstat→open swap and FIFO-blocking windows.
|
|
1325
|
+
python3 - "$GATE" "$TMP" "$REPO" <<'PY'
|
|
1326
|
+
import importlib.util
|
|
1327
|
+
import os
|
|
1328
|
+
import signal
|
|
1329
|
+
import sys
|
|
1330
|
+
from pathlib import Path
|
|
1331
|
+
|
|
1332
|
+
gate_path = Path(sys.argv[1])
|
|
1333
|
+
probe_path = Path(sys.argv[2]) / "reader-mechanism-probe.json"
|
|
1334
|
+
repo_path = Path(sys.argv[3])
|
|
1335
|
+
probe_path.write_text('{"probe": true}', encoding="utf-8")
|
|
1336
|
+
spec = importlib.util.spec_from_file_location("shared_git_surface_gate_probe", gate_path)
|
|
1337
|
+
module = importlib.util.module_from_spec(spec)
|
|
1338
|
+
sys.modules[spec.name] = module
|
|
1339
|
+
spec.loader.exec_module(module)
|
|
1340
|
+
|
|
1341
|
+
# A candidate controls commit/PR text. Repeating a recognized origin without a
|
|
1342
|
+
# session path must remain bounded by the public PR-text limit rather than make
|
|
1343
|
+
# the regex rescan the full suffix once per origin.
|
|
1344
|
+
class RegexDeadline(Exception):
|
|
1345
|
+
pass
|
|
1346
|
+
|
|
1347
|
+
def regex_deadline(_signum, _frame):
|
|
1348
|
+
raise RegexDeadline
|
|
1349
|
+
|
|
1350
|
+
origin_near_miss = "https://claude" + "." + "ai/aaaaaaaa"
|
|
1351
|
+
adversarial_text = (origin_near_miss * 4_000)[:module.MAX_PR_TEXT_BYTES]
|
|
1352
|
+
previous_alarm = signal.signal(signal.SIGALRM, regex_deadline)
|
|
1353
|
+
signal.setitimer(signal.ITIMER_REAL, 1.0)
|
|
1354
|
+
try:
|
|
1355
|
+
assert module.violations("proposed_pr_text", "file", adversarial_text) == []
|
|
1356
|
+
except RegexDeadline as exc:
|
|
1357
|
+
raise AssertionError("session URL near-miss scan exceeded its bounded deadline") from exc
|
|
1358
|
+
finally:
|
|
1359
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
1360
|
+
signal.signal(signal.SIGALRM, previous_alarm)
|
|
1361
|
+
|
|
1362
|
+
# Branch slug separators are also input-controlled. Boundary recognition must
|
|
1363
|
+
# not greedily rescan the remaining suffix at every separator position.
|
|
1364
|
+
branch_near_miss = "-" * 8_000 + "neutral"
|
|
1365
|
+
previous_alarm = signal.signal(signal.SIGALRM, regex_deadline)
|
|
1366
|
+
signal.setitimer(signal.ITIMER_REAL, 1.0)
|
|
1367
|
+
try:
|
|
1368
|
+
assert module.violations("branch", "current", branch_near_miss) == []
|
|
1369
|
+
except RegexDeadline as exc:
|
|
1370
|
+
raise AssertionError("branch separator near-miss scan exceeded its bounded deadline") from exc
|
|
1371
|
+
finally:
|
|
1372
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
1373
|
+
signal.signal(signal.SIGALRM, previous_alarm)
|
|
1374
|
+
|
|
1375
|
+
real_git = module.git
|
|
1376
|
+
seen_commit_args = []
|
|
1377
|
+
|
|
1378
|
+
def batch_frame(oid, payload=b"synthetic raw commit without a NUL\n"):
|
|
1379
|
+
return (
|
|
1380
|
+
oid
|
|
1381
|
+
+ b" commit "
|
|
1382
|
+
+ str(len(payload)).encode("ascii")
|
|
1383
|
+
+ b"\n"
|
|
1384
|
+
+ payload
|
|
1385
|
+
+ b"\n"
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
sha1_oid = b"e" * 40
|
|
1389
|
+
sha256_oid = b"f" * 64
|
|
1390
|
+
module.validate_commit_batch(batch_frame(sha1_oid), [sha1_oid.decode("ascii")])
|
|
1391
|
+
module.validate_commit_batch(batch_frame(sha256_oid), [sha256_oid.decode("ascii")])
|
|
1392
|
+
malformed_batches = (
|
|
1393
|
+
b"",
|
|
1394
|
+
b"wrong commit 0\n\n",
|
|
1395
|
+
sha1_oid + b" blob 0\n\n",
|
|
1396
|
+
sha1_oid + b" commit 01\nx\n",
|
|
1397
|
+
sha1_oid + b" commit 4\nabc\n",
|
|
1398
|
+
sha1_oid + b" commit 3\nabc",
|
|
1399
|
+
batch_frame(sha1_oid) + b"trailing",
|
|
1400
|
+
)
|
|
1401
|
+
for malformed_batch in malformed_batches:
|
|
1402
|
+
try:
|
|
1403
|
+
module.validate_commit_batch(malformed_batch, [sha1_oid.decode("ascii")])
|
|
1404
|
+
except module.GateError as exc:
|
|
1405
|
+
assert str(exc) == "candidate commit object scan returned malformed records"
|
|
1406
|
+
else:
|
|
1407
|
+
raise AssertionError("malformed batch boundary did not fail closed")
|
|
1408
|
+
try:
|
|
1409
|
+
module.validate_commit_batch(batch_frame(sha1_oid, b"left\x00right"), [sha1_oid.decode("ascii")])
|
|
1410
|
+
except module.GateError as exc:
|
|
1411
|
+
assert str(exc) == "candidate commit object contains NUL"
|
|
1412
|
+
else:
|
|
1413
|
+
raise AssertionError("a NUL inside a length-framed payload did not fail closed")
|
|
1414
|
+
|
|
1415
|
+
def clean_commit_git(repo, *args, **kwargs):
|
|
1416
|
+
seen_commit_args.extend(args)
|
|
1417
|
+
if args[0] == "rev-list":
|
|
1418
|
+
return b"a" * 40 + b"\n"
|
|
1419
|
+
if args[:2] == ("cat-file", "--batch"):
|
|
1420
|
+
assert kwargs["input_bytes"] == b"a" * 40 + b"\n"
|
|
1421
|
+
return batch_frame(b"a" * 40)
|
|
1422
|
+
return b"a" * 40 + b"\0neutral message\0Human <h@example.invalid>\0Human <h@example.invalid>\0"
|
|
1423
|
+
|
|
1424
|
+
module.git = clean_commit_git
|
|
1425
|
+
assert len(module.commit_messages(repo_path, "base", "head")) == 1
|
|
1426
|
+
assert "--encoding=UTF-8" in seen_commit_args
|
|
1427
|
+
|
|
1428
|
+
def malformed_batch_git(repo, *args, **kwargs):
|
|
1429
|
+
if args[0] == "rev-list":
|
|
1430
|
+
return b"d" * 40 + b"\n"
|
|
1431
|
+
if args[:2] == ("cat-file", "--batch"):
|
|
1432
|
+
return b"not a length-framed batch response\n"
|
|
1433
|
+
return b"d" * 40 + b"\0neutral\0Human <h@example.invalid>\0Human <h@example.invalid>\0"
|
|
1434
|
+
|
|
1435
|
+
module.git = malformed_batch_git
|
|
1436
|
+
try:
|
|
1437
|
+
module.commit_messages(repo_path, "base", "head")
|
|
1438
|
+
except module.GateError as exc:
|
|
1439
|
+
assert str(exc) == "candidate commit object scan returned malformed records"
|
|
1440
|
+
else:
|
|
1441
|
+
raise AssertionError("malformed cat-file batch framing did not fail closed")
|
|
1442
|
+
|
|
1443
|
+
prefix = b"c" * 12
|
|
1444
|
+
first_oid = prefix + b"0" * 28
|
|
1445
|
+
second_oid = prefix + b"1" * 28
|
|
1446
|
+
|
|
1447
|
+
def colliding_prefix_git(repo, *args, **kwargs):
|
|
1448
|
+
if args[0] == "rev-list":
|
|
1449
|
+
return first_oid + b"\n" + second_oid + b"\n"
|
|
1450
|
+
if args[:2] == ("cat-file", "--batch"):
|
|
1451
|
+
return batch_frame(first_oid) + batch_frame(second_oid)
|
|
1452
|
+
one = b"\0neutral one\0Human <h@example.invalid>\0Human <h@example.invalid>\0"
|
|
1453
|
+
two = b"\0neutral two\0Human <h@example.invalid>\0Human <h@example.invalid>\0"
|
|
1454
|
+
return second_oid + two + first_oid + one
|
|
1455
|
+
|
|
1456
|
+
module.git = colliding_prefix_git
|
|
1457
|
+
try:
|
|
1458
|
+
module.commit_messages(repo_path, "base", "head")
|
|
1459
|
+
except module.GateError as exc:
|
|
1460
|
+
assert str(exc) == "candidate commit scan returned inconsistent records"
|
|
1461
|
+
else:
|
|
1462
|
+
raise AssertionError("full object-id ordering was compared only by locator")
|
|
1463
|
+
|
|
1464
|
+
for undecodable in (b"\xff", "\ufffd".encode("utf-8")):
|
|
1465
|
+
def undecodable_commit_git(repo, *args, payload=undecodable, **kwargs):
|
|
1466
|
+
if args[0] == "rev-list":
|
|
1467
|
+
return b"b" * 40 + b"\n"
|
|
1468
|
+
if args[:2] == ("cat-file", "--batch"):
|
|
1469
|
+
return batch_frame(b"b" * 40)
|
|
1470
|
+
return (
|
|
1471
|
+
b"b" * 40
|
|
1472
|
+
+ b"\0"
|
|
1473
|
+
+ payload
|
|
1474
|
+
+ b"\0Human <h@example.invalid>\0Human <h@example.invalid>\0"
|
|
1475
|
+
)
|
|
1476
|
+
|
|
1477
|
+
module.git = undecodable_commit_git
|
|
1478
|
+
try:
|
|
1479
|
+
module.commit_messages(repo_path, "base", "head")
|
|
1480
|
+
except module.GateError as exc:
|
|
1481
|
+
assert str(exc) == (
|
|
1482
|
+
"candidate commit metadata is not valid UTF-8 "
|
|
1483
|
+
"locator=bbbbbbbbbbbb field=message"
|
|
1484
|
+
)
|
|
1485
|
+
else:
|
|
1486
|
+
raise AssertionError("undecodable commit metadata did not fail closed")
|
|
1487
|
+
|
|
1488
|
+
module.git = real_git
|
|
1489
|
+
seen = {"flags": None, "fstat": 0, "max_read": 0}
|
|
1490
|
+
real_open = module.os.open
|
|
1491
|
+
real_fstat = module.os.fstat
|
|
1492
|
+
real_read = module.read_fd
|
|
1493
|
+
|
|
1494
|
+
def tracked_open(path, flags):
|
|
1495
|
+
seen["flags"] = flags
|
|
1496
|
+
return real_open(path, flags)
|
|
1497
|
+
|
|
1498
|
+
def tracked_fstat(fd):
|
|
1499
|
+
seen["fstat"] += 1
|
|
1500
|
+
return real_fstat(fd)
|
|
1501
|
+
|
|
1502
|
+
def tracked_read(fd, size):
|
|
1503
|
+
seen["max_read"] = max(seen["max_read"], size)
|
|
1504
|
+
return real_read(fd, size)
|
|
1505
|
+
|
|
1506
|
+
module.os.open = tracked_open
|
|
1507
|
+
module.os.fstat = tracked_fstat
|
|
1508
|
+
module.read_fd = tracked_read
|
|
1509
|
+
text = module.read_regular(probe_path, max_bytes=32, label="probe")
|
|
1510
|
+
assert text == '{"probe": true}'
|
|
1511
|
+
assert seen["flags"] & os.O_NOFOLLOW
|
|
1512
|
+
assert seen["flags"] & os.O_NONBLOCK
|
|
1513
|
+
assert seen["fstat"] == 1
|
|
1514
|
+
assert 0 < seen["max_read"] <= 33
|
|
1515
|
+
|
|
1516
|
+
module.os.open = real_open
|
|
1517
|
+
module.os.fstat = real_fstat
|
|
1518
|
+
seen["max_read"] = 0
|
|
1519
|
+
try:
|
|
1520
|
+
module.git(
|
|
1521
|
+
repo_path,
|
|
1522
|
+
"log",
|
|
1523
|
+
"-1",
|
|
1524
|
+
"--format=%B",
|
|
1525
|
+
max_bytes=8,
|
|
1526
|
+
operation="bounded git probe",
|
|
1527
|
+
)
|
|
1528
|
+
except module.GateError as exc:
|
|
1529
|
+
assert str(exc) == "bounded git probe output exceeds the safety limit"
|
|
1530
|
+
else:
|
|
1531
|
+
raise AssertionError("oversized Git output did not fail closed")
|
|
1532
|
+
assert 0 < seen["max_read"] <= 9, seen
|
|
1533
|
+
PY
|
|
1534
|
+
|
|
1535
|
+
# Exercise the hook against a synthetic repository because string matching
|
|
1536
|
+
# cannot prove that every ref is actually scanned with the right head/base.
|
|
1537
|
+
# Makefile/CI wiring belongs to test_ci_checkout_ref_binding.sh so this focused
|
|
1538
|
+
# suite remains a self-contained behavior check for the gate and hook.
|
|
1539
|
+
ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd -P)"
|
|
1540
|
+
python3 - "$ROOT/AGENTS.md" <<'PY'
|
|
1541
|
+
import re
|
|
1542
|
+
import sys
|
|
1543
|
+
from pathlib import Path
|
|
1544
|
+
|
|
1545
|
+
source = Path(sys.argv[1]).read_text(encoding="utf-8")
|
|
1546
|
+
rule = re.search(
|
|
1547
|
+
r"^- \*\*共享 Git 面去会话化\*\*:.*?(?=^- \*\*|\Z)",
|
|
1548
|
+
source,
|
|
1549
|
+
re.MULTILINE | re.DOTALL,
|
|
1550
|
+
)
|
|
1551
|
+
if rule is None or "--pr-text-file <proposed-pr.md>" not in rule.group(0):
|
|
1552
|
+
raise SystemExit(
|
|
1553
|
+
"FAIL: the root pre-PR contract must scan the proposed title/body "
|
|
1554
|
+
"with --pr-text-file <proposed-pr.md>"
|
|
1555
|
+
)
|
|
1556
|
+
PY
|
|
1557
|
+
HOOK_REPO="$TMP/hook-repo"
|
|
1558
|
+
mkdir -p "$HOOK_REPO"
|
|
1559
|
+
git init -q -b dev "$HOOK_REPO"
|
|
1560
|
+
git -C "$HOOK_REPO" config user.email tester@example.invalid
|
|
1561
|
+
git -C "$HOOK_REPO" config user.name "Synthetic Tester"
|
|
1562
|
+
printf 'baseline\n' >"$HOOK_REPO/file.txt"
|
|
1563
|
+
git -C "$HOOK_REPO" add file.txt
|
|
1564
|
+
git -C "$HOOK_REPO" commit -qm "baseline"
|
|
1565
|
+
HOOK_BASE="$(git -C "$HOOK_REPO" rev-parse HEAD)"
|
|
1566
|
+
HOOK_TREE="$(git -C "$HOOK_REPO" rev-parse 'HEAD^{tree}')"
|
|
1567
|
+
git -C "$HOOK_REPO" update-ref refs/remotes/origin/dev "$HOOK_BASE"
|
|
1568
|
+
mkdir -p "$HOOK_REPO/.githooks" "$HOOK_REPO/skills/skill-extraction-workflow/scripts"
|
|
1569
|
+
cp "$ROOT/.githooks/pre-push" "$HOOK_REPO/.githooks/pre-push"
|
|
1570
|
+
cp "$GATE" "$HOOK_REPO/skills/skill-extraction-workflow/scripts/shared_git_surface_gate.py"
|
|
1571
|
+
printf '#!/usr/bin/env bash\nexit 0\n' >"$HOOK_REPO/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh"
|
|
1572
|
+
chmod +x "$HOOK_REPO/.githooks/pre-push" "$HOOK_REPO/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh"
|
|
1573
|
+
|
|
1574
|
+
run_hook_for_remote() {
|
|
1575
|
+
local remote_name="$1"
|
|
1576
|
+
shift
|
|
1577
|
+
set +e
|
|
1578
|
+
out="$(printf '%s\n' "$@" | (
|
|
1579
|
+
cd "$HOOK_REPO"
|
|
1580
|
+
env -u CCL_SKILL_BASE_REF -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \
|
|
1581
|
+
.githooks/pre-push "$remote_name" synthetic
|
|
1582
|
+
) 2>&1)"
|
|
1583
|
+
rc=$?
|
|
1584
|
+
set -e
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
run_hook() {
|
|
1588
|
+
run_hook_for_remote origin "$@"
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
run_hook_with_base() {
|
|
1592
|
+
local base_override="$1"
|
|
1593
|
+
shift
|
|
1594
|
+
set +e
|
|
1595
|
+
out="$(printf '%s\n' "$@" | (
|
|
1596
|
+
cd "$HOOK_REPO"
|
|
1597
|
+
env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \
|
|
1598
|
+
CCL_SKILL_BASE_REF="$base_override" .githooks/pre-push origin synthetic
|
|
1599
|
+
) 2>&1)"
|
|
1600
|
+
rc=$?
|
|
1601
|
+
set -e
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
zero=0000000000000000000000000000000000000000
|
|
1605
|
+
run_hook "refs/heads/feature/deleted $zero refs/heads/feature/deleted $HOOK_BASE"
|
|
1606
|
+
assert_rc "$rc" 0 "a delete-only push publishes no new metadata and must be skipped"
|
|
1607
|
+
assert_equals "" "$out" "a delete-only push exits before checkout or metadata gates"
|
|
1608
|
+
|
|
1609
|
+
printf 'candidate leak\n\nClaude-Session-ID: SYNTH_REMOTE_TIP_SENTINEL\n' >"$TMP/hook-bad-message.txt"
|
|
1610
|
+
hook_bad="$(git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$HOOK_BASE" -F "$TMP/hook-bad-message.txt")"
|
|
1611
|
+
hook_follow="$(printf 'neutral follow-up\n' | git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$hook_bad")"
|
|
1612
|
+
git -C "$HOOK_REPO" update-ref refs/heads/feature/base-probe "$hook_follow"
|
|
1613
|
+
git -C "$HOOK_REPO" symbolic-ref HEAD refs/heads/feature/base-probe
|
|
1614
|
+
run_hook "refs/heads/feature/base-probe $hook_follow refs/heads/feature/base-probe $hook_bad"
|
|
1615
|
+
assert_rc "$rc" 1 "an existing feature remote tip must not hide an earlier candidate leak"
|
|
1616
|
+
assert_contains "category=ai_session_id" "$out" "feature push uses the landing target base"
|
|
1617
|
+
assert_not_contains "SYNTH_REMOTE_TIP_SENTINEL" "$out" "hook failure remains redacted"
|
|
1618
|
+
|
|
1619
|
+
hook_clean="$(printf 'neutral candidate\n' | git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$HOOK_BASE")"
|
|
1620
|
+
printf 'other ref leak\n\nCursor-Session-ID: SYNTH_MULTI_REF_SENTINEL\n' >"$TMP/hook-other-message.txt"
|
|
1621
|
+
hook_other="$(git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$HOOK_BASE" -F "$TMP/hook-other-message.txt")"
|
|
1622
|
+
git -C "$HOOK_REPO" update-ref refs/heads/feature/clean "$hook_clean"
|
|
1623
|
+
git -C "$HOOK_REPO" update-ref refs/heads/feature/other "$hook_other"
|
|
1624
|
+
git -C "$HOOK_REPO" symbolic-ref HEAD refs/heads/feature/clean
|
|
1625
|
+
before_head="$(git -C "$HOOK_REPO" rev-parse HEAD)"
|
|
1626
|
+
before_status="$(git -C "$HOOK_REPO" status --porcelain=v1)"
|
|
1627
|
+
run_hook \
|
|
1628
|
+
"refs/heads/feature/clean $hook_clean refs/heads/feature/clean $zero" \
|
|
1629
|
+
"refs/heads/feature/other $hook_other refs/heads/feature/other $zero"
|
|
1630
|
+
assert_rc "$rc" 1 "every non-delete ref in a multi-ref push must be scanned"
|
|
1631
|
+
assert_contains "category=ai_session_id" "$out" "non-HEAD pushed ref finding"
|
|
1632
|
+
assert_not_contains "SYNTH_MULTI_REF_SENTINEL" "$out" "multi-ref finding remains redacted"
|
|
1633
|
+
assert_equals "$before_head" "$(git -C "$HOOK_REPO" rev-parse HEAD)" "hook must not move HEAD"
|
|
1634
|
+
assert_equals "$before_status" "$(git -C "$HOOK_REPO" status --porcelain=v1)" "hook must not alter the working tree"
|
|
1635
|
+
|
|
1636
|
+
# The checkout-level structural gate is about HEAD/working-tree state. A clean
|
|
1637
|
+
# non-HEAD ref must still receive metadata scanning without being rejected by
|
|
1638
|
+
# an unrelated ambient checkout failure.
|
|
1639
|
+
hook_non_head="$(printf 'neutral non-head candidate\n' | git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$HOOK_BASE")"
|
|
1640
|
+
git -C "$HOOK_REPO" update-ref refs/heads/feature/non-head "$hook_non_head"
|
|
1641
|
+
printf '#!/usr/bin/env bash\necho SYNTH_AMBIENT_GATE_RAN\nexit 9\n' >"$HOOK_REPO/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh"
|
|
1642
|
+
chmod +x "$HOOK_REPO/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh"
|
|
1643
|
+
run_hook "refs/heads/feature/non-head $hook_non_head refs/heads/feature/non-head $zero"
|
|
1644
|
+
assert_rc "$rc" 0 "a non-HEAD push must not inherit ambient checkout failure"
|
|
1645
|
+
assert_not_contains "SYNTH_AMBIENT_GATE_RAN" "$out" "checkout gate must be skipped for a non-HEAD-only push"
|
|
1646
|
+
printf '#!/usr/bin/env bash\nexit 0\n' >"$HOOK_REPO/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh"
|
|
1647
|
+
chmod +x "$HOOK_REPO/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh"
|
|
1648
|
+
|
|
1649
|
+
run_hook "refs/heads/feature/clean $hook_clean refs/heads/feature/claude-session_SYNTH_DEST_SENTINEL $zero"
|
|
1650
|
+
assert_rc "$rc" 1 "the destination branch name must be scanned"
|
|
1651
|
+
assert_contains "surface=branch" "$out" "destination branch finding"
|
|
1652
|
+
assert_not_contains "SYNTH_DEST_SENTINEL" "$out" "destination finding remains redacted"
|
|
1653
|
+
|
|
1654
|
+
# A violation already present on the actual main target is outside the new
|
|
1655
|
+
# main candidate range. This allowed case prevents replacing every base with
|
|
1656
|
+
# origin/dev and re-policing target history.
|
|
1657
|
+
printf 'main known debt\n\nClaude-Session-ID: SYNTH_MAIN_DEBT\n' >"$TMP/hook-main-message.txt"
|
|
1658
|
+
hook_main="$(git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$HOOK_BASE" -F "$TMP/hook-main-message.txt")"
|
|
1659
|
+
hook_main_head="$(printf 'neutral promotion\n' | git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$hook_main")"
|
|
1660
|
+
git -C "$HOOK_REPO" update-ref refs/heads/dev "$hook_main_head"
|
|
1661
|
+
git -C "$HOOK_REPO" symbolic-ref HEAD refs/heads/dev
|
|
1662
|
+
run_hook "refs/heads/dev $hook_main_head refs/heads/main $hook_main"
|
|
1663
|
+
assert_rc "$rc" 0 "an existing landing target SHA is the main promotion base"
|
|
1664
|
+
|
|
1665
|
+
printf 'landing candidate leak\n\nCodex-Session-ID: SYNTH_LANDING_BASE_SENTINEL\n' >"$TMP/hook-landing-leak-message.txt"
|
|
1666
|
+
hook_landing_leak="$(git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$hook_main" -F "$TMP/hook-landing-leak-message.txt")"
|
|
1667
|
+
git -C "$HOOK_REPO" update-ref refs/heads/dev "$hook_landing_leak"
|
|
1668
|
+
for landing_ref in refs/heads/dev refs/heads/main; do
|
|
1669
|
+
run_hook_with_base "$hook_landing_leak" \
|
|
1670
|
+
"refs/heads/dev $hook_landing_leak $landing_ref $hook_main"
|
|
1671
|
+
assert_rc "$rc" 1 "an environment base must not narrow an existing $landing_ref candidate"
|
|
1672
|
+
assert_contains "category=ai_session_id" "$out" "existing landing target remote SHA outranks environment base"
|
|
1673
|
+
assert_not_contains "SYNTH_LANDING_BASE_SENTINEL" "$out" "landing-target finding remains redacted"
|
|
1674
|
+
done
|
|
1675
|
+
git -C "$HOOK_REPO" update-ref refs/heads/dev "$hook_main_head"
|
|
1676
|
+
|
|
1677
|
+
run_hook "refs/heads/dev $hook_main_head refs/heads/main $zero"
|
|
1678
|
+
assert_rc "$rc" 1 "a new landing target without an explicit base must fail closed"
|
|
1679
|
+
assert_contains "candidate base is unresolved" "$out" "new landing target failure reason"
|
|
1680
|
+
|
|
1681
|
+
run_hook_with_base "$HOOK_BASE" \
|
|
1682
|
+
"refs/heads/feature/clean $hook_clean refs/heads/main $zero"
|
|
1683
|
+
assert_rc "$rc" 0 "an explicit base may delimit a new landing target"
|
|
1684
|
+
|
|
1685
|
+
git -C "$HOOK_REPO" update-ref -d refs/remotes/origin/dev
|
|
1686
|
+
git -C "$HOOK_REPO" update-ref refs/remotes/upstream/dev "$HOOK_BASE"
|
|
1687
|
+
run_hook_for_remote upstream \
|
|
1688
|
+
"refs/heads/feature/clean $hook_clean refs/heads/feature/clean $zero"
|
|
1689
|
+
assert_rc "$rc" 0 "a feature push derives its dev base from the pushed remote name"
|
|
1690
|
+
git -C "$HOOK_REPO" update-ref -d refs/remotes/upstream/dev
|
|
1691
|
+
run_hook "refs/heads/feature/clean $hook_clean refs/heads/feature/clean $zero"
|
|
1692
|
+
assert_rc "$rc" 1 "a feature push without a resolvable default landing base must fail closed"
|
|
1693
|
+
assert_contains "set CCL_SKILL_BASE_REF" "$out" "missing default-base remediation hint"
|
|
1694
|
+
git -C "$HOOK_REPO" update-ref refs/remotes/origin/dev "$HOOK_BASE"
|
|
1695
|
+
|
|
1696
|
+
echo "test_shared_git_surface_gate: ok"
|