fix(security): normalize context-file content before injection scanning - #41594
fix(security): normalize context-file content before injection scanning#41594petrichor-op wants to merge 1 commit into
Conversation
## What does this PR do?
The context-file scanner in `agent/prompt_builder.py` (`_scan_context_content`)
is the only barrier that *blocks*, rather than merely warns, before a
discovered context file (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`,
`.hermes.md`/`HERMES.md`, `SOUL.md`) is injected verbatim into the trusted
system-prompt channel. It relies entirely on
`scan_for_threats(scope="context")` from `tools/threat_patterns.py`, and the
user has no opportunity to intervene at that layer.
The threat patterns use a `(?:\w+\s+)*` filler segment between anchor tokens
to defeat word-insertion bypasses, but that segment only spans word
characters and whitespace. As a result the anchors are trivially defeated by
splicing *non-word* characters between the tokens: punctuation
("ignore, all previous instructions"), hyphens ("ignore all-prior
instructions"), markup, or zero-width characters — none of which `\w`
matches and, in the zero-width case, `\s` does not match either. A poisoned
context file in a cloned repository (auto-loaded merely by changing into the
directory) could therefore plant durable instructions in the system prompt
while passing the scan.
This change normalizes the content before matching. `scan_for_threats` now
evaluates every pattern against both the raw content and a normalized copy
(NFKC, with runs of non-word/non-space characters collapsed to a single
space) and unions the results. The raw pass preserves patterns that
legitimately depend on punctuation (curl `$VAR` exfiltration, `<!-- -->`
comments, `.env` reads); the normalized pass restores the canonical token
sequence so the anchors fire on spliced payloads. Invisible-unicode
detection continues to run on the raw content so the offending codepoint is
still surfaced.
## Related Issue
N/A
## Type of Change
- [x] 🔒 Security fix
## Changes Made
- `tools/threat_patterns.py`: added `_normalize_for_matching()` (NFKC plus
collapsing non-word/non-space runs to a single space) and updated
`scan_for_threats()` to match each pattern against both the raw and the
normalized content, deduplicating findings. Documented the rationale in
the module docstring.
- `tests/tools/test_threat_patterns.py`: added a `TestSpliceEvasion` class
covering comma, hyphen, punctuation, zero-width, and NFKC full-width
splices, plus regressions confirming punctuation-dependent patterns still
match and that normalization introduces no new false positives.
## How to Test
1. Before the change, confirm the bypass:
`python -c "from tools.threat_patterns import scan_for_threats as s; print(s('ignore, all previous instructions', scope='context'))"`
prints `[]` (the payload is not detected).
2. After the change, the same command prints `['prompt_injection']`, and the
hyphen and zero-width variants are likewise detected.
3. Run the suite: `scripts/run_tests.sh tests/tools/test_threat_patterns.py`
(46 tests pass, including the 9 new splice-evasion cases) and
`scripts/run_tests.sh tests/tools/test_memory_tool.py tests/agent/test_tool_dispatch_helpers.py tests/agent/test_prompt_builder.py`
to confirm the shared callers are unaffected.
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix/feature (no unrelated commits)
- [x] I've run the test suite for the affected areas and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS (Darwin 25.5.0)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — module docstring updated
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A
- [x] I've considered cross-platform impact (Windows, macOS) per the compatibility guide
- [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A
|
✅ Verified — Splice-evasion normalization in threat pattern scanner Reviewed the diff for
No issues found. The implementation is clean, well-tested, and addresses a real evasion vector. |
|
Thanks for identifying a real scanner-evasion path. The premise remains valid on current main: Problems
Suggested changes
Automated hermes-sweeper review. |
What does this PR do?
The context-file scanner in
agent/prompt_builder.py(_scan_context_content)is the only barrier that blocks, rather than merely warns, before a
discovered context file (
AGENTS.md,CLAUDE.md,.cursorrules,.hermes.md/HERMES.md,SOUL.md) is injected verbatim into the trustedsystem-prompt channel. It relies entirely on
scan_for_threats(scope="context")fromtools/threat_patterns.py, and theuser has no opportunity to intervene at that layer.
The threat patterns use a
(?:\w+\s+)*filler segment between anchor tokensto defeat word-insertion bypasses, but that segment only spans word
characters and whitespace. As a result the anchors are trivially defeated by
splicing non-word characters between the tokens: punctuation
("ignore, all previous instructions"), hyphens ("ignore all-prior
instructions"), markup, or zero-width characters — none of which
\wmatches and, in the zero-width case,
\sdoes not match either. A poisonedcontext file in a cloned repository (auto-loaded merely by changing into the
directory) could therefore plant durable instructions in the system prompt
while passing the scan.
This change normalizes the content before matching.
scan_for_threatsnowevaluates every pattern against both the raw content and a normalized copy
(NFKC, with runs of non-word/non-space characters collapsed to a single
space) and unions the results. The raw pass preserves patterns that
legitimately depend on punctuation (curl
$VARexfiltration,<!-- -->comments,
.envreads); the normalized pass restores the canonical tokensequence so the anchors fire on spliced payloads. Invisible-unicode
detection continues to run on the raw content so the offending codepoint is
still surfaced.
Related Issue
N/A
Type of Change
Changes Made
tools/threat_patterns.py: added_normalize_for_matching()(NFKC pluscollapsing non-word/non-space runs to a single space) and updated
scan_for_threats()to match each pattern against both the raw and thenormalized content, deduplicating findings. Documented the rationale in
the module docstring.
tests/tools/test_threat_patterns.py: added aTestSpliceEvasionclasscovering comma, hyphen, punctuation, zero-width, and NFKC full-width
splices, plus regressions confirming punctuation-dependent patterns still
match and that normalization introduces no new false positives.
How to Test
python -c "from tools.threat_patterns import scan_for_threats as s; print(s('ignore, all previous instructions', scope='context'))"prints
[](the payload is not detected).['prompt_injection'], and thehyphen and zero-width variants are likewise detected.
scripts/run_tests.sh tests/tools/test_threat_patterns.py(46 tests pass, including the 9 new splice-evasion cases) and
scripts/run_tests.sh tests/tools/test_memory_tool.py tests/agent/test_tool_dispatch_helpers.py tests/agent/test_prompt_builder.pyto confirm the shared callers are unaffected.
Checklist
Code
fix(scope):,feat(scope):, etc.)Documentation & Housekeeping
docs/, docstrings) — module docstring updatedcli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/A