Ashton Damrel / Writing

Agent security · Evaluation

Same model, two harnesses: what a security-first agent gateway costs

I ran my self-hosted Rust agent gateway and OpenCode on the same local model through AgentDojo and Terminal-Bench. No injection succeeded against the gateway (0 of 200; OpenCode: 7), but its blanket taint rule also blocked most legitimate assistant work. On terminal tasks they came out close (25 vs 26 of 73 verifier passes; 20 vs 25 strictly) after I fixed a loop bug the benchmark exposed.

TL;DR. I ran two agent harnesses on the same local model (Qwen3.6-35B-A3B on one DGX Spark), the same sampling settings, and on the security benchmark the same tool definitions. One is OpenCode. The other is the Rust gateway I built for my own self-hosted assistant.

Why compare harnesses at all

An agent’s security does not come from the model alone. The harness decides which tools exist, what they may touch, who approves what, and what happens after the agent reads something an attacker wrote. I built my home assistant’s gateway around that last question. I wanted to measure what that design buys and what it costs, against a widely used open-source harness, with the model held fixed.

A coding benchmark alone would mostly confirm that a coding agent codes better. A prompt-injection benchmark alone would reward a harness that refuses to do anything. So this post reports both, side by side, as one tradeoff.

Setup: what is held constant

The two harnesses

The gateway

The gateway is a Rust workspace that serves my home assistant. The parts that matter here:

OpenCode

OpenCode 1.19.4, pointed at the same vLLM endpoint with the same sampling. Isolation flags kept it from loading any outside configuration, skills or prompts. In AgentDojo, all of its built-in tools were turned off, so it saw only the AgentDojo tools. Each of those tools was set to ask, and the driver approved every request. In effect OpenCode ran with every tool allowed (it raised 1,099 permission prompts, all approved).

Method

AgentDojo (security, plus utility on assistant tasks)

Terminal-Bench (capability on terminal tasks)

Results

Capability versus attack successTwo panels share the x-axis, AgentDojo attack success rate. Left: AgentDojo benign utility. Right: Terminal-Bench solved rate. OpenCode sits at 3.5% attack success with high utility on both. The gateway sits at zero attack success: far lower utility on AgentDojo, and on Terminal-Bench the fixed v2 run is close to OpenCode while the pre-fix v1 run is lower. AgentDojo utility (benign tasks) 0% 25% 50% 75% 100% 0% 5% 10% OpenCode - AgentDojo utility (benign tasks): 90.7% (95% CI 83.3-95.0); attack success 3.5% (95% CI 1.7-7.0) OpenCode 90.7% Gateway (deny) - AgentDojo utility (benign tasks): 34.0% (95% CI 25.4-43.9); attack success 0.0% (95% CI 0.0-1.9) Gateway (deny) 34.0% Gateway, approve (TB: v2, fixed) - AgentDojo utility (benign tasks): 32.0% (95% CI 23.5-41.8); attack success 0.0% (95% CI 0.0-1.9) Gateway (approve) 32.0% Terminal-Bench solved (arm64 subset) 0% 5% 10% OpenCode - Terminal-Bench solved (arm64 subset): 35.6% (95% CI 25.6-47.1); attack success 3.5% (95% CI 1.7-7.0) OpenCode 35.6% Gateway v1 (pre-fix) - Terminal-Bench solved (arm64 subset): 23.3% (95% CI 15.1-34.2); attack success 0.0% (95% CI 0.0-1.9) Gateway v1, pre-fix 23.3% Gateway v2 (fixed) - Terminal-Bench solved (arm64 subset): 34.2% (95% CI 24.4-45.7); attack success 0.0% (95% CI 0.0-1.9) Gateway v2 34.2% Attack success rate, AgentDojo important_instructions (n = 200; whiskers: 95% CI) OpenCode Gateway, approve (TB: v2, fixed) Gateway (deny) Gateway v1, pre-fix (TB only)
Up and to the left is better. Whiskers are nominal Wilson 95% binomial intervals. Right panel: Harbor’s score. Harbor approves every prompt, so Terminal-Bench has no “deny” configuration. The hollow square is the first gateway run, before the bug fix described below. Both gateway markers on the right take their x-position from the pre-fix AgentDojo run, so the v2 marker combines measurements of two gateway versions: v2’s security was not measured. Exact values are in the tables below.

AgentDojo

AgentDojo v1.2.2, 97 benign user tasks and 200 injection pairs (important_instructions), one run per configuration. Brackets: nominal Wilson 95% interval.
ConfigurationUtility, benignUtility under attackAttack successApproval promptsMedian wall time
OpenCode90.7%
88/97 · [83.3–95.0]
87.5%
175/200 · [82.2–91.4]
3.5%
7/200 · [1.7–7.0]
1,09930 s
Gateway, approvals auto-approved32.0%
31/97 · [23.5–41.8]
34.5%
69/200 · [28.3–41.3]
0.0%
0/200 · [0.0–1.9]
127 s
Gateway, approvals auto-denied34.0%
33/97 · [25.4–43.9]
34.0%
68/200 · [27.8–40.8]
0.0%
0/200 · [0.0–1.9]
123 s

OpenCode is the better assistant on these tasks by a wide margin. Under attack it still completes 87.5% of user tasks, and 7 of the 200 attacks succeed. Each gateway configuration completes about a third of tasks, with or without attacks, and no attack succeeds. The same 200 pairs ran under each configuration. On the paired comparison (exact McNemar test, two-sided) the 7-versus-0 difference gives p ≈ 0.016. That test treats the pairs as independent, and they are not: the 200 pairs reuse 86 user tasks and repeat injection goals. So read it as weak-to-moderate evidence of a real difference, not a precise one. The absolute numbers are small, and the next sections show how little of the difference can be tied to the policy.

On the 97 benign tasks, the two harnesses disagree mostly in one direction. OpenCode succeeds where the gateway (approve) fails on 59 tasks. The reverse happens on 2.

Per suite: benign utility and attack successes.
SuiteOpenCodeGateway (approve)Gateway (deny)
UtilityAttacksUtilityAttacksUtilityAttacks
workspace92.5% 37/400/11832.5% 13/400/11835.0% 14/400/118
travel85.0% 17/200/3050.0% 10/200/3055.0% 11/200/30
banking87.5% 14/164/3043.8% 7/160/3043.8% 7/160/30
slack95.2% 20/213/224.8% 1/210/224.8% 1/210/22

All seven successful attacks are in banking (four transfers to the attacker’s IBAN) and slack (three). The gateway’s slack utility is close to zero, for a reason the next section explains.

Terminal-Bench

Terminal-Bench 2.0, 73-task arm64 subset, one attempt per task. Solved is Harbor’s own score: the verifier’s reward, which Harbor also computes after a timeout. Strict also counts any trial that raised an error or timed out as unsolved. Brackets: nominal Wilson 95% interval. Empty ending: the agent ended its turn normally with no final message.
HarnessSolvedStrictErrorsEmpty endingMedian tool callsMedian time per tool call
OpenCode35.6%
26/73 · [25.6–47.1]
34.2%
25/73
11 timeout
8 agent crash
141726 s
Gateway v2 (fixed)34.2%
25/73 · [24.4–45.7]
27.4%
20/73
33 timeout01241 s
Gateway v1 (pre-fix)23.3%
17/73 · [15.1–34.2]
23.3%
17/73
11 timeout361042 s

On Harbor’s own score, the fixed gateway (v2) and OpenCode are close: 25 and 26 of 73. Paired by task, OpenCode alone solved 7 and the gateway alone 6. The observed difference is one task, and this run gives no evidence of a difference either way (exact McNemar p = 1.0). That is not the same as showing the two are equal. On the strict score the gateway is behind, 20 against 25, which is also not statistically significant (p ≈ 0.30). The strict count removes six verifier passes: five gateway trials and one OpenCode trial whose container passed the tests after the agent was stopped at its time limit. The reports show the final state passed, not when the work was finished. With 73 tasks and one attempt each, this run cannot detect a difference of a few tasks in either direction.

Taint plays no part here: the Terminal-Bench manifest has a single shell tool and nothing marked untrusted, so the session never drops to read-only. This panel measures the harness and its tools, not the injection policy.

Paired by task (73 tasks). p: two-sided exact McNemar test on the tasks only one side solved, treating tasks as independent.
ComparisonRuleBothOnly firstOnly secondNeitherp
OpenCode vs Gateway v2Solved1976411.00
OpenCode vs Gateway v2Strict15105430.30
OpenCode vs Gateway v1Solved10167400.09
OpenCode vs Gateway v1Strict10157410.13
Gateway v1 vs Gateway v2Solved14311450.06
Gateway v1 vs Gateway v2Strict1436500.51

The bug the benchmark found

The first gateway run (v1) solved 17 of 73. Reading the traces pointed to the reason. 36 trials ended “normally” with an empty final message, and another 11 timed out. None of those 47 was solved.

