The local loop
Pro. For “how do I run the fix loop, and what does it remember?”Since v1.4, Penthera has a loop that runs entirely on your machine: scan, fix on a local branch, verify, record the outcome, repeat. Nothing in it talks to a Penthera server, because there isn’t one. This page covers the loop itself (
penthera agent --loop) and the local learning store it records into. The
store counts which fix recipes worked for which framework. It re-orders and
suppresses findings. It does not train a model.
The agent loop
- Scan the repo (white-box scan plus the fix recipes’ static probes) and,
with
--url, the running app. - Triage: drop findings suppressed in memory or
penthera.suppress.json, and rank the rest with memory (severity always first). - Plan: a deterministic recipe first. The local model is asked only with
--fix-model <name>, and only for findings no recipe matches. - Patch on a local branch
penthera/fix-<yyyymmdd-hhmm>, created from the current HEAD when the run starts. - Verify: re-scan. A fix passes when the findings it targeted are gone from
the repo scan, no new finding appeared, and, with
--run-tests, the project’stestscript exits 0 (node --run test). Verification uses the checker the run began with: no fix may edit tests, test config or Penthera’s own files (see the protected files), and if any of them changed since the run began, the fix fails without the tests being run. - Learn:
scan,fix_appliedandfix_verifiedorfix_failedgo to memory, andgave_upwhen the loop could not fix a finding. - Commit each verified fix on its own, with a message that lists the
findings, the recipe, and how it was verified. A fix that fails is reverted:
each file gets back the bytes it had before the fix, and files it created
are removed. A file that changed again after the fix was written (you saved
it meanwhile) is left alone and the fix is marked
conflict. The commit contains only the fix’s own files (git commit --only).
--max-iterations (default 5) is reached, or you stop it.
A fix you decline (n) is not proposed again in the same run; s skips it
until the next iteration.
The safe exit. “Could not fix” is a normal outcome, not something to work
around. When a fix fails verification, its diff is refused, or the local model
has no usable fix, the loop gives up on those findings for the rest of the run
and does not try them again. Each give-up is a gave_up event in memory with
the reason, is listed in loopRun.gaveUp, and is printed in the summary
(gave up on N (not retried this run; fix by hand): title (reason); ...).
A give-up never counts as a recipe failure, so neither the ranking nor
--auto low can learn to avoid it.
A live URL serves the code it was started with, so a fix to response headers
shows up in the URL scan only after the app restarts. The loop counts those as
liveRemaining in the fix’s verify block, not as a failure; the repo scan
decides whether the fix landed.
What it guarantees
Each of these has a test intests/agent.test.js.
- No remote operations. All git goes through
src/agent/git.js, an allow-list of local subcommands (rev-parse,symbolic-ref,status,diff,log,show-ref,checkout,add,commit,config --get).push,fetch,pull,remote,clone,ls-remote,submoduleand everything else are refused before a process starts, as are URL arguments and-coverrides. Every call also runs withprotocol.allow=neverandGIT_NO_LAZY_FETCH=1, so no transport can work. The loop never runsghand never opens a pull request. - The repo’s hooks never run. Git runs with
core.hooksPath=/dev/null,core.fsmonitor=falseandcommit --no-verify, because a scanned repo can ship hooks that run anything. - Network. The whole run shares one egress context with
--offlinealways on, so the URL target is the only host. Loopback is added only when--fix-modelis set, and the model client then accepts onlyhttp://127.0.0.1:11434. - Clean start. A dirty tree (untracked files included), a detached HEAD, a
repo without commits, or a directory that is not a git repo is refused.
--allow-dirtylets the loop start anyway, but it never patches a file that has uncommitted changes; this is checked again right before each write and each commit. Plans that touch untracked or git-ignored files are skipped. - Git. The loop’s git calls ignore hooks, fsmonitor, signing, and any
filter the repository configures, and treat paths literally. Writes are
refused when the repository’s own config sets
filter.*,gpg.*orinclude.*keys, and on git older than 2.31: the loop stops before its first write with one message naming the key and the way forward (move it to your global config, or apply fixes by hand frompenthera fix --plan). - The checker stays as it was. A fix diff, from a recipe or the model, may
not touch tests (
test/,tests/,__tests__/,*.test.*,*.spec.*,conftest.py), test runner config or the test script (vitest.*,jest.*,playwright.*,pytest.ini,pyproject.toml,setup.cfg,tox.ini,package.json), Penthera’s own files (penthera.*,audit/baseline.json,.penthera/),.github/workflows/, git hooks (.husky/,.githooks/, lefthook, pre-commit), lockfiles or.gitmodules, in any case.applyDiffrefuses it withPROTECTEDand the finding is given up. Before the tests run and again before the verdict, the loop checks that every protected file is as it was when the run began; if not, the fix fails. A real fix that needs a test change is yours to make by hand. - Approval. Every diff is shown, secrets masked, with
[y]es / [n]o / [s]kip / [q]uit. When input runs out (piped stdin), the answer isn.--auto lowapplies a diff without asking only when it comes from a recipe (not the model), its confidence is notneeds-human-review, memory’s chain verifies, and memory shows that recipe with at least 3 verified fixes and no failures for this framework. - Interruption. The first Ctrl-C lets the fix in flight finish: it is verified and committed, or reverted. The loop then stops on the fix branch, which holds only verified commits. A second Ctrl-C reverts the fix in flight at once and exits.
--run-tests runs the project’s own code, so it can do anything that code
does, including network calls. It is off by default for that reason.
The loopRun object
-o <file> writes { "loopRun": { ... } }. The fix timeline in the audience
reports (evidence.md) renders it.
Every step carries startedAt and durationMs.
obsolete means an earlier fix in the same iteration already covered the plan.
The same object, without testOutput and with finding ids in place of finding
objects, is appended to memory as a loop_run event.
Exit codes: 0 nothing fixable left, 1 fixable findings remain (declined,
failed, or out of iterations), 2 the loop could not start (dirty tree,
detached HEAD, bad options), 130 interrupted.
The learning store
Scans run with--memory record what happened, and the next scan uses that
record to rank and suppress findings.
<id> is the 16-character fid shown next to each finding in a --memory
report.
Where it lives
<project-id> is a SHA-256 of the repo’s absolute path plus its git remote URL,
or of the target origin for URL-only scans. Set PENTHERA_HOME to move the
root (the tests do). Penthera reads the remote from .git/config itself rather
than running git, because running git inside an untrusted repo can execute
that repo’s hooks or core.fsmonitor command.
What it records
One JSON event per line, append-only:hash = sha256(prev + canonical JSON of { v, ts, type, data }), with object
keys sorted. The first line’s prev is derived from the project id.
From these,
penthera memory show derives four views: recipe success rate per
framework, active suppressions, probe ranking by yield per stack, and median
time to fix (first seen in a scan to fix_verified).
How a scan uses it
Memory can do two things to a report, and nothing else:- Suppress a finding the user (or the loop, on the user’s behalf) marked as a false positive, with a reason. Suppressed findings move to a separate “Suppressed” section in the terminal and Markdown reports, each with who suppressed it and why. They do not count toward the exit code.
- Re-order findings within a severity: categories with a fix recipe that has worked on this framework come first, then findings that have shown up in more past scans. Severity is never changed and no finding is dropped.
Threat model
The learning store is an attack surface. An agent loop that learns from its own past is open to memory poisoning (OWASP Agentic Security Initiative, ASI06): if an attacker can write to what the loop remembers, they can steer what it does next. In Penthera’s case the two goals would be hiding a real finding and getting the loop to run something. The store is built so that neither can happen unnoticed.Why it never executes
Memory is data. No value read from it is ever executed, evaluated, imported, used as a file path, or passed to a shell. A recipe id in memory is only a string that gets counted. The recipe code lives in Penthera’s own source (lib/fix/) and is chosen by Penthera, not by the log. Every field a view reads
is type- and length-checked, and an event that fails a check is ignored rather
than interpreted. Strings shown in the terminal have control characters stripped,
so a stored reason cannot inject ANSI escape sequences.
The worst a fully poisoned memory can do is hide findings, and hidden findings
are listed, with their reason, in every report.
Why it is hash-chained
The chain makes tampering visible. Editing a line, reordering lines, deleting a line from the start or middle, or splicing in another project’s log all break the chain, andpenthera memory verify names the first broken line. head.json
records the count and hash of the last event, so cutting lines off the end is
caught too.
When the chain is broken, scans fail closed: memory suppressions and
ranking are not applied, nothing new is appended, and the scan prints a warning
telling you to run penthera memory verify. You can then inspect the log or
start over with penthera memory forget.
The limit: the chain is tamper-evident, not tamper-proof. Anyone who
can write to your home directory can rewrite the whole log and recompute every
hash. Keying the chain would not help, since the key would sit in the same home
directory. The chain is there to catch a partial edit, an accidental truncation,
or a tool that writes where it should not. It is not a defense against malware
already running as you.
Why it lives in your home directory
Memory is never read from the scanned repo. If it were, a repo could ship pre-poisoned memory, for example a suppression for the secret it is about to leak. Keeping memory under~/.penthera means only runs on your machine, as
you, write to it.
The one thing a repo can say: penthera.suppress.json
Teams do need to share decisions like “this key is a revoked test fixture”. For
that a repo may contain penthera.suppress.json:
- Every entry needs a reason. Entries without one, with a malformed id or date, or past their expiry are dropped with a warning.
- Every finding it hides is listed in the report as suppressed by repo file with the reason, so a reviewer sees both the file change in the PR and its effect in the report.
- It only applies with
--memory, and a local suppression for the same finding takes precedence.
penthera.suppress.json in a pull request like a change to
CI config: it decides what the scanner reports.
Concurrency
Two scans of the same project can run at once. Each append takes an exclusive lock file (events.lock, created with O_EXCL), reads the tip, and writes one
line with a single O_APPEND write. Lines never interleave and the chain never
forks. A lock older than 30 seconds is treated as stale and removed.