ctxsentry 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ctxsentry/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """ctxsentry - inbound prompt-injection scanner for AI coding agents.
2
+
3
+ ctxsentry inspects the files an AI coding agent ingests the moment it opens a
4
+ repository (READMEs, docs, issue text, agent rule files, ``.mcp.json`` tool
5
+ descriptions, filenames) and reports content that looks engineered to hijack
6
+ the agent: instruction overrides, invisible Unicode, HTML smuggling, and
7
+ data-exfiltration primitives.
8
+ """
9
+
10
+ from ctxsentry.finding import Finding, Severity
11
+
12
+ __version__ = "0.4.0"
13
+ __all__ = ["Finding", "Severity", "__version__"]
ctxsentry/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from ctxsentry.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
ctxsentry/benchmark.py ADDED
@@ -0,0 +1,121 @@
1
+ """Score ctxsentry against a labelled corpus.
2
+
3
+ The corpus lives in ``benchmark/`` at the repo root: ``cases.jsonl`` lists each
4
+ fixture with a label (``malicious`` / ``benign``) and, for malicious cases, the
5
+ rule id that *should* fire. ``run_benchmark`` scans every fixture and returns
6
+ precision / recall / F1 / false-positive-rate plus a list of the individual
7
+ mismatches, so "is it better?" has a number behind it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import List, Optional
16
+
17
+ from ctxsentry.scanner import ScanConfig, scan
18
+
19
+ DEFAULT_BENCH_DIR = Path(__file__).resolve().parents[2] / "benchmark"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Case:
24
+ path: str
25
+ label: str # "malicious" | "benign"
26
+ expect_rule: Optional[str] = None
27
+ note: str = ""
28
+
29
+
30
+ @dataclass
31
+ class BenchResult:
32
+ tp: int = 0 # malicious, flagged
33
+ fn: int = 0 # malicious, missed
34
+ fp: int = 0 # benign, flagged
35
+ tn: int = 0 # benign, clean
36
+ rule_expected: int = 0
37
+ rule_hit: int = 0 # malicious case where the *named* rule fired
38
+ misses: List[tuple] = field(default_factory=list) # (case, reason)
39
+
40
+ @property
41
+ def total(self) -> int:
42
+ return self.tp + self.fn + self.fp + self.tn
43
+
44
+ @property
45
+ def precision(self) -> float:
46
+ d = self.tp + self.fp
47
+ return self.tp / d if d else 1.0
48
+
49
+ @property
50
+ def recall(self) -> float:
51
+ d = self.tp + self.fn
52
+ return self.tp / d if d else 1.0
53
+
54
+ @property
55
+ def f1(self) -> float:
56
+ p, r = self.precision, self.recall
57
+ return 2 * p * r / (p + r) if (p + r) else 0.0
58
+
59
+ @property
60
+ def fp_rate(self) -> float:
61
+ d = self.fp + self.tn
62
+ return self.fp / d if d else 0.0
63
+
64
+ @property
65
+ def rule_accuracy(self) -> float:
66
+ return self.rule_hit / self.rule_expected if self.rule_expected else 1.0
67
+
68
+ def to_dict(self) -> dict:
69
+ return {
70
+ "counts": {"tp": self.tp, "fn": self.fn, "fp": self.fp, "tn": self.tn},
71
+ "precision": round(self.precision, 4),
72
+ "recall": round(self.recall, 4),
73
+ "f1": round(self.f1, 4),
74
+ "fp_rate": round(self.fp_rate, 4),
75
+ "rule_accuracy": round(self.rule_accuracy, 4),
76
+ "misses": [{"path": c.path, "reason": reason} for c, reason in self.misses],
77
+ }
78
+
79
+
80
+ def load_cases(bench_dir: Path = DEFAULT_BENCH_DIR) -> List[Case]:
81
+ manifest = bench_dir / "cases.jsonl"
82
+ cases: List[Case] = []
83
+ for line in manifest.read_text(encoding="utf-8").splitlines():
84
+ line = line.strip()
85
+ if not line or line.startswith("#"):
86
+ continue
87
+ cases.append(Case(**json.loads(line)))
88
+ return cases
89
+
90
+
91
+ def run_benchmark(bench_dir: Path = DEFAULT_BENCH_DIR) -> BenchResult:
92
+ res = BenchResult()
93
+ for case in load_cases(bench_dir):
94
+ target = (bench_dir / case.path).resolve()
95
+ findings = scan(ScanConfig(root=target)).findings
96
+ rule_ids = {f.rule_id for f in findings}
97
+
98
+ if case.label == "malicious":
99
+ if findings:
100
+ res.tp += 1
101
+ else:
102
+ res.fn += 1
103
+ res.misses.append((case, "false negative: no finding"))
104
+ if case.expect_rule:
105
+ res.rule_expected += 1
106
+ if case.expect_rule in rule_ids:
107
+ res.rule_hit += 1
108
+ else:
109
+ got = ", ".join(sorted(rule_ids)) or "nothing"
110
+ res.misses.append(
111
+ (case, f"expected {case.expect_rule}, got {got}")
112
+ )
113
+ else:
114
+ if findings:
115
+ res.fp += 1
116
+ res.misses.append(
117
+ (case, "false positive: " + ", ".join(sorted(rule_ids)))
118
+ )
119
+ else:
120
+ res.tn += 1
121
+ return res
ctxsentry/cli.py ADDED
@@ -0,0 +1,249 @@
1
+ """Command-line entry point for ctxsentry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import List, Optional, Sequence
9
+
10
+ from ctxsentry import __version__
11
+ from ctxsentry.finding import Severity
12
+ from ctxsentry.report import render
13
+ from ctxsentry.rules import RULES
14
+ from ctxsentry.scanner import DEFAULT_MAX_BYTES, ScanConfig, scan
15
+
16
+ _EPILOG = """\
17
+ exit codes:
18
+ 0 scan completed, nothing at or above --fail-on
19
+ 1 findings at or above --fail-on severity
20
+ 2 usage / runtime error
21
+
22
+ examples:
23
+ ctxsentry scan .
24
+ ctxsentry scan ../some-repo --format sarif -o ctxsentry.sarif
25
+ ctxsentry scan . --fail-on medium --git-history
26
+ """
27
+
28
+
29
+ def build_parser() -> argparse.ArgumentParser:
30
+ parser = argparse.ArgumentParser(
31
+ prog="ctxsentry",
32
+ description=(
33
+ "Scan a repository for prompt-injection payloads before you point "
34
+ "an AI coding agent at it."
35
+ ),
36
+ epilog=_EPILOG,
37
+ formatter_class=argparse.RawDescriptionHelpFormatter,
38
+ )
39
+ parser.add_argument("--version", action="version", version=f"ctxsentry {__version__}")
40
+ sub = parser.add_subparsers(dest="command", required=True)
41
+
42
+ scan_p = sub.add_parser("scan", help="scan a path", description="Scan a file or directory.")
43
+ scan_p.add_argument("path", nargs="?", default=".", help="file or directory (default: .)")
44
+ scan_p.add_argument(
45
+ "-f",
46
+ "--format",
47
+ default="text",
48
+ choices=["text", "json", "sarif", "markdown"],
49
+ help="output format (default: text)",
50
+ )
51
+ scan_p.add_argument("-o", "--output", help="write report to this file instead of stdout")
52
+ scan_p.add_argument(
53
+ "--fail-on",
54
+ default="high",
55
+ choices=[s.label for s in Severity] + ["none"],
56
+ help="exit 1 if any finding is at least this severe (default: high)",
57
+ )
58
+ scan_p.add_argument(
59
+ "--min-severity",
60
+ default="low",
61
+ choices=[s.label for s in Severity],
62
+ help="hide findings below this severity (default: low)",
63
+ )
64
+ scan_p.add_argument(
65
+ "--min-confidence",
66
+ default="low",
67
+ choices=["low", "medium", "high"],
68
+ help="hide findings below this confidence (default: low)",
69
+ )
70
+ scan_p.add_argument(
71
+ "--all-text",
72
+ action="store_true",
73
+ help="scan every UTF-8 text/source file, not just known agent-context files",
74
+ )
75
+ scan_p.add_argument(
76
+ "--git-history",
77
+ action="store_true",
78
+ help="also scan recent git commit messages",
79
+ )
80
+ scan_p.add_argument(
81
+ "--changed",
82
+ nargs="?",
83
+ const="HEAD",
84
+ default=None,
85
+ metavar="REF",
86
+ help="scan only files changed vs REF (default: HEAD) plus staged/unstaged/"
87
+ "untracked — fast pre-commit and PR gating",
88
+ )
89
+ scan_p.add_argument(
90
+ "--max-bytes",
91
+ type=int,
92
+ default=DEFAULT_MAX_BYTES,
93
+ help=f"skip files larger than this (default: {DEFAULT_MAX_BYTES})",
94
+ )
95
+ scan_p.add_argument("--exclude", action="append", default=[], metavar="DIR", help="extra directory name to skip (repeatable)")
96
+ scan_p.add_argument("--no-color", action="store_true", help="disable ANSI colour")
97
+
98
+ sub.add_parser("rules", help="list detection rules")
99
+
100
+ bench_p = sub.add_parser(
101
+ "bench", help="score the detectors against the labelled corpus in benchmark/"
102
+ )
103
+ bench_p.add_argument("--dir", help="benchmark directory (default: ./benchmark)")
104
+ bench_p.add_argument("--json", action="store_true", help="emit metrics as JSON")
105
+ bench_p.add_argument(
106
+ "--min-recall", type=float, default=0.0, help="exit 1 if recall is below this"
107
+ )
108
+ bench_p.add_argument(
109
+ "--max-fp-rate", type=float, default=1.0,
110
+ help="exit 1 if the false-positive rate exceeds this",
111
+ )
112
+ bench_p.add_argument(
113
+ "--min-rule-accuracy", type=float, default=0.0,
114
+ help="exit 1 if fewer than this fraction of malicious cases hit their named rule",
115
+ )
116
+
117
+ return parser
118
+
119
+
120
+ _CONF_ORDER = {"low": 0, "medium": 1, "high": 2}
121
+
122
+
123
+ def _run_scan(args: argparse.Namespace) -> int:
124
+ target = Path(args.path)
125
+ if not target.exists():
126
+ print(f"ctxsentry: path not found: {target}", file=sys.stderr)
127
+ return 2
128
+
129
+ config = ScanConfig(
130
+ root=target.resolve(),
131
+ max_bytes=args.max_bytes,
132
+ scan_all_text=args.all_text,
133
+ include_git_history=args.git_history,
134
+ changed_since=args.changed,
135
+ )
136
+ if args.exclude:
137
+ config.exclude_dirs = frozenset(config.exclude_dirs | set(args.exclude))
138
+
139
+ result = scan(config)
140
+
141
+ min_sev = Severity.parse(args.min_severity)
142
+ min_conf = _CONF_ORDER[args.min_confidence]
143
+ result.findings = [
144
+ f
145
+ for f in result.findings
146
+ if f.severity >= min_sev and _CONF_ORDER[f.confidence] >= min_conf
147
+ ]
148
+
149
+ report = render(result, args.format, color=not args.no_color and _stdout_is_tty(args))
150
+ if args.output:
151
+ Path(args.output).write_text(report + "\n", encoding="utf-8")
152
+ print(f"ctxsentry: wrote {len(result.findings)} finding(s) to {args.output}")
153
+ else:
154
+ print(report)
155
+
156
+ if args.fail_on == "none":
157
+ return 0
158
+ threshold = Severity.parse(args.fail_on)
159
+ worst = result.max_severity()
160
+ return 1 if worst is not None and worst >= threshold else 0
161
+
162
+
163
+ def _stdout_is_tty(args: argparse.Namespace) -> bool:
164
+ return sys.stdout.isatty() and not args.output
165
+
166
+
167
+ _EXTRA_RULES = [
168
+ ("CG403", "low", "obfuscation", "Long base64 blob that does not decode to readable text."),
169
+ ("CG404", "high", "obfuscation", "base64 / hex blob decodes to instruction or secret-like text."),
170
+ ("CG406", "high", "obfuscation", "Payload visible only after stripping invisible / look-alike characters."),
171
+ ("CG501", "high", "hidden-unicode", "Unicode Tag characters (U+E00xx); decoded and reported."),
172
+ ("CG502", "high", "hidden-unicode", "Bidirectional control character (Trojan Source)."),
173
+ ("CG503", "low", "hidden-unicode", "Zero-width / invisible character run."),
174
+ ("CG504", "medium", "hidden-unicode", "Private Use Area code-point run."),
175
+ ("CG505", "medium", "hidden-unicode", "Word mixes Latin with Cyrillic/Greek look-alikes."),
176
+ ("CG601", "medium", "obfuscation", "Instruction-like text pushed off-screen by a whitespace gap."),
177
+ ("CG602", "low", "obfuscation", "Very long line containing instruction-like text."),
178
+ ("CG701", "high", "mcp-tool-poisoning", "MCP tool description carries hidden instructions."),
179
+ ("CG702", "high", "mcp-tool-poisoning", "MCP server launch command pipes a download into a shell."),
180
+ ("CG801", "high", "filename-injection", "Filename contains control / invisible / bidi characters."),
181
+ ("CG802", "medium", "filename-injection", "Filename reads like an instruction to an assistant."),
182
+ ]
183
+
184
+
185
+ def _run_rules() -> int:
186
+ rows = [(r.id, r.base_severity.label, r.category, r.message) for r in RULES]
187
+ rows += _EXTRA_RULES
188
+ for rid, sev, cat, msg in sorted(rows):
189
+ print(f"{rid} {sev:<8} {cat}")
190
+ print(f" {msg}")
191
+ print(
192
+ f"\n{len(RULES)} regex rules + {len(_EXTRA_RULES)} analytic detectors "
193
+ "(encoded-payload, Unicode, layout, MCP, filename)."
194
+ )
195
+ return 0
196
+
197
+
198
+ def _run_bench(args: argparse.Namespace) -> int:
199
+ from ctxsentry.benchmark import DEFAULT_BENCH_DIR, run_benchmark
200
+
201
+ bench_dir = Path(args.dir).resolve() if args.dir else DEFAULT_BENCH_DIR
202
+ if not (bench_dir / "cases.jsonl").is_file():
203
+ print(f"ctxsentry: no benchmark corpus at {bench_dir}", file=sys.stderr)
204
+ return 2
205
+
206
+ res = run_benchmark(bench_dir)
207
+ if args.json:
208
+ import json as _json
209
+
210
+ print(_json.dumps(res.to_dict(), indent=2))
211
+ else:
212
+ print(
213
+ f"cases: {res.total} "
214
+ f"TP {res.tp} FN {res.fn} FP {res.fp} TN {res.tn}"
215
+ )
216
+ print(
217
+ f"precision {res.precision:.3f} recall {res.recall:.3f} "
218
+ f"F1 {res.f1:.3f} FP-rate {res.fp_rate:.3f} "
219
+ f"rule-accuracy {res.rule_accuracy:.3f}"
220
+ )
221
+ for case, reason in res.misses:
222
+ print(f" ✗ {case.path}: {reason}")
223
+
224
+ ok = (
225
+ res.recall >= args.min_recall
226
+ and res.fp_rate <= args.max_fp_rate
227
+ and res.rule_accuracy >= args.min_rule_accuracy
228
+ )
229
+ return 0 if ok else 1
230
+
231
+
232
+ def main(argv: Optional[Sequence[str]] = None) -> int:
233
+ parser = build_parser()
234
+ args = parser.parse_args(argv)
235
+ try:
236
+ if args.command == "scan":
237
+ return _run_scan(args)
238
+ if args.command == "rules":
239
+ return _run_rules()
240
+ if args.command == "bench":
241
+ return _run_bench(args)
242
+ except KeyboardInterrupt: # pragma: no cover
243
+ return 130
244
+ parser.error("unknown command")
245
+ return 2
246
+
247
+
248
+ if __name__ == "__main__": # pragma: no cover
249
+ sys.exit(main())
ctxsentry/contexts.py ADDED
@@ -0,0 +1,164 @@
1
+ """Classify scanned paths by how an AI coding agent treats them.
2
+
3
+ The same payload is far more dangerous in a file an agent loads automatically as
4
+ standing instructions (``CLAUDE.md``, ``.cursorrules``, ``.mcp.json``) than in an
5
+ arbitrary source file, so detectors use the context to boost severity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import posixpath
12
+ from typing import Iterable
13
+
14
+ # Contexts, most-trusted-by-agents first.
15
+ CTX_MCP_CONFIG = "mcp-config"
16
+ CTX_AGENT_INSTRUCTIONS = "agent-instructions"
17
+ CTX_AGENT_SKILL = "agent-skill"
18
+ CTX_DOCS = "docs"
19
+ CTX_VCS_META = "vcs-metadata" # commit messages, issue/PR bodies fed in via history
20
+ CTX_GENERIC = "generic"
21
+
22
+ # Files agents ingest automatically as authoritative instructions.
23
+ _AGENT_INSTRUCTION_GLOBS = (
24
+ "CLAUDE.md",
25
+ "CLAUDE.local.md",
26
+ ".claude/*.md",
27
+ ".claude/**/*.md",
28
+ "AGENTS.md",
29
+ "AGENT.md",
30
+ "GEMINI.md",
31
+ ".gemini/*.md",
32
+ ".cursorrules",
33
+ ".cursor/rules/*",
34
+ ".cursor/rules/**/*",
35
+ ".windsurfrules",
36
+ ".windsurf/rules/*",
37
+ ".clinerules",
38
+ ".clinerules/*",
39
+ ".aider.conf.yml",
40
+ ".aider/*",
41
+ ".github/copilot-instructions.md",
42
+ ".github/instructions/*",
43
+ ".continue/*",
44
+ )
45
+
46
+ _MCP_CONFIG_GLOBS = (
47
+ ".mcp.json",
48
+ "mcp.json",
49
+ ".vscode/mcp.json",
50
+ ".cursor/mcp.json",
51
+ "**/mcp.json",
52
+ "**/.mcp.json",
53
+ )
54
+
55
+ _AGENT_SKILL_GLOBS = (
56
+ "SKILL.md",
57
+ "skill.md",
58
+ "**/SKILL.md",
59
+ ".claude/skills/**/*",
60
+ "skills/**/*.md",
61
+ )
62
+
63
+ _DOCS_GLOBS = (
64
+ "README*",
65
+ "readme*",
66
+ "CONTRIBUTING*",
67
+ "SECURITY*",
68
+ "docs/*",
69
+ "docs/**/*",
70
+ "*.md",
71
+ "*.mdx",
72
+ "*.rst",
73
+ "*.txt",
74
+ )
75
+
76
+ # Directories that never carry agent context and bloat scans.
77
+ DEFAULT_EXCLUDE_DIRS = frozenset(
78
+ {
79
+ ".git",
80
+ ".hg",
81
+ ".svn",
82
+ "node_modules",
83
+ "venv",
84
+ ".venv",
85
+ "env",
86
+ ".env.d",
87
+ "__pycache__",
88
+ ".mypy_cache",
89
+ ".pytest_cache",
90
+ ".ruff_cache",
91
+ ".tox",
92
+ "dist",
93
+ "build",
94
+ ".next",
95
+ ".nuxt",
96
+ "target",
97
+ "vendor",
98
+ ".gradle",
99
+ ".idea",
100
+ }
101
+ )
102
+
103
+ # Extensions we treat as scannable text regardless of name.
104
+ TEXT_EXTENSIONS = frozenset(
105
+ {
106
+ ".md",
107
+ ".mdx",
108
+ ".markdown",
109
+ ".rst",
110
+ ".txt",
111
+ ".json",
112
+ ".jsonc",
113
+ ".yaml",
114
+ ".yml",
115
+ ".toml",
116
+ ".ini",
117
+ ".cfg",
118
+ ".xml",
119
+ ".html",
120
+ ".htm",
121
+ ".csv",
122
+ ".tsv",
123
+ ".rules",
124
+ ".mdc",
125
+ "", # dotfiles like .cursorrules
126
+ }
127
+ )
128
+
129
+
130
+ def _match_any(rel_path: str, globs: Iterable[str]) -> bool:
131
+ name = posixpath.basename(rel_path)
132
+ for pattern in globs:
133
+ if "/" in pattern:
134
+ if fnmatch.fnmatch(rel_path, pattern):
135
+ return True
136
+ elif fnmatch.fnmatch(name, pattern):
137
+ return True
138
+ return False
139
+
140
+
141
+ def classify(rel_path: str) -> str:
142
+ """Return the context constant for a repo-relative POSIX path."""
143
+
144
+ rel_path = rel_path.replace("\\", "/").lstrip("./")
145
+ if _match_any(rel_path, _MCP_CONFIG_GLOBS):
146
+ return CTX_MCP_CONFIG
147
+ if _match_any(rel_path, _AGENT_INSTRUCTION_GLOBS):
148
+ return CTX_AGENT_INSTRUCTIONS
149
+ if _match_any(rel_path, _AGENT_SKILL_GLOBS):
150
+ return CTX_AGENT_SKILL
151
+ if _match_any(rel_path, _DOCS_GLOBS):
152
+ return CTX_DOCS
153
+ return CTX_GENERIC
154
+
155
+
156
+ # How much to bump a rule's base severity for a given context (in levels).
157
+ CONTEXT_SEVERITY_BOOST = {
158
+ CTX_MCP_CONFIG: 2,
159
+ CTX_AGENT_INSTRUCTIONS: 2,
160
+ CTX_AGENT_SKILL: 1,
161
+ CTX_DOCS: 1,
162
+ CTX_VCS_META: 1,
163
+ CTX_GENERIC: 0,
164
+ }