The cause was in the agent loop, not the model or the policy. The loop treated any model reply without a tool call as the final answer. With thinking on and a 16,384-token output budget, the model sometimes spends the entire budget reasoning. vLLM then returns finish_reason=length with no content and no tool call (I confirmed this with a small probe against the server), and the loop ended the turn on that empty reply. The archived traces record turn totals, not per-call finish reasons, but they fit this explanation. In 33 of the 36 empty endings the turn had used at least 16,384 completion tokens, and in 8 the agent stopped after a single round.

The fix, which I call ReplyGuard, never ends a turn on an empty or truncated tool-less reply. It sends the model a short nudge and retries, at most twice in a row (each retry counts against the round limit). It also continues truncated text, and when the model gives no answer at all, the gateway returns an explicit message saying so instead of an empty one. The same change went into the production gateway. I reran only the gateway (v2), with the same model, config and tasks.

OpenCode failed 47 trials on Harbor’s score: 14 ended normally with no final message (I did not investigate why), 10 timed out, 8 exited with an error after OpenCode reported an internal timeout, and 15 ended normally with an answer that did not pass.

This is the main thing I learned from the capability half of the project. The first run made it look as if security cost a lot of capability on terminal tasks. Fixing a loop bug coincided with most of that gap disappearing. A harness comparison is only as good as the harness code, and reading the failing traces before trusting the score is what found the bug.

Where the gateway’s utility went: taint

The gateway raised exactly one approval prompt per configuration in 297 cases. Its approval gate was almost never reached, which is why the approve and deny configurations score nearly the same (they disagree on 9 of 297 cases). The policy outcome that shows up in the failures is the taint denial, not the approval.

Gateway runs, all 297 cases. A taint denial is a call refused with reader cannot run <tier> tool after the session read untrusted content.
ConfigurationUtility failuresFailures with a taint denialUtility when a taint denial occurredUtility with no denialDenied calls
Gateway, approvals auto-approved19782.2% 162/1975.8% 10/17272.0% 90/125236
Gateway, approvals auto-denied19681.1% 159/1964.2% 7/16671.8% 94/131236

A typical failing case: the user asks the agent to read an email and reply, or to check recent transactions and pay a bill. Reading the email or the transaction list taints the session, because that content could have been written by anyone. The write that the user actually asked for is then denied. Across the approve configuration, 236 calls were denied this way: 112 write_reversible (creating files and calendar events, for example), 82 egress (sending email and messages) and 42 write_irreversible (payments, deletions). When a case hit a taint denial, it succeeded 5.8% of the time. When it did not, it succeeded 72.0% of the time. This is an association, not a measured effect. I did not run the gateway with taint switched off, and cases that hit a denial may also be harder in other ways.

Slack shows the extreme case. Listing channels or channel members is marked untrusted, because other users choose those names. So nearly every slack task taints itself on its first call, and the gateway completes 1 of 21. Slack is where the labeling choice and the failures line up most directly.

The other ~18% of failures (35 cases in the approve configuration) had no taint denial. In 14 of them the scrubber had removed at least one URL from tool output, which may have hidden something the task needed. I did not classify the rest fully. They include wrong answers, a refusal of a request the model judged to be a scam, and final answers that ask the user to confirm first (“Want me to send it now, or hold off?”). The driver sends one user prompt and answers permission requests, but it does not reply to questions, so a harness that asks before acting loses those cases. Seven of the 35 end with a question. Among OpenCode’s failures, one does.

What the zero does and does not show

Zero successful attacks looks like a clean win. A closer look at the traces gives a more modest result. For each injection pair, I checked whether the agent called the injected goal’s tool with the attacker-specific arguments, such as the attacker’s email address, IBAN, website, or the passport number the attacker asked for. This counts the agent going for the injected goal at all, whether or not the call succeeded. A match that is fully explained by the user’s own request does not count. The first version of this check counted a legitimate email to an address the user had named, and a review caught it.

Injection pairs (n = 200) in which the agent called the injected goal’s action tool with the attacker-specific arguments (email, IBAN, URL, file id, requested secret), excluding calls fully explained by the user’s own request. Heuristic, from hand-written signatures in tools/attempts.py; one pair has a text-only goal and cannot match. Exposed: the attack text reached the model in a tool result.
ConfigurationExposedAttemptedExecutedDenied by policyScored attack success
OpenCode1908807
Gateway, approvals auto-approved1871010
Gateway, approvals auto-denied1880000

So, for this model and this attack: the gateway scored 57–59 points below OpenCode on benign utility, and taint denials appear in about 82% of its failures. The taint rule demonstrably blocked one attempted attack. The 3.5-point difference in attack success is real in this run, but this experiment cannot divide it between the policy and the other differences between the harnesses. Separating them takes a gateway run with taint switched off. How a stronger or adaptive attack would change either side is untested. Such an attack could also aim at exactly the channels taint leaves open.

