Skip to content

Commit 0f2cfbc

Browse files
fix(handoff): flag injection payloads in content-guard lint (#477)
Extend handoff lint --content-guard with line-numbered injection heuristics alongside the leak scan, add a fixture corpus, and document the broader scope. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6aba1c2 commit 0f2cfbc

17 files changed

Lines changed: 515 additions & 42 deletions

docs/security.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Content Guard is Brigade's publish and memory-safety scanner. Brigade shells out
1010

1111
Use it in three places:
1212

13-
- `brigade handoff lint --content-guard --guard-policy personal` checks pending handoffs before memory ingest.
13+
- `brigade handoff lint --content-guard --guard-policy personal` checks pending handoffs before memory ingest. The flag runs content-guard for secret and identity leaks and Brigade injection heuristics for instruction-shaped payloads in handoff bodies (for example override phrases, fake system blocks, and base64-decode chains). Injection hits are reported as line-numbered warnings; benign discussion of prompt injection may appear as info-level notes.
1414
- `brigade handoff draft --guard --guard-policy personal ...` writes a draft and returns failure if Content Guard blocks it.
1515
- `brigade work import content-guard --policy public-repo` runs a scan and turns blocking findings into reviewable work imports.
1616

src/brigade/cli/handoff.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ def register(sub: argparse._SubParsersAction) -> None:
4545
)
4646
p_handoff_lint.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
4747
p_handoff_lint.add_argument(
48-
"--content-guard", action="store_true", help="Also scan handoff files with content-guard."
48+
"--content-guard",
49+
action="store_true",
50+
help="Run content-guard leak scan plus handoff injection heuristics (secrets/PII and instruction-shaped payloads).",
4951
)
5052
p_handoff_lint.add_argument(
5153
"--guard-policy", default="personal", help="Content Guard policy name or path for --content-guard."

src/brigade/handoff_cmd/linting.py

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,30 @@
2424
globals().update({name: value for name, value in vars(_family_base).items() if not name.startswith("__")})
2525

2626

27+
def _injection_hit_dict(hit: Any) -> dict[str, Any]:
28+
return {
29+
"line": hit.line,
30+
"severity": hit.severity,
31+
"rule": hit.rule,
32+
"excerpt": hit.excerpt,
33+
}
34+
35+
36+
def _injection_messages(hits: tuple[Any, ...]) -> tuple[str, ...]:
37+
messages: list[str] = []
38+
for hit in hits:
39+
prefix = "info" if hit.severity == "info" else "warning"
40+
messages.append(f"line {hit.line}: {prefix}: [{hit.rule}] {hit.excerpt}")
41+
return tuple(messages)
42+
43+
44+
def _read_handoff_text(path: Path) -> str | None:
45+
try:
46+
return path.read_text(errors="replace")
47+
except OSError:
48+
return None
49+
50+
2751
def lint(
2852
*,
2953
target: Path,
@@ -36,29 +60,55 @@ def lint(
3660
if not target.is_dir():
3761
print(f"error: --target is not a directory: {target}", file=sys.stderr)
3862
return 2
39-
from ..untrusted import scan_untrusted
63+
from ..untrusted import scan_handoff_injection_heuristics, scan_untrusted
4064

4165
results = lint_targets(target, paths=paths)
42-
guard_results = (
43-
[_guard_handoff_path(path, target=target, policy=guard_policy) for path in [result.path for result in results]]
44-
if content_guard
45-
else []
46-
)
66+
guard_results: list[dict[str, Any]] = []
67+
if content_guard:
68+
for result in results:
69+
guard_item = _guard_handoff_path(result.path, target=target, policy=guard_policy)
70+
text = _read_handoff_text(result.path)
71+
hits = scan_handoff_injection_heuristics(text or "") if text is not None else ()
72+
guard_item["injection_heuristics"] = [_injection_hit_dict(hit) for hit in hits]
73+
guard_item["injection_warning_count"] = len([hit for hit in hits if hit.severity == "warning"])
74+
guard_results.append(guard_item)
4775
guard_ok = all(item.get("exit_code") == 0 for item in guard_results)
48-
# Content-guard checks egress (secrets/PII), not instructions. Surface the
49-
# injection signal here too so a poisoned note never reads as fully clean.
5076
injection_counts: dict[str, int] = {}
77+
injection_hits_by_path: dict[str, tuple[Any, ...]] = {}
78+
enriched_results: list[HandoffLintResult] = []
5179
for result in results:
52-
try:
53-
signal = scan_untrusted(result.path.read_text(errors="replace"))
54-
except OSError:
80+
text = _read_handoff_text(result.path)
81+
if text is None:
82+
enriched_results.append(result)
5583
continue
84+
hits = scan_handoff_injection_heuristics(text)
85+
injection_hits_by_path[str(result.path)] = hits
86+
signal = scan_untrusted(text)
5687
if signal.flagged:
5788
injection_counts[str(result.path)] = signal.count
89+
injection_messages = _injection_messages(hits) if content_guard or hits else ()
90+
if not injection_messages and signal.flagged:
91+
injection_messages = (
92+
f"line ?: warning: [{signal.count} prompt-injection signal(s); see `brigade security scan`]",
93+
)
94+
enriched_results.append(
95+
HandoffLintResult(
96+
path=result.path,
97+
action=result.action,
98+
valid=result.valid,
99+
errors=result.errors,
100+
warnings=result.warnings + injection_messages,
101+
hints=result.hints,
102+
)
103+
)
104+
results = tuple(enriched_results)
58105
result_dicts = []
59106
for result in results:
60107
row = result.as_dict()
61-
row["injection_signals"] = injection_counts.get(str(result.path), 0)
108+
path_key = str(result.path)
109+
row["injection_signals"] = injection_counts.get(path_key, 0)
110+
hits = injection_hits_by_path.get(path_key, ())
111+
row["injection_heuristics"] = [_injection_hit_dict(hit) for hit in hits]
62112
result_dicts.append(row)
63113
payload = {
64114
"target": str(target),
@@ -82,17 +132,23 @@ def lint(
82132
for hint in result.hints:
83133
print(f" hint: {hint}")
84134
for warning in result.warnings:
85-
print(f" warning: {warning}")
86-
signals = injection_counts.get(str(result.path), 0)
87-
if signals:
88-
print(
89-
f" warning: {signals} prompt-injection signal(s); content-guard does not check this, see `brigade security scan`"
90-
)
135+
if warning.startswith("line "):
136+
print(f" {warning}")
137+
else:
138+
print(f" warning: {warning}")
91139
if content_guard:
92-
print(f"content_guard_policy: {guard_policy}")
140+
print(f"content_guard_policy: {guard_policy} (leak scan + injection heuristics)")
93141
for item in guard_results:
94-
status = OK if item.get("exit_code") == 0 else FAIL
95-
print(f"[{status}] content_guard: {item.get('path')} {item.get('detail')}")
142+
leak_status = OK if item.get("exit_code") == 0 else FAIL
143+
print(f"[{leak_status}] content_guard leaks: {item.get('path')} {item.get('detail')}")
144+
warning_count = int(item.get("injection_warning_count") or 0)
145+
if warning_count:
146+
print(f" warning: {warning_count} injection heuristic hit(s)")
147+
for hit in item.get("injection_heuristics") or []:
148+
if hit.get("severity") == "info":
149+
print(f" line {hit['line']}: info: [{hit['rule']}] {hit['excerpt']}")
150+
elif hit.get("severity") == "warning":
151+
print(f" line {hit['line']}: warning: [{hit['rule']}] {hit['excerpt']}")
96152
return 0 if payload["valid"] else 1
97153

98154

src/brigade/templates/policies/personal.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"_comment": [
33
"Personal policy: for local working notes and memory handoffs before ingest.",
44
"It blocks secrets and attribution trailers, while warning on personal or infrastructure-like context.",
5-
"Use via: brigade handoff lint --content-guard --guard-policy personal"
5+
"Use via: brigade handoff lint --content-guard --guard-policy personal (leak scan + injection heuristics)"
66
],
77
"_brigade_version": "0.25.1",
88
"categories": {

src/brigade/untrusted.py

Lines changed: 139 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import hashlib
1515
import re
1616
from dataclasses import dataclass
17-
from typing import List, Optional
17+
from typing import Iterable, List, Optional
1818

1919
_INJECTION_PATTERNS = (
2020
"ig" + "nore (all )?(previous|prior) instructions",
@@ -82,27 +82,150 @@ def wrap_untrusted(
8282
return "\n".join(parts)
8383

8484

85+
@dataclass(frozen=True)
86+
class InjectionHit:
87+
line: int
88+
severity: str
89+
rule: str
90+
excerpt: str
91+
92+
8593
@dataclass
8694
class InjectionSignal:
8795
flagged: bool
8896
count: int
8997
markers: List[str]
9098

9199

100+
_LINE_RULES: tuple[tuple[str, re.Pattern[str]], ...] = (
101+
("classic-injection", PROMPT_INJECTION_RE),
102+
("disregard-system-prompt", re.compile(r"(?i)disregard (your |the )?system prompt")),
103+
(
104+
"ignore-instructions",
105+
re.compile(r"(?i)ignore (all |any )?(previous|prior|above) (instructions|directives)"),
106+
),
107+
("fake-system-block", re.compile(r"(?i)(<\s*/?\s*system\b|\[INST\]|\[/INST\]|<<\s*SYS\s*>>)")),
108+
("role-override", re.compile(r"(?i)\byou are now\b")),
109+
(
110+
"assistant-directive",
111+
re.compile(
112+
r"(?i)(?:^(?:assistant|agent|ai)\s*[,:-]\s*(?:you )?(?:must|should|need to)\b"
113+
r"|(?:dear|hey) (?:assistant|agent|ai)\b.*\b(?:ignore|disregard|override)\b)"
114+
),
115+
),
116+
)
117+
118+
_BASE64_BLOB = re.compile(r"[A-Za-z0-9+/]{80,}={0,2}")
119+
_DECODE_INSTRUCTION = re.compile(r"(?i)\b(?:base64|atob|decode|decrypt)\b")
120+
121+
_BENIGN_LINE_MARKERS = (
122+
re.compile(r"(?i)\bprompt[- ]injection\b"),
123+
re.compile(r"(?i)\binjection (?:heuristic|signal|scan|detection|mitigation|fixture)\b"),
124+
re.compile(r"(?i)\b(?:example|documented|detected|benign|fixture|quoted|pattern|mitigation)\b"),
125+
re.compile(r"(?i)\b(?:scans?|checks?) for\b"),
126+
)
127+
128+
129+
def _excerpt(line: str) -> str:
130+
return line.strip()[:_MARKER_MAX]
131+
132+
133+
def _benign_injection_discussion(line: str, *, text: str) -> bool:
134+
if any(marker.search(line) for marker in _BENIGN_LINE_MARKERS):
135+
return True
136+
if "`" in line and any(token in line.lower() for token in ("ignore", "disregard", "<system", "[inst]")):
137+
return True
138+
if re.search(r'(?i)["\'].*(?:ignore|disregard).*(?:instructions|system prompt).*["\']', line):
139+
return True
140+
if "prompt injection" in text.lower() and re.search(r"(?i)\b(?:issue|handoff|heuristic|#)\b", line):
141+
return True
142+
return False
143+
144+
145+
def _line_hits(line: str, line_number: int, *, text: str) -> list[InjectionHit]:
146+
hits: list[InjectionHit] = []
147+
seen_rules: set[str] = set()
148+
for rule_id, pattern in _LINE_RULES:
149+
if not pattern.search(line):
150+
continue
151+
severity = "info" if _benign_injection_discussion(line, text=text) else "warning"
152+
if rule_id in seen_rules:
153+
continue
154+
seen_rules.add(rule_id)
155+
hits.append(InjectionHit(line=line_number, severity=severity, rule=rule_id, excerpt=_excerpt(line)))
156+
return hits
157+
158+
159+
def _cross_line_hits(text: str) -> list[InjectionHit]:
160+
normalized = re.sub(r"\s+", " ", text)
161+
if not PROMPT_INJECTION_RE.search(normalized):
162+
return []
163+
for _line_number, line in enumerate(text.splitlines(), start=1):
164+
if PROMPT_INJECTION_RE.search(line):
165+
return []
166+
start = PROMPT_INJECTION_RE.search(normalized)
167+
if not start:
168+
return []
169+
excerpt = normalized[start.start() :].strip()[:_MARKER_MAX]
170+
severity = "info" if _benign_injection_discussion(excerpt, text=text) else "warning"
171+
return [InjectionHit(line=1, severity=severity, rule="classic-injection", excerpt=excerpt)]
172+
173+
174+
def _base64_decode_hits(lines: list[str]) -> list[InjectionHit]:
175+
hits: list[InjectionHit] = []
176+
for index, line in enumerate(lines):
177+
if not _BASE64_BLOB.search(line):
178+
continue
179+
window = "\n".join(lines[index : index + 4])
180+
if not _DECODE_INSTRUCTION.search(window):
181+
continue
182+
line_number = index + 1
183+
severity = "info" if _benign_injection_discussion(line, text=window) else "warning"
184+
hits.append(
185+
InjectionHit(
186+
line=line_number,
187+
severity=severity,
188+
rule="base64-decode-chain",
189+
excerpt=_excerpt(line),
190+
)
191+
)
192+
return hits
193+
194+
195+
def scan_handoff_injection_heuristics(content: str) -> tuple[InjectionHit, ...]:
196+
"""Scan handoff bodies for instruction-shaped injection payloads."""
197+
text = content if isinstance(content, str) else ""
198+
lines = text.splitlines()
199+
hits: list[InjectionHit] = []
200+
seen: set[tuple[int, str]] = set()
201+
for line_number, line in enumerate(lines, start=1):
202+
for hit in _line_hits(line, line_number, text=text):
203+
key = (hit.line, hit.rule)
204+
if key in seen:
205+
continue
206+
seen.add(key)
207+
hits.append(hit)
208+
for hit in _cross_line_hits(text):
209+
key = (hit.line, hit.rule)
210+
if key not in seen:
211+
seen.add(key)
212+
hits.append(hit)
213+
for hit in _base64_decode_hits(lines):
214+
key = (hit.line, hit.rule)
215+
if key in seen:
216+
continue
217+
seen.add(key)
218+
hits.append(hit)
219+
return tuple(hits)
220+
221+
222+
def _warning_hits(hits: Iterable[InjectionHit]) -> list[InjectionHit]:
223+
return [hit for hit in hits if hit.severity == "warning"]
224+
225+
92226
def scan_untrusted(content: str) -> InjectionSignal:
93227
"""Report whether `content` carries injection-style instructions."""
94-
text = content if isinstance(content, str) else ""
95-
markers: List[str] = []
96-
for line in text.splitlines():
97-
if PROMPT_INJECTION_RE.search(line):
98-
markers.append(line.strip()[:_MARKER_MAX])
99-
# Per-line matching alone is evadable by splitting a phrase across newlines
100-
# ("ignore all\nprevious instructions"). Scan a whitespace-normalized copy
101-
# too so a cross-line phrase is still caught; only add a marker if the
102-
# per-line pass missed it, to avoid double-counting single-line hits.
103-
if not markers:
104-
normalized = re.sub(r"\s+", " ", text)
105-
m = PROMPT_INJECTION_RE.search(normalized)
106-
if m:
107-
markers.append(normalized[m.start() :].strip()[:_MARKER_MAX])
108-
return InjectionSignal(flagged=bool(markers), count=len(markers), markers=markers)
228+
hits = scan_handoff_injection_heuristics(content)
229+
warnings = _warning_hits(hits)
230+
markers = [hit.excerpt for hit in warnings]
231+
return InjectionSignal(flagged=bool(warnings), count=len(warnings), markers=markers)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Memory Handoff
2+
3+
## Type
4+
decision
5+
6+
## Title
7+
Add handoff injection heuristics for issue 477
8+
9+
## Summary
10+
Document the decision to scan handoffs for prompt-injection payloads during content-guard lint.
11+
12+
## Recommended memory action
13+
no-card
14+
15+
## Target document
16+
.learnings/LEARNINGS.md
17+
18+
## Suggested document content
19+
### Add handoff injection heuristics for issue 477
20+
21+
We added injection heuristics to `brigade handoff lint --content-guard` so instruction-shaped payloads are flagged with line numbers before ingest.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Memory Handoff
2+
3+
## Type
4+
workflow
5+
6+
## Title
7+
Normal user instruction routing
8+
9+
## Summary
10+
Describe how explicit user instructions should flow through ingest without injection heuristics firing.
11+
12+
## Recommended memory action
13+
no-card
14+
15+
## Target document
16+
.learnings/LEARNINGS.md
17+
18+
## Suggested document content
19+
### Normal user instruction routing
20+
21+
Operators should write explicit user instructions in the handoff summary and evidence sections. The ingest path promotes only lint-valid routes and never auto-executes suggested document content.

0 commit comments

Comments
 (0)