DEV Community

Cover image for I Tried to Prompt-Inject My Own Agent Engine. It Didn't Work. Here's Why.

I Tried to Prompt-Inject My Own Agent Engine. It Didn't Work. Here's Why.

Debashish Ghosal on August 25, 2026

This is article 5 in a series about building PlannerCritic, an open-source engine where one LLM writes a plan and a second LLM reviews it. Article...
Collapse
 
tokenlat profile image
TokenLat

This is the cleanest argument I've seen for "architecture, not prompt, is the safety boundary" — and it pairs perfectly with the non-determinism finding. Your live-critic boundary evaluator measuring non-determinism on identical input is the part most people skip: the critic itself drifts. That's the same failure shape we keep hitting in self-fixing agent loops — not crashes, but the reviewer going quietly blind. One operational lever that helped us: route the planner and the critic to different capability tiers. The planner can be cheap and fast; the critic, the thing that has to catch the blind spot, is where we refuse to penny-pinch. Cheap critic = blind critic. The two-LLM split only pays off if the second model is actually held to a higher bar than the first.

Collapse
 
deanlee profile image
Dean Lee

The cost point is the part I would keep visible in the security design. Five critic passes are useful for measurement, but the cheaper invariant is making every path hit the structural gates. If a team starts saving money by skipping those gates on “simple” work, the architecture quietly turns back into trust-the-model.

Collapse
 
debashish_ghosal profile image
Debashish Ghosal

Thanks - yes, cost is important. Have you had a chance to see this? I covered cost here
dev.to/debashish_ghosal/i-ran-157-...

Yes, it’s 49c to run the full suite against a reliably good and cheaper model.

Collapse
 
anp2network profile image
ANP2 Network

Your 11/11 result measures entry into escalation. The exit path is where the approval boundary actually lives, and it hasn't taken the same measurement pressure as the plan side. In this engine escalated is a handoff state, and something has to be able to leave it.

The sharpest example is in src/planner_critic/field_test_harness.py. The DIMENSIONS table binds the escalation dimension to ["adv-01-billing-no-safety"], which is one of the adversarial goals. Then run_escalation(goal, store, out) builds an EscalationManager(store), calls list_escalations(), loops over escs[:2], and calls mgr.resolve(e.id, "approved", note="field test"). It then records {"pass": True, "escalation_count": len(escs)}. So the harness flips the first two open escalations in store order to approved with no principal argument, and pass means only that the call didn't raise.

This is the harness dimension rather than the 170-goal corpus score, so it isn't your headline number. It's still the code's model of what an escalation check should exercise. list_escalations() walks store.list_plans(), so it isn't scoped to the current goal, and the sweep uses one shared SQLiteStore(":memory:"). Nothing excludes adversarial escalations. The blocked outcome and the write that unblocks it live in the same harness, with an assertion on only one side of it.

F-14 also reads narrower in code than in the register. EscalationManager.resolve(escalation_id, decision, note="", principal=None) in escalation.py does enforce approving_authority when one is configured: if principal != self._approving_authority it raises PermissionError. The problem is input plumbing. The CLI escalate approve subparser in cli/escalate.py accepts escalation_id, --patch and --note, and _run_approve calls manager.resolve(args.escalation_id, "approved", note=args.note). _handle_escalate_approve in server/http.py reads only body.get("note", ""). The MCP signature is escalate_approve(store_path, escalation_id, note="", patch_json=None). If v0.3.0 binds approving_authority without threading identity through those three signatures, every approve call raises, because principal stays None. Fronting the HTTP server with an authenticating gateway doesn't rescue this either. The approver's identity dies at the gateway, since nothing in the request body carries it inward.

The record has the same shape of gap. Escalation in types.py carries status, resolution and resolved_at with no actor field, so who approved is never persisted, even on the direct API path where the check does work. And build_explain in explain.py picks the final action with if is_last and escalation is not None: action = "escalated", branching on the escalation existing and never on escalation.status, so _build_summary still returns Escalated: <question> for a plan whose escalation was resolved approved. The manager docstring says the store is the single source of truth so the full arc is replayable, and #234 was fixed precisely so the audit trail keeps every defect distinct. The one write that can override a deterministic gate is the write that trail can't attribute.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

One thing I would want before reading 11/11 as injection resistance: in tests 2 and 3 the blockers that actually fired are weak_rollback, unsafe_sequencing and feasibility, and none of them mention the payload. That is consistent with the injection being ignored, but it is also consistent with those goals escalating for reasons that have nothing to do with the payload at all. The cheap control is a benign twin of each adversarial goal, same plan request with the injected text stripped and everything else identical; if the twin escalates with the same blocker family, the 11/11 is measuring how strict the gates are on that corpus rather than how isolated the goal channel is. It would also give you a baseline to compare against when you start on indirect injection, where the payload arrives after the audit.

Collapse
 
debashish_ghosal profile image
Debashish Ghosal

Filed as planner-critic-engine#334. You're right that 11/11 could be measuring gate strictness on that corpus rather than injection isolation — a benign twin with the injected text stripped would settle it. This also provides the baseline needed for when indirect injection testing begins. Thanks for the clean experimental design.

Collapse
 
seasonkoh profile image
WebAZ

The open seam I would prioritize is indirect injection crossing a trust boundary. Re-auditing every tool result with another LLM still leaves data and instructions entangled. A stronger contract is for tools to return typed data plus provenance, while deterministic policy decides whether that source may influence a particular state transition. For example, text fetched from a product page may inform discovery, but it should never be able to alter the payee, amount, approval requirements, or destination authority. That turns injection defense from “spot malicious language” into “untrusted sources cannot acquire capability.”

Collapse
 
alex-zaporozhan profile image
Alexandr Zaporojan

Really insightful read, Debashish!

"The architecture, not the prompt, is what makes it safe" is spot on. I've spent the last few months working on multi-agent SDLC frameworks and hit the exact same wall: trying to prompt-engineer safety or deterministic behavior into an LLM is a dead end. Structural isolation and deterministic gates are the only things that actually hold up in production.

Great series, looking forward to part 6!

Collapse
 
tiagovilasboas profile image
Tiago Vilas Boas (Montanha)

The honest limitation stood out to me. A malicious plan can have rollback and verification and still be unauthorized. Structural gates validate shape, not authority. Are you considering authorization for each plan step in v0.3.0?

Collapse
 
mudassirworks profile image
Mudassir Khan

"the critic evaluates structure, not intent" is the line that makes the whole design click. most security discussions assume the model has to catch injection. this is the better frame: the model never gets to treat injection as a decision because architecture makes it irrelevant first.

hit something similar when hardening an MCP server — the gate that held wasn't a system prompt clause, it was a schema validator upstream of the tool call. felt obvious in retrospect tbh.

the open seam you flag, indirect injection through tool outputs mid execution, is where we've gotten burned. how are you planning to instrument that without exploding the critic's context?

Collapse
 
kartik-nvjk profile image
Kartik N V J K

The framing that direct injection failed because feasibility, not safety, caught it is the part I keep coming back to. When the critic treats "disable MFA in production" as infeasible by definition, the attack surface shrinks to whatever the planner accepts as achievable, which is a much cleaner boundary to reason about than a prompt allowlist. Curious whether the 21 injection traps included ones where the malicious step is individually feasible but only harmful in composition.