feat(fixer): verify-after-fix loop with rollback and attestation - #108
feat(fixer): verify-after-fix loop with rollback and attestation#108affaan-m wants to merge 1 commit into
Conversation
--fix no longer trusts itself. applyFixesVerified snapshots every file before writing, applies fixes, re-scans, and rolls all files back if the posture score regressed or a new high/critical finding appeared (the churn a naive permission tighten can cause — issue #102 shape). On success it emits a tamper-evident attestation (sha256 over before/after score + finding deltas). - src/fixer/index.ts: applyFixesVerified (injected rescan+score for testability) + FixVerification/FixAttestation types + renderFixVerification. applyFixes is unchanged and still exported. - src/index.ts: --fix now runs the verified loop and prints the verdict + attestation; logs the outcome. - README: documents verify-after-fix. 5 new tests (kept-on-improve, revert-on-score-regression, revert-on-new-critical, no-op-no-rescan, render). Full suite 1868 green, tsc + eslint clean. CLI smoke: 8 fixes applied + verified + attested on the vulnerable example. OSS gets verify-after-fix locally; the hosted ecc-tools App uses the same primitive to open an autofix PR with before/after evidence.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
|
Note
|
| Layer / File(s) | Summary |
|---|---|
Verification types, applyFixesVerified, and renderFixVerification src/fixer/index.ts |
Adds createHash/Severity imports; defines IntroducedFinding, FixAttestation, FixVerification, and VerifyFixesOptions interfaces; implements applyFixesVerified with file snapshot, rescan, regression detection, best-effort rollback, and sha256 attestation; adds renderFixVerification for formatted verdict output. |
CLI --fix phase integration src/index.ts |
Replaces applyFixes/renderFixSummary imports with applyFixesVerified/renderFixVerification; computes pre-fix numeric score; calls applyFixesVerified with scan/calculateScore callbacks; logs revert reason or verification summary. |
Tests and README docs tests/fixer/fixer.test.ts, README.md |
Expands fixer test imports and adds five test cases covering verified success, score-regression rollback, new-high/critical-finding rollback, no-op verified path, and renderFixVerification output assertions; documents the verify-after-fix behavior in the Auto-Fix Engine (--fix) README section. |
Estimated code review effort
🎯 3 (Moderate) | ⏱️ ~25 minutes
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
| Check name | Status | Explanation |
|---|---|---|
| Description Check | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled. |
| Title check | ✅ Passed | The title 'feat(fixer): verify-after-fix loop with rollback and attestation' directly and accurately summarizes the main changes across the PR—introducing a verification loop for fixes with rollback capability and attestation generation. |
| Docstring Coverage | ✅ Passed | No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. |
| Linked Issues check | ✅ Passed | Check skipped because no linked issues were found for this pull request. |
| Out of Scope Changes check | ✅ Passed | Check skipped because no linked issues were found for this pull request. |
✏️ Tip: You can configure your own custom pre-merge checks in the settings.
✨ Finishing Touches
📝 Generate docstrings
- Create stacked PR
- Commit on current branch
🧪 Generate unit tests (beta)
- Create PR with unit tests
- Commit unit tests in branch
feat/autofix-verification-loop
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands.
| const scoreBefore = calculateScore(result).score.numericScore; | ||
| const fixVerification = applyFixesVerified(result, { | ||
| scoreBefore, | ||
| rescan: () => scan(targetPath), | ||
| score: (rescanned) => calculateScore(rescanned).score.numericScore, |
There was a problem hiding this comment.
Unfiltered
result used instead of filteredResult
The original applyFixes(filteredResult) call respected the --min-severity filter; this rewrite passes the raw result. When a user runs with --min-severity high, the report only shows high/critical findings, but applyFixesVerified will now attempt to fix medium/low findings that were filtered out of the report — applying changes the user never saw and didn't consent to.
Additionally, calculateScore(result) computes a different numeric score than report.score.numericScore (which is derived from filteredResult), so scoreBefore in the attestation will diverge from the score that was printed to the user, making the "score held" guarantee misleading.
| if (regressed) { | ||
| for (const [filePath, original] of snapshots) { | ||
| try { | ||
| writeFileSync(filePath, original, "utf-8"); | ||
| } catch { | ||
| // Best-effort revert; surfaced via reason below. | ||
| } | ||
| } | ||
| const reason = | ||
| scoreAfter < scoreBefore | ||
| ? `score regressed ${scoreBefore} -> ${scoreAfter}` | ||
| : `introduced ${introducedHigh.length} new high/critical finding(s)`; | ||
| return { | ||
| result, | ||
| verified: false, | ||
| reverted: true, | ||
| scoreBefore, | ||
| scoreAfter, | ||
| resolvedFindingIds: [], | ||
| introducedFindings, | ||
| reason, | ||
| attestation: buildAttestation({ | ||
| tool: "agentshield", | ||
| version, | ||
| scoreBefore, | ||
| scoreAfter, | ||
| fixesApplied: 0, | ||
| findingsResolved: 0, | ||
| findingsIntroduced: introducedFindings.length, | ||
| verified: false, | ||
| }), | ||
| }; |
There was a problem hiding this comment.
Silent revert failure leaves disk state unknown but reports
reverted: true
When any writeFileSync call in the revert loop throws (e.g., permission error, read-only filesystem), the exception is swallowed and the function still returns reverted: true. The caller — including the attestation and the log line in src/index.ts — will report a successful rollback when one or more files are actually still in their post-fix state. The reason field only explains why the revert was triggered (score regression or new finding), not whether it succeeded, so there is no signal to surface partial failures to the user.
| // Identity of a finding that survives the index churn caused by editing a file. | ||
| function fingerprintFinding(finding: Finding): string { | ||
| return `${finding.file}|${finding.severity}|${finding.title}`; | ||
| } |
There was a problem hiding this comment.
Fingerprint collisions suppress introduced-finding detection
file|severity|title collapses all occurrences of the same issue type in the same file to a single bucket. If a fix for finding A in foo.ts produces a second, independent occurrence of the same rule (same severity + title, different position), beforePrints already contains that fingerprint and the new occurrence will not appear in introducedFindings. The regression guard therefore cannot fire for this common class of transform side-effect, silently defeating the protection the verify loop exists to provide.
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@README.md`:
- Around line 362-363: The documentation for the Verify-after-fix feature in the
README.md at the "Verify-after-fix" section currently makes a hard guarantee
that all modified files are rolled back when fixes cause regression, but the
actual implementation performs best-effort rollback per file which can fail.
Soften the language in that section from "it rolls every modified file back" to
"it attempts to roll back modified files" (or similar wording) to accurately
reflect that the rollback is best-effort and individual files may fail to
revert, rather than guaranteeing complete rollback.
In `@src/fixer/index.ts`:
- Around line 275-280: The `introducedFindings` variable is currently typed as a
regular array but should be typed as `ReadonlyArray` to comply with the
immutability guideline since it is constructed immutably through filter and map
operations and is never mutated. Change the type annotation of
`introducedFindings` from `IntroducedFinding[]` to
`ReadonlyArray<IntroducedFinding>` to indicate that this array should not be
modified.
- Around line 284-316: In the regressed condition block where writeFileSync is
called within the snapshots loop, track which file paths fail to revert by
collecting them instead of silently discarding the error in the catch block.
After the loop completes, append the list of failed file paths to the reason
string so that the returned object with reverted: true accurately reflects which
files on disk were not actually reverted, giving the caller visibility into the
partial failure state.
- Around line 205-320: The fingerprintFinding function uses a simplified key of
file, severity, and title, which can collide when multiple findings share these
attributes, causing the Set-based deduplication at lines 273-274 to incorrectly
filter out introduced high/critical findings and under-report resolved findings.
Update the fingerprintFinding function to include the unique id field from the
Finding object (and optionally evidence hash) to match the robust fingerprinting
approach used in src/fingerprint.ts, ensuring each finding has a truly unique
identifier regardless of shared title, severity, or file attributes.
In `@tests/fixer/fixer.test.ts`:
- Around line 268-283: Replace the custom `secretFinding()` function with the
shared test factory helper `makeFinding()` to centralize finding shape
construction and maintain consistency with the test suite's coding guidelines.
Identify where `secretFinding()` is called in the test file and replace those
calls with `makeFinding()`, passing the appropriate parameters such as filePath
and the specific finding properties (id, severity, category, title, description,
file, and fix details). Remove the `secretFinding()` function definition
entirely once all calls have been migrated to use `makeFinding()`.
- Around line 285-353: The test cases are passing absolute file paths to the
secretFinding function for the finding.file property, but the scanner contract
returns relative paths that should be combined with a target path. In the three
test cases ("keeps fixes and emits a verified attestation when the score
improves", "rolls the file back when the re-scan score regresses", and "reverts
when a fix would introduce a new high/critical finding"), keep creating the
temporary files at their absolute paths but change the secretFinding calls to
use relative paths (like just the filename "CLAUDE.md" or "settings.json")
instead of the full filePath. This will better simulate the actual scanner
behavior and catch regressions in path handling inside applyFixesVerified.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 5714466a-6026-4dfb-b91a-9d50ddde4772
📒 Files selected for processing (4)
README.mdsrc/fixer/index.tssrc/index.tstests/fixer/fixer.test.ts
| **Verify-after-fix:** `--fix` does not trust itself. After applying fixes it re-scans the target, and if the posture score regressed or a new high/critical finding appeared (the kind of churn a naive permission tighten can cause), it rolls every modified file back to its original content. On success it prints a tamper-evident attestation digest binding the before/after score and finding deltas, so the kept fixes are provably non-regressing. | ||
|
|
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Avoid a hard rollback guarantee in docs.
Line 362 currently states all modified files are rolled back, but the implementation reverts files in a best-effort loop and can fail per-file. Please soften this to “attempts to roll back modified files” (or equivalent) to match runtime behavior.
Suggested wording
-**Verify-after-fix:** `--fix` does not trust itself. After applying fixes it re-scans the target, and if the posture score regressed or a new high/critical finding appeared (the kind of churn a naive permission tighten can cause), it rolls every modified file back to its original content. On success it prints a tamper-evident attestation digest binding the before/after score and finding deltas, so the kept fixes are provably non-regressing.
+**Verify-after-fix:** `--fix` does not trust itself. After applying fixes it re-scans the target, and if the posture score regressed or a new high/critical finding appeared (the kind of churn a naive permission tighten can cause), it attempts to roll modified files back to their original content. On success it prints a tamper-evident attestation digest binding the before/after score and finding deltas, so the kept fixes are provably non-regressing.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| **Verify-after-fix:** `--fix` does not trust itself. After applying fixes it re-scans the target, and if the posture score regressed or a new high/critical finding appeared (the kind of churn a naive permission tighten can cause), it rolls every modified file back to its original content. On success it prints a tamper-evident attestation digest binding the before/after score and finding deltas, so the kept fixes are provably non-regressing. | |
| **Verify-after-fix:** `--fix` does not trust itself. After applying fixes it re-scans the target, and if the posture score regressed or a new high/critical finding appeared (the kind of churn a naive permission tighten can cause), it attempts to roll modified files back to their original content. On success it prints a tamper-evident attestation digest binding the before/after score and finding deltas, so the kept fixes are provably non-regressing. |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@README.md` around lines 362 - 363, The documentation for the Verify-after-fix
feature in the README.md at the "Verify-after-fix" section currently makes a
hard guarantee that all modified files are rolled back when fixes cause
regression, but the actual implementation performs best-effort rollback per file
which can fail. Soften the language in that section from "it rolls every
modified file back" to "it attempts to roll back modified files" (or similar
wording) to accurately reflect that the rollback is best-effort and individual
files may fail to revert, rather than guaranteeing complete rollback.
| // Identity of a finding that survives the index churn caused by editing a file. | ||
| function fingerprintFinding(finding: Finding): string { | ||
| return `${finding.file}|${finding.severity}|${finding.title}`; | ||
| } | ||
|
|
||
| function buildAttestation(fields: Omit<FixAttestation, "digest">): FixAttestation { | ||
| const digest = | ||
| "sha256:" + createHash("sha256").update(JSON.stringify(fields)).digest("hex"); | ||
| return { ...fields, digest }; | ||
| } | ||
|
|
||
| /** | ||
| * Apply auto-fixes, then re-scan and verify the fixes did not regress posture. | ||
| * | ||
| * The loop closes the gap that made naive auto-fix risky (issue #102 showed a | ||
| * permission tighten that the scanner then re-flagged): we snapshot every file | ||
| * before writing, apply fixes, re-scan, and roll everything back if the score | ||
| * dropped or a new high/critical finding appeared. On success it emits a | ||
| * tamper-evident attestation (OSS verify-after-fix; the hosted ecc-tools App | ||
| * uses the same primitive to open an autofix PR with before/after evidence). | ||
| */ | ||
| export function applyFixesVerified( | ||
| scanResult: ScanResult, | ||
| options: VerifyFixesOptions | ||
| ): FixVerification { | ||
| const { scoreBefore, rescan, score, version = "unknown" } = options; | ||
|
|
||
| // Snapshot originals BEFORE applyFixes writes, so a regression can be undone. | ||
| const snapshots = new Map<string, string>(); | ||
| for (const finding of getAutoFixableFindings(scanResult.findings)) { | ||
| const filePath = resolve(scanResult.target.path, finding.file); | ||
| if (!snapshots.has(filePath)) { | ||
| try { | ||
| snapshots.set(filePath, readFileSync(filePath, "utf-8")); | ||
| } catch { | ||
| // Unreadable files are reported as skipped by applyFixes below. | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const result = applyFixes(scanResult); | ||
|
|
||
| // Nothing was written: trivially verified, no re-scan needed. | ||
| if (result.applied.length === 0) { | ||
| return { | ||
| result, | ||
| verified: true, | ||
| reverted: false, | ||
| scoreBefore, | ||
| scoreAfter: scoreBefore, | ||
| resolvedFindingIds: [], | ||
| introducedFindings: [], | ||
| attestation: buildAttestation({ | ||
| tool: "agentshield", | ||
| version, | ||
| scoreBefore, | ||
| scoreAfter: scoreBefore, | ||
| fixesApplied: 0, | ||
| findingsResolved: 0, | ||
| findingsIntroduced: 0, | ||
| verified: true, | ||
| }), | ||
| }; | ||
| } | ||
|
|
||
| const after = rescan(); | ||
| const scoreAfter = score(after); | ||
|
|
||
| const beforePrints = new Set(scanResult.findings.map(fingerprintFinding)); | ||
| const afterPrints = new Set(after.findings.map(fingerprintFinding)); | ||
| const introducedFindings: IntroducedFinding[] = after.findings | ||
| .filter((f) => !beforePrints.has(fingerprintFinding(f))) | ||
| .map((f) => ({ id: f.id, severity: f.severity, title: f.title, file: f.file })); | ||
| const introducedHigh = introducedFindings.filter( | ||
| (f) => f.severity === "critical" || f.severity === "high" | ||
| ); | ||
|
|
||
| const regressed = scoreAfter < scoreBefore || introducedHigh.length > 0; | ||
|
|
||
| if (regressed) { | ||
| for (const [filePath, original] of snapshots) { | ||
| try { | ||
| writeFileSync(filePath, original, "utf-8"); | ||
| } catch { | ||
| // Best-effort revert; surfaced via reason below. | ||
| } | ||
| } | ||
| const reason = | ||
| scoreAfter < scoreBefore | ||
| ? `score regressed ${scoreBefore} -> ${scoreAfter}` | ||
| : `introduced ${introducedHigh.length} new high/critical finding(s)`; | ||
| return { | ||
| result, | ||
| verified: false, | ||
| reverted: true, | ||
| scoreBefore, | ||
| scoreAfter, | ||
| resolvedFindingIds: [], | ||
| introducedFindings, | ||
| reason, | ||
| attestation: buildAttestation({ | ||
| tool: "agentshield", | ||
| version, | ||
| scoreBefore, | ||
| scoreAfter, | ||
| fixesApplied: 0, | ||
| findingsResolved: 0, | ||
| findingsIntroduced: introducedFindings.length, | ||
| verified: false, | ||
| }), | ||
| }; | ||
| } | ||
|
|
||
| const resolvedFindingIds = scanResult.findings | ||
| .filter((f) => !afterPrints.has(fingerprintFinding(f))) | ||
| .map((f) => f.id); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Look for rules/scanners that can emit multiple findings per file with identical title+severity
rg -nP --type=ts -C3 '\bpush\s*\(\s*\{' src/scanner src/rules 2>/dev/null | rg -n 'title|severity|id' -C2
# Inspect test fixtures for duplicate (file,severity,title) findings
fd -e ts . tests | xargs rg -nP -C2 '\bseverity:\s*"(high|critical)"' 2>/dev/null | head -50Repository: affaan-m/agentshield
Length of output: 48027
🏁 Script executed:
# Read the actual fingerprintFinding implementation
sed -n '205,215p' src/fixer/index.tsRepository: affaan-m/agentshield
Length of output: 582
🏁 Script executed:
# Look at the test that checks fingerprinting behavior
sed -n '60,75p' tests/watch/diff.test.tsRepository: affaan-m/agentshield
Length of output: 896
🏁 Script executed:
# Search for examples where a single file+rule could generate multiple findings with identical title
rg -n "findAllMatches|findAllHookMatches" src/rules --type=ts -A3 -B3 | head -80Repository: affaan-m/agentshield
Length of output: 4746
🏁 Script executed:
# Look at the test setup and makeFinding function
sed -n '1,80p' tests/watch/diff.test.tsRepository: affaan-m/agentshield
Length of output: 2606
🏁 Script executed:
# Search for makeFinding definition and imports
rg -n "makeFinding|fingerprintFinding" tests/watch/diff.test.ts -B5 -A5 | head -60Repository: affaan-m/agentshield
Length of output: 2316
🏁 Script executed:
# Check test helper/fixture file for makeFinding
fd -e ts . tests | xargs grep -l "function makeFinding"Repository: affaan-m/agentshield
Length of output: 398
🏁 Script executed:
# Check if fingerprintFinding exists in src/watch/diff.js
grep -n "fingerprintFinding" src/watch/diff.jsRepository: affaan-m/agentshield
Length of output: 209
🏁 Script executed:
# Check the actual implementation of fingerprintFinding in src/watch/diff.js
sed -n '1,100p' src/watch/diff.js | grep -A10 "fingerprintFinding"Repository: affaan-m/agentshield
Length of output: 219
🏁 Script executed:
# Now verify the fixer version uses file|severity|title like the review says
grep -n "fingerprintFinding" src/fixer/index.tsRepository: affaan-m/agentshield
Length of output: 503
🏁 Script executed:
# Search for scenarios where multiple findings can have same file, severity, title in a single scan
# Look for rules with multiple findings per file and check if they use idx/match.index properly
rg -n "findings.push" src/rules/secrets.ts -B5 | grep -E "(title|id|idx|match.index)" -A2 -B2 | head -100Repository: affaan-m/agentshield
Length of output: 923
🏁 Script executed:
# The test imports from src/watch/diff.js but it's likely .ts, check both
fd "diff" src/watch/ --type=fRepository: affaan-m/agentshield
Length of output: 176
🏁 Script executed:
# Check if there's a separate fingerprintFinding in src/watch/
rg -n "fingerprintFinding" src/watch/ -A5Repository: affaan-m/agentshield
Length of output: 1991
🏁 Script executed:
# Let me see the full context of the fixer version - check if it's used in applyFixesVerified
sed -n '205,330p' src/fixer/index.tsRepository: affaan-m/agentshield
Length of output: 4212
🏁 Script executed:
# Check src/fingerprint.js to see the actual implementation there
cat src/fingerprint.ts 2>/dev/null || cat src/fingerprint.js 2>/dev/null || fd fingerprint src/Repository: affaan-m/agentshield
Length of output: 845
🏁 Script executed:
# Verify: can a single rule emit multiple findings with identical title in one file?
# Check if titles are dynamically generated or static per rule
rg -n "title:" src/rules/secrets.ts | head -20Repository: affaan-m/agentshield
Length of output: 871
🏁 Script executed:
# Check if different rules can produce the same title
rg -n "title:" src/rules/ --type=ts | cut -d: -f3- | sort | uniq -d | head -10Repository: affaan-m/agentshield
Length of output: 408
🏁 Script executed:
# Most importantly: can the same title+severity+file combination appear from different rules/matches?
# Check if prompt-defense rule generates multiple findings with same title
sed -n '160,180p' src/rules/prompt-defense.tsRepository: affaan-m/agentshield
Length of output: 744
The review correctly identifies a fingerprint collision vulnerability in the fixer, but the implementation details differ from what was stated.
The fingerprintFinding in src/fixer/index.ts (line 206) keys on file|severity|title as claimed. However, this is a local definition separate from the canonical src/fingerprint.ts, which uses id::file::evidence-hash (correctly including the unique id and evidence).
The fixer's simplified fingerprint can indeed collide when two findings share the same file, severity, and title. While rule titles are typically dynamic (including pattern names or variable content), the collision risk exists. For example, if a rule generates multiple findings per file with identical titles and severity, or if two rules emit findings with the same title+severity+file combination, the Set-based deduplication at lines 273–274 will mask one finding. This means:
- An introduced high/critical finding matching a pre-existing fingerprint is silently filtered out (line 276)
resolvedFindingIdsmay under-report fixed findings if multiple findings share a fingerprint (line 319)
The discrepancy between the fixer's simplified fingerprint and the watch module's correct id::file::evidence fingerprint suggests the fixer should adopt the same robust approach: include id and/or evidence to ensure uniqueness per finding.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/fixer/index.ts` around lines 205 - 320, The fingerprintFinding function
uses a simplified key of file, severity, and title, which can collide when
multiple findings share these attributes, causing the Set-based deduplication at
lines 273-274 to incorrectly filter out introduced high/critical findings and
under-report resolved findings. Update the fingerprintFinding function to
include the unique id field from the Finding object (and optionally evidence
hash) to match the robust fingerprinting approach used in src/fingerprint.ts,
ensuring each finding has a truly unique identifier regardless of shared title,
severity, or file attributes.
| const introducedFindings: IntroducedFinding[] = after.findings | ||
| .filter((f) => !beforePrints.has(fingerprintFinding(f))) | ||
| .map((f) => ({ id: f.id, severity: f.severity, title: f.title, file: f.file })); | ||
| const introducedHigh = introducedFindings.filter( | ||
| (f) => f.severity === "critical" || f.severity === "high" | ||
| ); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Type introducedFindings as ReadonlyArray.
The array is built immutably via filter().map() and never mutated, so it can satisfy the immutability guideline without any logic change.
♻️ Proposed change
- const introducedFindings: IntroducedFinding[] = after.findings
+ const introducedFindings: ReadonlyArray<IntroducedFinding> = after.findings
.filter((f) => !beforePrints.has(fingerprintFinding(f)))
.map((f) => ({ id: f.id, severity: f.severity, title: f.title, file: f.file }));📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const introducedFindings: IntroducedFinding[] = after.findings | |
| .filter((f) => !beforePrints.has(fingerprintFinding(f))) | |
| .map((f) => ({ id: f.id, severity: f.severity, title: f.title, file: f.file })); | |
| const introducedHigh = introducedFindings.filter( | |
| (f) => f.severity === "critical" || f.severity === "high" | |
| ); | |
| const introducedFindings: ReadonlyArray<IntroducedFinding> = after.findings | |
| .filter((f) => !beforePrints.has(fingerprintFinding(f))) | |
| .map((f) => ({ id: f.id, severity: f.severity, title: f.title, file: f.file })); | |
| const introducedHigh = introducedFindings.filter( | |
| (f) => f.severity === "critical" || f.severity === "high" | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/fixer/index.ts` around lines 275 - 280, The `introducedFindings` variable
is currently typed as a regular array but should be typed as `ReadonlyArray` to
comply with the immutability guideline since it is constructed immutably through
filter and map operations and is never mutated. Change the type annotation of
`introducedFindings` from `IntroducedFinding[]` to
`ReadonlyArray<IntroducedFinding>` to indicate that this array should not be
modified.
Source: Coding guidelines
| if (regressed) { | ||
| for (const [filePath, original] of snapshots) { | ||
| try { | ||
| writeFileSync(filePath, original, "utf-8"); | ||
| } catch { | ||
| // Best-effort revert; surfaced via reason below. | ||
| } | ||
| } | ||
| const reason = | ||
| scoreAfter < scoreBefore | ||
| ? `score regressed ${scoreBefore} -> ${scoreAfter}` | ||
| : `introduced ${introducedHigh.length} new high/critical finding(s)`; | ||
| return { | ||
| result, | ||
| verified: false, | ||
| reverted: true, | ||
| scoreBefore, | ||
| scoreAfter, | ||
| resolvedFindingIds: [], | ||
| introducedFindings, | ||
| reason, | ||
| attestation: buildAttestation({ | ||
| tool: "agentshield", | ||
| version, | ||
| scoreBefore, | ||
| scoreAfter, | ||
| fixesApplied: 0, | ||
| findingsResolved: 0, | ||
| findingsIntroduced: introducedFindings.length, | ||
| verified: false, | ||
| }), | ||
| }; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Rollback failures are silently swallowed, leaving files modified while reporting reverted: true.
If writeFileSync throws during the rollback loop (Line 287-290), the error is caught and discarded. The function still returns reverted: true with a regression reason, but the file remains in its (rejected, potentially less-secure) fixed state on disk. The caller and attestation then assert a clean revert that did not actually happen.
Track per-file revert success and surface partial failures (e.g. append failed paths to reason, or set a flag) so the report reflects on-disk reality.
🛡️ Sketch: track failed reverts
if (regressed) {
+ const revertFailures: string[] = [];
for (const [filePath, original] of snapshots) {
try {
writeFileSync(filePath, original, "utf-8");
} catch {
- // Best-effort revert; surfaced via reason below.
+ revertFailures.push(filePath);
}
}
const reason =
scoreAfter < scoreBefore
? `score regressed ${scoreBefore} -> ${scoreAfter}`
: `introduced ${introducedHigh.length} new high/critical finding(s)`;
+ const fullReason =
+ revertFailures.length > 0
+ ? `${reason}; WARNING: ${revertFailures.length} file(s) could not be reverted`
+ : reason;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/fixer/index.ts` around lines 284 - 316, In the regressed condition block
where writeFileSync is called within the snapshots loop, track which file paths
fail to revert by collecting them instead of silently discarding the error in
the catch block. After the loop completes, append the list of failed file paths
to the reason string so that the returned object with reverted: true accurately
reflects which files on disk were not actually reverted, giving the caller
visibility into the partial failure state.
| function secretFinding(filePath: string) { | ||
| return { | ||
| id: "SEC-001", | ||
| severity: "critical" as const, | ||
| category: "secrets" as const, | ||
| title: "Hardcoded Anthropic key", | ||
| description: "Found key", | ||
| file: filePath, | ||
| fix: { | ||
| description: "Use env var", | ||
| before: "sk-ant-api03-abc123xyz", | ||
| after: "${ANTHROPIC_API_KEY}", | ||
| auto: true, | ||
| }, | ||
| }; | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Use shared test factories instead of bespoke finding builders.
secretFinding() duplicates finding-shape construction in this test. Please switch this suite to the shared factory helpers to keep fixture shape centralized and resilient to schema evolution.
As per coding guidelines, tests/**/*.test.ts: “Tests should use helper factories like makeFinding() and makeSettings() for test data creation.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/fixer/fixer.test.ts` around lines 268 - 283, Replace the custom
`secretFinding()` function with the shared test factory helper `makeFinding()`
to centralize finding shape construction and maintain consistency with the test
suite's coding guidelines. Identify where `secretFinding()` is called in the
test file and replace those calls with `makeFinding()`, passing the appropriate
parameters such as filePath and the specific finding properties (id, severity,
category, title, description, file, and fix details). Remove the
`secretFinding()` function definition entirely once all calls have been migrated
to use `makeFinding()`.
Source: Coding guidelines
| it("keeps fixes and emits a verified attestation when the score improves", () => { | ||
| const dir = createTempDir(); | ||
| const filePath = join(dir, "CLAUDE.md"); | ||
| writeFileSync(filePath, "key: sk-ant-api03-abc123xyz"); | ||
|
|
||
| const verification = applyFixesVerified(makeScanResult([secretFinding(filePath)]), { | ||
| scoreBefore: 40, | ||
| rescan: () => makeScanResult([]), // re-scan finds nothing | ||
| score: () => 100, | ||
| version: "1.4.0", | ||
| }); | ||
|
|
||
| expect(verification.verified).toBe(true); | ||
| expect(verification.reverted).toBe(false); | ||
| expect(verification.scoreAfter).toBe(100); | ||
| expect(verification.resolvedFindingIds).toContain("SEC-001"); | ||
| expect(readFileSync(filePath, "utf-8")).toBe("key: ${ANTHROPIC_API_KEY}"); | ||
| expect(verification.attestation.digest).toMatch(/^sha256:[0-9a-f]{64}$/); | ||
| expect(verification.attestation.verified).toBe(true); | ||
| expect(verification.attestation.fixesApplied).toBe(1); | ||
| }); | ||
|
|
||
| it("rolls the file back when the re-scan score regresses", () => { | ||
| const dir = createTempDir(); | ||
| const filePath = join(dir, "CLAUDE.md"); | ||
| const original = "key: sk-ant-api03-abc123xyz"; | ||
| writeFileSync(filePath, original); | ||
|
|
||
| const verification = applyFixesVerified(makeScanResult([secretFinding(filePath)]), { | ||
| scoreBefore: 80, | ||
| rescan: () => makeScanResult([]), | ||
| score: () => 50, // worse than before -> revert | ||
| version: "1.4.0", | ||
| }); | ||
|
|
||
| expect(verification.verified).toBe(false); | ||
| expect(verification.reverted).toBe(true); | ||
| expect(verification.reason).toMatch(/regressed 80 -> 50/); | ||
| expect(readFileSync(filePath, "utf-8")).toBe(original); // restored | ||
| expect(verification.attestation.verified).toBe(false); | ||
| expect(verification.attestation.fixesApplied).toBe(0); | ||
| }); | ||
|
|
||
| it("reverts when a fix would introduce a new high/critical finding (issue #102 shape)", () => { | ||
| const dir = createTempDir(); | ||
| const filePath = join(dir, "settings.json"); | ||
| writeFileSync(filePath, "key: sk-ant-api03-abc123xyz"); | ||
|
|
||
| const introduced = { | ||
| id: "PERM-NEW", | ||
| severity: "critical" as const, | ||
| category: "permissions" as const, | ||
| title: "Newly flagged deny rule", | ||
| description: "introduced by the fix", | ||
| file: filePath, | ||
| }; | ||
|
|
||
| const verification = applyFixesVerified(makeScanResult([secretFinding(filePath)]), { | ||
| scoreBefore: 70, | ||
| rescan: () => makeScanResult([introduced]), | ||
| score: () => 70, // same score, but a new critical appeared | ||
| version: "1.4.0", | ||
| }); | ||
|
|
||
| expect(verification.reverted).toBe(true); | ||
| expect(verification.reason).toMatch(/new high\/critical/); | ||
| expect(verification.introducedFindings.some((f) => f.id === "PERM-NEW")).toBe(true); | ||
| expect(readFileSync(filePath, "utf-8")).toBe("key: sk-ant-api03-abc123xyz"); | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
Use relative finding.file fixtures to match scanner contracts.
These cases pass absolute paths into finding.file (for example Line 290 and Line 342), which bypasses realistic target.path + finding.file resolution behavior. Using relative paths here would better catch regressions in path handling inside applyFixesVerified.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/fixer/fixer.test.ts` around lines 285 - 353, The test cases are passing
absolute file paths to the secretFinding function for the finding.file property,
but the scanner contract returns relative paths that should be combined with a
target path. In the three test cases ("keeps fixes and emits a verified
attestation when the score improves", "rolls the file back when the re-scan
score regresses", and "reverts when a fix would introduce a new high/critical
finding"), keep creating the temporary files at their absolute paths but change
the secretFinding calls to use relative paths (like just the filename
"CLAUDE.md" or "settings.json") instead of the full filePath. This will better
simulate the actual scanner behavior and catch regressions in path handling
inside applyFixesVerified.
|
Local review recommendation: request changes before merge. The verify-after-fix flow appears to mix |
Closes the gap that made naive auto-fix risky:
--fixnow proves it did not make things worse, and rolls back if it did. This is the activation moment that makes the free CLI trustworthy and seeds the paid managed-remediation (autofix-as-PR) workflow.What
applyFixesVerifiedsnapshots every file before writing, applies fixes, re-scans, and:On success it emits a tamper-evident attestation (
sha256over the before/after score and finding deltas), so kept fixes are provably non-regressing — the primitive the hosted ecc-tools App reuses to open an autofix PR with before/after evidence.Implementation
src/fixer/index.ts—applyFixesVerified(scanResult, { scoreBefore, rescan, score, version })withrescan/scoreinjected (clean to test, no circular import);FixVerification/FixAttestationtypes;renderFixVerification.applyFixesis unchanged and still exported.src/index.ts—--fixruns the verified loop, prints the verdict + attestation, and logs the outcome.README— documents verify-after-fix.Verification
tsc --noEmit+eslintclean.Summary by CodeRabbit
New Features
--fixflag now verifies fixes by re-scanning after auto-fix application. If security score regresses or new high/critical findings emerge, all changes are automatically rolled back. Successful fixes include a tamper-evident attestation summary.Tests
Greptile Summary
This PR adds a verify-after-fix loop to
--fix: before writing anything it snapshots every affected file, applies fixes, re-scans, and rolls back all changes if the posture score drops or a new high/critical finding appears, emitting a SHA-256 attestation on success. The dependency-injection design (rescan/scorecallbacks) keeps the fixer cleanly testable and avoids a circular import.src/fixer/index.tsgainsapplyFixesVerified,FixVerification,FixAttestation, andrenderFixVerification;applyFixesis untouched and still exported.src/index.tswires the verified path into the--fixbranch, but passes the unfilteredresultinstead offilteredResult, bypassing--min-severityfor both fix selection andscoreBefore; two silent correctness issues also exist in the rollback path.Confidence Score: 3/5
Not safe to merge as-is: the --fix path now applies fixes to findings that were filtered out by --min-severity, and a failed rollback returns success without any signal to the user that files are in an inconsistent state.
The
resultvsfilteredResultswap insrc/index.tsis a concrete behavioral regression — fixes are attempted on severity levels the user opted out of seeing, and the attestation's scoreBefore is computed from a different finding set than the one displayed in the report. Separately, the revert loop silently swallows write errors and returnsreverted: trueregardless, so a user can believe their files are restored when they are not. Both issues are on the hot path of the feature this PR exists to deliver.src/index.ts (unfiltered result passed to applyFixesVerified) and src/fixer/index.ts (silent revert failure and fingerprint collision).
Important Files Changed
resultinstead offilteredResult, bypassing --min-severity filtering for both fix selection and scoreBefore calculation.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A["--fix flag set"] --> B["calculateScore(result)\n→ scoreBefore"] B --> C["snapshot auto-fixable files\n(readFileSync each)"] C --> D["applyFixes(scanResult)\n→ FixResult"] D --> E{result.applied\n.length === 0?} E -- yes --> F["return verified=true\nno rescan needed"] E -- no --> G["rescan()\n→ ScanResult after"] G --> H["score(after)\n→ scoreAfter"] H --> I["compute introducedFindings\nvia fingerprintFinding diff"] I --> J{regressed?\nscore dropped OR\nnew high/critical?} J -- yes --> K["revert loop\nwriteFileSync each snapshot"] K --> L{write throws?} L -- yes --> M["silently swallowed\nreverted=true returned\n⚠ files may still be modified"] L -- no --> N["return reverted=true\nattestation verified=false"] J -- no --> O["compute resolvedFindingIds"] O --> P["return verified=true\nattestation with digest"] P --> Q["renderFixVerification\n→ console.log"] N --> Q F --> Q%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A["--fix flag set"] --> B["calculateScore(result)\n→ scoreBefore"] B --> C["snapshot auto-fixable files\n(readFileSync each)"] C --> D["applyFixes(scanResult)\n→ FixResult"] D --> E{result.applied\n.length === 0?} E -- yes --> F["return verified=true\nno rescan needed"] E -- no --> G["rescan()\n→ ScanResult after"] G --> H["score(after)\n→ scoreAfter"] H --> I["compute introducedFindings\nvia fingerprintFinding diff"] I --> J{regressed?\nscore dropped OR\nnew high/critical?} J -- yes --> K["revert loop\nwriteFileSync each snapshot"] K --> L{write throws?} L -- yes --> M["silently swallowed\nreverted=true returned\n⚠ files may still be modified"] L -- no --> N["return reverted=true\nattestation verified=false"] J -- no --> O["compute resolvedFindingIds"] O --> P["return verified=true\nattestation with digest"] P --> Q["renderFixVerification\n→ console.log"] N --> Q F --> QReviews (1): Last reviewed commit: "feat(fixer): verify-after-fix loop with ..." | Re-trigger Greptile