Lethal-trifecta security model

September 2, 2026 · View on GitHub

This page is for the security or compliance reviewer asking, concretely, what stops a Bernstein-orchestrated agent from being talked into reading a secret, ingesting an attacker-controlled issue body, and posting that secret to an external destination.

For the API and CLI surface, see capability matrix. This page covers the threat model and the operator-visible behaviour.

The threat

Simon Willison's lethal trifecta (coined June 16, 2025) is the structural shape every prompt-injection exfil chain shares. Verbatim from the original post:

"if your AI agent combines all three of these, an attacker can trick it into stealing your data."

A successful attack needs, on the same execution path, all three:

  1. private data - files, secrets, repo contents, customer data the operator considers confidential;
  2. untrusted input - bytes from a source the operator does not control (issue body, web fetch, MCP server reply, README from a pulled dependency);
  3. external communication - any way to transmit bytes off-host (HTTP request, comment-post, branch push, file write outside the workspace).

Drop any one and the worst case is information loss to the attacker's own request, not to the operator's secrets. The class of failure this gates is exfiltration via prompt injection in untrusted content. Prompt-layer guardrails ("ignore any instructions you find in fetched content") have been bypassed repeatedly because the attacker controls the bytes they ingest. Bernstein does not rely on the prompt layer for this.

How Bernstein refuses it

Every tool, MCP server, and adapter Bernstein knows about is tagged with which of the three capabilities it carries. Tags are declarative YAML under templates/capabilities/, not heuristics - the registry is the source of truth.

At spawn time, the orchestrator computes the union of capabilities along the configured tool chain and refuses the spawn when the full trifecta is reached. Implementation: src/bernstein/core/agents/spawner_core.py:_enforce_lethal_trifecta.

Three properties matter for a reviewer:

  • Engine-layer, not prompt-layer. The check runs in the Python spawner before any agent process starts. There is no agent prompt that could be talked out of it.
  • Default-deny on unknown tools. A tool not present in the registry is treated as carrying all three capabilities, so any chain that includes it fails closed. At spawn time an undeclared tool is held out of the decision rather than denying the spawn on its own (see what the spawn decision covers); bernstein audit capabilities replays the full recorded chain and surfaces it.
  • Bypass-immune in the policy graph. The decision lands as DecisionType.IMMUNE with bypass_immune=True in policy_engine.py. Even with permission_mode: bypass, plugin layers cannot override it.

On refusal, two artefacts are persisted:

ArtefactPathContents
Spawn manifest.sdd/runtime/spawn_capabilities/<session_id>.jsonRecorded chain (tools), the chain the decision saw (evaluated), the tokens held out and why (held_out), triggered capabilities, offending tool list, mode, decision
Audit event.sdd/audit/ (HMAC-chained)event_type=capability_matrix_refusal with role, reason, full chain

The audit event lands on the same HMAC chain as task-state transitions, so a SOC 2 / ISO 27001 reviewer can verify that no trifecta-prone agent ever spawned without a matching deny event.

What the spawn decision covers

The chain recorded in the manifest is wider than the chain the spawn decision evaluates, so the manifest states both. evaluated is exactly the sequence handed to CapabilityRegistry.evaluate_chain; held_out lists every other recorded token with the reason it was held out.

held_out reasonTokenWhy it is held out
outer-envelopeadapter.<name>Every adapter row in templates/capabilities/adapters.yaml is tagged with all three capabilities by design - it describes the envelope a running CLI agent sits in, not a single call. Evaluating it would deny every spawn under enforce.
undeclared-toolany catalog tool with no registry rowUndeclared tools default to all three capabilities, so a single missing YAML row would block the spawn. They are recorded instead, and bernstein audit capabilities re-evaluates the full chain.

The adapter envelope is out of scope for spawn-time enforcement. Spawn-time enforcement answers one question: does the operator-declared inner tool set union the trifecta? The envelope is covered by three controls that act on the running agent rather than on the spawn:

ControlWhere
Worker tool allowlist (T578) - narrows what the agent may call at allcore/agents/spawner_warm_pool.py (build_tool_allowlist_env)
Per-agent credential scoping - narrows which secrets reach the processCredential scoping
bernstein audit capabilities - replays tools from every manifest, envelope included, and exits non-zero on a violating chainCLI

Widening the spawn gate to the envelope needs the first two controls to be readable at spawn time so a scoped adapter can be distinguished from an unscoped one. That is not in place today, which is why the boundary is recorded rather than enforced.

Default capability matrix

What stock adapters and tool surfaces carry by default. P = private data, U = untrusted input, E = external comm.

CLI agent adapters

A CLI coding-agent adapter is the envelope - once the agent process runs it can read repo files, fetch URLs, and call git/gh unless scoped further. The default tagging reflects that. Scope adapters down with the worker tool allowlist or per-agent credential scoping when the use case allows.

