An open, offline-verifiable admission layer that stops instruction-privilege
escalation in coding agents by enforcing a plan-first information-flow policy.
v0.3.0 adds a real Anthropic tool-calling adapter and label-preserving
persistence across iterations; the IFC engine itself is unchanged from v0.2.0.
A research prototype with a rigorous audit — not a turnkey production defense.
The problem
LLM coding agents run over untrusted web pages, docs and tool output while
holding authority over security-sensitive resources (deploy keys, CI configs,
secret stores). Two structural gaps are documented in the literature:
- Instruction privilege escalation — harnesses assemble per-invocation context and can elevate low-privilege content (repo text, tool output) to a higher instruction level, making the agent obey what it would refuse at the original level (Girrens & Wang, arXiv:2608.27234 — SPA).
- Non-decaying loop state — autonomous loops re-initialize their safety monitor every trajectory, so attacker evidence fragmented across iterations is never seen in any single window; a monitor retaining cross-iteration state separates true from false positives, trajectory-scoped monitors do not (arXiv:2608.27141).
The harnesses themselves are evaluated across 13 attack objectives in 6 real
coding-agent frameworks (arXiv:2608.27299).
What it is
agent-harness-defense is an admission layer: before a change is applied,
you call run_admission() with an explicit description of what the agent
proposes to do, and it returns a verdict — admit this, deny that, and why. It is
not a runtime firewall watching a live agent. It is a library you (or the
harness you integrate) invoke.
The decision core is a dual-lattice information-flow control (IFC) engine.
Each datum in the plan carries two labels:
- Confidentiality — is it secret?
- Integrity — do we trust who put it there?
When an action depends on low-integrity data (a repo README, say), the action
inherits that distrust even if the content contains no trigger word. That is
exactly what lets it catch prompt injection that the v0.1 heuristic (trigger
phrases) could not see. As a second layer, the v0.1 heuristic (trigger phrases +
forbidden paths) is retained as a backup signal, not the primary mechanism.
How the engine works
The planner emits one Plan per query. evaluate_plan applies a componentwise
lattice join over the depends_on graph:
- Confidentiality = max — the result is as sensitive as the most sensitive operand.
- Integrity = min — taint does not wash: an UNTRUSTED value joined with a SYSTEM intent stays UNTRUSTED, which blocks the no-upgrade rule.
SourceTag (SYSTEM / USER / TOOL_RESULT / REPO_TEXT / ENV / DATA) is the
per-fragment origin label. Reads are classified by path: reading repo text
yields UNTRUSTED integrity (the prompt-injection surface); reading a system file
yields SYSTEM.
- A
writethat depends on an untrustedreadinherits UNTRUSTED integrity and is denied (no-upgrade). - A
writesourced fromenv.SECRETto a public sink is denied (no-downgrade). - A
writethat depends only on SYSTEM/USER input is admitted.
The Anthropic tool-calling adapter (v0.3.0)
agent_harness_defense/adapter/ ships a minimal but real example of how to
build a Plan from a real LLM’s tool-calls. The mapping is mechanical and
declarative on purpose — it does not read file contents to infer dependencies.
tool_call
action
value_source
depends_on
read_file / read / open_file
read
None
only if content_ref declared
write_file (literal content)
write
literal
only if content_ref declared
write_file with content_ref=env.*
write
env.<name>
only if content_ref declared
bash / execute / shell / run_command
execute
repo.cmd (UNTRUSTED)
only if content_ref declared
any other tool name
execute
repo.cmd (UNTRUSTED)
only if content_ref declared
A depends_on is created ONLY from an explicit content_ref the agent
returns (step_<k>.content). Without it, the step stands alone. The
reasoning is in the next section.
value_source="repo.cmd" on bash is deliberate. The shell can read and
write anything without the Plan ever seeing it, so the only honest default
is to mark it UNTRUSTED and let the IFC deny it. The original mapping
(value_source=None → DATA/USER) left bash("echo $SECRET > report.md")
admitted, because the secret travels through the shell and never appears in
any value_source of the Plan. This is the bash exfiltration fix that
shipped with the v0.3.0 release.
A successful call to run_admission returns a PlanVerdict with the
admitted steps, denied steps, denial reasons, and taint summary. The
caller’s job is to materialize only the admitted steps; the denied ones
do not happen. AgentSession drives multiple iterations and reuses one
LoopStateMonitor so cross-iteration signal actually accumulates (v0.2
evaluated each call independently).
The example in examples/anthropic_incident_report/ ships with a frozen
cassette (no API key, no network) and a “teeth” test that materializes the
attack on disk if run_admission is bypassed — so the test fails with
ATTACK MATERIALIZED if the defense is switched off, proving the example
is not vacuous.
Label-preserving persistence (v0.3.0)
The non-vacuous scenario for this feature, taken from test_persistence.py:
-
Iteration 1: the agent reads a planted
README.mdand writes/home/agent/.cache/result.jsonwithcontent_ref="step_1.content". The IFC denies the write forintegrity_violation(the read was UNTRUSTED, the write declared it used the read’s content). -
Iteration 2: the agent reads the same cache file and writes
published.jsonwithcontent_ref="step_1.content". The sameAgentSessionis used;persisted_labelsre-injects the UNTRUSTED label from iteration 1. The write is denied.
Without persistence (a fresh AgentSession for iteration 2), the
iteration-2 write would be admitted. The test
test_two_iterations_non_vacuous asserts both halves of that flip in
the same fixture. The artifact content is never stored or re-exposed
— only the IFC Label (a pair of integers) plus a 16-character
sha256(path + reason) summary, which is what the persistence
PersistedArtifact dataclass holds.
The case v0.1 missed (AC-EVAL-1)
The public Signetry/eval IPI corpus models the agent writing the malicious
artifact to disk. We add INCIDENT_REPORT_INJECTION: the plan reads an
untrusted README.md and writes incident-report.md sourced from env.SECRET,
depending on the read. The v0.1 heuristic — five hard-coded trigger phrases — does
not fire, because the planted README contains none of them. The dual-lattice
IFC denies the step on both axes (integrity + confidentiality) because the
write transitively inherits the README’s UNTRUSTED label and carries a SECRET to
a public sink.
This is verified, not asserted: test_v01_would_have_missed_this re-runs the v0.1
scan over the materialized repo and confirms escalation_detected is False — the
eval is non-vacuous. The suite covers 3 scenarios: the 2 Signetry IPI scenarios
(README injection, CLAUDE.md scope expansion) plus this own secret-leak scenario.
Each one has a test proving v0.1 would have let it through and the new engine does not.
The bugs the audits caught
Two real defects were caught by independent clone-fresh audits before
publication, and fixed before merge. This is the part most security posts
omit; I include it because the trade-offs are real and you should see them.
Bug 1 — _step_initial_label over-classified reads (v0.2.0 cycle). An
independent audit reproduced a real defect introduced during the build:
_step_initial_label returned (PUBLIC, SYSTEM) for every read, so
propagation through depends_on only worked via magic prefixes baked
into value_source. The fix (_classify_read_path) derives the read
label from the path. A regression test
(test_read_propagates_untrusted_via_depends_on) now fails if the bug
returns. The audit also flagged a CI regression the fix caused
(a bandit B108 and a ruff format miss), both closed before merge.
Bug 2 — temporal-chaining false-positive avalanche (v0.3.0 cycle). The
original _depends_on_for() chained every tool_call to its immediate
predecessor by temporal order (depends_on=[prev_id]). Because the
integrity join is min and propagates transitively through depends_on,
a SINGLE untrusted read anywhere in a session tainted EVERY later action
regardless of real relation. Reproduced end-to-end by both the author and
the auditor on the same day, in fresh clones: read_file(README.md) + 5
unrelated write_file(module_N.py) calls → all 5 writes denied by
integrity_violation. Since almost every real agent reads a README /
docs at startup, this would have blocked normal work (false-positive
avalanche). It also contradicted the project’s own Constitution C1
(“NO reconstruye dependencias transversales que el agente no declaró”).
The fix: _depends_on_for() now returns [] by default. A step
acquires a depends_on ONLY via an EXPLICIT agent-supplied
content_ref / value_ref (step_<k>.content). Without it, the step
stands alone.
The honest trade-off, copied verbatim from KNOWN_ISSUES.md §6: the
adapter now has FALSE NEGATIVES for undeclared dependencies — if the
agent reads an untrusted source and writes its content WITHOUT declaring
the content_ref, the taint is NOT caught. This is the documented,
preferable alternative to denying every normal session. The realistic
“teeth” test models the attacker DECLARING the dependency, which is the
only case the IFC can honestly catch. A new guardrail
(test_adapter_false_positive_scale_read_then_many_unrelated_writes)
locks the correct behaviour so the bug cannot silently return.
What it does NOT do (read this before you integrate it)
This is the part most security posts omit. Stated plainly:
-
It does not extract the plan itself. The
Plan(what the agent will read, write, and where each value comes from) must be supplied explicitly by the caller. The v0.3.0 adapter (agent_harness_defense/adapter/) shows ONE way to build it from Anthropic tool-calls; it is an example, not a generic integration framework. OpenAI / MCP and a full autonomous loop are separate features. Nothing observes a real LLM acting and builds the plan automatically for arbitrary harnesses — that integration is on the user. -
Propagation is over what is declared, not over real content. If the
Plansays step B depends on step A, the engine propagates the label. But nothing analyzes disk to detect “this file literally cites that other file” on its own. If the caller declares dependencies wrong, the engine cannot know — which is whyassert_plan_matches_materializedexists, but it is a test guard, not something that runs in production against a live agent. After the v0.3.0 temporal-chaining fix, an undeclared dependency is a false negative, not a denial. - Small evaluation corpus. Three scenarios, all with fairly literal attack text in English. No evidence it resists phrasing variation, other languages, or subtler attacks.
-
Cross-iteration persistence is now implemented (v0.3.0). The roadmap item
“label-preserving persistence between iterations” is closed:
AgentSessionremembers tainted paths across iterations and re-injects their labels viapersisted_labels. The structural guarantee the v0.2 release advertised is now also exercised by a non-vacuous test (test_persistence.py::test_two_iterations_non_vacuous). - It has never run against a real agent or seen production traffic. The code is well-tested in software-engineering terms (tests, CI, audits), but zero flight hours against real traffic.
Is it “production-ready”? Two honest axes
-
Engineering hygiene: yes, solid. Correct AGPL license with consistent
attribution, SPDX headers, real CI that actually fails when something breaks, 43
tests that are not vacuous (I checked explicitly, not just trusted them to pass),
honest documentation of what is missing (
KNOWN_ISSUES.mddoes not whitewash anything), and a real audit trail where TWO propagation bugs were found and fixed by independent clone-fresh audits before publication. That already puts the repo above the median of security projects shipped to GitHub without external scrutiny. - As a turnkey product for the community to run in production: not yet — and saying so costs credibility, not the opposite. Present it as what it is: a research prototype with a rigorous audit, a reference implementation of IFC defense for agents that demonstrates the concept and documents its own limits — not “install this and your agent is safe.” The first person who tries to wire it to a real harness outside the Anthropic tool-calling shape hits a wall, and that burns credibility fast.
What would move it toward usable (in order of impact)
-
An example adapter with a real harness — ✅ DONE in v0.3.0
(
agent_harness_defense/adapter/, Anthropic tool-calling). The next gap is the same shape for OpenAI / MCP and a full autonomous loop. - An explicit threat model document: what it protects, what it does not, what it assumes of the caller. For a security tool this is nearly as important as the code.
- A larger evaluation corpus — phrasing variations, other languages, longer multi-step attacks.
-
A 5-minute quickstart in the README — ✅ DONE in v0.3.0
(
README.md→ “## Quickstart” withgit clone+pip install -e ".[dev]"-
pytest).
-
How it is verified
All claims are reproducible offline. The suite models both Signetry IPI scenarios
faithfully: the agent’s obey() step writes the attack artifacts to disk, so the
defense is exercised on real materialized state, not a mock. Four guard rails keep
the eval honest:
-
Teeth assert (
test_admission.py) — fails ifobey()does not land the artifact on disk. -
Drift guard (
assert_plan_matches_materialized) — fails if the declaredPlandiverges from what the agent actually wrote. -
Regression guard (
test_eval_catches_regression.py) — monkey-patchesevaluate_planto admit everything and asserts the guard observes the broken boundary. -
False-positive scale guardrail (
test_adapter_plan.py, v0.3.0) — locks the no-temporal-chaining fix: a normal session (1 read + 5 unrelated writes) must have all writes admitted. If a future change reintroduces the bug, this test fails immediately.
Results (clean runner, fresh venv, v0.3.0)
Gate Resultpytest
43 passed (0.4s) — 23 v0.2 IFC + 14 adapter (002) + 5 persistence (003) + 1 FP guardrail
ruff check
clean
ruff format --check
clean
bandit -r agent_harness_defense -ll
clean (B108 suppressed, justified)
Try it
The package is not on PyPI — install from the repo:
git clone https://github.com/amurlaniakea/agent-harness-defense
cd agent-harness-defense
git checkout v0.3.0 # or `main` for the latest
pip install -e ".[dev]" # editable install; [dev] pulls pytest/ruff/bandit
pytest # 43 tests
ahd eval # run bundled IPI + AC-EVAL-1 scenarios
ahd run REPO --plan plan.yaml # evaluate a declarative Plan
Enter fullscreen mode Exit fullscreen mode
Links
- Repo: https://github.com/amurlaniakea/agent-harness-defense
- Tag v0.3.0: https://github.com/amurlaniakea/agent-harness-defense/releases/tag/v0.3.0
- Girrens & Wang (2026) — SPA: Securing Persistent LLM Agents
- Cross-iteration loop-state monitor (2026)
- Harness privilege-escalation benchmark (2026)
Last updated: 2026-08-29 — v0.3.0 release (Anthropic adapter + label-preserving
persistence, two independent audits, two real bugs caught and fixed). The article
URL keeps its v020 slug because dev.to does not allow editing slugs after
publication; the content is the v0.3.0 release.
License: AGPL-3.0-or-later — Pedro Sordo Martínez
Implementation → independent audit on a clean clone → merge gated on green CI.
Prototype, not a turnkey defense: read “What it does NOT do” before integrating.