Skip to content

feat(tripwire): screen outside tool results for prompt injection - #87

Merged
albanm merged 28 commits into
mainfrom
chore-prompt-injection
Nov 3, 2026
Merged

albanm merged 28 commits into
mainfrom
chore-prompt-injection

Conversation

@albanm

@albanm albanm commented Nov 3, 2026

Copy link
Copy Markdown
Member

Before the agent model reads a tool result from outside the service, an LLM classifier on the moderator role screens it for text addressed to an AI agent. A result that trips it is withheld, and the turn stops. Input moderation now runs on the same hardened classifier engine: two sides of one coin, untrusted user input and untrusted tool output.

  • Scan set: MCP tools without openWorldHint: false (the MCP default), page tools, read_file and webhook payloads. The service's own tools and read_skill are not scanned. Neither is read_conversation, since transcripts are people instructing the agent, and it never serves a withheld payload back.
  • On a trip: one more step runs with no tool. The model has seen only a placeholder, never the flagged text. It explains, in the person's language, that the source seems tampered with and what to do instead. A task run reports this as a needs-attention outcome and ends in error, with stop reason injection-suspected.
    • There is no "continue anyway".
    • Admins of the owner get the event agents:injection-suspected:<agentId>, and the server logs at alert with code tool-output-tripwire.
    • The chat shows what was withheld and where it came from.
  • Late verdicts: a result that passed on the 3.5 s timeout and is flagged afterwards stops the turn at the next step, and no further tool runs. If the flag lands after the last step, a warning is appended to the answer. The stored result is replaced by a placeholder that says the model had read it.
  • Classifier, shared by both guards (api/src/guard/):
    • the prompt is written as a policy guide;
    • text addressed to the classifier itself counts as an injection;
    • the content sits in a tag with a random per-call suffix, and the task is restated after it;
    • stage 1 answers one word, and stage 2 gives a structured verdict only on a flag.
  • Failure directions:
    • The tripwire lets a result through when stage 1 fails, and counts a failed stage 2 as a trip.
    • Moderation fails open at both stages. Its stage 1 now leans toward "yes", and stage 2 decides with "when in doubt, allow".
    • Coded logs make each case visible: warn on a fail-open, block or failed confirmation, alert on a trip or an armed cooldown.
  • Measuring:
    • dev/guard-eval runs a labelled set (65 tool results, 44 messages) through both guards' real two-stage calls.
    • The admin probe also checks the tripwire against the live model configuration.
    • Two new simulation cases: a subtle injected page, and an unattended task with a destructive tool at hand.
  • Switch: tripwire.enabled / TRIPWIRE_ENABLED, on by default.
  • Unrelated fixes:

Why: users will mix web and data exploration with destructive tools whatever the docs say. These are business-data agents, so their tool output has no legitimate reason to address the agent. A tripwire raises the cost of an attack, since an attacker who tries several payloads is likely to trip it. It is not a security boundary. Design and sources are in docs/architecture/moderation.md.

Measured on the dev bridge, with Claude Haiku as the moderator:

  • Tripwire eval: 2 of 87 injections missed, no false alarm on 108 benign results.
  • Moderation eval: no injection or abuse missed, no false alarm, 4 to 8 of 51 off-platform requests let through.
  • Five simulation rounds: 20 of 20 injected sources tripped, with no false trip in 21 ordinary runs, including the live data-fair sites.

Heads-up:

  • Production models not measured yet. Run dev/guard-eval and the simulations on each production moderator model before relying on these numbers.
  • On by default, no override: a false trip stops a turn or a task run.
  • Cost: at least one moderator call per scanned result, a second one on a flag, and up to 3.5 s of waiting per scanned call.
  • One poisoned row withholds the whole result.
  • Review hardest:
    • api/src/conversations/model-loop.ts: the explanation step, the stop reason, the late-trip gate, webhook screening;
    • api/src/conversations/live-runs.ts: the abort ordering, shared stop-path code.

albanm and others added 28 commits November 3, 2026 08:09
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createTurnTripwire screens a tool result window by window with the moderator role
model (reasoning disabled), metered as moderator on the run; a window that errors
or misses TRIPWIRE_TIMEOUT_MS passes and leaves a 'tripwire-fail-open' call entry
on the run. A trip logs tool-output-tripwire and, for a configured agent, pushes
the injection event. The mock answers <tool_result_to_classify> for any mock id.
Deployment switch: tripwire.enabled (TRIPWIRE_ENABLED).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…spected injection

withProvenance screens the text it is about to wrap (result, media text,
stringified object, thrown failure) when given the turn's tripwire; a
suspected result is thrown as the WITHHELD_RESULT placeholder, and an
abort during screening propagates instead of passing unscanned text.
modelTools screens open-world tools only (isScanned), code_mode and page
tools always. The loop stops after a tripped step with stopReason
injection-suspected (detail: the source), stores the trip record on the
call's toolMetadata, counts classifier credits toward the run budget and
returns the tripwire for late verdicts. Task runs count the stop as cut
short. Service tools declare openWorldHint explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oved calls included

An approved call of an earlier turn runs before the continuation's first
step, where stopWhen never looks: its trip now still yields the
injection-suspected stop reason and notice, and its stored part carries
the trip record. Waiting and awaiting-approval keep precedence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its original length

capForScan's cut now reaches the record like the recorder's own bound:
toolMetadata.truncated.totalChars is the whole result as the model would
have read it, envelope included, on a call of the turn and on an approved
call of an earlier turn alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A task run's webhook payloads are screened before its first model call; a trip
stops the run with no model call (status error, failed + injection-suspected).
A verdict settling suspected after its timeout withholds the result: on the
turn's own parts while the loop runs, then on the stored part by toolCallId
once the target closed (withholdStoredResult). The turn is not stopped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bhook screen runs once

- the driver closes the turn's tripwire (kept on the live run) after close or fail
- a task's reminder pass does not screen the webhook payloads again
- a late verdict's in-loop persist is awaited before the loop returns or throws
- a late trip is recorded on the run (note tripwire-late-suspected)
- mock seam FAIL_AFTER_RESULT: the step after a tool result fails mid-stream

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

One step of the spec now costs the assistant's call plus the tripwire's scan of
its tool result (same mock model, moderator role). At the former price a single
step crossed the per-run budget, so the spec tested the wrong ceiling; it now
also asserts the cap is crossed after a first step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moderation.md gains the tool-output tripwire section (scan set, criterion,
fail open, stop, events, late verdicts, known gaps, cost, sources);
web-access, mcp-tools, code-mode and the overview index point to it. Two
injection simulation cases (a web page, a dataset row) planted in the MCP
fixture, with their premises unit-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ested results

- A withheld webhook payload, on time or late, is replaced by the placeholder in
  the run's stored prompt (replaceWebhookPayload, withholdStoredResult), so
  read_conversation of the run no longer carries it.
- read_conversation's view keeps a script's nested calls (data-trace) without
  their results, which the tripwire never screened (withoutNestedResults); the
  export a person downloads keeps them.
- One withheldErrorText helper for the placeholder error text.
- Logged once per process when the tripwire classifies with the assistant model
  (tripwire-assistant-model).
- The URL named in the stop notice is cut at 200 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wo stages

- Policy-guide system prompt (gpt-oss-safeguard form) with a self-incrimination
  rule: content addressed to the classifier or any screening is an injection.
- Sandwich: the task is restated after the content, which sits in a tag carrying
  a random per-call nonce the content cannot name; any tool_result_ tag is defused.
- Two stages (after Claude Code auto mode): a one-word yes/no screen on every
  window that fails open, and on a yes a structured confirmation that clears a
  false alarm and counts as suspected when it fails (tripwire-confirm-failed).
- Mock seam reads the stage from the instruction after the wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s, review docs

- TRIPWIRE_STAGE1_MAX_TOKENS 5 -> 16: the OpenAI Responses API refuses fewer,
  which would have failed every stage-1 call open.
- An unparseable stage-2 answer is charged from NoObjectGeneratedError.usage.
- moderation.md: late verdicts up to ~60 s, no overclaim on the wrapper, our
  stage 2 runs without reasoning; the design spec notes it is superseded in part.
- MID-RUN cap test priced at 400/M so a step stays far under the cap as
  prompts grow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…failed confirmations