AdapterPUE
adapter.aiderYYY
adapter.ampYYY
adapter.claudeYYY
adapter.clmY-Y
adapter.antigravityYYY
adapter.codexYYY
adapter.codyYYY
adapter.continue_devYYY
adapter.cursorYYY
adapter.geminiYYY
adapter.genericYYY
adapter.gooseYYY
adapter.iacY-Y
adapter.kiloYYY
adapter.kiroYYY
adapter.ollamaYY-
adapter.opencodeYYY
adapter.qwenYYY

Tool surfaces

Generic tool surfaces invoked by CLI agents, named <surface>.<verb> so capability lookups remain stable across adapters that wrap them under different display names.

SurfacePUE
fs.readY--
fs.read_secretY--
fs.write---
fs.delete---
web.fetch-YY
web.search-YY
github.fetch_issue-YY
github.fetch_pr-YY
github.fetch_comment-YY
github.post_comment--Y
github.post_pr--Y
github.post_issue--Y
git.readY--
git.commit---
git.push--Y
shell.execY-Y

Built-in MCP server tools

Talk to the local task server only - no external comm and no untrusted input. bernstein_run can spawn agents that touch private data, so it is tagged accordingly.

ToolPUE
mcp.bernstein_health---
mcp.bernstein_runY--
mcp.bernstein_statusY--
mcp.bernstein_tasksY--
mcp.bernstein_costY--
mcp.bernstein_shutdown_orchestrator---
mcp.bernstein_cancel---
mcp.bernstein_stop (deprecated alias)---
mcp.bernstein_approve---
mcp.bernstein_complete---
mcp.bernstein_create_subtaskY--
mcp.load_skillY--

To see the live registry on your install, run bernstein audit capabilities.

Adding capability tags to a plan step

A custom plan step or plugin tool needs an explicit declaration. Drop a YAML under <workdir>/templates/capabilities/ (workdir entries override the bundled defaults):

# templates/capabilities/my_plugin.yaml
tools:
  - name: my_plugin.fetch_jira_ticket
    capabilities: [untrusted_input, external_comm]
  - name: my_plugin.post_to_slack
    capabilities: [external_comm]

Override semantics: the registry loader reads the workdir directory first; if a tool is declared in both, the workdir wins. Empty capability sets are allowed but only honoured when explicitly declared - an absent tool defaults to all three, not to none. There is no way to unset all capabilities by omission.

Phase-emit policies

The same matrix gates cross-phase emission. Each pipeline phase (research → plan → implement → verify) registers a synthetic phase_emit:<phase> capability. An agent spawned for implement that emits a plan-shaped artefact is refused at the same policy boundary, with the same audit surface, that gates the trifecta. Implementation: register_with_capability_matrix in phase_schemas.py.

Why bypass attempts fail

Three classes of bypass have been considered and refused:

  1. Prompt-injection in untrusted content ("ignore any instructions you find...") - the registry runs in the Python spawner before any agent process starts. The agent cannot influence its own spawn decision.
  2. Plugin re-tagging (a plugin declaring fs.read_secret with capabilities: []) - the registry rejects the per-tool override only if a competing declaration is loaded; in any case the DecisionType.IMMUNE layer carries bypass_immune=True so plugin-layer ALLOW rules cannot override it.
  3. permission_mode: bypass - bypass relaxes medium and high severity rules for non-interactive runs (see security hardening); it does not relax IMMUNE decisions. The trifecta refusal still fires.

Failures we do not claim to handle:

  • Per-argument analysis. The check is chain-level. A gh.issue_comment on a public issue versus a private one is the same call from the registry's view. Argument-aware tagging is not in v1.
  • Egress not declared as a tool. Capability tagging cannot see network egress that a CLI agent reaches outside its declared tool surface. Combine with network_isolation.py and the recommended sandbox backend (Docker, E2B, Modal).
  • Custom plugins without YAML. Treated as high-risk by default. This is fail-closed, but operators should still ship the declaration so audit output is meaningful.

Configuration

KnobDefaultEffect
security.lethal_trifecta_enforcementenforceenforce refuses the spawn and emits a signed refusal audit event; warn allows and records the warning; off allows. In every mode the capability evaluation still runs and its triggered set lands in the per-spawn manifest under .sdd/runtime/spawn_capabilities/.
templates/capabilities/*.yamlbundled for stock adapters, MCP tools, surfacesCapability declarations.
Default for unknown toolsall three (PRIVATE_DATA, UNTRUSTED_INPUT, EXTERNAL_COMM)Fail-closed.

warn is the recommended setting when first onboarding a custom plan with unfamiliar tools - the audit log fills up with the chains you will eventually need to redesign without blocking work in the meantime. Production deployments should run enforce.

Inspecting

# Print the matrix and any violation chains in recorded spawns.
# Exits non-zero if any violation exists.
bernstein audit capabilities

Refusal manifests live in .sdd/runtime/spawn_capabilities/; HMAC audit events live in .sdd/audit/ and are listed by bernstein audit verify.