feat!: tw-guard, one guard engine for both gateways - #149
Merged
Merged
Conversation
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]>
This was referenced Sep 23, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
redact: tw-redact, moved.tools: the tool-call wall from tw-gateway, the rule engine from tw-scan, and the built-inrules.yamlfrom tw-config.config.yaml.tool_rules(disable, cut, custom)takes plain arguments.ToolPolicy::rules/one_builtinin 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.Commit 2: placeholders by scheme, restoration by lane and format
Scheme, and a custom rule may carry a label:<<TW_SECRET_1>>; the server edition gets{{EMAIL_1}}.{{EMAIL_1}} {{PHONE_1}}).{{ name }}in templates is never held, and invented placeholders are not waited for.*.doneframes and Gemini function calls get complete placeholders restored.FrameRestorerworks on parsed frames for callers that frame the stream themselves.SseRestorerframes bytes and forwards frames it doesn't understand byte for byte.Commits 3–5: what the server edition needs from this engine
scan_text/redact_textmatch 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-shapedscan_plaindoes, would replacepassword=and send the secret.restore_jsonputs 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 --checkandcargo clippy --workspace --all-targets -D warningsare clean.cargo test --workspacepasses. New cases:doneframes;finishReasonin the same frame, and Gemini function calls;ToolPolicyreaching the rules.type,indexanddelta.type).scripts/smoke.sh: 55 passed, 0 failed, same as before the change.Breaking
tw_guard::redact(redact.rsis nowreplace.rs).Ledger::new(scheme)replacesLedger::default().apply/redacttake a ledger (*_intoremoved).sse::Body::newtakes the stream's dialect.tw_scan::rules→tw_guard::tools::rulesplusToolPolicy::rules.Nothing changes for lite's UI: the control API is untouched.
🤖 Generated with Claude Code