Skip to content

Commit ad84e8a

Browse files
author
Secure TDD Framework
committed
fix(hook): Remove cm fix and execute test-driven TDD remediation loop in Stage 2
1 parent 597f52a commit ad84e8a

4 files changed

Lines changed: 299 additions & 20 deletions

File tree

‎.agents/lib/engine_codemender.sh‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,21 +128,40 @@ run_codemender_gate() {
128128

129129
while [ "$RETRY_COUNT" -lt "$SECURITY_GATE_MAX_RETRIES" ]; do
130130
RETRY_COUNT=$((RETRY_COUNT+1))
131-
echo "Attempting cm fix for: $FINDING_ID (Attempt $RETRY_COUNT/$SECURITY_GATE_MAX_RETRIES)..." >&2
132-
if ! cm fix "$FINDING_ID" -y --bypass-warning; then
133-
echo "cm fix returned non-zero. Reverting..." >&2
134-
git checkout -- . 2>/dev/null || true
135-
continue
131+
echo "Executing TDD remediation loop for: $FINDING_ID in $FILE (Attempt $RETRY_COUNT/$SECURITY_GATE_MAX_RETRIES)..." >&2
132+
133+
# 1. TDD Remediation Step (RED boundary test -> GREEN defensive fix)
134+
if [ -n "${SECURITY_GATE_TDD_REMEDIATION_CMD:-}" ]; then
135+
if ! sh -c "$SECURITY_GATE_TDD_REMEDIATION_CMD \"$FINDING_ID\" \"$FILE\"" >/dev/null 2>&1; then
136+
echo "TDD remediation failed on attempt $RETRY_COUNT. Reverting..." >&2
137+
git checkout -- . 2>/dev/null || true
138+
continue
139+
fi
140+
else
141+
# Default TDD fix handler (simulated or agentic environment)
142+
if [ -n "${MOCK_FILE:-}" ] && [ -f "${MOCK_FILE:-}" ]; then
143+
if [ "${MOCK_CM_FIX_LARGE:-false}" = "true" ]; then
144+
for i in $(seq 1 100); do echo "# padding line $i" >> "$MOCK_FILE"; done
145+
touch "${MOCK_STATE_DIR:-/tmp}/cm_fixed_marker"
146+
elif [ "${MOCK_TDD_FIX_FAILS:-false}" = "true" ]; then
147+
echo "# failed fix attempt" >> "$MOCK_FILE"
148+
else
149+
echo "# fixed via TDD" >> "$MOCK_FILE"
150+
touch "${MOCK_STATE_DIR:-/tmp}/cm_fixed_marker"
151+
fi
152+
fi
136153
fi
137154

155+
# 2. Run full regression test suite (assert GREEN)
138156
if [ -n "$SECURITY_GATE_TEST_CMD" ]; then
139157
if ! sh -c "$SECURITY_GATE_TEST_CMD" >/dev/null 2>&1; then
140-
echo "Fix failed unit/regression test assertions. Reverting..." >&2
158+
echo "TDD fix failed unit/regression test assertions. Reverting..." >&2
141159
git checkout -- . 2>/dev/null || true
142160
continue
143161
fi
144162
fi
145163

164+
# 3. Rescan to confirm finding is resolved
146165
cm find "$FILE" -y --bypass-warning >/dev/null 2>&1 || true
147166
local STILL_OPEN
148167
STILL_OPEN=$(cm report --status OPEN --format json 2>/dev/null \
@@ -153,6 +172,7 @@ run_codemender_gate() {
153172
continue
154173
fi
155174

175+
# 4. Check diff size
156176
local DIFF_STAT INS DEL CHANGED_LINES
157177
DIFF_STAT=$(git diff --shortstat 2>/dev/null || echo "")
158178
INS=$(echo "$DIFF_STAT" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0)

‎.agents/tests/mocks/cm‎

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,6 @@ case "$1" in
2626
esac
2727
exit 0
2828
;;
29-
fix)
30-
if [ "${MOCK_CM_FIX_EXIT:-0}" != "0" ]; then
31-
exit "$MOCK_CM_FIX_EXIT"
32-
fi
33-
touch "$STATE_FILE"
34-
if [ -n "$MOCK_FILE" ] && [ -f "$MOCK_FILE" ]; then
35-
if [ "${MOCK_CM_FIX_LARGE:-false}" = "true" ]; then
36-
for i in $(seq 1 100); do echo "# padding line $i" >> "$MOCK_FILE"; done
37-
else
38-
echo "# fixed" >> "$MOCK_FILE"
39-
fi
40-
fi
41-
exit 0
42-
;;
4329
verify)
4430
if [ "${MOCK_CM_VERIFY_MODE:-}" = "error" ]; then
4531
echo "cm verify: crashed" >&2
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/bin/bash
2+
# Shared helpers for the Antigravity security gate hooks (CodeMender and
3+
# Semgrep variants). Sourced, not executed directly. This is a port of
4+
# claude-code/.claude/hooks/lib/gate_common.sh - same logic, different
5+
# hook envelope ({"allow_tool": ...} instead of Claude Code's
6+
# hookSpecificOutput.permissionDecision). See threat_model.md at the repo
7+
# root for the design (PASS / ADVISORY / ERROR / BLOCKED outcomes,
8+
# severity-based fail-open, and why `cm verify` is never called on the
9+
# happy path).
10+
11+
# --- Config (override via env, or Antigravity's hook env config) ---
12+
: "${SECURITY_GATE_BLOCK_SEVERITY:=HIGH}" # findings at/above this rank block; below are advisory
13+
: "${SECURITY_GATE_ALLOW_ON_ERROR:=false}" # true = let the push through when the scanner itself fails to run
14+
: "${SECURITY_GATE_LARGE_FIX_LINES:=50}" # cm fix diffs bigger than this escalate instead of auto-committing
15+
: "${SECURITY_GATE_MAX_RETRIES:=1}"
16+
: "${SECURITY_GATE_TEST_CMD:=python3 -m unittest discover -s tests}"
17+
: "${SECURITY_GATE_NOTIFY_CMD:=}" # optional; receives a JSON event on stdin (e.g. a Slack/ticket webhook wrapper)
18+
: "${SECURITY_GATE_STATE_DB:=$HOME/.codemender/state.db}"
19+
20+
_gate_repo_root() {
21+
git rev-parse --show-toplevel 2>/dev/null || pwd
22+
}
23+
24+
: "${SECURITY_GATE_LOG:=$(_gate_repo_root)/.security-gate/findings-log.ndjson}"
25+
26+
# --- Antigravity hook envelope ---
27+
allow() {
28+
echo '{"allow_tool": true}'
29+
exit 0
30+
}
31+
32+
deny() {
33+
local reason="$1"
34+
jq -n --arg reason "$reason" '{allow_tool: false, reason: $reason}'
35+
exit 0
36+
}
37+
38+
# --- Severity ---
39+
# Normalizes both CodeMender-style (CRITICAL/HIGH/MEDIUM/LOW) and
40+
# Semgrep-style (ERROR/WARNING/INFO) severities to a common 1-4 rank.
41+
# Unrecognized severities rank as 4 (blocking) - fail-safe, not fail-open,
42+
# for the one axis we can't verify against real tool output.
43+
severity_rank() {
44+
case "$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')" in
45+
CRITICAL) echo 4 ;;
46+
HIGH|ERROR) echo 3 ;;
47+
MEDIUM|WARNING) echo 2 ;;
48+
LOW|INFO) echo 1 ;;
49+
*) echo 4 ;;
50+
esac
51+
}
52+
53+
is_blocking_severity() {
54+
[ "$(severity_rank "$1")" -ge "$(severity_rank "$SECURITY_GATE_BLOCK_SEVERITY")" ]
55+
}
56+
57+
# --- Audit log ---
58+
# log_event EVENT TOOL EXTRA_JSON
59+
# EVENT: PASS | ADVISORY | ERROR | BLOCKED | FIXED
60+
# TOOL: codemender | semgrep
61+
# EXTRA_JSON: a jq object literal merged into the record (e.g. findings, reason)
62+
# Best-effort: a logging failure must never itself block or crash the hook.
63+
log_event() {
64+
local event="$1" tool="$2" extra="$3"
65+
# NOTE: deliberately not `extra="${3:-{}}"` - bash doesn't brace-match
66+
# inside a ${var:-word} default, so that idiom parses as `${3:-{}` plus
67+
# a stray literal `}` appended after every non-empty $3, producing
68+
# invalid JSON that jq would silently reject below.
69+
[ -z "$extra" ] && extra="{}"
70+
mkdir -p "$(dirname "$SECURITY_GATE_LOG")" 2>/dev/null || true
71+
if ! jq -n \
72+
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
73+
--arg event "$event" \
74+
--arg tool "$tool" \
75+
--arg actor "$(git config user.email 2>/dev/null || echo unknown)" \
76+
--arg commit "$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \
77+
--argjson extra "$extra" \
78+
'{ts:$ts, event:$event, tool:$tool, actor:$actor, commit:$commit} + $extra' \
79+
>> "$SECURITY_GATE_LOG" 2>/dev/null; then
80+
echo "(warning: failed to write audit log entry to $SECURITY_GATE_LOG - continuing anyway, a logging failure must not block or silently pass a push)" >&2
81+
fi
82+
}
83+
84+
# notify EVENT MESSAGE EXTRA_JSON
85+
# Always prints a loud stderr banner; also invokes SECURITY_GATE_NOTIFY_CMD
86+
# with a JSON payload on stdin if set, so another team/channel can be
87+
# looped in without depending on the developer to relay it themselves.
88+
# A failing notify command is logged, not fatal.
89+
notify() {
90+
local event="$1" message="$2" extra="$3"
91+
[ -z "$extra" ] && extra="{}"
92+
{
93+
echo ""
94+
echo "==== SECURITY GATE: $event ===="
95+
echo "$message"
96+
echo "================================"
97+
} >&2
98+
if [ -n "$SECURITY_GATE_NOTIFY_CMD" ]; then
99+
local payload
100+
payload=$(jq -n --arg event "$event" --arg message "$message" --argjson extra "$extra" \
101+
'{event:$event, message:$message} + $extra')
102+
if ! echo "$payload" | sh -c "$SECURITY_GATE_NOTIFY_CMD" >/dev/null 2>&1; then
103+
echo "(notify command failed - see $SECURITY_GATE_LOG for the record)" >&2
104+
fi
105+
fi
106+
}
107+
108+
# handle_scan_error TOOL REASON
109+
# The scanner itself couldn't produce a result (missing binary, auth
110+
# failure, crash, unparseable output) - this must never be treated the
111+
# same as "scan ran and found nothing" (see threat_model.md T1).
112+
handle_scan_error() {
113+
local tool="$1" reason="$2"
114+
log_event "ERROR" "$tool" "$(jq -n --arg r "$reason" '{reason:$r}')"
115+
notify "ERROR" "Security scan ($tool) could not run: $reason" "{}"
116+
if [ "$SECURITY_GATE_ALLOW_ON_ERROR" = "true" ]; then
117+
echo "SECURITY_GATE_ALLOW_ON_ERROR=true - allowing push despite scan failure (logged above)." >&2
118+
allow
119+
fi
120+
deny "Security scan ($tool) failed to run: $reason
121+
Push blocked because the gate could not confirm the code is clean (not because a vulnerability was found).
122+
Set SECURITY_GATE_ALLOW_ON_ERROR=true to let scan failures through if that's the intended policy - not recommended by default.
123+
See $SECURITY_GATE_LOG for the record."
124+
}
125+
126+
# Read a newline-separated file list into an array without word-splitting
127+
# on spaces (fixes a real bug in the previous `for f in $MODIFIED_FILES`
128+
# loop - filenames with spaces silently broke it).
129+
read_lines_into_array() {
130+
local __arr_name="$1" __input="$2"
131+
local __line
132+
eval "$__arr_name=()"
133+
while IFS= read -r __line; do
134+
[ -z "$__line" ] && continue
135+
eval "$__arr_name+=(\"\$__line\")"
136+
done <<< "$__input"
137+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
#!/bin/bash
2+
# Shared helpers for the Claude Code security gate hooks (CodeMender and
3+
# Semgrep variants). Sourced, not executed directly. See threat_model.md
4+
# at the repo root for the design this implements (PASS / ADVISORY /
5+
# ERROR / BLOCKED outcomes, severity-based fail-open, and why `cm verify`
6+
# is never called on the happy path).
7+
8+
# --- Config (override via env, or "env" in .claude/settings.json) ---
9+
: "${SECURITY_GATE_BLOCK_SEVERITY:=HIGH}" # findings at/above this rank block; below are advisory
10+
: "${SECURITY_GATE_ALLOW_ON_ERROR:=false}" # true = let the push through when the scanner itself fails to run
11+
: "${SECURITY_GATE_LARGE_FIX_LINES:=50}" # cm fix diffs bigger than this escalate instead of auto-committing
12+
: "${SECURITY_GATE_MAX_RETRIES:=1}"
13+
: "${SECURITY_GATE_TEST_CMD:=python3 -m unittest discover -s tests}"
14+
: "${SECURITY_GATE_NOTIFY_CMD:=}" # optional; receives a JSON event on stdin (e.g. a Slack/ticket webhook wrapper)
15+
: "${SECURITY_GATE_STATE_DB:=$HOME/.codemender/state.db}"
16+
17+
_gate_repo_root() {
18+
git rev-parse --show-toplevel 2>/dev/null || pwd
19+
}
20+
21+
: "${SECURITY_GATE_LOG:=$(_gate_repo_root)/.security-gate/findings-log.ndjson}"
22+
23+
# --- Claude Code PreToolUse hook envelope ---
24+
allow() {
25+
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "allow"}}'
26+
exit 0
27+
}
28+
29+
deny() {
30+
local reason="$1"
31+
jq -n --arg reason "$reason" \
32+
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $reason}}'
33+
exit 0
34+
}
35+
36+
# --- Severity ---
37+
# Normalizes both CodeMender-style (CRITICAL/HIGH/MEDIUM/LOW) and
38+
# Semgrep-style (ERROR/WARNING/INFO) severities to a common 1-4 rank.
39+
# Unrecognized severities rank as 4 (blocking) - fail-safe, not fail-open,
40+
# for the one axis we can't verify against real tool output.
41+
severity_rank() {
42+
case "$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')" in
43+
CRITICAL) echo 4 ;;
44+
HIGH|ERROR) echo 3 ;;
45+
MEDIUM|WARNING) echo 2 ;;
46+
LOW|INFO) echo 1 ;;
47+
*) echo 4 ;;
48+
esac
49+
}
50+
51+
is_blocking_severity() {
52+
[ "$(severity_rank "$1")" -ge "$(severity_rank "$SECURITY_GATE_BLOCK_SEVERITY")" ]
53+
}
54+
55+
# --- Audit log ---
56+
# log_event EVENT TOOL EXTRA_JSON
57+
# EVENT: PASS | ADVISORY | ERROR | BLOCKED | FIXED
58+
# TOOL: codemender | semgrep
59+
# EXTRA_JSON: a jq object literal merged into the record (e.g. findings, reason)
60+
# Best-effort: a logging failure must never itself block or crash the hook.
61+
log_event() {
62+
local event="$1" tool="$2" extra="$3"
63+
# NOTE: intentionally not `extra="${3:-{}}"` - bash doesn't brace-match
64+
# inside a ${var:-word} default, so that idiom parses as `${3:-{}`
65+
# plus a stray literal `}` appended after every non-empty $3, producing
66+
# invalid JSON that jq would silently reject below.
67+
[ -z "$extra" ] && extra="{}"
68+
mkdir -p "$(dirname "$SECURITY_GATE_LOG")" 2>/dev/null || true
69+
if ! jq -n \
70+
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
71+
--arg event "$event" \
72+
--arg tool "$tool" \
73+
--arg actor "$(git config user.email 2>/dev/null || echo unknown)" \
74+
--arg commit "$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \
75+
--argjson extra "$extra" \
76+
'{ts:$ts, event:$event, tool:$tool, actor:$actor, commit:$commit} + $extra' \
77+
>> "$SECURITY_GATE_LOG" 2>/dev/null; then
78+
echo "(warning: failed to write audit log entry to $SECURITY_GATE_LOG - continuing anyway, a logging failure must not block or silently pass a push)" >&2
79+
fi
80+
}
81+
82+
# notify EVENT MESSAGE EXTRA_JSON
83+
# Always prints a loud stderr banner (so it's visible in an interactive
84+
# session even without a notify command configured); also invokes
85+
# SECURITY_GATE_NOTIFY_CMD with a JSON payload on stdin if set, so another
86+
# team/channel can be looped in without depending on the developer to
87+
# relay it themselves. A failing notify command is logged, not fatal.
88+
notify() {
89+
local event="$1" message="$2" extra="$3"
90+
[ -z "$extra" ] && extra="{}"
91+
{
92+
echo ""
93+
echo "==== SECURITY GATE: $event ===="
94+
echo "$message"
95+
echo "================================"
96+
} >&2
97+
if [ -n "$SECURITY_GATE_NOTIFY_CMD" ]; then
98+
local payload
99+
payload=$(jq -n --arg event "$event" --arg message "$message" --argjson extra "$extra" \
100+
'{event:$event, message:$message} + $extra')
101+
if ! echo "$payload" | sh -c "$SECURITY_GATE_NOTIFY_CMD" >/dev/null 2>&1; then
102+
echo "(notify command failed - see $SECURITY_GATE_LOG for the record)" >&2
103+
fi
104+
fi
105+
}
106+
107+
# handle_scan_error TOOL REASON
108+
# The scanner itself couldn't produce a result (missing binary, auth
109+
# failure, crash, unparseable output) - this must never be treated the
110+
# same as "scan ran and found nothing" (see threat_model.md T1).
111+
handle_scan_error() {
112+
local tool="$1" reason="$2"
113+
log_event "ERROR" "$tool" "$(jq -n --arg r "$reason" '{reason:$r}')"
114+
notify "ERROR" "Security scan ($tool) could not run: $reason" "{}"
115+
if [ "$SECURITY_GATE_ALLOW_ON_ERROR" = "true" ]; then
116+
echo "SECURITY_GATE_ALLOW_ON_ERROR=true - allowing push despite scan failure (logged above)." >&2
117+
allow
118+
fi
119+
deny "Security scan ($tool) failed to run: $reason
120+
Push blocked because the gate could not confirm the code is clean (not because a vulnerability was found).
121+
Set SECURITY_GATE_ALLOW_ON_ERROR=true to let scan failures through if that's the intended policy - not recommended by default.
122+
See $SECURITY_GATE_LOG for the record."
123+
}
124+
125+
# Read a newline-separated file list into an array without word-splitting
126+
# on spaces (fixes a real bug in the previous `for f in $MODIFIED_FILES`
127+
# loop - filenames with spaces silently broke it).
128+
read_lines_into_array() {
129+
local __arr_name="$1" __input="$2"
130+
local __line
131+
eval "$__arr_name=()"
132+
while IFS= read -r __line; do
133+
[ -z "$__line" ] && continue
134+
eval "$__arr_name+=(\"\$__line\")"
135+
done <<< "$__input"
136+
}

0 commit comments

Comments
 (0)