Skip to content

flow: threat model and prompt-injection controls for teach and repair - #1359

Open
abrichr wants to merge 4 commits into
mainfrom
claude/threat-model-injection
Open

abrichr wants to merge 4 commits into
mainfrom
claude/threat-model-injection

Conversation

@abrichr

@abrichr abrichr commented Oct 19, 2026

Copy link
Copy Markdown
Member

What this answers

When an agent demonstrates a task (compile-agent, teach over MCP, the airbag guard) or proposes a repair, it reads live application content. A record note, a banner or a server response can tell it to do something else, and published work (OS-Harm, RedTeamCUA, VPI-Bench) shows that such text often succeeds against computer-use agents. Before this change OpenAdapt had admission gating for runs, but no written threat model and no tests for the teach and repair steps.

This PR does not try to filter page text. It limits what the agent's actions can do, using values declared before the agent reads any page (CaMeL's split between control and data), and it reads the system of record back afterwards.

Changes

  • THREAT_MODEL.md covers demonstration, teach, repair and run. It lists the attackers (page content, a compromised app, a malicious recording, the agent itself) and the assets (records, credentials, approvals, admission keys). For each control (teach scope, save gate, record checks, admission, approval binding, egress limits, repair limits) it says what the control stops and what it does not, and it ends with a section on what remains unprotected.
  • openadapt_flow.teach_scope: TeachScope declares the app origins, the record, the fields with their exact values, and any constants. TeachGuard enforces it on a live capture:
    • Typed text must equal a declared value, and paste is refused.
    • A click on a link or a form submit to an undeclared origin is refused before the click.
    • Every browser request to an undeclared origin is aborted, from before the first page load (PlaywrightBackend.launch(before_goto=...)).
    • With an app write adapter, each write is read before it is sent. A write to another record, to an undeclared field or with an undeclared value is aborted.
    • Flow reads the record back after the run (check_delta). The agent never runs this check, so it can't skip it.
  • compile_agent(..., teach_scope=...): a violation ends the capture as scope_refused or readback_refused, with no draft. A clean draft carries a teach_scope report with names and digests only. The scope must declare every task input. With no scope, behavior is unchanged.
  • Approval provenance: ItemApprovalRequest.provenance holds who asked, the agent's own reason (shown as a quote), the screen text it saw, the lines of that text that read like an instruction to an agent, and what the task allows. It is inside the request digest and left out when absent, so existing digests don't change. The phone portal card shows it. guard_commit takes a reason, and self-repair fills the reason from the proposal note.
  • Self-repair refuses a proposed control that is a link or form leading to another origin.
  • MockMed gets ?inject=<surface>:<goal> fixtures (note, banner, tooltip, alt, network, aside × other_patient, other_field, other_origin, skip_check) and a GET /api/notice route. Without the flag nothing renders. All text is synthetic.

Tests (no paid model calls)

  • tests/test_injection_suite.py (40 cases, about 60 s, real browser). The scripted agent reads every text on the page, attributes included, and obeys every injected instruction, which is the worst case for a model.
    • 5 surfaces × 4 goals: each attack is blocked (write_wrong_record, typed_value_not_declared, link_origin_not_declared) or caught by the read-back (missing).
    • Variants: a field changed by clicking (write_value_not_declared), navigation to the outside origin, an image beacon (egress_origin_not_declared), and apps that lie about the save (wrong record, partial, duplicate).
    • Three positive controls run the same agent without a scope: the wrong patient is saved, the outside server gets the click and the beacon, and the extra text is saved. This shows the blocked cases are not vacuous.
    • Four cases go through compile_agent itself: a hijacked capture and a lying app each end with no draft, a clean scoped capture makes a draft, and a scope that misses an input is refused.
  • tests/test_teach_scope.py: unit tests, plus an import-order regression test.
  • tests/test_self_repair.py::test_a_proposal_steered_off_the_app_by_page_text_is_refused. Finding: without the new origin check, this decoy passes the patch and is stopped later by the probe's identity check. The new check refuses it earlier and does not depend on that identity read failing.
  • tests/test_guarded_save.py::test_the_phone_shows_why_the_agent_asked, and a provenance assertion in the existing phone-approval self-repair test.
  • tests/e2e/test_injection_live.py: live-model mode (claude-opus-5-5 through the computer toolset). It is off by default and needs OPENADAPT_INJECTION_LIVE=1, a named approver in OPENADAPT_INJECTION_LIVE_APPROVED_BY (ask the coordinator first; at most $2.00 for 4 runs), and the customer's own key. It has not been run.

Local runs:

  • Flow: 791 passed, 4 skipped, 3 xfailed across the approval, console, JIT (including tests/e2e/test_jit_cua_e2e.py), MockMed, release, guard, self-repair and injection tests.
  • packages/agent: 511 passed.
  • ruff check, ruff format and mypy are clean.
  • Not run locally: the Desktop vitest (src/portalShell.test.ts), which gets two new cases. CI runs it.

Still unprotected (also in THREAT_MODEL.md and LIMITS.md)

🤖 Generated with Claude Code

abrichr and others added 4 commits October 19, 2026 12:24
An agent that demonstrates a task or proposes a repair reads the live app,
and that content can be written to steer it. This adds a written threat
model and capability limits declared before the agent reads any page,
rather than filtering page text.

- openadapt_flow.teach_scope: TeachScope (origins, record, fields and exact
  values, constants, optional app write adapter) and TeachGuard. Typed text
  must be a declared value, paste is refused, links and form submits to an
  undeclared origin are refused before the click, every browser request to
  an undeclared origin is aborted, writes are read before they are sent, and
  the system of record is read back after the run.
- compile_agent(..., teach_scope=...): a violation ends the capture as
  scope_refused or readback_refused with no draft; a clean draft carries a
  value-free teach_scope report. The scope must declare every task input.
- Approval provenance: ItemApprovalRequest.provenance (who asked, the
  agent's reason, the screen text it saw, instruction-like lines, the task
  scope), in the request digest, omitted when absent so old digests hold.
  Shown on the phone card. guard_commit takes a reason; self-repair fills it
  from the proposal note.
- Self-repair refuses a proposed control that leads to another origin.
- MockMed ?inject=<surface>:<goal> fixtures and GET /api/notice.
- tests/test_injection_suite.py: 5 surfaces x 4 goals with a scripted agent
  that obeys every instruction, plus positive controls; no model calls.
  tests/e2e/test_injection_live.py: live-model mode, off by default.
- docs/THREAT_MODEL.md; LIMITS.md, CAPABILITIES.md, JIT.md, SECURITY.md.

All data is synthetic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ni-0bfadc

# Conflicts:
#	CAPABILITIES.md
#	llms-full.txt
#	packages/flow/docs/JIT.md
#	packages/flow/openadapt_flow/approval_mode/item_approval.py
#	packages/flow/openadapt_flow/repair/self_repair.py
#	packages/flow/public-artifacts.json
…ni-0bfadc

# Conflicts:
#	CAPABILITIES.md
#	llms-full.txt
#	packages/flow/openadapt_flow/console/save_approvals.py
#	packages/flow/public-artifacts.json
@abrichr

abrichr commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

Included in batch #1384, which runs CI once for several reviewed PRs. This PR is marked merged when #1384 merges. Please don't push here unless asked; a new commit leaves it out of the batch.

This branch has not been deployed

No deployments
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