Skip to content

Commit 4fadf50

Browse files
authored
Merge pull request #559 from escoffier-labs/fix/content-guard-injection-crossref
fix(handoff): fail content guard lint on injection warnings
2 parents 984a9fb + 998c632 commit 4fadf50

2 files changed

Lines changed: 127 additions & 33 deletions

File tree

src/brigade/handoff_cmd/linting.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ def _injection_messages(hits: tuple[Any, ...]) -> tuple[str, ...]:
4141
return tuple(messages)
4242

4343

44+
def _egress_verdict(exit_code: Any) -> str:
45+
return OK if exit_code == 0 else FAIL
46+
47+
48+
def _injection_verdict(hits: tuple[Any, ...]) -> str:
49+
return FAIL if any(hit.severity == "warning" for hit in hits) else OK
50+
51+
52+
def _injection_detail(*, warning_count: int) -> str:
53+
if warning_count:
54+
label = "warning" if warning_count == 1 else "warnings"
55+
return f"{warning_count} injection {label}"
56+
return "clean"
57+
58+
4459
def _read_handoff_text(path: Path) -> str | None:
4560
try:
4661
return path.read_text(errors="replace")
@@ -71,8 +86,10 @@ def lint(
7186
hits = scan_handoff_injection_heuristics(text or "") if text is not None else ()
7287
guard_item["injection_heuristics"] = [_injection_hit_dict(hit) for hit in hits]
7388
guard_item["injection_warning_count"] = len([hit for hit in hits if hit.severity == "warning"])
89+
guard_item["egress_verdict"] = _egress_verdict(guard_item.get("exit_code"))
90+
guard_item["injection_verdict"] = _injection_verdict(hits)
7491
guard_results.append(guard_item)
75-
guard_ok = all(item.get("exit_code") == 0 for item in guard_results)
92+
guard_ok = all(item.get("egress_verdict") == OK and item.get("injection_verdict") == OK for item in guard_results)
7693
injection_counts: dict[str, int] = {}
7794
injection_hits_by_path: dict[str, tuple[Any, ...]] = {}
7895
enriched_results: list[HandoffLintResult] = []
@@ -110,14 +127,21 @@ def lint(
110127
hits = injection_hits_by_path.get(path_key, ())
111128
row["injection_heuristics"] = [_injection_hit_dict(hit) for hit in hits]
112129
result_dicts.append(row)
113-
payload = {
130+
payload: dict[str, Any] = {
114131
"target": str(target),
115132
"count": len(results),
116133
"valid": all(result.valid for result in results) and guard_ok,
117134
"injection_flagged_count": len(injection_counts),
118135
"results": result_dicts,
119136
"content_guard": guard_results,
120137
}
138+
if content_guard:
139+
payload["content_guard_egress"] = (
140+
OK if all(item.get("egress_verdict") == OK for item in guard_results) else FAIL
141+
)
142+
payload["content_guard_injection"] = (
143+
OK if all(item.get("injection_verdict") == OK for item in guard_results) else FAIL
144+
)
121145
if json_output:
122146
print(json.dumps(payload, indent=2, sort_keys=True))
123147
return 0 if payload["valid"] else 1
@@ -137,14 +161,18 @@ def lint(
137161
else:
138162
print(f" warning: {warning}")
139163
if content_guard:
140-
print(f"content_guard_policy: {guard_policy} (leak scan + injection heuristics)")
164+
print(f"content_guard_policy: {guard_policy} (egress leak scan + injection heuristics)")
141165
for item in guard_results:
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')}")
166+
hits = item.get("injection_heuristics") or []
167+
egress_status = item.get("egress_verdict", OK)
168+
print(f"[{egress_status}] content_guard egress: {item.get('path')} {item.get('detail')}")
169+
injection_status = item.get("injection_verdict", OK)
144170
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 []:
171+
print(
172+
f"[{injection_status}] content_guard injection: {item.get('path')} "
173+
f"{_injection_detail(warning_count=warning_count)}"
174+
)
175+
for hit in hits:
148176
if hit.get("severity") == "info":
149177
print(f" line {hit['line']}: info: [{hit['rule']}] {hit['excerpt']}")
150178
elif hit.get("severity") == "warning":

tests/test_handoff_injection_lint.py

Lines changed: 91 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,28 @@
1414
BENIGN_FIXTURES = sorted(FIXTURE_DIR.glob("benign-*.md"))
1515

1616

17+
def _clean_egress_scan(*args, **kwargs):
18+
return {
19+
"available": True,
20+
"status": "ok",
21+
"exit_code": 0,
22+
"detail": "clean",
23+
"stdout": "",
24+
"stderr": "",
25+
}
26+
27+
28+
def _leaky_egress_scan(*args, **kwargs):
29+
return {
30+
"available": True,
31+
"status": "blocked",
32+
"exit_code": 1,
33+
"detail": "content-guard reported findings",
34+
"stdout": "finding in handoff",
35+
"stderr": "",
36+
}
37+
38+
1739
@pytest.mark.parametrize("fixture_path", EVIL_FIXTURES, ids=lambda path: path.name)
1840
def test_injection_fixture_flags_warning(fixture_path: Path):
1941
text = fixture_path.read_text()
@@ -31,51 +53,95 @@ def test_benign_injection_fixture_has_no_warnings(fixture_path: Path):
3153
assert not warnings, f"benign fixture should not warn: {warnings}"
3254

3355

34-
def test_handoff_lint_content_guard_reports_injection_with_line_numbers(tmp_path, capsys, monkeypatch):
56+
def test_handoff_lint_content_guard_fails_on_injection_laden_egress_clean(tmp_path, capsys, monkeypatch):
3557
evil = FIXTURE_DIR / "evil-ignore-previous.md"
3658
path = tmp_path / "evil.md"
3759
path.write_text(evil.read_text())
3860

39-
def fake_run_scan(scan_target, *, repo_target=None, policy="public-repo"):
40-
return {
41-
"available": True,
42-
"status": "ok",
43-
"exit_code": 0,
44-
"detail": "clean",
45-
"stdout": "",
46-
"stderr": "",
47-
}
48-
49-
monkeypatch.setattr("brigade.scrub.run_scan", fake_run_scan)
50-
assert handoff_cmd.lint(target=tmp_path, paths=[path], content_guard=True, json_output=True) == 0
61+
monkeypatch.setattr("brigade.scrub.run_scan", _clean_egress_scan)
62+
assert handoff_cmd.lint(target=tmp_path, paths=[path], content_guard=True, json_output=True) == 1
5163
payload = json.loads(capsys.readouterr().out)
64+
assert payload["valid"] is False
65+
assert payload["content_guard_egress"] == "ok"
66+
assert payload["content_guard_injection"] == "fail"
5267
heuristics = payload["results"][0]["injection_heuristics"]
5368
assert heuristics
5469
assert any(item["severity"] == "warning" for item in heuristics)
5570
assert all("line" in item and item["line"] >= 1 for item in heuristics)
5671
guard = payload["content_guard"][0]
72+
assert guard["egress_verdict"] == "ok"
73+
assert guard["injection_verdict"] == "fail"
5774
assert guard["injection_warning_count"] >= 1
5875
assert guard["injection_heuristics"]
5976

6077

78+
def test_handoff_lint_content_guard_fails_on_egress_leaky_injection_free(tmp_path, capsys, monkeypatch):
79+
benign = FIXTURE_DIR / "benign-about-injection.md"
80+
path = tmp_path / "benign.md"
81+
path.write_text(benign.read_text())
82+
83+
monkeypatch.setattr("brigade.scrub.run_scan", _leaky_egress_scan)
84+
assert handoff_cmd.lint(target=tmp_path, paths=[path], content_guard=True, json_output=True) == 1
85+
payload = json.loads(capsys.readouterr().out)
86+
assert payload["valid"] is False
87+
assert payload["content_guard_egress"] == "fail"
88+
assert payload["content_guard_injection"] == "ok"
89+
guard = payload["content_guard"][0]
90+
assert guard["egress_verdict"] == "fail"
91+
assert guard["injection_verdict"] == "ok"
92+
assert guard["exit_code"] == 1
93+
assert guard["injection_warning_count"] == 0
94+
95+
96+
def test_handoff_lint_content_guard_clean_passes_with_both_axes_named(tmp_path, capsys, monkeypatch):
97+
benign = FIXTURE_DIR / "benign-about-injection.md"
98+
path = tmp_path / "benign.md"
99+
path.write_text(benign.read_text())
100+
101+
monkeypatch.setattr("brigade.scrub.run_scan", _clean_egress_scan)
102+
assert handoff_cmd.lint(target=tmp_path, paths=[path], content_guard=True, json_output=True) == 0
103+
payload = json.loads(capsys.readouterr().out)
104+
assert payload["valid"] is True
105+
assert payload["content_guard_egress"] == "ok"
106+
assert payload["content_guard_injection"] == "ok"
107+
guard = payload["content_guard"][0]
108+
assert guard["egress_verdict"] == "ok"
109+
assert guard["injection_verdict"] == "ok"
110+
111+
handoff_cmd.lint(target=tmp_path, paths=[path], content_guard=True)
112+
out = capsys.readouterr().out
113+
assert "content_guard egress:" in out
114+
assert "content_guard injection:" in out
115+
assert "[ok] content_guard egress:" in out
116+
assert "[ok] content_guard injection:" in out
117+
118+
119+
def test_handoff_lint_content_guard_info_only_injection_does_not_fail(tmp_path, capsys, monkeypatch):
120+
benign = FIXTURE_DIR / "benign-quoted-payload.md"
121+
path = tmp_path / "benign.md"
122+
path.write_text(benign.read_text())
123+
124+
monkeypatch.setattr("brigade.scrub.run_scan", _clean_egress_scan)
125+
assert handoff_cmd.lint(target=tmp_path, paths=[path], content_guard=True, json_output=True) == 0
126+
payload = json.loads(capsys.readouterr().out)
127+
assert payload["valid"] is True
128+
assert payload["content_guard_injection"] == "ok"
129+
guard = payload["content_guard"][0]
130+
assert guard["injection_verdict"] == "ok"
131+
assert guard["injection_warning_count"] == 0
132+
assert any(hit["severity"] == "info" for hit in guard["injection_heuristics"])
133+
134+
61135
def test_handoff_lint_content_guard_prints_injection_scope(tmp_path, capsys, monkeypatch):
62136
evil = FIXTURE_DIR / "evil-disregard-system.md"
63137
path = tmp_path / "evil.md"
64138
path.write_text(evil.read_text())
65139

66-
monkeypatch.setattr(
67-
"brigade.scrub.run_scan",
68-
lambda *args, **kwargs: {
69-
"available": True,
70-
"status": "ok",
71-
"exit_code": 0,
72-
"detail": "clean",
73-
"stdout": "",
74-
"stderr": "",
75-
},
76-
)
140+
monkeypatch.setattr("brigade.scrub.run_scan", _clean_egress_scan)
77141
handoff_cmd.lint(target=tmp_path, paths=[path], content_guard=True)
78142
out = capsys.readouterr().out
79-
assert "leak scan + injection heuristics" in out
143+
assert "egress leak scan + injection heuristics" in out
144+
assert "[ok] content_guard egress:" in out
145+
assert "[fail] content_guard injection:" in out
80146
assert "line " in out
81147
assert "disregard-system-prompt" in out or "classic-injection" in out

0 commit comments

Comments
 (0)