Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 14 additions & 31 deletions .console/log.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
## 2026-06-20 — feat: INJ1 prompt-injection signature detector

New audit_kit detector (HARNESS_TRUST_HARDENING §2.2.6, the outer INJ layer):
detect_inj1 scans tracked text for invisible/bidi control characters (the
unambiguous injection/homoglyph-smuggling signal). Mirrors the boundary-detector
shape; wired into the runner as deprecated=True so it is SKIPPED by the default
gate (opt-in via --only INJ1 --include-deprecated) — a repo's own injection-
handling code must not red the fleet-wide audit. Reports codepoint+position only
(never surrounding text, D-INJ-3); legitimate handlers opt out via a
custodian:allow-invisible-chars content marker; \u escapes so it never
self-triggers. 7 tests; full suite 1126 passed.

# Log

_Chronological continuity log. Decisions, stop points, what changed and why._
Expand Down Expand Up @@ -366,34 +378,5 @@ Part of the PM context-management completeness-audit train (PM #74).
| call_graph scans tests_root as extra_roots | F1/D1 false positives for fields/functions used in tests but not in src; extra_roots contribute only usages, not definitions | 2026-04-30 |
| F1 skips dataclasses with serialization methods | to_dict/model_dump/asdict expose all fields indirectly; attribute-level analysis can't see this | 2026-04-30 |
| T2 recognizes mock assertions and raise AssertionError | mock.assert_called_once() / raise AssertionError(...) are legitimate test mechanisms | 2026-04-30 |
| C18 excludes f after quote chars | `"f", "h"` list elements matched `f", "` as f-string; add (?<!")(?<!') lookbehinds | 2026-04-30 |
## Stop Points
- Contract drift (docstring vs signature): needs docstring parser — complex, lower priority
- Duplicate code detection: needs hash/similarity pass — complex, likely out of scope
- D1 per-file context: module_functions is a flat set; exclude_paths can't target specific files; fix requires storing (file, name) pairs
- D1 module attribute monkey-patching: `mod.fn = wrapper` is attribute access, not call; D1 misses this pattern; workaround is __all__
- C23 regex false positive on docstrings: "shell=True" in docstring text matches; fix requires AST-based C23
- a private downstream repo D7 35 remaining: all keyword-only params — cannot rename with _ prefix without breaking callers; need to either wire them or accept as known
## Notes
**Detector class map (70 total: 57 core + 13 OC plugin [OC1–OC9, AI1–AI4]):**
- C (C1–C33): file-local code health — regex + inline AST; C33=ghost-work density (new)
- S (S1–S3): cross-file structure — import_graph (S1, S2) + ast_forest (S3)
- A (A1): architecture invariants — declarative YAML max_lines/max_classes/max_functions/forbidden_import (new)
- U (U1–U3): unimplemented stubs — ast_forest
- D (D1–D7): dead code — D7=dead method params (new); D5/D6=dead classes (ast_forest+call_graph)
- F (F1–F3): dead fields/constants — F3=Pydantic BaseModel field liveness (new)
- E (E1–E2): annotation gaps — ast_forest
- T (T1–T2): test shape — T1 uses ast_forest+tests_forest, T2 direct scan
- X (X1–X2): complexity — ast_forest
- G (G1): ghost work — symbol_index
- I (I1): import hygiene — ast_forest
**Analysis passes → detectors:** import_graph → S1/S2; ast_forest → U1-U3,
D2-D4, F2, E1-E2, X1-X2, T1, I1, S3, D5/D6 (class list); call_graph → D1, F1,
D5/D6 (reference/constructed_names check); symbol_index → G1; tests_forest → T1.
**D6 tracks:** direct/generic/classmethod constructor calls, enum access,
`default_factory=`, base-class inheritance. **False-positive risk:** LOW
file-local AST; MEDIUM call_graph (dynamic dispatch) + G1; HIGHER T1.

## Archived

_Archived completed history → `/home/dev/Documents/GitHub/PrivateManifest/archive/console/Custodian/log-2026-06-04.md`_

*(older entries archived for the R1 400-line budget)*
98 changes: 98 additions & 0 deletions src/custodian/audit_kit/detectors/injection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 ProtocolWarden
"""INJ1 — prompt-injection signature detector.

Deterministic scan of tracked text for the unambiguous, near-zero-false-positive
signal of injection / obfuscation smuggling: invisible and bidirectional control
characters (zero-width spaces/joiners, LTR/RTL overrides, the BOM mid-file). These
never legitimately appear in source or prose, but are the classic carrier for
hidden instructions and homoglyph tricks aimed at a model (or a human reviewer)
that ingests the text.

Mirrors the boundary-detector shape (``detect_inj1(context) -> DetectorResult``;
``build_injection_detectors()``) per HARNESS_TRUST_HARDENING.md §2.2.6. It is the
**outer** layer of the INJ defense — never load-bearing (the load-bearing control
is the reviewer's code-computed typed verdict). Registered ``deprecated=True`` so
it is SKIPPED by the default audit gate: a repo's own injection-handling code
(regexes that *match* these chars) would otherwise trip it fleet-wide. It is meant
to be invoked deliberately (``--only INJ1 --include-deprecated``) against ingested
PR content, where a hit drives the reviewer to the stricter deterministic path
(D-INJ-2: degrade, never fail-closed-to-human).

A file that legitimately contains such characters (an injection sanitizer, a
unicode test fixture) is exempted by carrying the marker
``custodian:allow-invisible-chars`` anywhere in its text — identified by content,
so consumers needn't maintain a path exclude list.
"""

from __future__ import annotations

import re

from custodian.audit_kit.detector import (
LOW,
AuditContext,
Detector,
DetectorResult,
)
from custodian.audit_kit.detectors.boundary import _is_binary, _tracked_files

_MAX_SAMPLES = 8
_EXEMPT_MARKER = "custodian:allow-invisible-chars"

# Invisible + bidirectional control characters (\u escapes so THIS file never
# trips its own rule). Covers: zero-width space/non-joiner/joiner, LTR/RTL marks,
# the bidi embedding/override/isolate controls, and a mid-file BOM.
_INVISIBLE = re.compile(
"["
"\u200b\u200c\u200d\u200e\u200f" # ZWSP ZWNJ ZWJ LRM RLM
"\u202a\u202b\u202c\u202d\u202e" # LRE RLE PDF LRO RLO
"\u2066\u2067\u2068\u2069" # LRI RLI FSI PDI
"\ufeff\u00ad" # BOM/ZWNBSP, SHY
"]"
)


def detect_inj1(context: AuditContext) -> DetectorResult:
"""Flag tracked text files containing invisible / bidi control characters."""
samples: list[str] = []
count = 0
for path in _tracked_files(context.repo_root):
if _is_binary(path):
continue
try:
rel = path.relative_to(context.repo_root)
except ValueError:
continue
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
if _EXEMPT_MARKER in text:
continue # a legitimate handler / fixture opted out
for lineno, line in enumerate(text.splitlines(), start=1):
for m in _INVISIBLE.finditer(line):
count += 1
if len(samples) < _MAX_SAMPLES:
cp = f"U+{ord(m.group()):04X}"
# Report codepoint + position only — never the surrounding
# text (that would re-launder attacker content through a
# trusted channel, the very thing D-INJ-3 forbids).
samples.append(f"{rel}:{lineno}: invisible/bidi control char {cp}")
break # one finding per line is enough
return DetectorResult(count=count, samples=samples)


def build_injection_detectors() -> list[Detector]:
return [
Detector(
"INJ1",
"Tracked file contains invisible / bidirectional control characters "
"(prompt-injection / homoglyph smuggling signature)",
"open",
detect_inj1,
LOW,
frozenset(),
deprecated=True, # outer defense; opt-in, never the fleet-wide gate
),
]
2 changes: 2 additions & 0 deletions src/custodian/cli/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from custodian.audit_kit.detectors.imports import build_import_detectors
from custodian.audit_kit.detectors.naming import build_naming_detectors
from custodian.audit_kit.detectors.boundary import build_boundary_detectors
from custodian.audit_kit.detectors.injection import build_injection_detectors
from custodian.audit_kit.detectors.capability_refs import build_capability_detectors
from custodian.audit_kit.detectors.cross_repo import build_cross_repo_detectors
from custodian.audit_kit.detectors.plumbing import build_plumbing_detectors
Expand Down Expand Up @@ -141,6 +142,7 @@ def run_repo_audit(
+ build_docs_detectors()
+ build_naming_detectors()
+ build_boundary_detectors()
+ build_injection_detectors()
+ build_readme_detectors()
+ build_doc_convention_detectors()
+ build_repo_meta_detectors()
Expand Down
92 changes: 92 additions & 0 deletions tests/test_injection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 ProtocolWarden
"""Tests for the INJ1 prompt-injection signature detector."""

from __future__ import annotations

import subprocess
from pathlib import Path

from custodian.audit_kit.detector import AuditContext
from custodian.audit_kit.detectors.injection import (
build_injection_detectors,
detect_inj1,
)

# Build the invisible chars from codepoints so THIS test file contains none
# (it would otherwise trip the very rule it tests).
_ZWSP = chr(0x200B)
_RLO = chr(0x202E)
_BOM = chr(0xFEFF)
_EXEMPT = "custodian:allow-invisible-chars"


def _ctx(repo_root: Path) -> AuditContext:
src_root = repo_root / "src"
tests_root = repo_root / "tests"
src_root.mkdir(parents=True, exist_ok=True)
tests_root.mkdir(parents=True, exist_ok=True)
return AuditContext(
repo_root=repo_root,
src_root=src_root,
tests_root=tests_root,
config={},
plugin_modules=[],
graph=None,
)


def _git_init(root: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
subprocess.run(["git", "add", "-A"], cwd=root, check=True)


class TestInj1:
def test_flags_zero_width_space(self, tmp_path: Path) -> None:
(tmp_path / "evil.py").write_text(f"x = 1{_ZWSP} # hidden\n", encoding="utf-8")
_git_init(tmp_path)
result = detect_inj1(_ctx(tmp_path))
assert result.count == 1
assert "U+200B" in result.samples[0]
assert "evil.py" in result.samples[0]

def test_flags_bidi_override(self, tmp_path: Path) -> None:
(tmp_path / "readme.md").write_text(f"approve{_RLO}reject\n", encoding="utf-8")
_git_init(tmp_path)
assert detect_inj1(_ctx(tmp_path)).count == 1

def test_flags_mid_file_bom(self, tmp_path: Path) -> None:
(tmp_path / "a.txt").write_text(f"line1\npre{_BOM}post\n", encoding="utf-8")
_git_init(tmp_path)
assert detect_inj1(_ctx(tmp_path)).count == 1

def test_clean_repo_no_findings(self, tmp_path: Path) -> None:
(tmp_path / "ok.py").write_text("def f():\n return 42\n", encoding="utf-8")
(tmp_path / "doc.md").write_text("# Title\n\nNormal prose.\n", encoding="utf-8")
_git_init(tmp_path)
assert detect_inj1(_ctx(tmp_path)).count == 0

def test_exempt_marker_skips_file(self, tmp_path: Path) -> None:
# A legitimate handler / fixture that opts out by carrying the marker.
(tmp_path / "sanitizer.py").write_text(
f"# {_EXEMPT}\nPATTERN = '{_ZWSP}'\n", encoding="utf-8"
)
_git_init(tmp_path)
assert detect_inj1(_ctx(tmp_path)).count == 0

def test_reports_codepoint_not_surrounding_text(self, tmp_path: Path) -> None:
# D-INJ-3: never re-launder attacker content; only the codepoint+position.
secret = "ATTACKER-PAYLOAD-DO-NOT-LEAK"
(tmp_path / "x.py").write_text(f"# {secret}{_ZWSP}\n", encoding="utf-8")
_git_init(tmp_path)
sample = detect_inj1(_ctx(tmp_path)).samples[0]
assert secret not in sample
assert "U+200B" in sample

def test_detector_is_deprecated_non_gating(self) -> None:
# Outer defense: must be skipped by the default gate (opt-in only) so a
# repo's own injection-handling code can't red the fleet-wide audit.
dets = build_injection_detectors()
assert len(dets) == 1
assert dets[0].id == "INJ1"
assert dets[0].deprecated is True
Loading