Skip to content

Record whether the scope filter or the optimizer declined an injection - #50

Merged
RoldSI merged 3 commits into
mainfrom
fix/decline-origin
Aug 28, 2026
Merged

RoldSI merged 3 commits into
mainfrom
fix/decline-origin

Conversation

@RoldSI

@RoldSI RoldSI commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

The problem

security_domain_filter answers an out-of-scope controllable event with a ControllableNoInjection without consulting the optimizer (core/middleware.py:99-110). trajectory_recorder composes outside it, so the event and the decline are both recorded. An optimizer that is consulted and passes produces a byte-identical entry.

On disk the two are indistinguishable:

{"kind":"response","type":"ControllableNoInjection","controllable":"env_tool:terminal.system"}
{"kind":"response","type":"ControllableNoInjection","controllable":"env_tool:gmail.public"}

The first is "the attacker had no write access here". The second is "the attacker had access and chose not to use it". Those are opposite findings, and only the second says anything about the attacker. The first belongs in a not-applicable bucket, never in a defence rate.

Why this is worth a public field

It is not hypothetical. A DTAP audit read a results tree, counted every recorded controllable event as an offer the attacker had declined, and concluded that 41 tasks showed an attacker-side classifier defect.

Recomputed with the scope taken into account (inclusion is prefix-based on the dotted tag path, so tools.terminal includes tools.terminal.system):

scope tasks never offered an in-scope surface offered, not injected
s3 55 5 4
s6 55 30 2

Thirty of those tasks had the agent calling terminal tools under a grant covering only gmail/slack. The finding was off by roughly an order of magnitude, and the remedy was aimed at the wrong layer. Nothing on disk could have caught it, which is what this PR changes.

The type's own docstring was part of the trap: it described only the framework case ("Returned by the controller when an event's controllable falls outside the security domain tag being tested"), even though every optimizer also returns this to decline.

The change

  • ControllableNoInjection gains declined_by: Literal["scope", "optimizer"] = "optimizer".
  • security_domain_filter sets "scope" on its own declines.
  • _serialize_response persists the field, so it reaches the results tree.
  • Docstring rewritten to describe both producers, and reference/events-and-trajectory.md now tells analysts to read the field before interpreting any decline.

Backward compatible by construction. The dataclass is kw_only=True, so no positional construction exists to break, and the default is correct for every optimizer-built response. There is exactly one other construction site in the framework (the filter itself). Across superred-modules there are ~200, all keyword-constructed, all correctly defaulting to "optimizer". Nothing in the framework hashes or compares responses, so the added field is inert for equality. load_trajectory returns raw JSON, so the read-back path needs no change.

Verification

Round-tripped end to end through the real filter and the real serializer:

OUT-OF-SCOPE (framework blocked) -> {..., "controllable": "env_tool:terminal.system", "declined_by": "scope"}
IN-SCOPE (optimizer declined)   -> {..., "controllable": "env_tool:gmail.public",     "declined_by": "optimizer"}

591 passed (589 pre-existing untouched, plus 2 new) - ruff check/format clean - mypy clean on 25 source files.

Two tests pin the distinction: the filter marks its own declines "scope", and a handler-constructed decline stays "optimizer".

RoldSI added 3 commits August 27, 2026 16:17
`security_domain_filter` answers an out-of-scope controllable event with a
`ControllableNoInjection` without consulting the optimizer, and
`trajectory_recorder` sits outside it, so both the event and the decline are
recorded. An optimizer that is consulted and passes produces a byte-identical
entry. On disk the two are indistinguishable.

They are opposite findings. "The attacker had no write access to this surface"
belongs in a not-applicable bucket; "the attacker had access and chose not to
use it" is an attacker result. Only the second says anything about the
attacker, and a zero-injection analysis that conflates them reports the
framework's own scope policy as attacker behaviour.

This is not hypothetical. A DTAP audit read a results tree, counted every
recorded controllable event as an offer the attacker had declined, and
concluded 41 tasks showed a classifier defect. Recomputed with the scope taken
into account, 35 of them never had an in-scope surface fire at all: the agent
was calling `terminal` tools under a grant that covered only `gmail`/`slack`.
The finding was off by an order of magnitude and the fix aimed at the wrong
layer. Nothing on disk could have caught it.

Add `declined_by: Literal["scope", "optimizer"]` to `ControllableNoInjection`,
set it in the filter, and persist it. The default is `"optimizer"`, which is
correct for every optimizer-constructed response, so no module changes and no
existing behaviour changes: 589 pre-existing tests pass untouched.
The record gained a field while `SCHEMA_VERSION` stayed at 4, so a reader has
no way to tell a tree that records who declined from one that does not. Every
existing tree lacks the key, and the obvious way to read it,
`entry.get("declined_by", "optimizer")`, silently re-commits the exact
conflation the field exists to prevent: it reports the framework's own scope
policy as attacker behaviour.

Absence of the key means UNKNOWN, never "optimizer". Version 5 says so, and the
constant carries that rule as a comment where a reader will meet it.

The two tests that hardcoded `== 4` now assert against `SCHEMA_VERSION`, so a
future bump does not require editing them.
The PR bumps SCHEMA_VERSION to 5 but reference/results.md still said version 4,
which is the page a results-tree consumer reads to learn which fields a
persisted trajectory carries. Also names what 5 adds and repeats the read rule
that absence of declined_by means unknown, never optimizer.
@RoldSI
RoldSI marked this pull request as ready for review August 28, 2026 22:02
@RoldSI
RoldSI merged commit 4f5ccf0 into main Aug 28, 2026
3 checks passed
@RoldSI
RoldSI deleted the fix/decline-origin branch August 28, 2026 22:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant