Skip to content

Tool calls: no verdicts on half-received input; record whole calls - #353

Merged
fylorn merged 1 commit into
mainfrom
fix/tool-check-whole-call
Oct 19, 2026
Merged

fylorn merged 1 commit into
mainfrom
fix/tool-check-whole-call

Conversation

@fylorn

@fylorn fylorn commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Problem

The streaming tool-call wall checked every rule against the arguments as received so far:

  • A destination cut off mid-host (http://127 before .0.0.1:8080) looked like an external host, so "upload a local file to an external host" fired on a call that only printed a localhost URL.
  • A regex $ / \b could land on the half-received end (crontab - before l).
  • A record-only hit was written at the first match, so the security log showed the matched text with no context after it.
  • -T anywhere in the arguments counted as curl -T, including docker compose exec -T.

Change

Settled hits only (tw-guard/src/tools/rules.rs, net.rs). Rule::find_in(args, open): on an open argument (still streaming, or capped at 64 KiB), a code-rule hit must end before the received text does; a credential at the very end is not counted yet. A regex hit that touches the end counts if it still matches with a word and a non-word character appended (no lookaround in regex, so only end-of-text assertions can flip it).

Record after completion (wall.rs). While a call streams, only rules that can cut are checked (early cut stays). Every rule runs again on the complete call:

  • Anthropic content_block_stop
  • Responses output_item.done (the item's full text)
  • Chat finish_reason, [DONE], or Wall::finish() when the stream ends normally

Wall::cut_early(false) defers every rule to completion. The relay and the WebSocket path pass inspect.acts(), so observe mode records whole calls. The converted whole-body path passes false because nothing has been sent yet.

Relay tail (relay.rs). The stream's tail (restorer's held bytes, converter's closing frames such as content_block_stop) now goes through the wall, then finish() runs; a cut there withholds the tail.

-T (net.rs). Counts as an upload only inside a curl command (from curl to the end of that command); also handles curl.exe and ["curl","-T",…].

Tests

  • wall: half host / end anchor do not fire; record-only waits for completion (Anthropic, Responses, Chat ×3); cut_early(false); a cut decided at completion withholds the completing frame.
  • rules / net: open vs closed matching, -T in and outside curl.
  • m5_toolwall: a recorded hit carries the whole call in enforce and observe.
  • cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace locally.

Enterprise follow-up is on the same-name branch fix/tool-check-whole-call (passes cut_early(mode.acts()), calls finish() at stream end); it builds and its gateway tests pass against this branch via [patch].

🤖 Generated with Claude Code

The streaming wall checked every rule against the arguments as they had
arrived so far. A host cut off at `http://127` looked external, a `$`
landed on the half-received end, and a record-only hit was written at
that moment with the rest of the call missing.

- A hit on an open (still streaming, or capped) argument counts only once
  it is settled: code rules must stop before the end; a regex hit that
  touches the end counts only if it still matches whatever character
  comes next (`Rule::find_in`).
- While a call streams, only rules that can cut are checked. Every rule
  runs again on the complete call: Anthropic `content_block_stop`,
  Responses `output_item.done`, Chat `finish_reason` / `[DONE]` /
  `Wall::finish` at the end of the stream. Record-only hits carry the
  whole call.
- `Wall::cut_early(false)` defers every rule to completion: observe mode,
  and a converted whole body where nothing has been sent yet.
- The relay passes the stream's tail (the restorer's held bytes, the
  converter's closing frames) through the wall, then calls `finish`.
- `-T` counts as an upload only inside a curl command (`docker compose
  exec -T`, `ssh -T` turn off the terminal).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fylorn
fylorn merged commit febe46b into main Oct 19, 2026
6 checks passed
@fylorn
fylorn deleted the fix/tool-check-whole-call branch October 19, 2026 06:25
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 19, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant