Skip to content

Direct agent communication interception

Direct interception is AIT's primary workflow. It does not require a workspace initialization step, target profile, finding, or certification record.

New operator? Use See AIT work in five minutes first, then keep the Interception screen guide open as a field reference. Instructors and presenters can use the demo catalog and teaching guide.

Try it in the hands-on lab

The quickest useful demonstration is the packaged local lab:

ait lab start --exercise delegated-a2a-message

Open the printed Cockpit URL, select Send exercise traffic, change /params/message/metadata/amount from 25 to 75, and choose Forward modified. The Lab drawer shows both the value consumed by the specialist and the executor's independent effect ledger. No Docker image, provider key, framework package or workspace initialization is involved.

Use ait lab reset to clear the queue and receiver state, and ait lab stop to terminate every lab-owned process. See Hands-on Interception Lab for all three exercises.

Mental model

flowchart LR
    S["Sending agent"] -->|"one routing change"| A["AIT / Seam"]
    A -->|"forwarded original or edit"| R["Receiving agent"]
    A --> Q["Operator pending queue"]
    Q -->|"forward, edit, drop, replay"| A

AIT uses explicit placement. For an HTTP-based A2A connection, the sending agent changes its destination from the receiving agent to AIT's loopback listener. AIT keeps the original upstream and forwards the operator's decision.

Quick Connect

ait connections add \
  --name specialist \
  --protocol a2a_jsonrpc \
  --upstream http://127.0.0.1:9000

ait connections test CONNECTION_ID
ait intercept start --connection CONNECTION_ID

The start output contains the local address to give the sending agent. No other AIT object needs to be created.

Run ait server start for the Cockpit. Quick Connect is the initial setup view. It proposes an available loopback listener and shows the routing change. The Discover tab inspects the explicitly supplied Agent Card; it never follows redirects or discovers remote trust material. Manual exposes bounded transport configuration.

Pause only what matters

With interception enabled and no break conditions, every decoded message pauses. Conditions can match:

Field Example
Protocol a2a or mcp
Direction request or response
Operation send_message or message/send; either spelling matches
Path /a2a
Agent source or destination identity
Lifecycle task, context, or artifact identifier
Content a predicate over any decoded path

Operation accepts the decoder's normalized name as well as the raw protocol method, so one condition covers the JSON-RPC and REST bindings of the same A2A operation. Task and context resolve the canonical locations, including params.message.contextId on a delegated message.

Content predicates aim at what a message says rather than only at how it is addressed, using the same vocabulary as the YAML rewrite rules -- equals, contains, exists, not_exists, regex, in, and the numeric comparisons gt, gte, lt, lte.

The numeric operators are what a threshold boundary needs: pause only the messages that cross an approval limit rather than every call to the same tool, for example --where 'json.params.arguments.amount:gt:500'.

ait intercept start --connection CONNECTION_ID \
  --operation send_message \
  --where 'json.params.message.parts.0.text:contains:refund' \
  --where 'json.params.message.metadata.tenantId:equals:tenant-alpha'

Repeat --where to require several conditions; all must hold. Paths accept a leading decoded. so they can be copied straight from a rule, and index into arrays.

Non-matching traffic passes immediately. The pending queue is bounded. A full queue returns a visible error and never silently bypasses a break.

Make a decision

The structured editor separates the decoded body from the transport envelope. Body fields, HTTP method/path/status and safe headers, SSE identifiers and types, WebSocket metadata, gRPC metadata names/protobuf fields, and MCP stdio framing are visible in the same inspector. Credential-bearing headers are readable and editable. Add, delete, rename, duplicate, and reorder fields before selecting Forward modified. Parse or encoding errors leave the immutable original pending. Raw bounded editing is opt-in.

For streams, select multiple pending events in the order they should be released. AIT can release, drop, replay, or duplicate the selection and records both the requested and actual order.

