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 +13 -0
- ctxsentry/__main__.py +6 -0
- ctxsentry/benchmark.py +121 -0
- ctxsentry/cli.py +249 -0
- ctxsentry/contexts.py +164 -0
- ctxsentry/detectors.py +626 -0
- ctxsentry/document.py +53 -0
- ctxsentry/finding.py +74 -0
- ctxsentry/report.py +169 -0
- ctxsentry/rules.py +335 -0
- ctxsentry/scanner.py +322 -0
- ctxsentry-0.4.0.dist-info/METADATA +256 -0
- ctxsentry-0.4.0.dist-info/RECORD +16 -0
- ctxsentry-0.4.0.dist-info/WHEEL +4 -0
- ctxsentry-0.4.0.dist-info/entry_points.txt +2 -0
- ctxsentry-0.4.0.dist-info/licenses/LICENSE +21 -0
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
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
|
+
}
|