Repository navigation
Per-rule actions for every guard and content filtering of answer text - #335
Merged
Merged
Conversation
…f answer text
Content filter
- Custom content rules gain `scope` (request and/or response), `case_sensitive`
and a `replace` action with a per-rule `replacement` (default `***`).
Built-in rules may be set to `replace` too; they check requests only.
- A rule that checks answers filters the text the client sees: text lanes of
Anthropic, Chat, Responses (including `custom_tool_call_input`), Gemini (SSE
and JSON array) and Bedrock streams, whole answers, and the Responses
WebSocket. Only the tail that could still grow into a match is held back
(longest keyword minus one; the regex's maximum length, capped at 256),
and held tails are flushed at lane end, stream end and on errors.
Responses `*.done`, `output_item.done` and `response.completed` repeat
exactly what was sent. The filter runs after reply plugins and before
tool-call inspection, and the stored answer is the one the client got.
- Answers cannot be blocked and are not matched by code points; both are
config errors (`config.rule_scope_block`, `config.rule_scope_codepoints`,
`config.rule_scope_empty`).
- Request-side replace shares the edit/merge logic with answers; replacement
text is never matched again.
Outbound redaction
- Every rule has an action: `placeholder` (default) or `record`. Record-only
rules are found and reported but neither replaced nor numbered in the
ledger. Built-in rules take an `actions` map, custom rules an `action`.
Control API (version 48)
- `RuleAction` gains `placeholder` and `replace`; rule views carry `scope`,
`replacement`, real `case_sensitive` for content matchers, and actions for
redaction rules.
- `CustomRuleSave` gains `scope` and `replacement`; `TestSecurity` gains
`scope` (test as request or answer) and `replacement`.
- New `PUT /security/{guard}/rules/enabled` switches several rules with one
config write.
- `ContentMatched` gains `direction`; answer hits are reported once per rule
when the answer ends. `SecretItem` gains `replaced`. Security log rows for
content replacements are `replaced` with segments and replacement text;
`SecurityCounts` gains `content_replaced`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cord-only redaction - Redaction: a placeholder rule's hit always wins over an overlapping record-only hit, so a record-only rule can no longer let a value that another rule replaces go upstream in clear. Record-only hits hidden behind a placeholder hit are still reported (`flow::reported`). - Request-side case-insensitive `contains` uses the same case-insensitive regex as answers; the lowercase-and-map-back fast path cut the wrong range when lowercasing changed a character's byte length (Ⱥ, Kelvin sign). - Gemini: text parts separated by a non-text part (function call, code, image) are separate lanes; held text no longer moves past the call, and a whole answer without a change stays byte-identical. - New text channels: Anthropic `content_block_start` text, Chat and Responses refusals. Logprobs are dropped while rules can delete or replace (streams) or when a whole answer's text changed. - Invalid UTF-8 in an SSE frame or a whole answer is read lossily instead of passing raw; an unparseable Gemini array element is dropped while rules can change text. - WebSocket: frames of an answer that already ended (`response.failed` after `error`) go through the filter; held tails are delivered when the upstream goes away mid-answer, and hits are reported on their turn first. - Memory: a lane keeps counts per rule and at most 50 located matches per rule instead of every match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he union of redaction hits - WebSocket: a frame of an answer that already ended now goes through that answer's own filter (kept with its response id), or a standalone one, and never closes the lanes of the answer in progress. Before, a late `response.failed` flushed the next answer's held text early and the stripped word reached the client across frames. - Redaction: overlapping placeholder hits merge into one placeholder over their union, so no byte of either value goes upstream. Record-only hits overlapping a placeholder hit keep the pieces outside it, so stored bodies and logs mask the whole record-only match; reporting still counts the record-only match once. - Answer filter: any frame with a top-level `error` (Chat, Gemini, others) ends every lane, so held text is sent before the error frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ues in storage, tighten error frames - Redaction: hits that do not count (on one of our placeholders, in payloads, custom hits in protocol ids) are dropped before overlaps merge, so a value that must be replaced is never dropped together with them. - Custom redaction regexes run one JSON string at a time, so a greedy pattern such as `\S+` can no longer start on structure, swallow quotes or leave the body invalid JSON. - Request-side strip and replace repeat until nothing more changes (only the segments changed in the last round are rescanned); a segment still changing after 64 rounds is emptied, so no match is ever sent. - New `tw_guard::redact::replace::hide_values(text, ledger, form)` puts the placeholder back wherever a ledger value appears literally. Stored bodies and log excerpts use it, so a restored value that needs context to be recognised (a connection-string password echoed alone) is not stored in clear. - Answer filter: only a top-level `error` object with a `message` or `code` ends the lanes; `null`, `false`, empty strings and objects no longer do. - WebSocket: hits from a late frame of an earlier answer are no longer reported on the turn in progress. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… match is left The answer filter made a single pass, so stripping nested input such as "sesecretcret" re-formed the matched word and sent it. Answers now get the same guarantee as requests: what was deleted or replaced goes to a further pass that looks again near each seam (every start position whose match could read across it), merges and edits, until nothing new turns up. Replacement text is never matched again. Record-only rules still see the model's original text only. After 64 further passes the rest of the lane is dropped, as the request side empties the segment. Whole answers (Lane::whole, whole bodies, the trial) are processed in one go and are always clean. Streamed lanes hold back as many characters again as the longest edit rule needs, so a re-formed match one level beyond what has been seen never reaches text already sent and the result equals the whole one at every split. A match nested deeper than that may have its start sent already; the unsent rest of it is deleted, so the client never receives a whole match (except a look-around regex whose match lies entirely in sent text). Tests: nested strips in streamed and whole answers split at every position, nesting past the bound, and both property tests now compare against a reference that iterates to a fixed point and assert that no edit-rule match outside replacement text remains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed answer rules Request side: positions found in later rounds map back to the original through one merged table (deletions at the same spot collapse into one entry) instead of round by round, and recorded places are deduplicated with a set. places() on a 64-deep nested request is linear again. Stored bodies and log rows: ledger values are found in one Aho-Corasick pass over the text (aho-corasick is already in the dependency tree) and replaced only inside JSON string contents, decoded at every escape depth up to four (\n, \", \uXXXX with surrogate pairs, \/), so numbers, literals and structure are never touched. Text that is not a whole JSON document (SSE, plain errors) is also searched outside strings, skipping whole numbers and literals. Overlapping values merge into one region. Matches on placeholders are found by binary search. JSON-ness is decided by walking the JSON structure instead of looking at the first character, so decoded text starting with a quote or bracket is scanned whole again; answer context in the security log is masked as decoded text. Answers: re-checks after a deletion search only the window a match can span; whole bodies filter a multi-field lane once and split it back by original position. Rules anchored to the start or end of the text or a line are checked once, and deletions at the ends of a lane are not seams, so whole and streamed answers agree instead of eroding. Past the round bound only the part still changing is dropped. Bare error frames (a string error, an error object without message or code, with no answer content) end the lanes. Late WebSocket frames of the last eight answers report hits in text never streamed on their own turn; repeats are not counted again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eplacements leave no seams
Security detail: the ledger's values are found once per text
(ValueSpans) and every excerpt takes its slice, instead of each piece
searching the whole body again. An answer match that falls inside a
ledger value now widens to the whole value, and excerpt edges never cut
one, so no fragment of a value shows in before/matched/after; a value
cut by a piece edge or partly covered by a rule hit is masked in full.
Value search keeps, at each position, only the longest value ending
there and unions the intervals, so nested values (runs matched by
`\d{6,}`) cost time and memory linear in the text. Text that is not
strictly valid JSON is masked everywhere, numbers included; valid JSON
stays strings-only, decoded up to six escape levels. is_json is a strict
parse now.
Answers: only deletions leave seams, so a non-empty replacement next to
a word boundary is not re-matched and eaten further; whole and streamed
agree. Several deletions at the very start of a lane no longer count as
a seam for the hold-back, so a stream does not emit early.
WebSocket: a lifecycle frame carrying another answer's id is filtered by
that answer's filter (or a standalone one) even when the turn window has
forgotten it, so it never closes the current answer's lanes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ding, lenient outbound JSON WebSocket: whether a lifecycle frame belongs to the answer in progress is decided by the connection's state, not by id inequality alone. A closing frame while an answer is in progress is that answer's unless its id belongs to an ended answer; unknown ids follow what the upstream has shown (rewritten ids at close mean unknown ones are current, consistent ids mean they are strays), and before any evidence an unknown response.completed is current while response.failed/incomplete are not. Frames judged to be another answer's no longer touch the current turn's bookkeeping, its reply hooks or its answer filter. Storage masking: escape levels are decoded one at a time, stopping at the first level that decodes nothing, with position maps composed into one u32 table (4 MiB of bogus escapes now peaks near 28 MB). A value region that overlaps an existing placeholder is masked outside it rather than skipped whole. Outbound redaction reads a body as JSON when it parses with raw control characters tolerated in strings, so numbers outside strings are never replaced; storage masking keeps its strict rule. Scale tests compare interleaved medians at an eightfold size step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
What this changes
Every rule of every guard now has its own action, and the content filter can check the text of answers as well as requests.
scope(requestand/orresponse),case_sensitive, and areplaceaction with a per-rulereplacement(default***). Built-in rules can be set toreplaceand stay request-only. Rules that check answers filter the text the client sees in streamed answers (Anthropic, Chat, Responses includingcustom_tool_call_input, Gemini SSE and JSON array, Bedrock), whole answers and the Responses WebSocket. Answers cannot be blocked and are not matched by code points; both are config errors.placeholder(default) orrecord. Record-only rules are found and reported, but the value is sent as it is and never enters the ledger.RuleActiongainsplaceholderandreplace; rule views carryscope,replacement, the realcase_sensitiveof content matchers, and redaction actions;CustomRuleSavegainsscopeandreplacement;TestSecuritygainsscope(test as request or answer) andreplacement; newPUT /security/{guard}/rules/enabled(SetRulesEnabled) switches several built-in and custom rules with one config write.ContentMatchedgainsdirection,SecretItemgainsreplaced,OutcomeDetail::Replacedgains optionalsegments/replacement,SecurityCountsgainscontent_replaced. New message codes are in the manifest;security.content_action_unknownbecamesecurity.content_action_bad(its sentence changed) andsecurity.no_action_of_its_ownis gone.Why
Users need to pick, per rule, between changing the text and only recording a match, and need a way to keep specific words out of what the model says back, not only out of what is sent.
How it was verified
tw-guard: the answer engine (content::answer) is tested for contains/regex, Unicode case variants, matches split at every position and across SSE chunks cut inside a UTF-8 character, several overlapping rules, strip vs replace vs record, observe mode (no hold-back, still reported), flush at lane end, stream end and on errors, and the regex cap. A randomized test (deterministic seed, 400 rounds) streams random text with random rules and random cuts and asserts the output equals whole-text application and equals a directfind_iterreference; it was also run with other seeds at 30,000 rounds. Per-dialect frame tests cover Anthropic, Chat, Responses (*.done,content_part.done,output_item.doneandresponse.completedrepeat exactly what was sent, deltas-free lanes counted once), Gemini SSE/JSON array and Bedrock; whole answers in all five formats. Request-side replace, replacement text not re-matched, case-sensitive matching, record-only redaction (found, reported, not replaced, not numbered), scope validation errors, andTestSecuritywithscope.tw-gateway: end-to-end tests stream an answer in 7-byte chunks through passthrough, Chat and Gemini (JSON array) conversions and whole answers, check the client never receives a matched word, that the hit is reported withdirection: responsebefore the request ends, and that the stored client answer is the filtered one; observe and off; one rule on both sides; record-only redaction; the Responses WebSocket per answer.tw-control: bulk enable endpoint (one write, factory values not written, unknown ids/names refused with nothing written), content rules with scope/replacement written and tried as an answer, redactionrecordaction.tw-store: answer hits logged with directionresponse, the sent model, outcomereplacedand the new counter; record-only secrets logged asrecorded.cargo test --workspace: 3528 passed, 0 failed, twice in a row (after the seven review follow-ups below).cargo clippy --workspace --all-targets -- -D warningsandcargo fmt --all -- --checkare clean. Message-code manifest and configuration manual regenerated.Notes for review
replacerule's text wins, otherwise the region is deleted. Requests and answers share this logic.tw-guard) needs these adaptations: addactiontoCustomRedactRuleliterals andactionswhereRedactPolicyis built field by field; addscope,case_sensitive,replacementtoCustomContentRuleliterals (andcase_sensitive,sides,replacementtocontent::RuleInput, or use..RuleInput::default()); handlecontent::Outcome::ReplacedandContentAction::Replacein exhaustive matches; newPolicyErrorvariants (EmptyScope,BlockInAnswer,CodepointsInAnswer) andTrialErrorvariants (BlockInAnswer,CodepointsInAnswer);RuleActiongained two variants.flow::look/flow::replacealready skip record-only rules; callers that replace fromscan_texthits themselves should pass them throughRuleSet::to_replace, and audit events should useRuleSet::recordsto tell recorded from replaced values. Answer filtering is available ascontent::answer::{Answer, Body, FrameFilter, whole}when enterprise wires it in.Review follow-up (second commit)
An adversarial review and fuzzing pass found no panics; these gaps are fixed with regression tests:
OPENAI_API_KEY=\S+over the built-in key, or built-inemailset to record over a custom placeholder rule). Placeholder hits are now resolved first (see the second follow-up for how overlaps merge). Record-only hits hidden behind a placeholder hit are still reported (flow::reported, used byfind,look_hitsand the WebSocket path). A randomized test asserts that adding record-only rules never changes what is replaced.containsnow uses the same case-insensitive regex as answers. The previous lowercase-and-map-back fast path cut the wrong range when a character's lowercase had a different byte length (Ⱥ, Kelvin sign).content_block_starttext, and Chat / Responses refusals (deltas,refusal.done, content parts, whole answers). Logprobs repeat the text token by token: while rules can delete or replace, they are removed from streamed answers, and from whole answers whose text changed (documented in the manual).response.failedaftererror) now goes through the filter. When the upstream goes away mid-answer, held text is delivered and hits are reported on their turn.Lane::take()returnsMatches { found, counts };Lane::keeping_all()keeps every position for the admin "test" endpoint only.API notes for consumers of
tw-guard:Lane::take_found()becameLane::take()(Matches),LaneKeygainedRefusal, andFrameFilter::edits(),flow::reportedandRuleSet::recording_onlyare new.FrameFilter::{new, frame, drain, take_hits},answer::whole,BodyandAnswerHitare unchanged.Second review follow-up (third commit)
response.failedclosed that answer's lanes, so the next answer's held text was released early and a stripped word could be assembled across frames. Late frames now use their own answer's filter (kept with its response id) or a standalone one, and never touch the answer in progress. Regression test: answer 1 errors, answer 2 starts, answer 1's lateresponse.failedarrives mid-word, answer 2 finishes.aaa<<TW_SECRET_1>>ccc).postgres://…around the built-in connection-string password), while what goes upstream is still decided by placeholder hits. Reporting counts the record-only match once.error(Chat, Gemini, others) ends every lane, so held text is sent before the error frame. A per-format test checks the ordering.No API changes in this commit:
scan/flow::hitsstill return sorted, disjoint hits. They may now contain record-only fragments next to a placeholder hit, so consumers that mask from them (enterprisepii::redact_blob,Redaction::mask) cover the union without code changes.Third review follow-up (fourth commit)
alice@corp.example.org<<TW_SECRET_1>>with a customexample\.org\S*rule, or a built-in key in a tool-callidoverlapping a custom hit.\S+and similar patterns can no longer start on{/:/,, swallow quotes, or make the request body invalid JSON.tw_guard::redact::replace::hide_values(text: &str, ledger: &Ledger, form: Form) -> Cow<'_, str>puts the placeholder back wherever a ledger value appears literally (raw, JSON-escaped once or twice), never inside an existing placeholder. Stored bodies and security-log excerpts use it, so a value that needs context to be recognised (a connection-string password, a customOPENAI_API_KEY=…value) echoed alone in an answer is not stored in clear.errorobject with amessageorcodeends the answer lanes.null,false,""and{}no longer do, so a word split around such a frame is still caught.Enterprise-facing API:
hide_values(above) is new.scan/flow::hitskeep their signatures and still return sorted, disjoint hits.Fourth review follow-up (fifth commit)
sesecretcretre-formedsecretand sent it. Answers now get the request side's guarantee. Text a pass deleted or replaced goes to a further pass that tries every start position whose match could read across a seam (up to the rule's hold before it), merges and edits, until nothing new turns up. Replacement text is never matched again. Record-only rules keep seeing the model's original text only. After 64 further passes the rest of the lane is dropped, as the request side empties the segment.Lane::whole, whole bodies, the trial endpoint) are processed in one go and are always clean. A whole body whose lane spans several fields is filtered as one text and split back like the streamed fields.reach; 0 when no edit can leave a seam), so a re-formed match one level beyond what has been seen never reaches text already sent. Such text gives the same result as the whole answer at every split. A match nested deeper than that may have its start sent already: the unsent rest is deleted, so the client never receives a whole match. The one exception is a look-around regex (\b,$) whose match lies entirely in text already sent. The manual describes the extra hold and this limit.\b), nesting past the bound, and a new nesting fuzz. Both property tests now compare against a reference that rescans every position until a fixed point, and assert that no edit-rule match outside replacement text remains. Deeper-than-held streams are counted and must stay rare. Stress-run at 4 seeds × 100k cases.Fifth review follow-up (sixth commit)
places()on a 64-deep nested 128 KiB request and onaabb×100k is linear again; a newcontent_scaletest guards it with a size-ratio check.hide_values, now built onvalue_spans):aho-corasick1.x was already in the lock file viaregex; it is now a direct workspace dependency, and no new crates were added.\n,\",\uXXXXincl. surrogate pairs,\/). Numbers, literals and structure are never touched, so stored bodies stay valid JSON.Redaction::piecetakes value spans on the original text and lets rule hits win.",{or[is scanned whole again. Answer context in the security log is masked as decoded text (Form::Text).Lane::whole_split) and split the result back by original position.^,$,\A,\z) are checked once in answers.(?s).\z,\d$and.\bno longer erode a whole answer, and whole and streamed agree.error, or an error object without message/code, on a frame with nochoices/candidates/typedepending on the dialect. Normal frames with nested or falseerrorfields still do not.Sixth review follow-up (seventh commit)
ValueSpans, new intw-guard). Every excerpt takes its slice by binary search, instead of each piece searching the whole body again. A new scale test runs the reported-items path with values and body growing together.\d{6,}(k=600) now take 6 ms with no memory blow-up. An adversarial scale test guards this, and a randomized test checks against brute force.is_jsonis now a strict parse, so[555-1234]is no longer JSON.\d{4}\b,\w\b), and whole and streamed agree.Seventh review follow-up (eighth commit)
response.completedcounts as the current answer's, andresponse.failed/incompletedo not.upstream,finish_front), not the reply hooks, not the answer filter.u32table. 4 MiB of\qnow peaks at about 28 MB RSS.🤖 Generated with Claude Code