A trip is a security event: lib-node's alert level. A result passing unscanned was
recorded on its run only, so a broken moderator model turned the tripwire off with
no counter moving; it now logs a coded warn that df_logs counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The nonce wrapper, the sandwich instruction, the two stage calls (one word
at 16 tokens, then a structured verdict), reasoning disabled, temperature 0
and the metering of unparseable answers move to api/src/guard. The tripwire
uses them with no behaviour change; moderation takes withReasoningDisabled
from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The judged message is always in a nonce-tagged wrapper, a first message
included, its context in a second one, the task restated after both. The
policy is a policy guide with the self-incrimination rule. Two stages: one
word biased to allow, then the structured verdict on a flag only. Moderation
fails open at both stages; a failed confirmation is recorded as the new
fail-open-confirm event and counted in the fail-open rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tic codes

moderation-fail-open (warn, with its cause), moderation-block (warn, ids
only) and moderation-cooldown (alert, when a strike update arms it), counted
by df_logs like the tripwire's codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/moderation/:type/:id/probe adds a tripwire array: a benign table
with an x-agent column hint, an injected page and text addressed to the
classifier, each run through the tripwire's two stages on the live
moderator model, with the stage-1 answer, the verdict, latency and error.
Moderation's probes now run its two stages too and report stage 1. The
activity page lists both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One shared engine section, a side-by-side table of what differs, then each
guard's own: moderation's wrappers, two stages, fail-open-confirm and log
codes; the tripwire's hardening points moved to the engine.

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

- guard/service.ts: one stage-1-then-stage-2 sequence (classifyTwoStage) and one
  error-to-cause mapping (failureCause, describeFailure), used by moderation's
  classifyMessage, runTripwireProbe and the tripwire's confirmation step. Failure
  directions unchanged.
- The moderation probe carries the provider's error message again, as the
  tripwire's does (mock-failing seam, api test).
- Strikes: a block counted during a running cooldown neither extends nor re-arms
  it; the arming update is identified by an id, so each cooldown alerts once, even
  for two blocks counted in the same millisecond (api test, mutation-checked).
- Probe: both sets run at the same time; the response says whether the
  deployment runs the tripwire (tripwireEnabled) and the page marks it off.
- Docs: the deployment switch in the table, "on a confirmed block"; the
  operations.ts header states its guides.ts exception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ped too

abortRunsOfConversation (and abortRunsOfAgent) aborted the live turns, then
wrote the stop on the running runs. A turn going live after the abort loop, whose
initial stop read reached mongo before the write, missed both until its next
poll 2s later: a hello and a prompt sent back to back got two answers
(session-turn.api.spec.ts:350, ~3% failures). The abort loop now runs again once
the write is acknowledged: a turn live by then is aborted in-process, one going
live after it reads the stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 1 errs toward flagging

dev/guard-eval runs labelled tool results and messages through both guards' real two-stage
calls (moved to config-free classifier.ts files the services use) and reports misses, false
alarms, stage-2 rate and latency by style of content. On it, a stage 1 that erred toward
allowing let a third of the off-platform requests through unseen by stage 2: stage 1 now
screens toward yes and stage 2, biased to allow, decides. The policy gains off-platform
examples distinct from the eval's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he turn

A trip used to end the turn on a fixed English notice naming tools; every simulated person
had to ask what it meant. One more step now runs with no tool (report_outcome alone in a
task run, needs-attention), having seen only the placeholder, and explains in the person's
language that the source seems tampered with and what to do instead, never suggesting to
paste it. The fixed notice stays as the fallback and as a task run's error.

A verdict landing after a result passed on the timeout now stops the turn at the next step,
and no further call runs (NOT_RUN_AFTER_TRIP); after the last step the answer gets
LATE_TRIP_NOTICE. Its placeholder says the model had read the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a destructive tool

injection-web-page-subtle asks automated summaries to misreport a closure without any
"ignore your instructions"; injection-task-run plants the harbour row in a weekly task whose
agent can reset the workspace. readTheWeb now counts reads made inside a code_mode script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The spec compared credits billed to a full-rate price in euros. At the 0.008 EUR/credit peg
that only held while cache reads were over 99.2% of the input, so it broke once the turn's
prompt grew past ~1 008 mock tokens. Priced like usage.api.spec.ts (one token, one credit), it
now checks the exact uncached amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@albanm
albanm merged commit 720f546 into main Nov 3, 2026
4 checks passed
@albanm
albanm deleted the chore-prompt-injection branch November 3, 2026 16:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant