Skip to main content

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

One iteration:
  1. Scan the repo (white-box scan plus the fix recipes’ static probes) and, with --url, the running app.
  2. Triage: drop findings suppressed in memory or penthera.suppress.json, and rank the rest with memory (severity always first).
  3. Plan: a deterministic recipe first. The local model is asked only with --fix-model <name>, and only for findings no recipe matches.
  4. Patch on a local branch penthera/fix-<yyyymmdd-hhmm>, created from the current HEAD when the run starts.
  5. 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’s test script 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.
  6. Learn: scan, fix_applied and fix_verified or fix_failed go to memory, and gave_up when the loop could not fix a finding.
  7. 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).
Plans are applied one at a time and re-planned against the tree after each fix, since two recipes can touch the same file. The loop repeats until nothing fixable is left, --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 in tests/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, submodule and everything else are refused before a process starts, as are URL arguments and -c overrides. Every call also runs with protocol.allow=never and GIT_NO_LAZY_FETCH=1, so no transport can work. The loop never runs gh and never opens a pull request.
  • The repo’s hooks never run. Git runs with core.hooksPath=/dev/null, core.fsmonitor=false and commit --no-verify, because a scanned repo can ship hooks that run anything.
  • Network. The whole run shares one egress context with --offline always on, so the URL target is the only host. Loopback is added only when --fix-model is set, and the model client then accepts only http://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-dirty lets 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.* or include.* 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 from penthera 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. applyDiff refuses it with PROTECTED and 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 is n. --auto low applies a diff without asking only when it comes from a recipe (not the model), its confidence is not needs-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:
  1. 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.
  2. 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, and penthera 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:
It is held to a stricter standard than memory:
  • 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.
Treat a change to 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.