Skip to content

fix(security): the fix loop cannot game its own checker, and can give up - #72

Merged
danoszz merged 1 commit into
mainfrom
hardening/verifier-integrity
Oct 20, 2026
Merged

danoszz merged 1 commit into
mainfrom
hardening/verifier-integrity

Conversation

@danoszz

@danoszz danoszz commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Actions A and B from docs/research/agent-incidents-2026.md. This is hardening during the 1.8.0 freeze: please don't merge yet. The maintainer decides whether it goes into 1.8.0 or 1.9. No version bump.

A. Verifier integrity

applyDiff (lib/fix/apply.js, one list PROTECTED_FILES next to the old .gitmodules check) refuses any fix diff, from a local model or from a recipe, that touches the files below. The error is PROTECTED and tells the user to make that change by hand. Matching runs on canonical segments, so case changes, trailing dots and 8.3 aliases are refused too.

Protected Paths
Tests test/, tests/, __tests__/, *.test.*, *.spec.*, conftest.py
Test runner config / test script vitest.*, jest.*, playwright.*, pytest.ini, pyproject.toml, setup.cfg, tox.ini, package.json (holds scripts.test and jest config)
Penthera's own files penthera.* (policy, suppress, lock), audit/baseline.json, .penthera/
CI .github/workflows/
Git hooks .husky/, .githooks/, lefthook.yml, .pre-commit-config.yaml (.git/ was already refused)
Lockfiles npm, yarn, pnpm, bun, deno, poetry, pipenv, uv, pdm, cargo, go.sum, bundler, composer
Other .gitmodules, Windows 8.3 short names (X~1)

How verification is protected: the brief offered two ways. I picked the simpler one, refuse instead of restore. Fix diffs can no longer edit a protected file, and fix commits use --only with the fix's own files, so HEAD's judge always matches the base. At the start of the run the loop records every protected file that differs from baseSha (tracked diffs plus untracked files, with their hashes). It checks this snapshot again before the tests run and before the verdict. If anything changed, the fix fails and the tests are not run. Comparing against the start of the run, not against base alone, keeps --allow-dirty usable when the user already has their own edits to a test file.

When a model diff edits a protected file, model.plan now throws PROTECTED instead of returning null. planFixes reports this as refused, so fix prints it and the loop records it. Before, it was dropped silently, which also makes E3's tamper rate measurable.

B. Safe exit

  • A new memory event, gave_up ({ id, reason, source, loop }), records three cases: the fix failed verification, its diff was refused (PROTECTED, dirty file, …), or --fix-model gave no usable fix.
  • After one honest failure, the finding is dropped from planning for the rest of the run. The model is not asked about it again either (tested: exactly 1 model call across iterations).
  • buildViews ignores gave_up, so recipe success and --auto low never count it. A recipe's own verification failure still writes fix_failed as before. Only the give-up is neutral.
  • loopRun.gaveUp holds [{ fid, title, reason }], and the CLI prints gave up on N (not retried this run; fix by hand): title (reason); ….

Code: about 95 lines across lib/fix/{apply,index,model}.js, lib/memory/store.js, src/agent/loop.js and src/cli/{agent,fix}.js.

Tests

tests/fix.test.js › verifier integrity: a fix never edits its own checker

  • refuses every protected path with PROTECTED and says to make the change by hand (29 paths, including case variants and 8.3 aliases)
  • still lets a fix edit ordinary source files with similar names
  • refuses a model diff that edits a test file to make it pass, and reports it
  • holds a recipe's diff to the same list (defence in depth): a recipe that sets scripts.test to exit 0

tests/agent.test.js › agent loop: verifier integrity and the safe exit

  • refuses a model diff that edits a test file to make it pass, gives the finding up and records gave_up
  • does not retry a finding after one honest failure in a run
  • records a model that gives no usable fix as gave_up, once
  • never counts a give-up as a recipe failure in memory
  • does not verify a fix when tests changed during the run, and never runs them
  • prints what it gave up and why in the run summary

To check that the tests catch regressions, I turned off the judge snapshot and the give-up filter one at a time. The matching tests failed each time.

npm test (1021 passed), npm run lint and npm run audit:internal (every gate passed) all pass locally.

Notes for review

  • Older Penthera versions do not know the gave_up event type, so they mark a memory chain that contains one as malformed. They then fail closed: no ranking, no --auto. This is noted in the CHANGELOG.
  • package.json and pyproject.toml are protected as a whole, because each holds the test script or runner config. No recipe edits them today. Model fixes were already told not to add dependencies.
  • Docs updated: docs/commands/fix.md, docs/local-loop.md, and the Security section of CHANGELOG [Unreleased].

🤖 Generated with Claude Code

A. Verifier integrity. applyDiff refuses, with PROTECTED, any fix diff (model
or recipe) that touches tests, test runner config or the test script,
Penthera's own files (policy, suppressions, lock, audit/baseline.json,
.penthera/), CI workflows, git hooks, lockfiles or .gitmodules. One list,
next to the old .gitmodules check, on canonical segments. A model diff that
does this is reported as refused, not silently dropped. The loop also
refuses to verify a fix if any protected file changed since the run began,
checked before the tests run and again before the verdict.

B. Safe exit. "Could not fix" is recorded as a gave_up event with a reason
(verification failed, diff refused, or no usable model fix). The finding is
not retried in that run, gave_up never touches recipe success, and the run
summary lists what was given up and why.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@danoszz
danoszz merged commit 79eda99 into main Oct 20, 2026
16 checks passed
@danoszz
danoszz deleted the hardening/verifier-integrity branch October 20, 2026 17:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant