A small coding agent for small local models. A dependency-free agent
runtime for local models via Ollama. Stdlib only: no pip install, no
requests, no ollama package, no ripgrep.

A real run, unedited: qwen3-coder:30b renaming slugify to make_slug
across four files, then leaving the vendored copy in node_modules/ alone.
90 seconds of real time, played at 2.6x with long pauses clipped. The frames
are the bytes the agent actually wrote (how it was made).
It does not pass every time; the rates are in the tables below.
It is one loop:
model emits a structured tool call
-> we execute it ourselves
-> the result goes back as a `tool` message
-> the model gets another turn
Repeat until the model answers in prose or the step budget runs out. Everything
in agent/ exists to make that loop survive a 7B.
Small local coding models, 7B to 32B on one consumer GPU, fail in the agent loop in specific, repeatable ways: they read one file and give up, they loop on the same call forever, they emit tool calls as prose, they rename a symbol in one file and report the refactor done. Almost every fix in this project is a change to the environment, the tools, the errors, the loop, rather than to the prompt or the model.
That bet is measured rather than asserted. The 4,133 runs behind it are in
this repository, under evals/results/; not a summary of them, the runs
themselves, so any number quoted here can be recomputed rather than taken on
trust. The things that were built, measured, and removed for zero benefit
are written down next to the things that worked.
Same tasks, same local models, one harness, scored on disk. Full method, disclosures and caveats: docs/comparison.md.
On this project's own fixtures, 19 cases × 2 models, three reps per cell, 342 runs:
| shape | ours | aider-told | aider-find |
|---|---|---|---|
| single-file edits | 46/48 | 45/48 | 42/48 |
| cascades + repair | 61/66 | 11/66 | 10/66 |
| all | 107/114 | 56/114 | 52/114 |
On single-file edits the three arms are the same tool. The entire margin is cascades, where it is close to six to one.
On pallets/click at 12,674 lines, judged by its own 1991 tests. Three reps
per cell, 108 runs:
| ours | aider-told | aider-find | |
|---|---|---|---|
| qwen3-coder:30b | 6/18 | 9/18 | 9/18 |
| nemotron 32.9B | 12/18 | 3/18 | 4/18 |
| both | 18/36 | 12/36 | 13/36 |
Read the second table before the first, and read it by row. The fixture advantage does not transfer, and on real code the answer depends on which model you run:
Aggregated, that is 50% against 33%, which is parity with a wide spread, not a lead. At the level of individual tasks: of 12 cells, we win 4, aider wins 1, and 7 are ties (mostly tasks neither tool can do).
These outcomes are stable, not noisy. 33 of the 36 cells were unanimous across all three reps, so the failures are capability, not luck. That also means the earlier single-sample table happened to be right, which we could not have known without paying for the reps.
So the claim this project makes is narrow and specific:
src/ and
left the suite red in 5 of 6;Caveats belong next to the numbers, not under them: aider 0.86.2 at default settings by someone who does not use it daily, and a 900s timeout that 19 of its 72 real-repo runs hit, all on nemotron. Both tables are 3 reps per cell, 450 runs in total. The harness ships so any of that can be corrected and re-run.
Because the model runs locally through Ollama and nothing is sent anywhere, the agent can review code you are not allowed to paste into a hosted LLM — under an NDA, a client engagement, or a data-egress rule. That is a real niche a frontier model in the cloud cannot fill at any quality, and it is worth measuring rather than asserting, so there are two eval suites for it.
python3 -m evals.run --cases tag:security # whole-file review
python3 -m evals.run --cases tag:diffreview # review a staged change
Both score two numbers, because a reviewer that flags everything has perfect recall and is useless: how many real vulnerabilities it finds, and how often it invents one in code that is fine. The fixtures plant SQL injection, command injection, weak hashing, a hardcoded secret, a predictable token and a path traversal, alongside a control file that does the same kinds of things correctly.
The honest headline, on qwen3-coder:30b, nemotron-3.5-lightning and
qwen2.5-coder:7b: recall is the easy part — all three find every planted
bug and propose the right fix. The cost of a local reviewer is false positives:
on a whole file, the strongest model flagged a correct subprocess.run([...])
call as command injection — the argument-list form that is the fix for it. On a
diff, where the model reasons about what changed rather than pattern-matching
the whole file, all three stayed clean and still caught every introduced bug.
So it is a useful first pass that a human then reads, not an oracle — which is the honest claim for a local security reviewer, and the reproduction commands above let you check it rather than take it.
Reviewing code is one half of a security assessment. The other is pointing the tool at a running target you are authorized to test, finding the hole, and proving it — recovering something a normal request cannot reach. That runs on the same privacy argument as the reviewer: an engagement whose target you may not describe to a hosted LLM can still be tested by a model on your own hardware.
python3 -m evals.run --cases tag:webexploit --security # against the offline target
The target is an offline vulnerable app (evals/webexploit_fixture.py), served
through the shipped web tools exactly the way the docs fixture is: a real
loopback server under a reserved .test name, so the URL guard runs and nothing
touches the internet. Its flaws are genuine behaviour, not substrings it
recognises — the SQL injection is a real boolean parser broken open by a
balanced-quote payload, the IDOR is a genuinely missing ownership check, the
sensitive-data exposure is an endpoint that really answers anyone.
Each flaw guards a distinctive token that appears nowhere a normal request can
reach. A model that only describes SQL injection in the abstract cannot
produce zsqli-8842-leak; only one that sent the payload and read the response
can. Scoring on the recovered secret rather than on prose is what separates
"found and proved it" from "said the right word". A fourth case is the
false-positive control — a parameterised endpoint that must not be reported
as vulnerable, the sec-clean lesson applied to a live target.
The agent finds the hole and produces a proof-of-concept; a person runs it. That
is deliberate: bobbin has no shell by design (narrow affordances, no
run_command), so "exploit" here means craft the working request, not execute
arbitrary code. The --security flag appends a security-testing playbook
(prompts/security.md); it is off by default and measured as an arm, so the gap
between the two runs is what the playbook is worth.
The honest headline inverts the reviewer's. On review, recall was the easy part; on exploitation, recall is the hard part. Four cases × three models, one rep each, scored on the recovered token:
| exploit class | qwen3-coder:30b | nemotron-3.5-lightning | qwen2.5-coder:7b |
|---|---|---|---|
| SQL injection | ✅ (with --security) | ✅ | ✗ |
| IDOR | ✗ | ✗ | ✗ |
| exposure (forced browsing) | ✗ | ✗ | ✗ |
| clean control (no false positive) | ✅ | ✅¹ | ✅ |
In this first, three-class cut, only SQL injection lands, and only on the two
30B-class models — it is the one class the target hands a breadcrumb, an
error-based disclosure that shows the injection point. (That "only SQLi" headline
does not survive widening the suite — see Seven classes, not one below,
where the 30B models land most of them once two measurement confounds are
removed.) IDOR and forced-browsing exposure fail on every model here: they
need enumeration the small models do not sustain (the flagship, handed the order
URL outright, went off to read the local workspace instead; on exposure it tried
/.env and /admin but never guessed the real /api/debug/config). The 7B
never reaches the target at all — the same "searches the workspace, never
fetches" failure already on record for web-search-then-fetch.
A discoverability breadcrumb turns exposure from unguessable into a skill
test — and shows the skill is scarce. WEBEXPLOIT_ROBOTS=1 serves a
/robots.txt that discloses the hidden /api/debug/config (the classic
robots.txt information disclosure), off by default so the blind baseline stays
the shipped default. Measured one case × three models:
| exposure, with breadcrumb | qwen3-coder:30b | nemotron-3.5-lightning | qwen2.5-coder:7b |
|---|---|---|---|
| breadcrumb + default playbook | ✗ | ✗ | ✗ |
breadcrumb + --security | ✅ (3 calls) | ✗ | ✗ |
The breadcrumb alone does nothing: no model reads robots.txt unprompted
(0/3). Only when the playbook is told to read it first does the flagship pick it
up — and then it is textbook and fast: / → /robots.txt → /api/debug/config,
three calls, token recovered. nemotron over-probes to budget exhaustion even
with the hint, and the 7B still never reaches the target. So the lever works, but
like the rest of this suite it works on the flagship and not below it.
The --security playbook is a two-sided lever, not a free win: it flips SQL
injection on for qwen3-coder:30b (1/4 → 2/4), but pushes
nemotron-3.5-lightning into over-probing the safe endpoint until its step
budget runs out, losing the clean control it passed without the text (2/4 → 1/4,
the ¹). That is why it ships off and measured rather than on — the same verdict
the web playbook earned. One rep is a signal, not a result (see the noise
caveat the runner prints); the runs are on disk under evals/results/, rescored
with python3 -m evals.rescore --expectations after a clean-verdict pattern fix.
So this is a lead generator for a human tester on the class that leaks loudly, not an autonomous exploitation tool — the honest claim, with the target and the runs included so it can be checked rather than taken.
AGENT_PROGRESS_NUDGE)nemotron's most expensive habit is not a capability ceiling, it is not stopping.
On webexploit-sqli it recovered the token on the third call and then spent
eleven more on variations of the same endpoint; on webexploit-clean it probed
the safe endpoint a dozen ways and ran the budget out without ever giving the
verdict it had earned. The exact-argument repeat guard cannot see this — every
payload is a different URL. So there is a second guard keyed on the request
target (host+path, query dropped): once one endpoint has been hit past a
threshold, a one-time note says, in effect, you are repeating; if you have your
answer, give it. Off by default, web-scoped (the coding suites never see it),
counted in both arms.
Measured, it is a real but narrow win, and an honest one about where advice lands:
| nudge off | nudge on | |
|---|---|---|
nemotron sqli (passes) | 14 calls, budget exhausted | 4 calls |
nemotron clean (fails) | budget exhausted, no verdict | budget exhausted, no verdict |
| qwen3-coder:30b, whole suite | 2/4 | 2/4 (no regression) |
It cuts the wasted budget on the case the model already answers — nemotron's
SQLi run drops from 14 calls to 4 — because once a positive result is in hand,
"give your answer" has something to point at. It does not rescue clean: told
it may stop, nemotron still will not commit to a negative verdict, and degenerates
into emitting a result value as a fake tool call instead. Advice lands on a
finding in hand, not on a model's refusal to conclude a negative — the same wall
the absence challenge was built for. No pass-rate moved and nothing regressed, so
it ships off, switchable, as a budget saver rather than a fix.
Enforcing it instead of advising it (AGENT_PROGRESS_BLOCK) is a negative
result, kept because the negative is the point. The obvious escalation: once an
endpoint is hit past a hard ceiling, refuse the call before it runs — the way
the exact-repeat guard does — rather than merely suggesting the model stop. It
does exactly that (on nemotron's clean the 7th and 8th requests to the endpoint
were refused), and it changes no outcome: nemotron 1/4, qwen3 2/4, no regression,
no gain. The trace says why. Cut off from the endpoint, nemotron does not
conclude — it spends its remaining budget emitting a value from an earlier
response (The Ridge) as a tool name, six times, until the budget ends. The
clean failure was never really about the endpoint: under it sits a refusal to
state a negative that collapses into fabricated tool calls, and stopping the
probing only exposes it sooner. Blocking a runaway is a reasonable guard in
principle, so it ships off and switchable; but on this suite it buys nothing the
nudge did not, and the real next lever is the fabrication, not the budget.
AGENT_FABRICATED_CALL_GUARD)If the failure under the budget is that nemotron passes a result value back as a
tool name, catch that the way the prose-fabrication rebuke already catches a
result passed back as an answer: a call to a name that is not a real tool gets a
one-time rebuke — that is not a tool; answer, or call a real one. It fires
(clean, on nemotron) and changes no outcome. The trace shows why, and it is the
honest end of this trail: the terminal symptom is not stable. In one run nemotron
fabricates six times and the guard has budget to redirect it; in the next it
spends thirteen calls probing and fabricates once on the last step, where no
budget remains. One root cause — a refusal to state a negative — wearing a
different mask each run, so a guard aimed at any one mask is routed around.
Run all three at once and the point is made cleanly. On nemotron's clean the
nudge fired, the block refused two calls, and the fabrication guard rebuked
six calls — every mechanism did its job — and the case still failed with the
budget exhausted and no verdict. qwen3 stayed 2/4 throughout (on sqli it
fabricated ten times and still passed, which is the tell: a model with a real
finding recovers from the same nudges that cannot move a model with none).
So the honest conclusion, with four configurations on disk to check it:
nemotron's clean failure is a capability limit, not an environment one. It
will not commit to a negative verdict, and advice, enforcement, and
fabrication-catching each relocate the symptom rather than remove it — the
environment cannot manufacture a conclusion the model refuses to reach. The one
mechanism that earns its keep is the nudge, as a budget saver on the case the
model does answer; the other two ship off and switchable as documented negative
results. Where the review suites showed recall is easy and false positives are the
cost, this shows the deeper floor for a local exploitation agent is getting the
weaker model to conclude at all.
AGENT_ENUM_TOOL)The failures above are the model's; this one was the tool set's. IDOR sat at
0/6 for a procedural reason: the model tried a handful of object ids by hand and
gave up short of the one that mattered. The question that raises is whether an
execution-style affordance — the thing a real tester reaches a shell for — would
help, without becoming a shell. enumerate_ids is the answer in this project's
own idiom (check_imports, not run_command): one purpose, no interpreter, a
GET sweep of an integer range into a {} template, capped at 100, behind the
same URL guard as fetch_url, and collapsed so the one record that differs is
the report. It is off by default and never enters the registry until turned on.
It works, and it does not come free:
--security + enum tool | qwen3-coder:30b | nemotron-3.5-lightning |
|---|---|---|
| IDOR (was 0/6) | pass — swept 1000–1050, recovered the token | pass — same |
| SQLi (was passing) | fail | fail |
| clean (was passing) | fail (qwen3) | — |
The affordance did exactly its job: both 30B models used it, swept a range
that covered the hidden order, and recovered zidor-4417-note — a class that no
prompt or budget mechanism had moved. And adding its schema regressed the cases
it was not for: on sqli and clean, where the model never even called the new
tool, its mere presence in the prompt shifted a brittle model off answers it had
been getting. That is this project's oldest measured rule, the one behind every
tool being opt-in — advertising a tool is prompt text charged on every request,
and it has cost cases before — now paid in the other direction. So the verdict
is not "add the tool" but "add it for the task it is for": switched on for an
IDOR sweep, off for everything else. Execution-style automation earns its place
on the class it was built for and taxes the rest, which is the honest shape of
the answer to "would running commands help" — yes, narrowly, and never for free.
(One rep; the IDOR wins are unambiguous, the regressions want reps to separate
from noise. Runs on disk under evals/results/enum-*.json.)
AGENT_ENUM_JIT)"For the task it is for" is a switch a person flips; the schema tax says the
advertising was the mistake, not the tool. So do not advertise it globally.
Register enumerate_ids unadvertised — dispatchable but out of the schema, at
zero prompt cost, exactly as undo_edit already is — and reveal it just in time:
the loop watches for the model fetching one endpoint with two different integer
ids (the definition of enumerating by hand) and, at that moment, advertises the
tool and says so. The task that needs it pays its schema; the tasks that never
enumerate never see it.
Same three models, --security, the enumeration tool present all three ways:
| baseline | static (AGENT_ENUM_TOOL) | JIT (AGENT_ENUM_JIT) | |
|---|---|---|---|
| qwen3-coder:30b | 2/4 (sqli, clean) | 1/4 (idor; lost sqli+clean) | 2/4 (sqli, clean; no tax) |
| nemotron-3.5-lightning | 1/4 (sqli) | 1/4 (idor; lost sqli) | 2/4 (sqli + idor) |
JIT is strictly better than static: the tax is gone — both models keep the
cases the static schema broke — and IDOR is still won when the model actually
enumerates. nemotron shows the whole point in one trace: it fetches
/api/orders/1001 and /1002 by hand, the reveal fires, it calls
enumerate_ids over the range, finds 1041, and recovers the token — SQLi and
IDOR passing together, which the static tool could never do because it always
broke SQLi to buy IDOR. qwen3 did not reach 3/4 here only because that run wandered
the local workspace and tested the target just once, too late to enumerate — an
orthogonal failure the tool cannot touch, and one it beat in the static run, so
3/4 is within reach on reps. The lesson is the sharper form of the schema rule:
a tool costs prompt on every request it is advertised on, so advertise it on the
requests that need it and no others. (One rep; wins unambiguous, the
workspace-wander variance wants reps. Runs under evals/results/jit-*.json.)
fetch_url(raw=true))fetch_url reduces HTML to text, which strips <script> first — so a page that
loads its login or its trackers from <script src=…> and fills a modal in at
runtime looks empty of them. That is a real recon blind spot: a site with only
Google and Apple sign-in, both behind a client-rendered modal, reads as "sign in
to continue" and nothing more. raw=true returns the source verbatim, where the
SDK URLs live. It is one optional flag on fetch_url, not a new tool — the
cheaper delta, and measured: with it added, tag:web is still 7/7 on
qwen3-coder, so the existing web cases pay nothing for it.
The recon case (recon-login-methods) is that exact page — providers named
nowhere the text reduction reaches — scored on naming both. It shows two things
at once. The affordance works: nemotron fetches /login, sees no providers,
fetches it again with raw=true, reads PROVIDERS = ["google", "apple"] and the
two SDK inits out of the source, and reports both. And reaching it depends on
not being distracted: pointed at a code repo as its workspace, all three models
wandered the repo and never fetched the target at all (0/3); given a workspace
with no local code, nemotron got it end to end (qwen3 recognised it was a remote
assessment but did not fetch on that rep; the 7B still does not reach a URL). The
usability lesson is concrete — for testing a live target, point --root at an
empty directory, not your source — or the read tools become a rabbit hole. One
rep; the raw path is unit-tested and regression-clean, the model-reaching-it half
is model-dependent and wants reps. Runs under evals/results/recon*.json.
The suite started at three classes, which made it look like a SQLi demo. It is
not: four more genuine flaws were added — command injection (/api/ping, a
real metacharacter break-out), path traversal (/api/download, a real ../
escape), SSRF (/api/fetch, the server fetching a link-local address), and
auth bypass via SQLi over POST (/api/login, reusing the same WHERE
evaluator) — each scored on a token only its exploit recovers. What the two 30B
models land, best configuration, one rep:
| class | qwen3-coder:30b | nemotron-3.5-lightning |
|---|---|---|
| SQL injection | ✅ | ✅ |
| IDOR | ✅ (enumerate_ids) | ✅ (enumerate_ids) |
| command injection | ✅ | ✅ |
| path traversal | ✅ | ✅ |
| SSRF | ✅ | ✅ |
| auth bypass (POST SQLi) | ✅ | ✅ |
| forced-browsing exposure | ✅ (robots breadcrumb) | ✗ |
The 7B lands none of the broader classes — it does not reliably reach a URL at all. But the more useful result is how the failures turned into passes, because both causes were the harness, not the model:
--root at a directory with no
source (the same lesson as recon). This alone turned auth-bypass from 0/2 to
2/2 on the 30B models.secrets/backup.env, a deep metadata URL). The models were exploiting
correctly — traversing with ../../../../etc/passwd, reaching
169.254.169.254 and calling it SSRF in prose — and scored as failures for
not guessing the author's filename. The fix is the sec-clean rule in a third
place: score the token on what the standard technique reaches (read
/etc/passwd; reach the metadata service at all), never on a path only the
author knew. With that, both classes went to 2/2.So the honest revision of the earlier headline: with the workspace not a
distraction and the target rewarding the real technique, a 30B local model
lands six of seven classes; the tool is broad, and the earlier "only SQLi" was
the measurement's own two blind spots, not the model's ceiling. Runs under
evals/results/broad*.json.
Three reps turned that from a claim into a rate — and caught the last of the
confound. Running the suite three times on qwen3 hardened the broader classes
to a flat 3/3 each (command injection, path traversal, SSRF, auth bypass) and
the clean control to 3/3. It also exposed that the original four cases were
still on the code-repo fixture: sqli and IDOR came back 0/3, wandering the
repo and exhausting the budget every time, while every code-free case was
perfect — the workspace confound, proven a third way. All eight cases were then
moved to the code-free workspace, and sqli and IDOR pass there. The lesson is now
unambiguous and load-bearing for anyone using this against a real target: for a
running-target assessment, give the agent a workspace with no source in it. A
code repo is not context here, it is a distraction the weaker model cannot resist.
(pass@3 runs under evals/results/pass3*.json; nemotron's three-rep pass was
cut short by a full swap file, an ops limit, not a result.)
fetch_url / http_post share a session)Most of a real app is behind auth, and the biggest gap was that the web tools
were stateless — a login went nowhere. Now the HTTP tools in one run share a
cookie jar: a login response's Set-Cookie is stored and sent on every later
request, through either tool, with no header for the model to carry by hand. The
jar is per run, so one assessment's session never leaks into another's.
The fixture proves the whole chain: /api/login issues a session cookie, and
/api/admin/metrics needs it but checks only that you are logged in, never that
you are an admin — a broken-function-level-access-control / privilege-escalation
flaw whose token is reachable only after login. qwen3 lands it end to end: it
POSTs the login as the regular user alice, the cookie carries across to a
fetch_url GET of the admin endpoint, and it recovers the token — logging in on
one tool and staying logged in on another. A jar that never logged in stays 401,
so the pass is the session working, not a hole. (Run: evals/results/auth-*.json.)
Cookies are carried automatically; bearer tokens are not, because they arrive
in the login body, not a Set-Cookie header — the dominant real-world API
pattern (JWT, OAuth2). So fetch_url and http_post take a bearer param (the
narrow-affordance way, not an arbitrary headers dict): the model reads the token
from the login response and passes it back, and it goes out as
Authorization: Bearer …. The fixture proves it — POST /api/token returns an
access_token, and GET /api/admin/report requires it in the header but checks
only that the token is valid, never that its bearer is an admin (the same broken
access control, on the bearer path). qwen3 does the whole flow: it POSTs the token
request as alice, reads tok_alice_… out of the body, sends it as the bearer on
the report, and recovers the token — the harder auth shape, since nothing carries
it for the model. (Run: evals/results/bearer-*.json.)
The last transport is the custom header — API-key auth (X-API-Key,
X-Auth-Token, and the like). The tools take a header param, curl style
(header="X-API-Key: abc123"), which composes with bearer and covers any
single custom header without opening a general headers escape hatch. The fixture
guards /api/internal/stats behind X-API-Key; qwen3 reaches it in one call,
sending the issued key in the header. So the tool now tests behind all three of
the common shapes: cookie sessions, bearer tokens, and API-key headers. (Run:
evals/results/apikey-*.json.)
header takes several headers at once, one per line — for an API that wants a
key and a client id on the same request. That one needed the schema to spell the
format out: told only "one per line", qwen3 sent the headers one at a time across
separate calls (which cannot work, since both must ride the same request); given an
explicit 'X-API-Key: …\nX-Client-ID: …' example and "the same request, not
separate calls", it composed the two-line value and recovered the token. A small
model uses the affordance the schema shows it, not the one the schema mentions —
the same environment-over-prose lesson, in a tool description. (Run:
evals/results/multiheader-*.json.)
Four more genuine, token-scored flaws round out the running-target suite:
SSTI (/api/greet, a real {{…}} renderer — {{7*7}}→49, and reaching
config/env/globals leaks the secret), mass assignment (/api/profile blindly
applies a role/is_admin field the form should not accept), JWT alg:none
(/api/vault trusts an unsigned token's claims), and open redirect
(/api/redirect accepts an off-site destination). That is twelve classes plus the
false-positive control.
One rep on qwen3, frugal, tells the honest story of each — and two of them repeated lessons from earlier rather than finding a model ceiling:
{{2*2}}→4) and escalated with {{process.env}}, {{config}},
{{constructor}} — exactly right — but the fixture only leaked on a narrow name
set it never guessed. The exact-name antipattern a third time; the renderer now
scores the technique (reaching config/env/globals), so those payloads land.{"role":"admin"} — which returns the token — then kept adding fields and ran
the budget out without ever answering, so the token sat in a response it never
reported. That is the over-probe/won't-conclude behaviour the AGENT_PROGRESS_NUDGE
exists for, not a fixture gap.header.payload. in
base64url is arithmetic a small model gets wrong; it exhausted the budget. A
real capability ceiling, and a useful one to have measured.All four are unit-tested and inert on normal input; the model rates want the swap
file cleared and reps. Runs under evals/results/moreclasses-*.json.
The one-rep numbers above invite the obvious doubt — is a pass a capability or a
coin flip? Three reps of the whole webexploit suite on qwen3, best config, answer
it: thirteen of the sixteen cases are 3/3 and the other three are 0/3 — not a
single 1/3 or 2/3 cell. The per-class outcome is essentially deterministic, so
the earlier singles were representative. The three 0/3 are honest and specific:
jwt (forging base64url is arithmetic the model gets wrong — a real ceiling),
ssti (it confirms the injection with {{2*2}} but does not escalate to the
secret), and exposure (with the app's index now advertising a dozen routes it
follows those and skips the /robots.txt→/api/debug/config chain — enriching
the map for the chained case cost the directed one).
The run also paid for itself by catching a scoring bug the singles had hidden:
the clean control was failing 3/3 not because qwen3 cried wolf but because its
correct verdict, "no evidence of SQL injection … it is not vulnerable", tripped
a forbidden pattern that read "is … vulnerable" straight through the "not" — the
sec-clean negation trap, still latent in the forbidden half after being fixed
in the required half. Tempering the gap so it cannot swallow a negation flips all
three reps to pass. This is the whole reason to pay for reps: it separated a real
capability floor (jwt, ssti) from a measurement artefact (clean) that a
single run reported as the same kind of failure. (Runs:
evals/results/rep3-webexploit-qwen3-*.json.)
nemotron could only be pushed to one complete rep plus a repeat of half the suite — its 25 GB reloads refill the swap file faster than a run can finish, an ops ceiling on this box, not a result — but that one rep is telling, because it is 12/16 with a different shape than qwen3's:
| qwen3 (3-rep) | nemotron (1 rep) | |
|---|---|---|
| ssti | 0/3 — confirms, won't escalate | pass — escalates to the secret |
| mass assignment | 3/3 | fail — exploits it, then won't conclude |
| clean control | 3/3 | fail — won't state the negative |
| the other 13 | 12 at 3/3, jwt 0/3 | 11 pass, exposure + jwt fail |
The two 30B models have different strengths: nemotron's refusal to stop — the
habit that costs it the clean control and massassign (it recovers the token
and keeps going instead of reporting it) — is the very thing that gets it ssti,
where it keeps trying escalation payloads until one leaks. qwen3 is the mirror. So
"a 30B lands most classes" is true of both, but which ones differ by model —
exactly the by-model split the aider comparison found on real code. (Runs:
evals/results/rep3-webexploit-nemotron-*.json; chunk A repeated identically
across its two reps, so these cells are stable, not single-shot.)
The hardest shape: hand the agent only http://shop.hazelmart.test/ and ask it to
discover the app and test everything. Scored as recall — how many of the twelve
planted tokens the report carries, counted from the stored answer. One rep, a
40-step budget, tag:chained:
| recall | steps used (of 40) | endpoints touched | |
|---|---|---|---|
| qwen3-coder:30b | 4/12 | 14 | 9 |
| nemotron-3.5-lightning | 6/12 | 26 | 14 |
From cold, a 30B does real chaining — it reads the index and /robots.txt,
forced-browses to the exposure endpoint, and logs in to reach the admin one — and
proves a meaningful fraction of the app's issues. But it is a broad, shallow
pass, not an exhaustive one: both models touch an endpoint and move on, so the
classes that need a crafted payload or a sweep (IDOR's id enumeration, a forged
JWT, an injection tried on the right parameter) get missed even where the endpoint
was visited. Two honest reads fall out of it:
Runs under evals/results/chained-*.json.
tag:fixreview)The two halves of the tool in one loop: the reviewer finds a vulnerability in
source, and the editor patches it — behind the same human gate as any write —
scored on the resulting file, not the prose. Four fixes on the review fixtures:
the SQL injection parameterised, the hardcoded secret moved to the environment,
the predictable token switched to secrets, the shell=True command turned into
a list-form call.
python3 -m evals.run --cases tag:fixreview --allow-edits
Both 30B models land 4/4 — they do not merely report the flaw, they apply a
correct patch to disk. The scoring is what makes that claim mean something: it
reads the file after the edit, so a model that describes a fix without applying
it fails, and a fix that leaves the flaw in fails too (the unit test pins both
directions). The one initial miss was the fixture's fault, not the model's —
qwen3 wrote a correct list-form subprocess call but assigned the list to a
variable, and the check wanted it inline: the exact-form antipattern a fourth
time, now fixed in the fix-scoring as well. So the tool is not only a scanner; on
these fixtures a local 30B finds the hole and closes it, on your own hardware.
Runs under evals/results/fixreview-*.json.
Python 3.9+: the standard library and nothing else. There is no
requirements.txt because there is nothing to install. (Developed and tested
on 3.14; no syntax or API newer than 3.9 is used, but older versions are
untested.)
Ollama running locally, and at least one model pulled:
ollama pull qwen2.5-coder:7b # small and fine to start with
ollama pull qwen3-coder:30b # what most numbers here were measured on
With no --model, the agent uses whatever ollama list shows first, so
the model you just pulled is the one it runs. Point elsewhere with --host
if Ollama is not on 127.0.0.1:11434.
Then clone and run. There is no build step and nothing to compile:
git clone <this repo> && cd llm-agent-project
python3 tests.py # 963 tests, no network, no model needed
./install.sh # symlinks `bobbin` into ~/.local/bin
bobbin qwen2.5-coder:7b
install.sh only makes a symlink; there is no package to install, so git pull updates the command. If you would rather not touch ~/.local/bin, skip
it: ./bobbin and ./main.py work identically from the checkout.
Reproducing the aider comparison additionally needs
aider on PATH, plus git and network for evals/compare/setup.sh.
bobbin # first model in `ollama list`, current dir
bobbin qwen3-coder:30b # or name one
bobbin --root ~/some/repo
bobbin -p "what does this project do?" # one-shot
bobbin --allow-edits # editing on, every diff confirmed
bobbin --allow-web -p "what changed in the argparse API in 3.13?"
bobbin --mode research -p "trace what happens when the CLI runs"
--allow-web adds read-only web search and fetch for when the answer is not in
the workspace; --allow-post additionally allows POST (for an API that needs
it), gated behind a per-request confirmation. Both are off by default and
absent from the schema the model sees unless asked for.
Naming a model you have not pulled fails immediately with the list of the ones you have, rather than a 404 from Ollama halfway through.
Drive the tools with no model involved. The fastest way to answer "was that the tool layer or the model?":
./tools_cli.py grep pattern='def build' file_glob='*.py'
./tools_cli.py --schemas # exact JSON the model receives
python3 tests.py # 963 tests, no network
python3 -m evals.run --cases tag:edit # score a suite
bobbin --help lists every flag.
Each one came from watching a 7B fail.
tool message that says how to fix the call."10" for an int and invent parameters;
coerce what you can, drop what you can't.docs/comparison.md has the method behind the two tables above: how aider was invoked, the timeout that 7 of its 24 real-repo runs hit, the matched-rep experiment that overturned the flattering explanation, and the four harness bugs found along the way, each of which favoured a different side.
agent/ the loop, tools, sandbox, edit session, context handling
prompts/ system contract, playbook, per-phase research prompts
evals/ cases, fixtures, runner, scoring, 3,879 stored runs
evals/compare/ the aider head-to-head and its real-repo oracle
bobbin the command (a symlinked launcher; `install.sh` links it)
main.py chat REPL
tools_cli.py run tools without a model
tests.py 963 tests, no network
MIT. See LICENSE.
evals/compare/setup.sh # clone click, build the oracle
python3 evals/compare/realrepo.py results.json # the real-repo matrix
python3 evals/compare/budget_matched.py budget.json # the 40-vs-80 experiment
python3 evals/compare/score.py # every table above
Requires aider on PATH and Ollama serving the two models. The click checkout
and its venv are not committed; setup.sh rebuilds both and verifies the oracle
reports 1991 passing tests.
45 commits
Python
99.6%
A small coding agent for small local models. A dependency-free agent
runtime for local models via Ollama. Stdlib only: no pip install, no
requests, no ollama package, no ripgrep.

A real run, unedited: qwen3-coder:30b renaming slugify to make_slug
across four files, then leaving the vendored copy in node_modules/ alone.
90 seconds of real time, played at 2.6x with long pauses clipped. The frames
are the bytes the agent actually wrote (how it was made).
It does not pass every time; the rates are in the tables below.
It is one loop:
model emits a structured tool call
-> we execute it ourselves
-> the result goes back as a `tool` message
-> the model gets another turn
Repeat until the model answers in prose or the step budget runs out. Everything
in agent/ exists to make that loop survive a 7B.
Small local coding models, 7B to 32B on one consumer GPU, fail in the agent loop in specific, repeatable ways: they read one file and give up, they loop on the same call forever, they emit tool calls as prose, they rename a symbol in one file and report the refactor done. Almost every fix in this project is a change to the environment, the tools, the errors, the loop, rather than to the prompt or the model.
That bet is measured rather than asserted. The 4,133 runs behind it are in
this repository, under evals/results/; not a summary of them, the runs
themselves, so any number quoted here can be recomputed rather than taken on
trust. The things that were built, measured, and removed for zero benefit
are written down next to the things that worked.
Same tasks, same local models, one harness, scored on disk. Full method, disclosures and caveats: docs/comparison.md.
On this project's own fixtures, 19 cases × 2 models, three reps per cell, 342 runs:
| shape | ours | aider-told | aider-find |
|---|---|---|---|
| single-file edits | 46/48 | 45/48 | 42/48 |
| cascades + repair | 61/66 | 11/66 | 10/66 |
| all | 107/114 | 56/114 | 52/114 |
On single-file edits the three arms are the same tool. The entire margin is cascades, where it is close to six to one.
On pallets/click at 12,674 lines, judged by its own 1991 tests. Three reps
per cell, 108 runs:
| ours | aider-told | aider-find | |
|---|---|---|---|
| qwen3-coder:30b | 6/18 | 9/18 | 9/18 |
| nemotron 32.9B | 12/18 | 3/18 | 4/18 |
| both | 18/36 | 12/36 | 13/36 |
Read the second table before the first, and read it by row. The fixture advantage does not transfer, and on real code the answer depends on which model you run:
Aggregated, that is 50% against 33%, which is parity with a wide spread, not a lead. At the level of individual tasks: of 12 cells, we win 4, aider wins 1, and 7 are ties (mostly tasks neither tool can do).
These outcomes are stable, not noisy. 33 of the 36 cells were unanimous across all three reps, so the failures are capability, not luck. That also means the earlier single-sample table happened to be right, which we could not have known without paying for the reps.
So the claim this project makes is narrow and specific:
src/ and
left the suite red in 5 of 6;Caveats belong next to the numbers, not under them: aider 0.86.2 at default settings by someone who does not use it daily, and a 900s timeout that 19 of its 72 real-repo runs hit, all on nemotron. Both tables are 3 reps per cell, 450 runs in total. The harness ships so any of that can be corrected and re-run.
Because the model runs locally through Ollama and nothing is sent anywhere, the agent can review code you are not allowed to paste into a hosted LLM — under an NDA, a client engagement, or a data-egress rule. That is a real niche a frontier model in the cloud cannot fill at any quality, and it is worth measuring rather than asserting, so there are two eval suites for it.
python3 -m evals.run --cases tag:security # whole-file review
python3 -m evals.run --cases tag:diffreview # review a staged change
Both score two numbers, because a reviewer that flags everything has perfect recall and is useless: how many real vulnerabilities it finds, and how often it invents one in code that is fine. The fixtures plant SQL injection, command injection, weak hashing, a hardcoded secret, a predictable token and a path traversal, alongside a control file that does the same kinds of things correctly.
The honest headline, on qwen3-coder:30b, nemotron-3.5-lightning and
qwen2.5-coder:7b: recall is the easy part — all three find every planted
bug and propose the right fix. The cost of a local reviewer is false positives:
on a whole file, the strongest model flagged a correct subprocess.run([...])
call as command injection — the argument-list form that is the fix for it. On a
diff, where the model reasons about what changed rather than pattern-matching
the whole file, all three stayed clean and still caught every introduced bug.
So it is a useful first pass that a human then reads, not an oracle — which is the honest claim for a local security reviewer, and the reproduction commands above let you check it rather than take it.
Reviewing code is one half of a security assessment. The other is pointing the tool at a running target you are authorized to test, finding the hole, and proving it — recovering something a normal request cannot reach. That runs on the same privacy argument as the reviewer: an engagement whose target you may not describe to a hosted LLM can still be tested by a model on your own hardware.
python3 -m evals.run --cases tag:webexploit --security # against the offline target
The target is an offline vulnerable app (evals/webexploit_fixture.py), served
through the shipped web tools exactly the way the docs fixture is: a real
loopback server under a reserved .test name, so the URL guard runs and nothing
touches the internet. Its flaws are genuine behaviour, not substrings it
recognises — the SQL injection is a real boolean parser broken open by a
balanced-quote payload, the IDOR is a genuinely missing ownership check, the
sensitive-data exposure is an endpoint that really answers anyone.
Each flaw guards a distinctive token that appears nowhere a normal request can
reach. A model that only describes SQL injection in the abstract cannot
produce zsqli-8842-leak; only one that sent the payload and read the response
can. Scoring on the recovered secret rather than on prose is what separates
"found and proved it" from "said the right word". A fourth case is the
false-positive control — a parameterised endpoint that must not be reported
as vulnerable, the sec-clean lesson applied to a live target.
The agent finds the hole and produces a proof-of-concept; a person runs it. That
is deliberate: bobbin has no shell by design (narrow affordances, no
run_command), so "exploit" here means craft the working request, not execute
arbitrary code. The --security flag appends a security-testing playbook
(prompts/security.md); it is off by default and measured as an arm, so the gap
between the two runs is what the playbook is worth.
The honest headline inverts the reviewer's. On review, recall was the easy part; on exploitation, recall is the hard part. Four cases × three models, one rep each, scored on the recovered token:
| exploit class | qwen3-coder:30b | nemotron-3.5-lightning | qwen2.5-coder:7b |
|---|---|---|---|
| SQL injection | ✅ (with --security) | ✅ | ✗ |
| IDOR | ✗ | ✗ | ✗ |
| exposure (forced browsing) | ✗ | ✗ | ✗ |
| clean control (no false positive) | ✅ | ✅¹ | ✅ |
In this first, three-class cut, only SQL injection lands, and only on the two
30B-class models — it is the one class the target hands a breadcrumb, an
error-based disclosure that shows the injection point. (That "only SQLi" headline
does not survive widening the suite — see Seven classes, not one below,
where the 30B models land most of them once two measurement confounds are
removed.) IDOR and forced-browsing exposure fail on every model here: they
need enumeration the small models do not sustain (the flagship, handed the order
URL outright, went off to read the local workspace instead; on exposure it tried
/.env and /admin but never guessed the real /api/debug/config). The 7B
never reaches the target at all — the same "searches the workspace, never
fetches" failure already on record for web-search-then-fetch.
A discoverability breadcrumb turns exposure from unguessable into a skill
test — and shows the skill is scarce. WEBEXPLOIT_ROBOTS=1 serves a
/robots.txt that discloses the hidden /api/debug/config (the classic
robots.txt information disclosure), off by default so the blind baseline stays
the shipped default. Measured one case × three models:
| exposure, with breadcrumb | qwen3-coder:30b | nemotron-3.5-lightning | qwen2.5-coder:7b |
|---|---|---|---|
| breadcrumb + default playbook | ✗ | ✗ | ✗ |
breadcrumb + --security | ✅ (3 calls) | ✗ | ✗ |
The breadcrumb alone does nothing: no model reads robots.txt unprompted
(0/3). Only when the playbook is told to read it first does the flagship pick it
up — and then it is textbook and fast: / → /robots.txt → /api/debug/config,
three calls, token recovered. nemotron over-probes to budget exhaustion even
with the hint, and the 7B still never reaches the target. So the lever works, but
like the rest of this suite it works on the flagship and not below it.
The --security playbook is a two-sided lever, not a free win: it flips SQL
injection on for qwen3-coder:30b (1/4 → 2/4), but pushes
nemotron-3.5-lightning into over-probing the safe endpoint until its step
budget runs out, losing the clean control it passed without the text (2/4 → 1/4,
the ¹). That is why it ships off and measured rather than on — the same verdict
the web playbook earned. One rep is a signal, not a result (see the noise
caveat the runner prints); the runs are on disk under evals/results/, rescored
with python3 -m evals.rescore --expectations after a clean-verdict pattern fix.
So this is a lead generator for a human tester on the class that leaks loudly, not an autonomous exploitation tool — the honest claim, with the target and the runs included so it can be checked rather than taken.
AGENT_PROGRESS_NUDGE)nemotron's most expensive habit is not a capability ceiling, it is not stopping.
On webexploit-sqli it recovered the token on the third call and then spent
eleven more on variations of the same endpoint; on webexploit-clean it probed
the safe endpoint a dozen ways and ran the budget out without ever giving the
verdict it had earned. The exact-argument repeat guard cannot see this — every
payload is a different URL. So there is a second guard keyed on the request
target (host+path, query dropped): once one endpoint has been hit past a
threshold, a one-time note says, in effect, you are repeating; if you have your
answer, give it. Off by default, web-scoped (the coding suites never see it),
counted in both arms.
Measured, it is a real but narrow win, and an honest one about where advice lands:
| nudge off | nudge on | |
|---|---|---|
nemotron sqli (passes) | 14 calls, budget exhausted | 4 calls |
nemotron clean (fails) | budget exhausted, no verdict | budget exhausted, no verdict |
| qwen3-coder:30b, whole suite | 2/4 | 2/4 (no regression) |
It cuts the wasted budget on the case the model already answers — nemotron's
SQLi run drops from 14 calls to 4 — because once a positive result is in hand,
"give your answer" has something to point at. It does not rescue clean: told
it may stop, nemotron still will not commit to a negative verdict, and degenerates
into emitting a result value as a fake tool call instead. Advice lands on a
finding in hand, not on a model's refusal to conclude a negative — the same wall
the absence challenge was built for. No pass-rate moved and nothing regressed, so
it ships off, switchable, as a budget saver rather than a fix.
Enforcing it instead of advising it (AGENT_PROGRESS_BLOCK) is a negative
result, kept because the negative is the point. The obvious escalation: once an
endpoint is hit past a hard ceiling, refuse the call before it runs — the way
the exact-repeat guard does — rather than merely suggesting the model stop. It
does exactly that (on nemotron's clean the 7th and 8th requests to the endpoint
were refused), and it changes no outcome: nemotron 1/4, qwen3 2/4, no regression,
no gain. The trace says why. Cut off from the endpoint, nemotron does not
conclude — it spends its remaining budget emitting a value from an earlier
response (The Ridge) as a tool name, six times, until the budget ends. The
clean failure was never really about the endpoint: under it sits a refusal to
state a negative that collapses into fabricated tool calls, and stopping the
probing only exposes it sooner. Blocking a runaway is a reasonable guard in
principle, so it ships off and switchable; but on this suite it buys nothing the
nudge did not, and the real next lever is the fabrication, not the budget.
AGENT_FABRICATED_CALL_GUARD)If the failure under the budget is that nemotron passes a result value back as a
tool name, catch that the way the prose-fabrication rebuke already catches a
result passed back as an answer: a call to a name that is not a real tool gets a
one-time rebuke — that is not a tool; answer, or call a real one. It fires
(clean, on nemotron) and changes no outcome. The trace shows why, and it is the
honest end of this trail: the terminal symptom is not stable. In one run nemotron
fabricates six times and the guard has budget to redirect it; in the next it
spends thirteen calls probing and fabricates once on the last step, where no
budget remains. One root cause — a refusal to state a negative — wearing a
different mask each run, so a guard aimed at any one mask is routed around.
Run all three at once and the point is made cleanly. On nemotron's clean the
nudge fired, the block refused two calls, and the fabrication guard rebuked
six calls — every mechanism did its job — and the case still failed with the
budget exhausted and no verdict. qwen3 stayed 2/4 throughout (on sqli it
fabricated ten times and still passed, which is the tell: a model with a real
finding recovers from the same nudges that cannot move a model with none).
So the honest conclusion, with four configurations on disk to check it:
nemotron's clean failure is a capability limit, not an environment one. It
will not commit to a negative verdict, and advice, enforcement, and
fabrication-catching each relocate the symptom rather than remove it — the
environment cannot manufacture a conclusion the model refuses to reach. The one
mechanism that earns its keep is the nudge, as a budget saver on the case the
model does answer; the other two ship off and switchable as documented negative
results. Where the review suites showed recall is easy and false positives are the
cost, this shows the deeper floor for a local exploitation agent is getting the
weaker model to conclude at all.
AGENT_ENUM_TOOL)The failures above are the model's; this one was the tool set's. IDOR sat at
0/6 for a procedural reason: the model tried a handful of object ids by hand and
gave up short of the one that mattered. The question that raises is whether an
execution-style affordance — the thing a real tester reaches a shell for — would
help, without becoming a shell. enumerate_ids is the answer in this project's
own idiom (check_imports, not run_command): one purpose, no interpreter, a
GET sweep of an integer range into a {} template, capped at 100, behind the
same URL guard as fetch_url, and collapsed so the one record that differs is
the report. It is off by default and never enters the registry until turned on.
It works, and it does not come free:
--security + enum tool | qwen3-coder:30b | nemotron-3.5-lightning |
|---|---|---|
| IDOR (was 0/6) | pass — swept 1000–1050, recovered the token | pass — same |
| SQLi (was passing) | fail | fail |
| clean (was passing) | fail (qwen3) | — |
The affordance did exactly its job: both 30B models used it, swept a range
that covered the hidden order, and recovered zidor-4417-note — a class that no
prompt or budget mechanism had moved. And adding its schema regressed the cases
it was not for: on sqli and clean, where the model never even called the new
tool, its mere presence in the prompt shifted a brittle model off answers it had
been getting. That is this project's oldest measured rule, the one behind every
tool being opt-in — advertising a tool is prompt text charged on every request,
and it has cost cases before — now paid in the other direction. So the verdict
is not "add the tool" but "add it for the task it is for": switched on for an
IDOR sweep, off for everything else. Execution-style automation earns its place
on the class it was built for and taxes the rest, which is the honest shape of
the answer to "would running commands help" — yes, narrowly, and never for free.
(One rep; the IDOR wins are unambiguous, the regressions want reps to separate
from noise. Runs on disk under evals/results/enum-*.json.)
AGENT_ENUM_JIT)"For the task it is for" is a switch a person flips; the schema tax says the
advertising was the mistake, not the tool. So do not advertise it globally.
Register enumerate_ids unadvertised — dispatchable but out of the schema, at
zero prompt cost, exactly as undo_edit already is — and reveal it just in time:
the loop watches for the model fetching one endpoint with two different integer
ids (the definition of enumerating by hand) and, at that moment, advertises the
tool and says so. The task that needs it pays its schema; the tasks that never
enumerate never see it.
Same three models, --security, the enumeration tool present all three ways:
| baseline | static (AGENT_ENUM_TOOL) | JIT (AGENT_ENUM_JIT) | |
|---|---|---|---|
| qwen3-coder:30b | 2/4 (sqli, clean) | 1/4 (idor; lost sqli+clean) | 2/4 (sqli, clean; no tax) |
| nemotron-3.5-lightning | 1/4 (sqli) | 1/4 (idor; lost sqli) | 2/4 (sqli + idor) |
JIT is strictly better than static: the tax is gone — both models keep the
cases the static schema broke — and IDOR is still won when the model actually
enumerates. nemotron shows the whole point in one trace: it fetches
/api/orders/1001 and /1002 by hand, the reveal fires, it calls
enumerate_ids over the range, finds 1041, and recovers the token — SQLi and
IDOR passing together, which the static tool could never do because it always
broke SQLi to buy IDOR. qwen3 did not reach 3/4 here only because that run wandered
the local workspace and tested the target just once, too late to enumerate — an
orthogonal failure the tool cannot touch, and one it beat in the static run, so
3/4 is within reach on reps. The lesson is the sharper form of the schema rule:
a tool costs prompt on every request it is advertised on, so advertise it on the
requests that need it and no others. (One rep; wins unambiguous, the
workspace-wander variance wants reps. Runs under evals/results/jit-*.json.)
fetch_url(raw=true))fetch_url reduces HTML to text, which strips <script> first — so a page that
loads its login or its trackers from <script src=…> and fills a modal in at
runtime looks empty of them. That is a real recon blind spot: a site with only
Google and Apple sign-in, both behind a client-rendered modal, reads as "sign in
to continue" and nothing more. raw=true returns the source verbatim, where the
SDK URLs live. It is one optional flag on fetch_url, not a new tool — the
cheaper delta, and measured: with it added, tag:web is still 7/7 on
qwen3-coder, so the existing web cases pay nothing for it.
The recon case (recon-login-methods) is that exact page — providers named
nowhere the text reduction reaches — scored on naming both. It shows two things
at once. The affordance works: nemotron fetches /login, sees no providers,
fetches it again with raw=true, reads PROVIDERS = ["google", "apple"] and the
two SDK inits out of the source, and reports both. And reaching it depends on
not being distracted: pointed at a code repo as its workspace, all three models
wandered the repo and never fetched the target at all (0/3); given a workspace
with no local code, nemotron got it end to end (qwen3 recognised it was a remote
assessment but did not fetch on that rep; the 7B still does not reach a URL). The
usability lesson is concrete — for testing a live target, point --root at an
empty directory, not your source — or the read tools become a rabbit hole. One
rep; the raw path is unit-tested and regression-clean, the model-reaching-it half
is model-dependent and wants reps. Runs under evals/results/recon*.json.
The suite started at three classes, which made it look like a SQLi demo. It is
not: four more genuine flaws were added — command injection (/api/ping, a
real metacharacter break-out), path traversal (/api/download, a real ../
escape), SSRF (/api/fetch, the server fetching a link-local address), and
auth bypass via SQLi over POST (/api/login, reusing the same WHERE
evaluator) — each scored on a token only its exploit recovers. What the two 30B
models land, best configuration, one rep:
| class | qwen3-coder:30b | nemotron-3.5-lightning |
|---|---|---|
| SQL injection | ✅ | ✅ |
| IDOR | ✅ (enumerate_ids) | ✅ (enumerate_ids) |
| command injection | ✅ | ✅ |
| path traversal | ✅ | ✅ |
| SSRF | ✅ | ✅ |
| auth bypass (POST SQLi) | ✅ | ✅ |
| forced-browsing exposure | ✅ (robots breadcrumb) | ✗ |
The 7B lands none of the broader classes — it does not reliably reach a URL at all. But the more useful result is how the failures turned into passes, because both causes were the harness, not the model:
--root at a directory with no
source (the same lesson as recon). This alone turned auth-bypass from 0/2 to
2/2 on the 30B models.secrets/backup.env, a deep metadata URL). The models were exploiting
correctly — traversing with ../../../../etc/passwd, reaching
169.254.169.254 and calling it SSRF in prose — and scored as failures for
not guessing the author's filename. The fix is the sec-clean rule in a third
place: score the token on what the standard technique reaches (read
/etc/passwd; reach the metadata service at all), never on a path only the
author knew. With that, both classes went to 2/2.So the honest revision of the earlier headline: with the workspace not a
distraction and the target rewarding the real technique, a 30B local model
lands six of seven classes; the tool is broad, and the earlier "only SQLi" was
the measurement's own two blind spots, not the model's ceiling. Runs under
evals/results/broad*.json.
Three reps turned that from a claim into a rate — and caught the last of the
confound. Running the suite three times on qwen3 hardened the broader classes
to a flat 3/3 each (command injection, path traversal, SSRF, auth bypass) and
the clean control to 3/3. It also exposed that the original four cases were
still on the code-repo fixture: sqli and IDOR came back 0/3, wandering the
repo and exhausting the budget every time, while every code-free case was
perfect — the workspace confound, proven a third way. All eight cases were then
moved to the code-free workspace, and sqli and IDOR pass there. The lesson is now
unambiguous and load-bearing for anyone using this against a real target: for a
running-target assessment, give the agent a workspace with no source in it. A
code repo is not context here, it is a distraction the weaker model cannot resist.
(pass@3 runs under evals/results/pass3*.json; nemotron's three-rep pass was
cut short by a full swap file, an ops limit, not a result.)
fetch_url / http_post share a session)Most of a real app is behind auth, and the biggest gap was that the web tools
were stateless — a login went nowhere. Now the HTTP tools in one run share a
cookie jar: a login response's Set-Cookie is stored and sent on every later
request, through either tool, with no header for the model to carry by hand. The
jar is per run, so one assessment's session never leaks into another's.
The fixture proves the whole chain: /api/login issues a session cookie, and
/api/admin/metrics needs it but checks only that you are logged in, never that
you are an admin — a broken-function-level-access-control / privilege-escalation
flaw whose token is reachable only after login. qwen3 lands it end to end: it
POSTs the login as the regular user alice, the cookie carries across to a
fetch_url GET of the admin endpoint, and it recovers the token — logging in on
one tool and staying logged in on another. A jar that never logged in stays 401,
so the pass is the session working, not a hole. (Run: evals/results/auth-*.json.)
Cookies are carried automatically; bearer tokens are not, because they arrive
in the login body, not a Set-Cookie header — the dominant real-world API
pattern (JWT, OAuth2). So fetch_url and http_post take a bearer param (the
narrow-affordance way, not an arbitrary headers dict): the model reads the token
from the login response and passes it back, and it goes out as
Authorization: Bearer …. The fixture proves it — POST /api/token returns an
access_token, and GET /api/admin/report requires it in the header but checks
only that the token is valid, never that its bearer is an admin (the same broken
access control, on the bearer path). qwen3 does the whole flow: it POSTs the token
request as alice, reads tok_alice_… out of the body, sends it as the bearer on
the report, and recovers the token — the harder auth shape, since nothing carries
it for the model. (Run: evals/results/bearer-*.json.)
The last transport is the custom header — API-key auth (X-API-Key,
X-Auth-Token, and the like). The tools take a header param, curl style
(header="X-API-Key: abc123"), which composes with bearer and covers any
single custom header without opening a general headers escape hatch. The fixture
guards /api/internal/stats behind X-API-Key; qwen3 reaches it in one call,
sending the issued key in the header. So the tool now tests behind all three of
the common shapes: cookie sessions, bearer tokens, and API-key headers. (Run:
evals/results/apikey-*.json.)
header takes several headers at once, one per line — for an API that wants a
key and a client id on the same request. That one needed the schema to spell the
format out: told only "one per line", qwen3 sent the headers one at a time across
separate calls (which cannot work, since both must ride the same request); given an
explicit 'X-API-Key: …\nX-Client-ID: …' example and "the same request, not
separate calls", it composed the two-line value and recovered the token. A small
model uses the affordance the schema shows it, not the one the schema mentions —
the same environment-over-prose lesson, in a tool description. (Run:
evals/results/multiheader-*.json.)
Four more genuine, token-scored flaws round out the running-target suite:
SSTI (/api/greet, a real {{…}} renderer — {{7*7}}→49, and reaching
config/env/globals leaks the secret), mass assignment (/api/profile blindly
applies a role/is_admin field the form should not accept), JWT alg:none
(/api/vault trusts an unsigned token's claims), and open redirect
(/api/redirect accepts an off-site destination). That is twelve classes plus the
false-positive control.
One rep on qwen3, frugal, tells the honest story of each — and two of them repeated lessons from earlier rather than finding a model ceiling:
{{2*2}}→4) and escalated with {{process.env}}, {{config}},
{{constructor}} — exactly right — but the fixture only leaked on a narrow name
set it never guessed. The exact-name antipattern a third time; the renderer now
scores the technique (reaching config/env/globals), so those payloads land.{"role":"admin"} — which returns the token — then kept adding fields and ran
the budget out without ever answering, so the token sat in a response it never
reported. That is the over-probe/won't-conclude behaviour the AGENT_PROGRESS_NUDGE
exists for, not a fixture gap.header.payload. in
base64url is arithmetic a small model gets wrong; it exhausted the budget. A
real capability ceiling, and a useful one to have measured.All four are unit-tested and inert on normal input; the model rates want the swap
file cleared and reps. Runs under evals/results/moreclasses-*.json.
The one-rep numbers above invite the obvious doubt — is a pass a capability or a
coin flip? Three reps of the whole webexploit suite on qwen3, best config, answer
it: thirteen of the sixteen cases are 3/3 and the other three are 0/3 — not a
single 1/3 or 2/3 cell. The per-class outcome is essentially deterministic, so
the earlier singles were representative. The three 0/3 are honest and specific:
jwt (forging base64url is arithmetic the model gets wrong — a real ceiling),
ssti (it confirms the injection with {{2*2}} but does not escalate to the
secret), and exposure (with the app's index now advertising a dozen routes it
follows those and skips the /robots.txt→/api/debug/config chain — enriching
the map for the chained case cost the directed one).
The run also paid for itself by catching a scoring bug the singles had hidden:
the clean control was failing 3/3 not because qwen3 cried wolf but because its
correct verdict, "no evidence of SQL injection … it is not vulnerable", tripped
a forbidden pattern that read "is … vulnerable" straight through the "not" — the
sec-clean negation trap, still latent in the forbidden half after being fixed
in the required half. Tempering the gap so it cannot swallow a negation flips all
three reps to pass. This is the whole reason to pay for reps: it separated a real
capability floor (jwt, ssti) from a measurement artefact (clean) that a
single run reported as the same kind of failure. (Runs:
evals/results/rep3-webexploit-qwen3-*.json.)
nemotron could only be pushed to one complete rep plus a repeat of half the suite — its 25 GB reloads refill the swap file faster than a run can finish, an ops ceiling on this box, not a result — but that one rep is telling, because it is 12/16 with a different shape than qwen3's:
| qwen3 (3-rep) | nemotron (1 rep) | |
|---|---|---|
| ssti | 0/3 — confirms, won't escalate | pass — escalates to the secret |
| mass assignment | 3/3 | fail — exploits it, then won't conclude |
| clean control | 3/3 | fail — won't state the negative |
| the other 13 | 12 at 3/3, jwt 0/3 | 11 pass, exposure + jwt fail |
The two 30B models have different strengths: nemotron's refusal to stop — the
habit that costs it the clean control and massassign (it recovers the token
and keeps going instead of reporting it) — is the very thing that gets it ssti,
where it keeps trying escalation payloads until one leaks. qwen3 is the mirror. So
"a 30B lands most classes" is true of both, but which ones differ by model —
exactly the by-model split the aider comparison found on real code. (Runs:
evals/results/rep3-webexploit-nemotron-*.json; chunk A repeated identically
across its two reps, so these cells are stable, not single-shot.)
The hardest shape: hand the agent only http://shop.hazelmart.test/ and ask it to
discover the app and test everything. Scored as recall — how many of the twelve
planted tokens the report carries, counted from the stored answer. One rep, a
40-step budget, tag:chained:
| recall | steps used (of 40) | endpoints touched | |
|---|---|---|---|
| qwen3-coder:30b | 4/12 | 14 | 9 |
| nemotron-3.5-lightning | 6/12 | 26 | 14 |
From cold, a 30B does real chaining — it reads the index and /robots.txt,
forced-browses to the exposure endpoint, and logs in to reach the admin one — and
proves a meaningful fraction of the app's issues. But it is a broad, shallow
pass, not an exhaustive one: both models touch an endpoint and move on, so the
classes that need a crafted payload or a sweep (IDOR's id enumeration, a forged
JWT, an injection tried on the right parameter) get missed even where the endpoint
was visited. Two honest reads fall out of it:
Runs under evals/results/chained-*.json.
tag:fixreview)The two halves of the tool in one loop: the reviewer finds a vulnerability in
source, and the editor patches it — behind the same human gate as any write —
scored on the resulting file, not the prose. Four fixes on the review fixtures:
the SQL injection parameterised, the hardcoded secret moved to the environment,
the predictable token switched to secrets, the shell=True command turned into
a list-form call.
python3 -m evals.run --cases tag:fixreview --allow-edits
Both 30B models land 4/4 — they do not merely report the flaw, they apply a
correct patch to disk. The scoring is what makes that claim mean something: it
reads the file after the edit, so a model that describes a fix without applying
it fails, and a fix that leaves the flaw in fails too (the unit test pins both
directions). The one initial miss was the fixture's fault, not the model's —
qwen3 wrote a correct list-form subprocess call but assigned the list to a
variable, and the check wanted it inline: the exact-form antipattern a fourth
time, now fixed in the fix-scoring as well. So the tool is not only a scanner; on
these fixtures a local 30B finds the hole and closes it, on your own hardware.
Runs under evals/results/fixreview-*.json.
Python 3.9+: the standard library and nothing else. There is no
requirements.txt because there is nothing to install. (Developed and tested
on 3.14; no syntax or API newer than 3.9 is used, but older versions are
untested.)
Ollama running locally, and at least one model pulled:
ollama pull qwen2.5-coder:7b # small and fine to start with
ollama pull qwen3-coder:30b # what most numbers here were measured on
With no --model, the agent uses whatever ollama list shows first, so
the model you just pulled is the one it runs. Point elsewhere with --host
if Ollama is not on 127.0.0.1:11434.
Then clone and run. There is no build step and nothing to compile:
git clone <this repo> && cd llm-agent-project
python3 tests.py # 963 tests, no network, no model needed
./install.sh # symlinks `bobbin` into ~/.local/bin
bobbin qwen2.5-coder:7b
install.sh only makes a symlink; there is no package to install, so git pull updates the command. If you would rather not touch ~/.local/bin, skip
it: ./bobbin and ./main.py work identically from the checkout.
Reproducing the aider comparison additionally needs
aider on PATH, plus git and network for evals/compare/setup.sh.
bobbin # first model in `ollama list`, current dir
bobbin qwen3-coder:30b # or name one
bobbin --root ~/some/repo
bobbin -p "what does this project do?" # one-shot
bobbin --allow-edits # editing on, every diff confirmed
bobbin --allow-web -p "what changed in the argparse API in 3.13?"
bobbin --mode research -p "trace what happens when the CLI runs"
--allow-web adds read-only web search and fetch for when the answer is not in
the workspace; --allow-post additionally allows POST (for an API that needs
it), gated behind a per-request confirmation. Both are off by default and
absent from the schema the model sees unless asked for.
Naming a model you have not pulled fails immediately with the list of the ones you have, rather than a 404 from Ollama halfway through.
Drive the tools with no model involved. The fastest way to answer "was that the tool layer or the model?":
./tools_cli.py grep pattern='def build' file_glob='*.py'
./tools_cli.py --schemas # exact JSON the model receives
python3 tests.py # 963 tests, no network
python3 -m evals.run --cases tag:edit # score a suite
bobbin --help lists every flag.
Each one came from watching a 7B fail.
tool message that says how to fix the call."10" for an int and invent parameters;
coerce what you can, drop what you can't.docs/comparison.md has the method behind the two tables above: how aider was invoked, the timeout that 7 of its 24 real-repo runs hit, the matched-rep experiment that overturned the flattering explanation, and the four harness bugs found along the way, each of which favoured a different side.
agent/ the loop, tools, sandbox, edit session, context handling
prompts/ system contract, playbook, per-phase research prompts
evals/ cases, fixtures, runner, scoring, 3,879 stored runs
evals/compare/ the aider head-to-head and its real-repo oracle
bobbin the command (a symlinked launcher; `install.sh` links it)
main.py chat REPL
tools_cli.py run tools without a model
tests.py 963 tests, no network
MIT. See LICENSE.
evals/compare/setup.sh # clone click, build the oracle
python3 evals/compare/realrepo.py results.json # the real-repo matrix
python3 evals/compare/budget_matched.py budget.json # the 40-vs-80 experiment
python3 evals/compare/score.py # every table above
Requires aider on PATH and Ollama serving the two models. The click checkout
and its venv are not committed; setup.sh rebuilds both and verifies the oracle
reports 1991 passing tests.
45 commits
Python
99.6%