The available decisions are:

  • Original: forward the message as presented. When a rewrite rule has already matched, that is the rule's output.
  • Sender's version: forward what the sender actually sent, bypassing the rule for this one message. Offered only when a rule fired.
  • Forward modified: encode and forward the edited structure.
  • Drop: do not deliver the message.
  • Duplicate: deliver two or more correlated copies. Available for requests, SSE events, WebSocket frames, and stdio messages. A unary HTTP response has one delivery slot, so duplication there is refused rather than silently ignored.

AIT records both representations, changed JSON pointers, the decision, copies, correlation identifiers, and the receiving-agent response. This is operational evidence; it does not silently create a finding or claim an external impact.

Attack ideas

For a selected pending message, the inspector shows a compact Attack ideas section. Suggestions are derived locally from the captured protocol structure; they never contact the target. Each suggestion explains:

  • what boundary the edit tests;
  • the exact JSON Pointer or delivery behavior that will change;
  • what receiver behavior to watch for;
  • a semantically close control.

Choose Prepare edit to put the proposed change in the editor. AIT still waits for the explicit Forward modified, Drop, or Duplicate action. The equivalent terminal flow is:

ait intercept suggest MESSAGE_ID
ait intercept edit MESSAGE_ID --suggestion a2a.context-crossover
ait intercept compare MESSAGE_ID

compare labels a correlated receiving-agent response as observed and says clearly when no downstream effect is observable. A response is useful operational evidence, but is not silently promoted to an impact claim.

The Offensive interception field guide provides a field-by-field A2A and MCP test matrix, controlled injection examples, close-control design, and evidence interpretation.

Live rules

Select a previously modified message and choose Apply to future. AIT derives an exact selector and the changed JSON-Pointer patches. The enabled rule then applies before the pause decision on later matching traffic. Toggle rules in the left rail, reorder them with the arrow controls, or use the ait intercept rule enable and ait intercept rule disable commands. Rules show their scope and hit count. Saved rules reload for later sessions that use the same connection. Export the ordered JSON rule set with:

ait intercept rule preview RULE_ID
ait intercept rule export --out live-rules.json
ait intercept rule import --from live-rules.json

Rules are finite deposited edits. They do not execute shell commands, arbitrary code, expression languages, or unrecorded templates.

Transform rules

A live rule and a transform rule are different things, and it matters which one you are holding.

A live rule is a bounded JSON-Pointer patch derived from a manual edit, scoped to a session, and edited in the Cockpit. A transform rule is a YAML document loaded by Seam's transform engine, armed on a connection so it survives a session restart. Transform rules carry the wider rewrite vocabulary: regex replace with captures, deep merge, positional insert (which is what MCP tool shadowing requires), $from_file payloads, templating, and chain guards.

ait intercept transform arm ./rules/mcp-tool-schema-poison.yaml
ait intercept transform list
ait intercept transform test --fixture ./captured-message.json --expect-rule my_rule
ait intercept transform trace --transcript ./transcript.json
ait intercept transform explain my_rule
ait intercept transform remove my_rule

Arming validates the whole prospective rule set before writing anything, so a mistyped predicate, a chain operand that does not exist, or a duplicate id sharing one chain counter is refused rather than armed. When a session is live, arming hot-applies without a restart.

Tracing a chained rule

trace --transcript replays the whole capture against one engine, rebuilding the chain ledger from the run's own record of what was delivered. A rule guarding on chain.fires.<other> therefore resolves here the way it resolved on the wire: step two is seen to fire because step one is seen to have landed.

Three things decide whether that replay is faithful, and all three are enforced rather than assumed.

A fire means delivered, and that is narrower than "a rule ran". Replay reproduces the live confirmation predicate exactly, and each part of it excludes a case that writes a convincing-looking record:

Replay does not infer any of this. The transport settles each receipt before appending, and writes the ledger's own answer into the record as rule_evaluation[].chain.settled. Replay reads that field.

