Subcanopy Guard
Context-aware indirect prompt injection scanner for AI agent tool outputs.
Fast, dependency-free, and built to catch what whole-sequence classifiers miss.
The problem
The buried-injections benchmark ran 10 open-source prompt-injection detectors against 629 real AgentDojo attacks, each embedded inside ordinary tool output — the way an agent firewall actually sees them.
The best detector caught 51%. Meta's Prompt Guard 2 caught 1%. A regex baseline caught 0%.
The reason is context dilution: the same attack that a classifier catches with 100% accuracy on its own drops to 23% when surrounded by benign text. Whole-sequence transformers see one big input and the injection signal gets washed out.
The approach
Subcanopy Guard attacks the problem from a different angle. Instead of classifying the whole text at once, it slides a window across the input and measures local instruction density — the concentration of imperative verbs and jailbreak phrases within that window. It then layers on two more signals:
- 🧠 Density — imperative-verb and phrase concentration in a sliding window
- 📐 Discontinuity — style breaks between adjacent sentences
- 🎯 Provenance — risk multiplier by source (tool output, retrieved doc, user input, system prompt)
No ML models. No external API calls. Pure Python standard library. Sub-millisecond scans.
Results
AgentDojo v1 — 629 attacks buried in tool output, 97 benign
| Threshold | Caught | False positives | p50 |
|---|---|---|---|
| CRITICAL | 544/629 (86.5%) | 5/97 (5.2%) | 0.85 ms |
| HIGH | 629/629 (100%) | 11/97 (11.3%) | 0.67 ms |
Comparison to published baselines on the same corpus
| Detector | Caught | FP | p50 |
|---|---|---|---|
| regex-baseline | 0% | 0% | 0.05 ms |
| Meta Prompt Guard 2 (86M) | 1% | 0% | 149 ms |
| llm-guard | 20% | 2% | 124 ms |
| protectai-deberta-v2 | 23% | 4% | 163 ms |
| jailbreak-detector-large | 51% | 2% | 110 ms |
| subcanopy-guard (CRITICAL) | 86.5% | 5.2% | 0.85 ms |
Baselines from rudratoshs/buried-injections. All detectors evaluated on CPU. At --block-at CRITICAL we beat the paper's leader by 35 points at comparable false-positive rate and ~130× lower latency.
PromptWall — 430 attacks, 8 categories, 70 safe prompts
The generalization test: 8 different attack families with distinct templates, not one. Direct user input, source=user_input.
| Category | Caught | Rate |
|---|---|---|
| multi_turn_drift | 23/35 | 65.7% |
| prompt_exfiltration | 28/51 | 54.9% |
| indirect_injection | 25/46 | 54.3% |
| social_engineering | 21/42 | 50.0% |
| direct_injection | 44/95 | 46.3% |
| jailbreak | 28/74 | 37.8% |
| persona_hijacking | 16/43 | 37.2% |
| encoded_attack | 9/44 | 20.5% |
| Overall | 194/430 | 45.1% |
False positives: 0/70 (0%). p50 latency: 0.09 ms.
Dataset: cyberec/promptwall-injection-dataset. Overall recall has more than tripled from v0.2.0 (14.7% → 45.1%) with zero false positives. Remaining gap is encoded attacks (base64, homoglyphs), scheduled for v0.4.0 — see ROADMAP.md.
Install
pip install subcanopy-guard
Quick start
CLI
scg scan tool_output.json # scan a file
scg scan - # read from stdin
cat response.txt | scg scan - # pipe from another tool
scg scan --json suspicious.txt # structured output for CI
scg scan --source retrieved_doc doc.txt
scg scan --block-at MEDIUM file.txt
Exit codes: 0 clean · 1 blocked · 2 usage/file error. Drop-in for CI pipelines and pre-tool-call gates.
Python
from subcanopy_guard import ContextScanner
scanner = ContextScanner(source="tool_output")
result = scanner.scan(tool_response)
if result.severity in ("HIGH", "CRITICAL"):
log.warning("blocked: %s at %s", result.matches, result.hotspots)
Or as a decorator:
@scanner.protect(arg_name="tool_result")
def process(tool_result: str) -> str:
... # raises InjectionRiskError if the scan blocks
How it works
Three signals, combined.
1. Density — sliding window over imperatives and phrases
Two scoring layers feed a sliding 25-token window with 10-token stride:
- Token layer. A ~60-verb lexicon with two tiers. STRONG verbs (
ignore,disregard,jailbreak,override,repeat) weigh 1.5; REGULAR verbs (print,execute,act,send) weigh 0.5. - Phrase layer. Multi-word patterns (
developer mode,system prompt,no restrictions,DAN,ignore all previous,act as,pretend you are,word for word) contribute their weight once per occurrence to the first overlapping token.
Window score is min(weighted / 2.0, 1.0). The highest window score is the density risk. A single STRONG verb or a single jailbreak phrase pushes a window to HIGH on its own.
Why it defeats context dilution: the window measures local concentration, not global presence. An injection buried in 500 tokens of benign JSON still produces a localized spike that the window catches.
2. Discontinuity — adjacent-sentence style delta
Split on sentence-ending punctuation, JSON structural characters, and commas. Extract two features per sentence: second-person pronoun density and imperative verb density. Compute the L1 distance between adjacent sentences. The maximum delta is the discontinuity risk.
Why it works: benign tool output is declarative and third-person. Injection voice is imperative and second-person. The transition between them is the signal, even when either sentence alone looks benign.
3. Provenance — source-aware multiplier
| Source | Multiplier | Meaning |
|---|---|---|
system_prompt |
0.5× | Developer-controlled |
user_input |
1.0× | Baseline |
retrieved_doc |
1.3× | RAG chunks |
tool_output |
1.5× | External service responses |
Combination
if both signals available:
base = 0.6 * density + 0.4 * discontinuity
if both > 0.3:
base *= 1.15 # agreement bonus
elif only density available:
base = density
elif only discontinuity available:
base = discontinuity
else:
base = 0.0
final = min(base * provenance_multiplier, 1.0)
severity = classify(final)
Availability rule: a signal that cannot be computed (input too short) does not vote against a signal that can. This matters for single-sentence injections where discontinuity has no adjacent sentences to compare against.
Severity bands: CLEAN < 0.15 · LOW < 0.35 · MEDIUM < 0.55 · HIGH < 0.75 · CRITICAL ≥ 0.75.
Limitations
This is a heuristic detector, not a classifier. It is deliberately complementary to transformer-based scanners, not a replacement.
Good at:
- ✅ Template-based indirect injection buried in tool output (AgentDojo)
- ✅ Distinguishing injection register from tool-output register
- ✅ Recognising jailbreak, persona-hijack, and prompt-exfiltration vocabulary
- ✅ Running fast enough for per-tool-call scanning in production
Weak at:
- ❌ Encoded attacks — base64, morse, unicode homoglyphs are not decoded (v0.4.0)
- ❌ Paraphrased attacks that don't change register or use known verbs
- ❌ Sophisticated social engineering — polite, embedded, indistinguishable from benign requests
- ❌ Attacks relying on procedural knowledge rather than vocabulary
Known false positives on AgentDojo v1: 5/97 (5.2%) at --block-at CRITICAL. The v0.3.0 phrase layer introduced 2 additional false positives (3/97 → 5/97) in exchange for a large recall gain on the PromptWall generalization benchmark. Tradeoff documented; tracked for a future tuning pass.
⚠️ Do not use Subcanopy Guard as your only defense. It is a fast pre-filter. Pair it with a transformer classifier for direct injection and with taint-tracking for agent tool-call security.
Performance
| Per-scan latency | 0.09–0.85 ms |
| ML models | None |
| Runtime dependencies | None |
| Python | 3.14+ |
Compare to the fastest transformer detector (fmops-distilbert, 31 ms) — Subcanopy Guard is ~40–150× faster, but trades recall on encoded and socially-engineered attacks for that speed. That tradeoff is deliberate.
License
Dual-licensed:
| Edition | License | Audience |
|---|---|---|
| Community | AGPL-3.0-or-later | Open-source, research, AGPL-compatible |
| Commercial | Proprietary | Organizations embedding in closed-source products |
See COMMERCIAL_LICENSE.md for commercial terms and CLA.md for the contributor agreement.
Contributing
All contributions require a signed CLA. See CONTRIBUTING.md for the development setup.
Roadmap
See ROADMAP.md. Highlights:
- v0.3.0 — lexicon expansion for jailbreak and persona attacks ✅ shipped
- v0.3.1 — prompt-exfiltration phrases ✅ shipped
- v0.4.0 — optional decoding layer for base64 and homoglyphs
- v0.5.0 — streaming scanner and framework integrations
Acknowledgments
Built on the findings of the buried-injections benchmark. The discontinuity signal is inspired by the stylometric approach described in "Beyond Pattern Matching" (April 2026). The severity band model follows ASCEND and tester311249/llm-security.
Built with ☕ and ❤️ by Victor 🐍
If Subcanopy Guard saves you time, consider starring the repo or opening an issue with feedback.
Release files for subcanopy-guard 0.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| subcanopy_guard-0.3.1.tar.gz | 17.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| subcanopy_guard-0.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 40.2 kB
Release files / subcanopy_guard-0.3.1.tar.gz
| Download URL | subcanopy_guard-0.3.1.tar.gz |
|---|---|
| Size | 17.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
597bbc0c2cb61c489050be0fddc28fd484f73c9ca706d802265c33d492839bc2
|
|
BLAKE2b-256 checksum How to use checksums |
482aca06033a572507f67599b53f3e44533c68877b43977d59c1a1ed9deb7e40
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.
Transparency logRelease files / subcanopy_guard-0.3.1-py3-none-any.whl
| Download URL | subcanopy_guard-0.3.1-py3-none-any.whl |
|---|---|
| Size | 22.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7695d0e7d9f479ae96c51792b703a2ca39b96bfb8b5cf11641fdd00715cf3bd6
|
|
BLAKE2b-256 checksum How to use checksums |
e8f283908d054ff90e26b261ec026c79fb2989be6df21296a06c343392ad0042
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.
Transparency log