Skip to content

feat!: tw-guard, one guard engine for both gateways - #149

Merged
fylorn merged 5 commits into
mainfrom
feat/tw-guard
Sep 23, 2026
Merged

fylorn merged 5 commits into
mainfrom
feat/tw-guard

Conversation

@fylorn

@fylorn fylorn commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

The guard side of the shared layer: outbound redaction and restoration, and inspection of the tool calls an upstream returns. It moves into one crate that the server edition can run on instead of its own copies. Two desktop bugs are fixed along the way.

Commit 1: one crate, and no tw-resil

  • tw-guard has two parts:
    • redact: tw-redact, moved.
    • tools: the tool-call wall from tw-gateway, the rule engine from tw-scan, and the built-in rules.yaml from tw-config.
  • The rule engine no longer knows config.yaml. tool_rules(disable, cut, custom) takes plain arguments. ToolPolicy::rules / one_builtin in tw-config translate the desktop policy into them, and the server edition will translate its system settings the same way. tw-scan's rules.rs is gone.
  • tw-resil is deleted. Nothing here used it, and the server edition took back the two modules it used (refactor: take the circuit-breaker registry and metric labels back from core ThinkWatch#27).

Commit 2: placeholders by scheme, restoration by lane and format

  • Placeholders follow a Scheme, and a custom rule may carry a label:
    • The desktop keeps <<TW_SECRET_1>>; the server edition gets {{EMAIL_1}}.
    • Numbers are issued per label ({{EMAIL_1}} {{PHONE_1}}).
  • The stream restorer holds a tail only while it is a strict prefix of a placeholder this ledger issued. This is exact for either scheme: {{ name }} in templates is never held, and invented placeholders are not waited for.
  • The SSE restorer knows the stream's format and keeps one lane per block, tool call or output item. Bug fixes:
    • A tail held at the end of a text block used to be prepended to the next block's tool-argument delta, corrupting the arguments. It now goes out in its own block, before that block's stop.
    • The end-of-stream tail was always written as an Anthropic delta, so Chat, Responses and Gemini clients dropped it. It is now written in the stream's own format.
    • Tool arguments are restored for Chat and Responses too. *.done frames and Gemini function calls get complete placeholders restored.
    • Thinking and its signature are left alone.
  • FrameRestorer works on parsed frames for callers that frame the stream themselves. SseRestorer frames bytes and forwards frames it doesn't understand byte for byte.

Commits 3–5: what the server edition needs from this engine

  • Both rule engines compile through one bounded compile, with the NFA and the lazy DFA each capped at 1 MiB. The server edition caps both today; tw-guard capped only the NFA, so moving it onto this engine would have loosened its guard.
  • scan_text / redact_text match custom rules as written on decoded text. The server edition finds PII in the decoded request, so a hit may carry a quote (password="x"). Cutting it at the quote, as the wire-shaped scan_plain does, would replace password= and send the secret. restore_json puts such a value back escaped.
  • GatewayError::PolicyBlocked (403) in tw-types, for refusing an answer by policy. The server edition is about to cut tool calls with it.

Tests

  • cargo fmt --check and cargo clippy --workspace --all-targets -D warnings are clean.
  • cargo test --workspace passes. New cases:
    • per-label numbering;
    • brace placeholders held while templates are not;
    • a tail kept in its own block;
    • Chat text and tool arguments;
    • the Chat tail written as a Chat chunk;
    • Responses text, arguments and done frames;
    • Gemini text with finishReason in the same frame, and Gemini function calls;
    • thinking left untouched;
    • replaced values never carrying a quote, so a whole-body restore stays valid JSON;
    • ToolPolicy reaching the rules.
  • The m5_redact fake upstream now sends real Anthropic frames (with type, index and delta.type).
  • scripts/smoke.sh: 55 passed, 0 failed, same as before the change.

Breaking

  • tw-redact → tw_guard::redact (redact.rs is now replace.rs).
  • Ledger::new(scheme) replaces Ledger::default().
  • apply/redact take a ledger (*_into removed).
  • sse::Body::new takes the stream's dialect.
  • tw_scan::rules → tw_guard::tools::rules plus ToolPolicy::rules.
  • tw-resil is removed.

Nothing changes for lite's UI: the control API is untouched.

🤖 Generated with Claude Code

fylorn and others added 5 commits September 24, 2026 02:27
tw-guard takes what the server edition can share with the desktop
gateway on the guard side, split by what it guards:

- `redact`: tw-redact, moved as is (`redact.rs` is now
  `redact/replace.rs`).
- `tools`: the tool-call wall from tw-gateway, and the rule engine it
  runs on from tw-scan, with the built-in rules file from tw-config.

The rule engine no longer knows `config.yaml`. `tool_rules` takes the
disabled ids, the action overrides and the caller's own rules as plain
arguments; `ToolPolicy::rules` / `one_builtin` in tw-config translate
the desktop's policy into them, and the server edition will translate
its system settings the same way. tw-scan's rules.rs is gone.

tw-resil is deleted. Nothing in this workspace used it, and the server
edition has taken back the two modules it used (ThinkWatch#27).

BREAKING: tw-redact is now tw_guard::redact; tw_scan::rules is
tw_guard::tools::rules plus ToolPolicy::rules; tw-resil is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Two changes that let the server edition run on this engine instead of
its own copy, and that fix two bugs the desktop gateway had.

Placeholders follow a `Scheme` (open, close, default label), and a
custom rule may carry its own label: the desktop keeps
`<<TW_SECRET_1>>`, the server edition gets `{{EMAIL_1}}`. Numbers are
issued per label, so `{{EMAIL_1}} {{PHONE_1}}` rather than
`{{PHONE_2}}`. `Ledger::new(scheme)` replaces the default; `apply` and
`redact` always continue a ledger; `redact_plain` scans plain text;
`Ledger::replacements` exposes value → placeholder for a caller that
finds values in one place and replaces them in another.

The stream restorer holds back a tail only while it is a strict prefix
of a placeholder this ledger actually issued. That is exact for either
scheme, so `{{ name }}` in a template no longer waits the way `<<` in
C++ once did, and a placeholder the model made up is not waited for.

The SSE restorer now knows the stream's format and keeps one lane per
content block, tool call or output item:

- A tail held at the end of a text block used to be prepended to the
  next block's tool-argument delta, corrupting the arguments. It now
  goes out as a delta of its own block, before that block's stop.
- The tail at the end of a stream was always written as an Anthropic
  delta, which a Chat, Responses or Gemini client ignores, dropping the
  text. It is written in the stream's own format now.
- Tool arguments are restored in Chat and Responses too, not only in
  Anthropic's partial_json; `*.done` frames and Gemini function calls
  have their complete placeholders restored.
- Thinking and its signature are left alone.

`FrameRestorer` works on a parsed frame for callers that frame the
stream themselves; `SseRestorer` frames bytes and still forwards frames
it does not understand byte for byte. The restorer takes the stream's
dialect; tw-gateway passes the upstream's, since it restores before
converting.

The m5_redact fake upstream now sends Anthropic frames the way
Anthropic does (with `type`, `index` and `delta.type`).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The server edition compiles operator patterns with both the NFA and the
DFA capped at 1 MiB; tw-guard capped only the NFA. Moving the server
edition onto this engine would have loosened its guard. Both rule
engines now go through one bounded compile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The desktop scans the request as it goes over the wire, a JSON body, so
a custom rule's hit stops at a quote or backslash to keep the body
valid. A caller that decodes the request first, finds values in the
text and carries them back (the server edition) sees the text itself:
cutting `password="x"` at the quote replaces `password=` and sends
the secret. `scan_text` / `redact_text` match custom rules as
written; what they replace may carry a quote, so `restore_json` puts
it back escaped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The server edition is about to cut tool calls an upstream returns when
they match a rule set to cut. That is neither the caller's fault (400)
nor the upstream failing (502); the answer exists and the gateway will
not hand it over. PolicyBlocked maps to 403.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@fylorn
fylorn merged commit 184b11d into main Sep 23, 2026
2 checks passed
@fylorn
fylorn deleted the feat/tw-guard branch September 23, 2026 19:28
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