Skip to content

feat(ENG-1084): detect-and-gate + sentence-level cleaning (v0.8.2) - #28

Merged
hiskudin merged 22 commits into
mainfrom
feat/eng-1084-detect-and-gate
Aug 19, 2026
Merged

feat(ENG-1084): detect-and-gate + sentence-level cleaning (v0.8.2)#28
hiskudin merged 22 commits into
mainfrom
feat/eng-1084-detect-and-gate

Conversation

@hiskudin

@hiskudin hiskudin commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ports the TS 0.8.2 detect-and-gate + sentence-level cleaning architecture (TS #85) to Python. Defender no longer mutates content in place; it returns sanitized — a sentence-cleaned copy of the flagged fields (restores the 0.7.4 cleaning) — plus the allowed verdict. sanitize_content=Falsesanitized is the input verbatim.

Blocking is allowed. The composite Sanitizer and pattern_remover are deleted; role_stripper is kept for the marker strip.

Default-behavior note: sanitize_content defaults to True, so sanitized is a sentence-cleaned copy by default — a Tier-2 false positive can drop legit sentences from field content. Callers that must not mutate payloads should pass sanitize_content=False (detect-and-gate — content verbatim) and gate on allowed.

Behavior changes (not breaking) / Migration

Feature + bug-fix release — no released (0.7.4) consumer changes code:

  • sanitized is a sentence-cleaned copy (its 0.7.4 meaning), now at sentence granularity; fires only at aggregate high/critical. sanitize_content=False → input verbatim.
  • default_risk_level now low (was medium) — reported risk tracks the model; blocking still keys on allowed.
  • Removed: block_high_risk option; pattern_remover / sanitizer modules.

Changes

  • Detect-and-gate core (tool_result_sanitizer.py) — detect-only; evidence-based fields_sanitized/methods_by_field/patterns_by_field; top-level string + non-plain-object handling.
  • Sentence-level cleaning (sentence_cleaner.py) — gated high/critical; drops offending sentences and leaves a [CONTENT SANITISED] marker where each run was cut (mid-content drops stay visible), keeps the rest; single-sentence → unchanged, all-high → just the marker; verbatim when nothing dropped.
  • fields_sanitized = fields the cleaner changed (was Tier-1 detect-only). API-visible semantic change.
  • Object-KEY scanning + non-destructive wide-container cap (coverage_degraded) + per-field cap.
  • Tier 2 availabilityrequire_tier2, tier2_available, warn-once.
  • Evidence-driven encoding — decode, then run the real detector; escalate only on a genuine pattern.
  • Hardening — ReDoS bounds; non-finite model output → skip.

Post-review fixes (StuBehan, ported from #85)

  • Strings inside a risky field's array now Tier-1 scanned ({"name": [INJ]}) — previously fell through _sanitize_value to the pass-through. Risky-field allowlist preserved.
  • New detected_field_count (keys of patterns_by_field) — first-class threat-count signal, since fields_sanitized now means cleaner-changed fields.
  • Size-metrics double-count fixed + iterate obj.items() directly instead of list(obj.items()) on wide payloads.
  • Tier 1 detection bounded by the call-scoped byte budget (max_size), replacing the per-container 100-item cap (per-container, bypassable, blinded normal >1000-item payloads). skip_large_arrays/large_array_threshold kept as deprecated, off-by-default opt-ins (non-breaking).

Validation

  • 292 tests pass, ruff clean.
  • Connector FPR (940 payloads, TS parity): content changed 3/940 (0.32%) SFE off / 2/940 (0.21%) SFE on (those few now carry a [CONTENT SANITISED] marker at the cut); verdict FPR byte-identical to detect-only.
  • SFE FPR (800 payloads): 79.9% low / 15.9% med / 4.25% high — matches TS ~81/14/5 (was 0% low / 95% med under the old floor).

AgentShield 537 (vs published v0.7.4, both onnxruntime@1.21.0 — by parity with TS #85)

Metric v0.7.4 This PR Δ
Final 87.5 87.8 +0.3
Composite 89.0 89.2 +0.2
Penalty 1.43 1.43
Prompt Injection 188/205 190/205 +2

All other categories identical — verdict-neutral. Real gates: Claude Code plugin repro 0.951 → 0.199 + connector FPR above.

Version

0.8.2, pinned via Release-As (below) to match the TS package (which landed on 0.8.2 after two publish-retry patch bumps). The org release-please config bumps feat to a patch pre-1.0, so the version is pinned explicitly. Squash-merge so the clean feat(ENG-1084): title is the release commit and the old ! footer is dropped.

Release-As: 0.8.2

🤖 Generated with Claude Code

…(v0.8.0)

Ports the TS defender 0.8.0 "detect-and-gate" architecture to Python.

Defender no longer rewrites/redacts tool-result content. `sanitized` returns
the ORIGINAL payload (optionally boundary-wrapped); threats are recorded as
detection evidence and blocking is expressed via `allowed`/`risk_level` only.
The mutation helpers (pattern_remover, role_stripper, the composite Sanitizer)
are deleted (~460 lines).

Also:
- default_risk_level medium -> low, plus a monotonic raise_overall_risk, so
  reported risk tracks the model (validated on 800 SFE payloads: 80% low /
  16% medium / 4% high, matching TS; was 0% low under the medium floor).
- Object-KEY injection scanning (detect_in_key); a non-destructive wide-container
  detection cap (analysis_truncated / coverage_degraded) replacing the lossy
  large-array truncation; per-field analysis-length cap (max_field_analysis_length).
- Tier 2 availability: require_tier2 (fail-closed), tier2_available, warn-once
  (module-scoped), cold-sample-before-warmup.
- Evidence-driven encoding escalation (decode then run the real pattern detector).
- ReDoS bounds (markdown-link, Morse); non-finite model output -> explicit skip;
  onnx load-failure warn moved to the caller.

BREAKING CHANGE: `sanitized` is no longer redacted/blocked — gate on `allowed`.
`default_risk_level` defaults to `low`. The `block_high_risk` sanitizer option and
the `pattern_remover` / `role_stripper` / `sanitizer` modules are removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 14, 2026 10:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Ports the “detect-and-gate” architecture to the Python Defender SDK so tool-result processing becomes detection-only: the returned sanitized payload preserves the original content, while threat signals are recorded as evidence and gating is expressed via allowed.

Changes:

  • Reworks Tier 1 tool-result traversal to detect threats without mutating content; adds key scanning and coverage/analysis caps.
  • Updates the risk model (default risk floor to low) and surfaces Tier 2 availability + coverage degradation in DefenseResult.
  • Removes mutation-based sanitizer modules and updates tests/docs; includes ReDoS hardening and Tier 2 robustness improvements.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/test_sanitizers.py Removes tests tied to deleted mutation sanitizers; retains unit coverage for remaining detection helpers.
tests/test_integration.py Updates integration assertions to “detect-and-gate” semantics (content preserved; evidence + allowed gate).
src/stackone_defender/types.py Adjusts defaults/metadata to reflect detection evidence; adds tier2_available and coverage_degraded.
src/stackone_defender/sanitizers/sanitizer.py Deleted: composite mutation sanitizer implementation removed.
src/stackone_defender/sanitizers/role_stripper.py Deleted: role-marker stripping module removed.
src/stackone_defender/sanitizers/pattern_remover.py Deleted: pattern redaction/removal module removed.
src/stackone_defender/sanitizers/encoding_detector.py ReDoS hardening for Morse detection gate.
src/stackone_defender/sanitizers/init.py Re-exports shift toward detection-only helpers (no mutation/redaction exports).
src/stackone_defender/core/tool_result_sanitizer.py Major rewrite to detect-only traversal, evidence recording, key scanning, and capped analysis without dropping data.
src/stackone_defender/core/prompt_defense.py Adds Tier 2 “require/fail-closed”, warn-once degraded behavior, and coverage_degraded plumbing.
src/stackone_defender/config.py Introduces default per-field analysis length cap for Tier 1 (ReDoS/cost guard).
src/stackone_defender/classifiers/tier2_classifier.py Treats non-finite model outputs as a classifier skip (not a benign score).
src/stackone_defender/classifiers/patterns.py ReDoS-hardens Morse + markdown hidden-instruction regexes.
src/stackone_defender/classifiers/onnx_classifier.py Moves repeated ONNX load-failure warning responsibility to the caller.
README.md Documents breaking “detect-and-gate” semantics and updated defaults/behavior.
Suppressed comments (3)

src/stackone_defender/core/tool_result_sanitizer.py:212

  • SizeMetrics.object_count is incremented twice for dicts: update_size_metrics() already increments object_count in _sanitize_value(), and _sanitize_object() increments it again. This doubles object_count in metadata.size_metrics.
        metadata.size_metrics.object_count += 1

src/stackone_defender/core/tool_result_sanitizer.py:252

  • entries = list(obj.items()) allocates an extra list of all key/value pairs; for large paginated responses this can be a significant and unnecessary memory hit. Use len(obj) for the scan limit and iterate obj.items() directly.
        entries = list(obj.items())
        scan_limit = self._detection_scan_limit(len(entries), metadata)
        for i, (key, val) in enumerate(entries):
            entry_detect = detect and i < scan_limit

src/stackone_defender/core/tool_result_sanitizer.py:278

  • entries = list(obj.items()) duplicates all key/value pairs in memory before iterating. For wide wrapped payloads this adds avoidable overhead; you can derive the scan limit from len(obj) and iterate obj.items() directly.
        entries = list(obj.items())
        scan_limit = self._detection_scan_limit(len(entries), metadata)
        for i, (key, val) in enumerate(entries):
            entry_detect = detect and i < scan_limit

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/stackone_defender/core/tool_result_sanitizer.py Outdated
Comment thread src/stackone_defender/core/tool_result_sanitizer.py Outdated
hiskudin and others added 11 commits August 14, 2026 11:14
…pping

Adversarial review of #28:
- HIGH: warmup_tier2() called warmup() unguarded, so it crashed app startup
  when the ONNX extra was missing and require_tier2=False (the fail-open
  default), contradicting the README and the defend path. Route it through
  _handle_tier2_unavailable (fail-closed when required, warn-once otherwise).
- MEDIUM: `type(value) is dict` skipped dict SUBCLASSES (OrderedDict, bson.SON)
  entirely — bypassing DANGEROUS_KEYS prototype-pollution stripping AND
  detection. In Python the TS plain-object check maps to `isinstance(value,
  dict)` (datetime/set are still non-dicts and pass through). Restore isinstance.

Regression tests for both, plus non-dict passthrough (datetime/set).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… in README

The README's DefenseResult block was missing the 0.7.4 telemetry fields
(phase_timings, tier2_stats, tier1_ms, cold_load), the 0.8.0 signals
(tier2_available, coverage_degraded), and several pre-existing fields
(tier2_raw_score, tier2_aux_score, tier2_multihead_blocked, tier3). Also
document the require_tier2 option.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ports the TS decorative-output false-positive fix. Box-drawing rules (`─`),
`===`, `---`, `###` tokenize one-token-per-char, so a rule line is ~85% one
repeated token; under mean pooling that lands off-distribution and the head
returns an arbitrary, often high score — flagging benign terminal output as
an injection, higher than a real one.

Collapse 4+ repeats of the same non-word char to 3 before classification
(classifier input only — the payload is never modified), via a single
_normalize_for_classification() routed through all four classify paths.
Python's re \w is Unicode-aware, so accented letters are preserved.

Realistic decoration-heavy logs now score low; the pure-decoration corner is
reduced (~0.97 -> ~0.70) but stays off-distribution — the argument for the
token-degeneracy (OOD) guard follow-up. Adds regression fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the TypeScript defender guard. Decorative terminal output (repeated
box-drawing/rule chars) tokenizes to a few repeated tokens and, under mean
pooling, sits off-distribution where the model's score is arbitrary — a bare
rule line outscored real injections (~0.97). Damp such rows to a benign 0 so
they drop out of the max, instead of trusting the score. Applied at the shared
onnx seam (classify_pair + classify_batch_pair), reusing the ids the model
already runs on — no extra tokenization — so every Tier 2 path is covered.

Guard fires only when all three hold over the content tokens:
  1. most-frequent token covers >= 2/3 (degeneracy_max_token_share), AND
  2. <= 4 distinct tokens, AND
  3. the dominant token is not [UNK].

Factor 2 blocks a padding attack: appending many copies of any repeated token
(`---` runs, or the word "the") would otherwise cross the share threshold and
damp an attack-bearing row to 0. Padding adds vocabulary but cannot remove the
attack's own, and an injection needs > 4 distinct tokens.

Factor 3 blocks a homoglyph attack: fullwidth / zero-width / other OOV
characters collapse to repeated [UNK], which satisfies 1 and 2 but is the
signature of encoding evasion — more suspicious, not less. It stops the guard
suppressing those rows (detection of them is a separate Tier-1 unicode-
normalization gap, unchanged here).

Adds regression fixtures for both engineered bypasses (assert not damped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the TypeScript change. Tier-2's classifier input now runs through
normalize_unicode (NFKC) in _normalize_for_classification, so fullwidth /
math-styled obfuscation tokenizes as real words (~0.95) instead of [UNK]
(~0.48). Defense-in-depth: Tier 1 already NFKC-folds these.

Analysis-only — _normalize_for_classification never mutates the returned payload.
Cross-script confusables (Greek/Cyrillic homoglyphs) are intentionally NOT folded:
a curated map is whack-a-mole (Armenian/Georgian remain) and NFKC already covers
the corpus-attested case (fullwidth). Left as a documented known-gap.

FPR gate: 940 benign SFE payloads, zero new false positives from NFKC folding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…inal

Mirrors the TypeScript change. Restores sanitize-by-default at SENTENCE
granularity (the old phrase-level regex was ~94% ineffective) and returns BOTH:
  - `sanitized`: sentence-level cleaned copy — high-scoring sentences dropped
    within high-risk fields, whole-field block for a single sentence, role
    markers stripped from survivors, boundary-wrapped when annotate_boundary.
  - `original`: the untouched content.
New `sanitize_content` option (default True); False = pure detect-and-gate.

Cleaning runs after Tier 2 (new core/sentence_cleaner.py) reusing
classify_chunks_batch for per-sentence scores, keyed on the un-damped
per-string scores. Restores role_stripper for in-survivor defense-in-depth.
Both sync and async defend paths wired.

Verdict-neutral (detection/verdict unchanged). 287 tests pass, ruff clean.

Known gap (deferred): a diluted list injection can be demoted to low by density
damping and never flagged/cleaned — per-string gating fix is a follow-up.
Treat `sanitized` as best-effort; gate on `allowed`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ield block

Clean the `sanitized` copy only when the aggregate verdict is high/critical
(not per-string), so a density-damped low verdict never rewrites content —
risk-low now always means sanitized == original. Never emit a whole-field
block marker: a single-sentence field is left as original (can't isolate a
bad sentence, and benign opaque tokens read as one), and an all-sentences-high
field drops to empty. Mirrors the TS change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er-1 detections

Mirror of the TS change: fields_sanitized now lists exactly the leaf paths the
return-both cleaner changed (empty under sanitize_content=False or without
Tier 2), instead of Tier-1 detect-only methods_by_field. Tier-1 detection stays
in detections/patterns_by_field; the Tier-1 signal is retained internally for
the block decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…al field; fix stale sanitized=original lines

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror of the TS guard: when kept == all sentences, return raw instead of
reconstructing via " ".join(kept), avoiding spurious sanitized != original
diffs on fields where nothing is dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pin the version explicitly: the org release-please config bumps feat to a
patch pre-1.0, so a plain feat commit would land 0.7.5. This keeps parity
with the TS defender 0.8.0 release without a breaking marker.

Release-As: 0.8.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hiskudin hiskudin changed the title feat(ENG-1084)!: detect-and-gate — stop mutating tool-result content (v0.8.0) feat(ENG-1084): detect-and-gate + return-both (v0.8.0) Aug 19, 2026
hiskudin and others added 10 commits August 19, 2026 10:48
… copy

A silent join hid mid-content cuts from consumers that read only
sanitized. Replace each contiguous high-risk run with one inline marker,
keeping surrounding sentences in place; an all-high field becomes just the
marker. Original stays untouched; fields_sanitized unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
update_size_metrics already counts each container; _sanitize_array/_sanitize_object
incremented again, doubling array_count/object_count on the normal path
(telemetry only — the counts don't gate traversal). Drop the redundant
increments and count the direct _sanitize_array call sites that bypass
update_size_metrics. Also iterate obj.items() directly instead of
materialising list(obj.items()) on wide payloads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d_count

Port of the TS review fixes (StuBehan, #85):
- Strings inside a risky field's array ({"name": [INJ]}) skipped Tier 1.
  Route risky-field strings to _sanitize_string_field, preserving the
  risky-field allowlist.
- Add detected_field_count (len of patterns_by_field) so downstream has a
  first-class threat-count signal instead of fields_sanitized.length.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…anning

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port of TS #85: replace the per-container 100-item cap (per-container,
bypassable, blinded normal >1000-item payloads) with the existing
call-scoped max_size byte budget. skip_large_arrays/large_array_threshold
kept as deprecated, off-by-default opt-ins for the legacy cap (non-breaking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e_degraded

Port of TS #85 docs: migration note that content past the max_size detection
budget is returned unanalysed (not dropped); coverage_degraded is None (not
False) when complete, and Tier 2 still scans every string when enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…data-loss bug

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… field

Port of TS: remove the return-both raw payload — sanitized + allowed is the
full surface; consumers keep their own raw copy. Internal detect-only value
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on uncaught)

Port of TS #85 review fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hiskudin hiskudin changed the title feat(ENG-1084): detect-and-gate + return-both (v0.8.0) feat(ENG-1084): detect-and-gate + sentence-level cleaning (v0.8.0) Aug 19, 2026
@hiskudin hiskudin changed the title feat(ENG-1084): detect-and-gate + sentence-level cleaning (v0.8.0) feat(ENG-1084): detect-and-gate + sentence-level cleaning (v0.8.2) Aug 19, 2026
@hiskudin
hiskudin merged commit 60b01d0 into main Aug 19, 2026
11 checks passed
@hiskudin
hiskudin deleted the feat/eng-1084-detect-and-gate branch August 19, 2026 16:10
hiskudin added a commit that referenced this pull request Aug 19, 2026
The non-squash merge of #28 left the release PR at 0.8.0 with the old
breaking footer. Correct the version to 0.8.2 and tidy the changelog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

2 participants