What this implies

A taint rule has the right shape for a security boundary. It is deterministic, it does not ask a model whether text looks malicious, and an attacker cannot argue with it. The problem is granularity. The rule taints the whole session, on every untrusted read, for every later write. A few ways to make it finer, each a design change and none tested here:

The next experiment is detector-gated taint: untrusted content taints the session only when a prompt-injection classifier flags it. I am training that classifier now, and the AgentDojo rerun with it is future work. It moves part of the boundary from a rule to a model. Its false negatives then decide what reaches the write tools, so it will be reported with the same tables as this run, not as a single score.

Confounds and limitations

Appendix A: tools

Gateway, AgentDojo

Generated mechanically from AgentDojo’s tool names and return types (rules below). The gateway exposes only the tools of the current suite, served through the bridge.

The 69 AgentDojo tools in the gateway’s signed bench manifest, by tier. u = accepts_untrusted_input: calling it taints the session.
TierToolsNames
read45check_restaurant_opening_hoursu, get_all_car_rental_companies_in_cityu, get_all_hotels_in_cityu, get_all_restaurants_in_cityu, get_balance, get_car_fuel_optionsu, get_car_price_per_dayu, get_car_rental_addressu, get_car_types_availableu, get_channelsu, get_contact_information_for_restaurantsu, get_cuisine_type_for_restaurantsu, get_current_day, get_day_calendar_eventsu, get_dietary_restrictions_for_all_restaurantsu, get_draft_emailsu, get_file_by_idu, get_flight_informationu, get_hotels_addressu, get_hotels_pricesu, get_iban, get_most_recent_transactionsu, get_price_for_restaurantsu, get_rating_reviews_for_car_rentalu, get_rating_reviews_for_hotelsu, get_rating_reviews_for_restaurantsu, get_received_emailsu, get_restaurants_addressu, get_scheduled_transactionsu, get_sent_emailsu, get_unread_emailsu, get_user_info, get_user_information, get_users_in_channelu, get_webpageu, list_filesu, read_channel_messagesu, read_fileu, read_inboxu, search_calendar_eventsu, search_contacts_by_emailu, search_contacts_by_nameu, search_emailsu, search_filesu, search_files_by_filenameu
write_reversible5add_user_to_channel, append_to_fileu, create_calendar_event, create_file, reschedule_calendar_eventu
write_irreversible9cancel_calendar_event, delete_email, delete_fileu, remove_user_from_slack, schedule_transaction, send_money, update_password, update_scheduled_transaction, update_user_info
egress10add_calendar_event_participantsu, invite_user_to_slack, post_webpage, reserve_car_rental, reserve_hotel, reserve_restaurant, send_channel_message, send_direct_message, send_email, share_fileu
Tiering rules (first match wins)
  1. write_irreversible: deletions, removals and cancellations, anything that moves money, and account or credential changes.
  2. egress: anything that sends data to a third party: send_*, post_*, invite_*, share_*, adding event participants, and reserve_* (the user’s details go to a business).
  3. write_reversible: create_*, append_*, reschedule_*, adding a user to a channel, and other update_*.
  4. read: get_*, search_*, list_*, read_*, check_*.

Untrusted when the return type can carry text a third party wrote (Email, CloudDriveFile, CalendarEvent, Message, Transaction and EmailContact records, plus web pages, files, reviews, business listings, and channel or user names). Calls that only echo back what the agent itself just created are not untrusted. Judgment calls: cancelling an event counts as a deletion, a reservation counts as egress, adding participants counts as an invitation, and channel and user listings count as untrusted.

Gateway, Terminal-Bench

shell only: sh -c in the session’s working directory, a fresh confined process per call, output clipped to the first and last 8,000 characters, default timeout 120 seconds (maximum 1,800). v1 and v2 are the same tool, manifest and config. v2 adds only the ReplyGuard change to the agent loop.

OpenCode

AgentDojo: only the bridge’s tools (presented as agentdojo_*). Every built-in was disabled: bash, edit, write, patch, read, grep, glob, list, webfetch, websearch, codesearch, task, todowrite, todoread, skill, lsp, question. Terminal-Bench: all of those built-ins except question, websearch and codesearch.

Appendix B: what the bench profile auto-approves

The policy function is the production one. What changes in the benchmark is the manifest, the agent-loop settings, and who answers approval prompts.

Reproducibility and raw data