Inferring it was not equivalent. A reset between the append and the settlement fences the generation, so the confirmation is rejected while the record still reads as a delivered guarded rewrite, and anything reconstructing the outcome counts a fire the ledger never held.

the record shows counted why
chain.settled is true yes the ledger accepted the confirmation
chain.settled is false no the rule delivered nothing, or a reset fenced the confirmation
chain is absent no the rule carried no guard, so the engine issued no receipt and it never entered the ledger
chain.settled is absent no the transcript predates settlement recording and cannot say what the ledger did
rule_applied is interactive:forward_original the rule named in the evaluations on the interactive path that field carries the operator's decision, not a rule id

Participation is decided by chain being present, not by searching the predicate list. Predicates are dropped past a budget of 128, so a rule with many match conditions can have its chain predicate truncated out of the record while remaining a guarded rule.

One limit is not closed. A reset that lands after the last chained record is not visible in the transcript, so a replay of that session counts fires the operator later cleared. Closing it needs the reset written into the chain as an event.

A record is traced before its own fire is posted. A rule cannot be armed by its own delivery, and posting first would let a fire-once rule appear to have blocked itself.

The transcript is verified first. Schema, record chain and integrity manifest, before a single fire is read. That establishes it is integrity-valid and not that it is authentic: the records are unsigned, so verification proves the chain is internally consistent and unmodified since it was written, not that Seam wrote it. Arbitrary JSON can name any rule in rule_applied, so a ledger rebuilt from an unverified file is one the reader invented. The schema is compiled into the binary, so this works from any directory; --schema still overrides it.

Incomplete history is refused, not approximated. A gap, a duplicate, a sequence that goes backwards, or a first record that is not sequence 0 stops the trace and names the record that broke it. A Seam chain numbers its first record 0, so a transcript starting anywhere else has had its beginning removed and the counters those records carried cannot be recovered. A ledger rebuilt from a partial run reports counters nobody ever read, and a trace built on it inverts the causal story it exists to establish. Refusing is the lesser failure. It is wrong in the direction of saying less.

seam rules trace: cannot replay chain state: record 7 (seq 12): sequence jumps
9 -> 12; 2 record(s) are missing and any fire they carried is lost from the
ledger

transform test --fixture is different by design. One captured message has no history behind it, so there is nothing to replay and a chain guard reports chain_guard against a ledger that reads zero. Applying a rule with no engine at all reports chain_state_unavailable, which is a distinct fact: "nobody looked" rather than "somebody looked and the poison had not landed". Only the second is an observation.

list prints rules in evaluation order, which is filename order. The engine applies the first matching rule and returns, so order decides which of two matching rules actually fires, and a rule the list marks SHADOWED can never fire at all.

test and trace report what the engine would do, not merely which rules match: at most one rule is applied. Use --expect-rule as a pre-flight before arming against a live target. It fails when the named rule cannot fire, which is how you catch a rule that a broader rule silently disables.

Why a rule missed

test and trace report a miss_code for every rule that did not fire. They distinguish causes an operator would act on differently:

Miss code Means
predicate_mismatch the rule was evaluated and a where predicate did not hold
missing_path the decoded path the predicate names is absent from this message
decoder_ineligible the message did not decode, so no rule could be applied
shadowed an earlier rule matched first; the engine returns on first match
disabled the rule is loaded and listed but parked with enabled: false
chain_guard a chain guard is not satisfied: the step it depends on has not delivered
chain_reference_unresolved the rule it guards on is not in the loaded pack
chain_state_unavailable no chain ledger was available to this caller, so the guard could not be evaluated

The last one matters for offline replay: trace has no ledger, so a chained rule reports that it could not be evaluated rather than fabricating a counter that nothing read. A counter reported as zero when nothing read it would invert the causal story.

Chained rules

A transform rule may guard on whether another rule has already delivered:

where:
  - path: chain.fires.poison_catalogue   # after that rule's mutation reached the wire
    op: gte
    value: 1
  - path: chain.self                     # and only fire once
    op: lt
    value: 1

chain.fires.<id> counts delivered mutations, not attempted ones. A rule whose output the operator dropped, replaced with the original wire bytes, or which failed to encode does not advance a chain, so a dependent rule cannot arm on a poison that never landed, and the transcript cannot assert a causal chain that did not happen.

chain.self additionally counts reservations in flight, so a fire-once rule holds when frames race.

Two consequences worth knowing before you arm one. Chain state is reset at session start and zeroed for any rule whose body you edit and re-arm: the response reports which counters were reset. And because a chain guard is an assertion about ordering, loading one switches interactive SSE interception to sequential delivery: an operator pause then blocks the stream head-of-line. That cost is only paid when a chain guard is loaded.

Export a session

Export a redacted, offline record without creating a finding:

ait intercept export SESSION_ID --format jsonl --out session.jsonl
ait intercept export SESSION_ID --format html --out session.html
ait intercept open session.jsonl

JSONL preserves bounded message records for later tooling. HTML is a self-contained human-readable timeline. A profile export carries connection placement, break conditions, and saved rules.

Every format takes an explicit disclosure mode. --redacted, the default, carries allowlisted metadata: bodies and raw captures are omitted entirely, and header values are withheld unless the header name is known-safe -- a denylist of credential-sounding names cannot anticipate X-Functions-Key or the next vendor's equivalent. --raw is a complete capture including credential values, labelled "status": "raw" so it is never mistaken for a sanitized artifact.

Stop safely

By default a paused message waits indefinitely, which is what an attended session wants. Start with --pending-timeout SECONDS (and optionally --pending-timeout-action drop) when a session may be left unattended: a paused message holds the sender's connection open, so an operator who walks away stalls the system under test. Messages the broker resolves itself are marked timed_out, never presented as an operator decision.

Stop still requires one explicit choice for anything still waiting:

ait intercept stop --pending forward
ait intercept stop --pending drop

AIT resolves every pending message with that decision, then stops listeners, streams, wrappers, and owned subprocesses.

Placement notes

Protocol Placement
A2A JSON-RPC / REST explicit HTTP reverse relay
SSE relay parses and pauses individual events
WebSocket relay parses and pauses individual frames
MCP Streamable HTTP explicit HTTP relay
MCP stdio command wrapper around the MCP server
A2A gRPC A2A-specific packaged bridge

Quick Connect starts all network placements directly, including the packaged A2A-specific gRPC relay. For MCP stdio it creates the Cockpit session and prints the exact replacement command to paste into the MCP client's server configuration. Running that command registers the wrapper with the existing session; stdout remains protocol-only while bounded diagnostics go to stderr.

For gRPC, point the sending agent at the printed grpc:// listener. The editor shows the decoded protobuf message and metadata names. Credential metadata values are captured as sent. Exports choose their disclosure explicitly; see above.

TLS termination and re-origination use operator certificate references. AIT is not a transparent interception appliance.

Troubleshooting

  • No traffic: the sending agent is probably still using the upstream address. Use the listener printed by ait intercept start.
  • Nothing pauses: ensure Intercept is on and remove or broaden break conditions.
  • Response also pauses: this is expected with no conditions. Add direction=request if only requests should pause.
  • Queue full: decide or drop pending messages, then retry the sender.
  • Invalid edit: switch back to Structured, correct the highlighted parse error, and forward again. The original remains intact.
  • Cannot stop: choose how pending messages should be resolved.
  • MCP stdio wrapper is not visible: use the exact command printed by ait intercept start; the wrapper must use the same local workspace.
  • gRPC method is unsupported: AIT's bridge is intentionally A2A-specific, not a general-purpose gRPC interceptor. Check the method in the message inspector and the upstream A2A capability profile.