Skip to content

security-guidance: push-sweep re-reviews the same diff because commit review records reviewed SHAs only after the review completes #5425

Open

Description

@kouno-takanori

Summary

In security-guidance 2.0.7, the commit reviewer records reviewed SHAs only after the
review finishes
, but the push-sweep's dedup reads that record at push time. When
git commit and git push happen back to back (the common agent flow), the push-sweep
starts before the record exists, so prefix_advanced stays 0 and the same diff is sent
through a second full agentic review
.

Over 6 days on one machine this produced 67 redundant reviews out of 208 (32%), each
carrying the full unified diff in the prompt.

Evidence 1 — plugin debug log (~/.claude/security/log.txt)

A single commit, committed and pushed in the same flow:

[2026-08-15 22:14:26.082] Commit review: detected git commit in command
[2026-08-15 22:14:26.213] Commit review: 1/1 sha(s) resolved, 20 files   <- review starts
[2026-08-15 22:15:21.115] Push sweep: range=1 prefix_advanced=0 base=<sha> tail=1
[2026-08-15 22:15:53.054] Commit review: no security issues found        <- SHA recorded here
[2026-08-15 22:17:19.931] Push sweep: no new findings

The push-sweep at 22:15:21 re-reviewed the very commit whose review did not complete
until 22:15:53 — 32 s later.

Distribution across all push-sweeps that actually ran a review (n=49):

pattern count
range=1 prefix_advanced=0 tail=1 37
other ranges, prefix_advanced=0 10
prefix_advanced>0 (dedup partially worked) 2

Separately, Push sweep: every pushed commit already reviewed fired 10 times — dedup does
work when the push happens long after the commit, which is consistent with the race.

Evidence 2 — transcripts

Scanning ~/.claude/projects/**/*.jsonl for prompts starting with
Review this change for security vulnerabilities between 2026-08-13 and 2026-08-19:

  • 208 occurrences, 141 unique prompt texts, 65 texts appearing 2–3 times
  • every duplicate pair is in a different Agent SDK session file (0 duplicates within a
    single session), i.e. these are genuinely separate review runs, not a logging artifact
  • example: one 222,835-char prompt at 15:31:10 / 15:31:53 / 15:47:06
  • total 12,383,810 chars (~3.5M tokens); the duplicate share is ~1.1M tokens

Root cause

hooks/security_reminder_hook.py:

  • ~line 1341: concrete_guidance, vulns, _am = _agentic_review_with_race(...)
  • line 1372: _append_reviewed_shas(repo_root, full_shas, vulns_found=len(vulns or []))

The append needs vulns, so it cannot run until the review returns. That review is long by
design — _agentic_review_with_race waits SG_AGENTIC_RACE_DELAY_S (default 180 s) before
starting the fallback leg, and agentic_review allows SG_AGENTIC_MAX_TURNS (default 18).
Meanwhile the push-sweep at line 1657 does reviewed = _load_reviewed_shas(repo_root) and
sees nothing.

There is no provisional record: grepping lines 1200–1372 shows _append_reviewed_shas only
at 1372.

Suggested fix

Write the SHAs before the review starts, then update the row after:

_append_reviewed_shas(repo_root, full_shas)                      # claim, vulns_found=0
concrete_guidance, vulns, _am = _agentic_review_with_race(...)
_append_reviewed_shas(repo_root, full_shas, vulns_found=len(vulns or []))  # dedup-by-sha
                                                                          # keeps newest

_append_reviewed_shas already dedups by SHA keeping the newest row, so the second call
overwrites the provisional one. If claiming a SHA that is never actually reviewed (crash
mid-review) is a concern, an in-flight marker the push-sweep also consults would be
equivalent and safer.

Secondary issue — reviewed-SHA state splits across git worktrees

hooks/diffstate.py:

def _reviewed_shas_path(repo_root):
    gd = _git_dir(repo_root)
    return os.path.join(gd, _REVIEWED_SHAS_BASENAME) if gd else None

_git_dir resolves git rev-parse --git-dir, which in a linked worktree returns
<common>/.git/worktrees/<name> rather than the shared .git. Each worktree therefore
keeps its own sg-reviewed-shas, so a commit reviewed in one worktree is not known to
another. The docstring says the record is "per-clone", which suggests
--git-common-dir was intended. This is independent of the race above.

Environment

  • security-guidance 2.0.7 (verified byte-identical to main for
    security_reminder_hook.py and review_api.py)
  • Claude Code on macOS
  • repository uses multiple linked git worktrees
  • no relevant env overrides at the time of measurement (defaults for
    SG_PUSH_SWEEP, SG_AGENTIC_RACE_DELAY_S, DIFF_TOTAL_BYTES)

Workaround in use

SG_PUSH_SWEEP=0, which removes the duplicate at the cost of losing review coverage for
commits created outside Claude Code.

Activity

  1. mbastawisy commented on Sep 14, 2026

    @mbastawisy

    Confirming this from a second repo, and adding that it is not only the push sweep: the commit-to-commit reflog path races the same way. Plugin 2.0.8 (identical to main), 23 commit reviews over four weeks, 5 overlapping pairs.

    Local reproduction from ~/.claude/security/log.txt:

    19:21:00.113  Commit review: reflog shows 2 fresh unreviewed commit(s) (67a386f...)
    19:21:00.201  Commit review: 2/2 sha(s) resolved, 15 files      <- review #1 starts
    19:21:26.752  Commit review: reflog shows 4 fresh unreviewed commit(s) (111a2f8...)
    19:21:26.883  Commit review: 4/4 sha(s) resolved, 9 files       <- review #2 starts
    19:22:07.558  Commit review: no security issues found           <- review #1 records its SHAs
    

    _append_reviewed_shas runs at security_reminder_hook.py:1426, after the 1 to 4 minute agentic review at :1395; both dedup reads (:1116-1117 reflog, :1725 push sweep) happen before that. A second commit 26 s later re-swept the first two SHAs, so the two reviews overlapped (15 vs 9 files) rather than duplicated exactly.

    Aggravating factor worth noting for anyone measuring this: when git commit runs inside a compound command with piped output, [branch sha] never reaches stdout, every commit logs stdout had no [branch sha], and dedup falls through to the 5-entry / 120 s reflog scan (gitutil.py:290). That makes the race window the normal path, not the fallback.

    Suggested shape. Simply moving _append_reviewed_shas before the review would regress #5322 (a crashed review permanently silences those SHAs). A TTL-expiring in-flight sentinel avoids both, following the existing sg-hook-once-<id> idiom (same gitdir, O_CREAT|O_EXCL, opportunistic GC):

    • diffstate.py: _mark_inflight(repo_root, shas) writes sg-inflight-<sha>; _load_inflight_shas(repo_root) returns those younger than ~900 s and unlinks older ones.
    • security_reminder_hook.py: call _mark_inflight right before the review starts (requires hoisting the rev-parse loop at :1417-1425 above it), and have both dedup reads use _load_reviewed_shas(root) | _load_inflight_shas(root).

    Trade-off: a genuinely failed review suppresses a duplicate pass for at most the TTL, after which the commit is eligible again. Files live in the common gitdir, so worktrees of one clone share the marker, which is the desired behaviour.

  2. rjmurillo commented on Sep 25, 2026

    @rjmurillo

    Independent confirmation on 2.0.8 (marketplace commit 55b58ec6e5649104f926ba7558b567dc8d33c5ff), plus a suggested dedupe key that also covers cases a SHA claim cannot.

    Measurement. I audited child review transcripts from one machine (2026-09-10 to 2026-09-20; 223 child sessions, 5,475,987 prompt bytes). I hashed each prompt's diff body with SHA-256.

    • 8 pairs of investigate sessions reviewed byte-identical diffs. That is 660,787 redundant prompt bytes, about 165,000 tokens, or 12.1% of all prompt bytes in the window. The count is before each session re-reads its context on every turn.
    • 7 of the 8 pairs ran in the same checkout. Five of them started 4.5 to 43 seconds apart, which matches the commit-then-push race described above. The plugin log had already rotated, so I cannot tie each pair to a specific hook event.
    • One pair ran in two different checkouts, 891 seconds apart. One was a linked worktree that no longer exists, so I cannot confirm it shared a repository with the other. If it did, the pair matches the worktree split in the secondary issue above.

    Suggested key. The Stop hook already skips an unchanged diff with reviewed_diff_hash (security_reminder_hook.py:2043-2046). Commit review and push sweep could use the same idea:

    1. Before calling _agentic_review_with_race, compute SHA-256 over the capped diff text that goes into the prompt. Scope it by repository identity (git rev-parse --git-common-dir) so all worktrees share it.
    2. Record the hash as in flight before the review starts. Record the result when the review finishes.
    3. Skip, or reuse the stored result, when an in-flight or finished record has the same hash.

    A hash of the exact prompt input catches the race, the worktree split, and identical content under a new SHA (rebase, cherry-pick, or a no-op amend). It never skips a diff that differs by even one byte. The SHA claim proposed above still makes sense next to it, because it keeps the sg-reviewed-shas record accurate.

    One more repeat, smaller. When pass 1 returns one or two candidates, iter2_prompt (llm.py:1421) is user_prompt plus exclusions, sent to a fresh child session. Each iter2 session I measured re-created the cache for the whole diff on its first turn: cache_creation_input_tokens was 5,351 to 16,240. Resuming the investigate session for pass 2 would read the diff from cache instead. It would also change the isolation between passes, so I note it only as an option.

    Checked and rejected: artifact references. I also tested replacing the inline diff with a content-addressed file reference that the reviewer fetches with a tool. With the plugin's own AGENTIC_INVESTIGATE_SYSTEM and FINDINGS_SCHEMA, the referenced prompt used more input tokens in 5 of 6 measured cells (+3% to +44%). It usually added a fetch turn and saved nothing, because the reviewer has to read the diff anyway. Removing the duplicate review is where the savings are.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions