jevg 0.3.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.
jev_guard/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .config import Config
2
+ from .findings import Finding, Severity
3
+ from .scanner import scan_path, scan_source
4
+
5
+ __version__ = "0.3.0"
6
+ __all__ = ["Config", "Finding", "Severity", "scan_path", "scan_source", "__version__"]
jev_guard/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
jev_guard/cli.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from . import __version__
7
+ from .findings import Severity
8
+ from .formats import to_json, to_sarif
9
+ from .rules import RULES
10
+ from .scanner import scan_path
11
+
12
+ _COLORS = {
13
+ Severity.CRITICAL: "\033[41;97m",
14
+ Severity.HIGH: "\033[91m",
15
+ Severity.MEDIUM: "\033[93m",
16
+ Severity.LOW: "\033[94m",
17
+ Severity.INFO: "\033[90m",
18
+ }
19
+ _RESET = "\033[0m"
20
+
21
+
22
+ def _fmt_text(findings, use_color: bool) -> str:
23
+ if not findings:
24
+ return "jev-guard: no findings."
25
+ lines = []
26
+ for f in findings:
27
+ tag = f.severity.name
28
+ if use_color:
29
+ tag = f"{_COLORS[f.severity]}{tag}{_RESET}"
30
+ lines.append(f"{f.file}:{f.line} [{tag}] {f.code} {f.message}")
31
+ lines.append(f" ↳ {f.remediation}")
32
+ counts: dict[str, int] = {}
33
+ for f in findings:
34
+ counts[f.severity.name] = counts.get(f.severity.name, 0) + 1
35
+ summary = ", ".join(f"{v} {k.lower()}" for k, v in counts.items())
36
+ lines.append(f"\n{len(findings)} finding(s): {summary}")
37
+ return "\n".join(lines)
38
+
39
+
40
+ def main(argv: list[str] | None = None) -> int:
41
+ parser = argparse.ArgumentParser(
42
+ prog="jev-guard",
43
+ description="Audit code that uses Jev / TypeSafe System One models as a guardrail.",
44
+ )
45
+ parser.add_argument("--version", action="version", version=f"jev-guard {__version__}")
46
+ sub = parser.add_subparsers(dest="cmd", required=True)
47
+
48
+ scan = sub.add_parser("scan", help="Scan a file or directory.")
49
+ scan.add_argument("path")
50
+ scan.add_argument("--format", choices=["text", "json", "sarif"], default="text")
51
+ scan.add_argument("--min-severity", default="INFO")
52
+ scan.add_argument("--fail-on", default="HIGH")
53
+ scan.add_argument("--output", "-o", help="Write report to a file instead of stdout.")
54
+ scan.add_argument("--no-color", action="store_true")
55
+
56
+ sub.add_parser("rules", help="List the rules jev-guard checks.")
57
+
58
+ args = parser.parse_args(argv)
59
+
60
+ if args.cmd == "rules":
61
+ for code, (severity, message, remediation) in sorted(RULES.items()):
62
+ print(f"{code} [{severity.name}] {message}")
63
+ print(f" {remediation}\n")
64
+ return 0
65
+
66
+ min_sev = Severity.parse(args.min_severity)
67
+ fail_on = Severity.parse(args.fail_on)
68
+ findings = [f for f in scan_path(args.path) if f.severity >= min_sev]
69
+
70
+ if args.format == "json":
71
+ report = to_json(findings)
72
+ elif args.format == "sarif":
73
+ report = to_sarif(findings, __version__)
74
+ else:
75
+ use_color = (not args.no_color) and sys.stdout.isatty() and not args.output
76
+ report = _fmt_text(findings, use_color)
77
+
78
+ if args.output:
79
+ with open(args.output, "w", encoding="utf-8") as fh:
80
+ fh.write(report + "\n")
81
+ else:
82
+ print(report)
83
+
84
+ return 1 if any(f.severity >= fail_on for f in findings) else 0
85
+
86
+
87
+ if __name__ == "__main__":
88
+ raise SystemExit(main())
jev_guard/config.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ if sys.version_info >= (3, 11):
8
+ import tomllib
9
+ else: # pragma: no cover
10
+ import tomli as tomllib
11
+
12
+ from .findings import Severity
13
+
14
+ CONFIG_NAME = ".jev-guard.toml"
15
+
16
+
17
+ @dataclass
18
+ class Config:
19
+ disabled: frozenset[str] = frozenset()
20
+ severity_overrides: dict[str, Severity] = field(default_factory=dict)
21
+ extra_dangerous_tools: frozenset[str] = frozenset()
22
+ extra_import_roots: frozenset[str] = frozenset()
23
+ extra_guardrail_calls: frozenset[str] = frozenset()
24
+
25
+ @classmethod
26
+ def load(cls, start: str | Path) -> Config:
27
+ path = _find(Path(start))
28
+ if path is None:
29
+ return cls()
30
+ try:
31
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
32
+ except (OSError, tomllib.TOMLDecodeError):
33
+ return cls()
34
+ section = data.get("jev-guard", data)
35
+ overrides = {
36
+ code.upper(): Severity.parse(name)
37
+ for code, name in (section.get("severity") or {}).items()
38
+ }
39
+ extra = section.get("extra") or {}
40
+ return cls(
41
+ disabled=frozenset(c.upper() for c in section.get("disable", [])),
42
+ severity_overrides=overrides,
43
+ extra_dangerous_tools=frozenset(extra.get("dangerous_tools", [])),
44
+ extra_import_roots=frozenset(extra.get("import_roots", [])),
45
+ extra_guardrail_calls=frozenset(extra.get("guardrail_calls", [])),
46
+ )
47
+
48
+
49
+ def _find(start: Path) -> Path | None:
50
+ start = start.resolve()
51
+ directory = start if start.is_dir() else start.parent
52
+ for candidate in [directory, *directory.parents]:
53
+ cfg = candidate / CONFIG_NAME
54
+ if cfg.is_file():
55
+ return cfg
56
+ return None
jev_guard/findings.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from enum import IntEnum
5
+
6
+
7
+ class Severity(IntEnum):
8
+ INFO = 0
9
+ LOW = 1
10
+ MEDIUM = 2
11
+ HIGH = 3
12
+ CRITICAL = 4
13
+
14
+ @classmethod
15
+ def parse(cls, name: str) -> Severity:
16
+ return cls[name.strip().upper()]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Finding:
21
+ code: str
22
+ severity: Severity
23
+ file: str
24
+ line: int
25
+ message: str
26
+ remediation: str
27
+
28
+ def to_dict(self) -> dict:
29
+ d = asdict(self)
30
+ d["severity"] = self.severity.name
31
+ return d
jev_guard/formats.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from .findings import Finding, Severity
6
+ from .rules import RULES
7
+
8
+ _SARIF_LEVEL = {
9
+ Severity.CRITICAL: "error",
10
+ Severity.HIGH: "error",
11
+ Severity.MEDIUM: "warning",
12
+ Severity.LOW: "note",
13
+ Severity.INFO: "note",
14
+ }
15
+
16
+ _INFO_URI = "https://github.com/ppradyoth/jev-guard"
17
+
18
+
19
+ def to_json(findings: list[Finding]) -> str:
20
+ return json.dumps([f.to_dict() for f in findings], indent=2)
21
+
22
+
23
+ def to_sarif(findings: list[Finding], version: str) -> str:
24
+ rules = [
25
+ {
26
+ "id": code,
27
+ "name": code,
28
+ "shortDescription": {"text": message},
29
+ "fullDescription": {"text": remediation},
30
+ "defaultConfiguration": {"level": _SARIF_LEVEL[severity]},
31
+ "helpUri": f"{_INFO_URI}/blob/main/GUIDE.md",
32
+ }
33
+ for code, (severity, message, remediation) in sorted(RULES.items())
34
+ ]
35
+ results = [
36
+ {
37
+ "ruleId": f.code,
38
+ "level": _SARIF_LEVEL[f.severity],
39
+ "message": {"text": f"{f.message} — {f.remediation}"},
40
+ "locations": [
41
+ {
42
+ "physicalLocation": {
43
+ "artifactLocation": {"uri": f.file},
44
+ "region": {"startLine": max(f.line, 1)},
45
+ }
46
+ }
47
+ ],
48
+ }
49
+ for f in findings
50
+ ]
51
+ doc = {
52
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
53
+ "version": "2.1.0",
54
+ "runs": [
55
+ {
56
+ "tool": {
57
+ "driver": {
58
+ "name": "jev-guard",
59
+ "version": version,
60
+ "informationUri": _INFO_URI,
61
+ "rules": rules,
62
+ }
63
+ },
64
+ "results": results,
65
+ }
66
+ ],
67
+ }
68
+ return json.dumps(doc, indent=2)
jev_guard/rules.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from .findings import Severity
4
+
5
+ # Names of tools whose misuse is high-impact. Matched case-insensitively as a
6
+ # substring of a tool string literal (e.g. "python_repl" matches "python").
7
+ DANGEROUS_TOOLS = frozenset({
8
+ "bash", "shell", "sh", "exec", "execute", "eval", "python", "repl",
9
+ "run_code", "code_interpreter", "sql", "database", "delete", "drop",
10
+ "terminal", "subprocess", "os_system", "system", "write_file", "fs_write",
11
+ "http", "request", "requests", "fetch", "send_email", "email", "transfer",
12
+ })
13
+
14
+ # kwargs that indicate a caller has configured how the guardrail decides to
15
+ # block, rather than relying on Jev's default point decision.
16
+ THRESHOLD_KWARGS = frozenset({
17
+ "threshold", "thresholds", "confidence", "min_confidence", "block_threshold",
18
+ "block_at", "decision_threshold", "review_threshold", "auto_threshold",
19
+ "escalate_below", "on_low_confidence",
20
+ })
21
+
22
+ # kwargs whose value is a probability above which the guardrail *blocks*. A high
23
+ # value here means "only block when almost certain" -> most attacks pass.
24
+ BLOCK_KWARGS = frozenset({
25
+ "threshold", "block_threshold", "block_at", "decision_threshold", "auto_threshold",
26
+ })
27
+
28
+ # kwargs whose value is a confidence below which the guardrail *escalates*. A low
29
+ # value here means "almost never escalate" -> low-confidence calls silently pass.
30
+ ESCALATE_KWARGS = frozenset({
31
+ "min_confidence", "escalate_below", "review_threshold", "confidence",
32
+ })
33
+
34
+ # Variable name fragments suggesting attacker-influenced content.
35
+ UNTRUSTED_HINTS = frozenset({
36
+ "user", "input", "message", "msg", "content", "payload", "request",
37
+ "body", "prompt", "query", "tool_output", "tool_result", "external",
38
+ "untrusted", "email_body", "resume", "ticket", "comment", "webhook",
39
+ })
40
+
41
+ JEV_IMPORT_ROOTS = frozenset({"langchain_typesafe", "typesafe"})
42
+
43
+ RULES = {
44
+ "JG001": (
45
+ Severity.HIGH,
46
+ "Jev guardrail gates actions with no configured confidence threshold",
47
+ "AutoModeMiddleware/classifier blocks on Jev's default decision. "
48
+ "'Zero hallucinations' is a type guarantee, not a correctness one: a "
49
+ "confidently-wrong noul still lets a dangerous call through. Set an "
50
+ "explicit threshold and escalate below it.",
51
+ ),
52
+ "JG002": (
53
+ Severity.CRITICAL,
54
+ "Dangerous tool exposed to an agent with no Jev guardrail present",
55
+ "A high-impact tool (bash/sql/http/...) is wired into the agent but no "
56
+ "AutoModeMiddleware appears in this module. Add a guardrail or remove "
57
+ "the tool from the autonomous path.",
58
+ ),
59
+ "JG003": (
60
+ Severity.MEDIUM,
61
+ "Jev decision consumed without checking calibrated confidence",
62
+ "A .noul/.choice/.score value is used to branch but the module never "
63
+ "reads a confidence field. Calibration is Jev's entire value; acting on "
64
+ "the point estimate discards it. Gate the branch on confidence.",
65
+ ),
66
+ "JG004": (
67
+ Severity.MEDIUM,
68
+ "Untrusted content flows into a Jev guardrail's state",
69
+ "The `state` argument is built from an attacker-influenceable variable. "
70
+ "If text in `state` can override the question `instructions`, the "
71
+ "guardrail is injectable by the very input it inspects. Separate and "
72
+ "sanitize untrusted state.",
73
+ ),
74
+ "JG005": (
75
+ Severity.INFO,
76
+ "Jev used as a security control",
77
+ "Validate block/escalate thresholds against your own adversarial "
78
+ "workload. Jev's published evals are self-graded on non-adversarial "
79
+ "distributions; calibration under attack is unproven.",
80
+ ),
81
+ "JG006": (
82
+ Severity.MEDIUM,
83
+ "Jev guardrail threshold set in an unsafe band",
84
+ "The block threshold is set so high (or the escalate-below confidence so "
85
+ "low) that the guardrail only reacts to near-certain danger. Adversarial "
86
+ "input is designed to sit in the ambiguous middle. Bias the block "
87
+ "threshold low and escalate generously.",
88
+ ),
89
+ "JG007": (
90
+ Severity.HIGH,
91
+ "Untrusted content interpolated into a Jev question's instructions",
92
+ "Attacker-influenceable text is concatenated or formatted into the "
93
+ "`instructions` of a question. That lets the inspected content rewrite "
94
+ "the question itself, collapsing the guardrail. Keep instructions static "
95
+ "and pass untrusted data only as `state`.",
96
+ ),
97
+ }
jev_guard/scanner.py ADDED
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import io
5
+ import tokenize
6
+ from pathlib import Path
7
+
8
+ from .config import Config
9
+ from .findings import Finding
10
+ from .rules import (
11
+ BLOCK_KWARGS,
12
+ DANGEROUS_TOOLS,
13
+ ESCALATE_KWARGS,
14
+ JEV_IMPORT_ROOTS,
15
+ RULES,
16
+ THRESHOLD_KWARGS,
17
+ UNTRUSTED_HINTS,
18
+ )
19
+
20
+ BASE_GUARDRAIL_CALLS = frozenset({"AutoModeMiddleware", "TypeSafeClassifier"})
21
+ DECISION_ATTRS = frozenset({"noul", "nouls", "choice", "choices", "score", "scores"})
22
+ _SUPPRESS = "jev-guard:"
23
+
24
+
25
+ def _call_name(node: ast.Call) -> str | None:
26
+ func = node.func
27
+ if isinstance(func, ast.Name):
28
+ return func.id
29
+ if isinstance(func, ast.Attribute):
30
+ return func.attr
31
+ return None
32
+
33
+
34
+ def _string_literals(node: ast.AST) -> list[str]:
35
+ return [
36
+ c.value
37
+ for c in ast.walk(node)
38
+ if isinstance(c, ast.Constant) and isinstance(c.value, str)
39
+ ]
40
+
41
+
42
+ def _names(node: ast.AST) -> set[str]:
43
+ return {n.id for n in ast.walk(node) if isinstance(n, ast.Name)}
44
+
45
+
46
+ def _name_is_untrusted(name: str) -> bool:
47
+ low = name.lower()
48
+ return any(h in low for h in UNTRUSTED_HINTS)
49
+
50
+
51
+ class Taint:
52
+ """Module-level, name-based taint with fixed-point propagation."""
53
+
54
+ def __init__(self, tree: ast.AST, dangerous_tools: frozenset[str]):
55
+ self.dangerous_tools = dangerous_tools
56
+ self.untrusted: set[str] = set()
57
+ self.dangerous: set[str] = set()
58
+ assigns: list[tuple[str, ast.AST]] = []
59
+ for node in ast.walk(tree):
60
+ if isinstance(node, ast.Assign):
61
+ for target in node.targets:
62
+ if isinstance(target, ast.Name):
63
+ assigns.append((target.id, node.value))
64
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
65
+ if node.value is not None:
66
+ assigns.append((node.target.id, node.value))
67
+ for name, _ in assigns:
68
+ if _name_is_untrusted(name):
69
+ self.untrusted.add(name)
70
+ for _ in range(6):
71
+ changed = False
72
+ for name, value in assigns:
73
+ if name not in self.untrusted and self._value_untrusted(value):
74
+ self.untrusted.add(name)
75
+ changed = True
76
+ if name not in self.dangerous and self._value_dangerous(value):
77
+ self.dangerous.add(name)
78
+ changed = True
79
+ if not changed:
80
+ break
81
+
82
+ def _value_untrusted(self, node: ast.AST) -> bool:
83
+ for nm in _names(node):
84
+ if _name_is_untrusted(nm) or nm in self.untrusted:
85
+ return True
86
+ return False
87
+
88
+ def _value_dangerous(self, node: ast.AST) -> bool:
89
+ for s in _string_literals(node):
90
+ low = s.lower()
91
+ if any(tok in low for tok in self.dangerous_tools):
92
+ return True
93
+ return any(nm in self.dangerous for nm in _names(node))
94
+
95
+ def refs_untrusted(self, node: ast.AST) -> bool:
96
+ return self._value_untrusted(node)
97
+
98
+ def is_dangerous(self, node: ast.AST) -> bool:
99
+ return self._value_dangerous(node)
100
+
101
+
102
+ def _suppressions(source: str) -> dict[int, object]:
103
+ out: dict[int, object] = {}
104
+ try:
105
+ tokens = tokenize.generate_tokens(io.StringIO(source).readline)
106
+ for tok in tokens:
107
+ if tok.type != tokenize.COMMENT or _SUPPRESS not in tok.string:
108
+ continue
109
+ body = tok.string.split(_SUPPRESS, 1)[1].strip()
110
+ if not body.lower().startswith("ignore"):
111
+ continue
112
+ rest = body[len("ignore"):].strip()
113
+ line = tok.start[0]
114
+ if not rest:
115
+ out[line] = "ALL"
116
+ else:
117
+ out[line] = {c.strip().upper() for c in rest.replace(",", " ").split()}
118
+ except (tokenize.TokenError, IndentationError):
119
+ pass
120
+ return out
121
+
122
+
123
+ def _numeric(node: ast.AST) -> float | None:
124
+ if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
125
+ return float(node.value)
126
+ return None
127
+
128
+
129
+ def _finding(code: str, file: str, line: int, config: Config) -> Finding:
130
+ severity, message, remediation = RULES[code]
131
+ severity = config.severity_overrides.get(code, severity)
132
+ return Finding(code, severity, file, line, message, remediation)
133
+
134
+
135
+ def _state_values(call: ast.Call) -> list[ast.AST]:
136
+ vals: list[ast.AST] = []
137
+ for kw in call.keywords:
138
+ if kw.arg == "state":
139
+ vals.append(kw.value)
140
+ for arg in call.args:
141
+ if isinstance(arg, ast.Dict):
142
+ for key, val in zip(arg.keys, arg.values, strict=False):
143
+ if isinstance(key, ast.Constant) and key.value == "state":
144
+ vals.append(val)
145
+ return vals
146
+
147
+
148
+ def _instruction_values(tree: ast.AST) -> list[ast.AST]:
149
+ vals: list[ast.AST] = []
150
+ for node in ast.walk(tree):
151
+ if isinstance(node, ast.Call):
152
+ for kw in node.keywords:
153
+ if kw.arg == "instructions":
154
+ vals.append(kw.value)
155
+ if isinstance(node, ast.Dict):
156
+ for key, val in zip(node.keys, node.values, strict=False):
157
+ if isinstance(key, ast.Constant) and key.value == "instructions":
158
+ vals.append(val)
159
+ return vals
160
+
161
+
162
+ def scan_source(source: str, filename: str, config: Config | None = None) -> list[Finding]:
163
+ config = config or Config()
164
+ try:
165
+ tree = ast.parse(source)
166
+ except SyntaxError:
167
+ return []
168
+
169
+ import_roots = JEV_IMPORT_ROOTS | config.extra_import_roots
170
+ uses_jev = any(
171
+ isinstance(n, (ast.Import, ast.ImportFrom))
172
+ and (
173
+ any(a.name.split(".")[0] in import_roots for a in n.names)
174
+ or (getattr(n, "module", "") or "").split(".")[0] in import_roots
175
+ )
176
+ for n in ast.walk(tree)
177
+ )
178
+ if not uses_jev:
179
+ return []
180
+
181
+ dangerous_tools = DANGEROUS_TOOLS | config.extra_dangerous_tools
182
+ guardrail_calls = BASE_GUARDRAIL_CALLS | config.extra_guardrail_calls
183
+ state_calls = guardrail_calls | {"invoke"}
184
+ taint = Taint(tree, dangerous_tools)
185
+
186
+ calls = [n for n in ast.walk(tree) if isinstance(n, ast.Call)]
187
+ has_automode = any(_call_name(c) == "AutoModeMiddleware" for c in calls)
188
+ reads_confidence = any(
189
+ isinstance(n, ast.Attribute) and "confidence" in n.attr.lower()
190
+ for n in ast.walk(tree)
191
+ ) or any(kw.arg in THRESHOLD_KWARGS for c in calls for kw in c.keywords if kw.arg)
192
+
193
+ findings: list[Finding] = []
194
+ guarded = False
195
+
196
+ def emit(code: str, line: int) -> None:
197
+ findings.append(_finding(code, filename, line, config))
198
+
199
+ for call in calls:
200
+ name = _call_name(call)
201
+ kwargs = {kw.arg for kw in call.keywords if kw.arg}
202
+
203
+ if name in guardrail_calls:
204
+ guarded = True
205
+ emit("JG005", call.lineno)
206
+ if not (kwargs & THRESHOLD_KWARGS):
207
+ emit("JG001", call.lineno)
208
+ for kw in call.keywords:
209
+ val = _numeric(kw.value)
210
+ if val is None:
211
+ continue
212
+ if kw.arg in BLOCK_KWARGS and val > 0.7:
213
+ emit("JG006", call.lineno)
214
+ elif kw.arg in ESCALATE_KWARGS and val < 0.3:
215
+ emit("JG006", call.lineno)
216
+
217
+ if name in state_calls:
218
+ for value in _state_values(call):
219
+ if taint.refs_untrusted(value):
220
+ emit("JG004", call.lineno)
221
+ break
222
+
223
+ if name in {"create_agent", "ToolNode"}:
224
+ for kw in call.keywords:
225
+ if kw.arg == "tools" and taint.is_dangerous(kw.value) and not has_automode:
226
+ emit("JG002", call.lineno)
227
+
228
+ for value in _instruction_values(tree):
229
+ if taint.refs_untrusted(value):
230
+ emit("JG007", value.lineno if hasattr(value, "lineno") else 1)
231
+
232
+ if guarded:
233
+ decisions = [
234
+ n
235
+ for n in ast.walk(tree)
236
+ if isinstance(n, ast.Attribute) and n.attr in DECISION_ATTRS
237
+ ]
238
+ if decisions and not reads_confidence:
239
+ emit("JG003", decisions[0].lineno)
240
+
241
+ suppress = _suppressions(source)
242
+ result = []
243
+ seen: set[tuple[str, int]] = set()
244
+ for f in findings:
245
+ if f.code in config.disabled or (f.code, f.line) in seen:
246
+ continue
247
+ rule = suppress.get(f.line)
248
+ if rule == "ALL" or (isinstance(rule, set) and f.code in rule):
249
+ continue
250
+ seen.add((f.code, f.line))
251
+ result.append(f)
252
+ return sorted(result, key=lambda f: (-f.severity, f.line, f.code))
253
+
254
+
255
+ def scan_path(path: str | Path, config: Config | None = None) -> list[Finding]:
256
+ p = Path(path)
257
+ config = config or Config.load(p)
258
+ files = [p] if p.is_file() else sorted(p.rglob("*.py"))
259
+ findings: list[Finding] = []
260
+ for f in files:
261
+ try:
262
+ findings.extend(scan_source(f.read_text(encoding="utf-8"), str(f), config))
263
+ except (UnicodeDecodeError, OSError):
264
+ continue
265
+ return findings
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.5
2
+ Name: jevg
3
+ Version: 0.3.0
4
+ Summary: Static auditor for code that uses Jev / TypeSafe System One models as a security guardrail.
5
+ Project-URL: Homepage, https://github.com/ppradyoth/jev-guard
6
+ Project-URL: Issues, https://github.com/ppradyoth/jev-guard/issues
7
+ Author: Pradyoth P.
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai-security,guardrail,jev,llm,red-team,static-analysis,typesafe
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Security
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8; extra == 'dev'
19
+ Requires-Dist: ruff>=0.6; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # jev-guard
23
+
24
+ [![ci](https://github.com/ppradyoth/jev-guard/actions/workflows/ci.yml/badge.svg)](https://github.com/ppradyoth/jev-guard/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![python](https://img.shields.io/badge/python-3.10%2B-blue)
25
+
26
+ **A static code scanner (SAST-style linter).** It reads your Python source and flags insecure usage of [Jev / TypeSafe "System One" models](https://typesafe.ai) when they're used as a security guardrail.
27
+
28
+ > jev-guard is **not** a guardrail and it does **not** run at runtime, call the model, or touch the network. It is a build-time analysis tool — think `ruff`/`bandit`, scoped to Jev guardrail patterns. It tells you where your guardrail *code* is misconfigured; it does not do any guarding itself.
29
+
30
+ **Thesis: type-safe is not the same as correct.** Jev can't emit a type error and it can't hallucinate a field — but "no hallucination" is a guarantee about *shape*, not about *truth*. A guardrail that returns a confidently wrong `noul: 0.02` for a `rm -rf /` still lets the call through. If you gate `bash` on that number, the type safety bought you nothing.
31
+
32
+ `jev-guard` scans your code for the ways a Jev-based guardrail fails open. It runs entirely offline — it reads your source, not the model — so it needs no API key and costs nothing.
33
+
34
+ ## Why this exists
35
+
36
+ Two things shipped in September 2026:
37
+
38
+ - TypeSafe released **Jev**, marketed for "score, judge, verify, guardrail, and detect jailbreaks."
39
+ - LangChain shipped **`AutoModeMiddleware`**, which uses Jev to classify tool calls as dangerous and block them before they run — the classifier pattern that used to live inside the closed-source parts of Claude Code / Codex / Cursor, now open to every agent.
40
+
41
+ The documented example is literally `AutoModeMiddleware(tools=["bash"])` — a shell gate with **no threshold set**, relying on the model's default point decision. That is the exact shape `jev-guard` was built to catch.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install jevg # PyPI package name (the CLI command is still `jev-guard`)
47
+ # or, from source:
48
+ git clone https://github.com/ppradyoth/jev-guard && cd jev-guard
49
+ pip install -e .
50
+ ```
51
+
52
+ ## Use
53
+
54
+ ```bash
55
+ jev-guard scan path/to/agent.py # scan a file
56
+ jev-guard scan src/ # scan a tree
57
+ jev-guard scan src/ --format json # machine-readable
58
+ jev-guard scan src/ --fail-on HIGH # CI gate (default): exit 1 on HIGH+
59
+ jev-guard rules # list what it checks
60
+ ```
61
+
62
+ Exit code is non-zero when any finding is at or above `--fail-on`, so it drops straight into CI:
63
+
64
+ ```yaml
65
+ - run: jev-guard scan src/ --fail-on HIGH
66
+ ```
67
+
68
+ ## In CI (GitHub Action)
69
+
70
+ ```yaml
71
+ - uses: ppradyoth/jev-guard@v0.3.0
72
+ with:
73
+ path: src/
74
+ fail-on: HIGH
75
+ ```
76
+
77
+ Surface findings in the **Security** tab by emitting SARIF and uploading it —
78
+ see [`examples/github-workflow.yml`](examples/github-workflow.yml).
79
+
80
+ ## Pre-commit
81
+
82
+ ```yaml
83
+ repos:
84
+ - repo: https://github.com/ppradyoth/jev-guard
85
+ rev: v0.3.0
86
+ hooks:
87
+ - id: jev-guard
88
+ ```
89
+
90
+ ## What it flags
91
+
92
+ | Code | Severity | What |
93
+ |-------|----------|------|
94
+ | JG001 | HIGH | Jev guardrail gates actions with no configured confidence threshold (blocks on the model default). |
95
+ | JG002 | CRITICAL | A dangerous tool (`bash`/`sql`/`http`/...) is wired into an agent with no `AutoModeMiddleware` present. |
96
+ | JG003 | MEDIUM | A `.noul`/`.choice`/`.score` decision is used to branch without ever reading a confidence field. |
97
+ | JG004 | MEDIUM | Attacker-influenceable content flows straight into a guardrail's `state` (injection surface). |
98
+ | JG005 | INFO | Jev is being used as a security control — reminder to validate thresholds against your own adversarial data. |
99
+ | JG006 | MEDIUM | Threshold set in an unsafe band (blocks only near-certain danger / almost never escalates). |
100
+ | JG007 | HIGH | Untrusted content interpolated into a question's `instructions` — the inspected text can rewrite the question. |
101
+
102
+ Full rationale and remediation for each: [`GUIDE.md`](GUIDE.md).
103
+
104
+ ## Example
105
+
106
+ ```
107
+ $ jev-guard scan examples/vulnerable_agent.py
108
+ examples/vulnerable_agent.py:5 [HIGH] JG001 Jev guardrail gates actions with no configured confidence threshold
109
+ ↳ Set an explicit threshold and escalate below it.
110
+ examples/vulnerable_agent.py:11 [MEDIUM] JG004 Untrusted content flows into a Jev guardrail's state
111
+ ↳ Separate and sanitize untrusted state.
112
+ ...
113
+ ```
114
+
115
+ ## Configuration
116
+
117
+ Drop a `.jev-guard.toml` at your repo root (jev-guard walks up to find it):
118
+
119
+ ```toml
120
+ [jev-guard]
121
+ disable = ["JG005"] # rules to silence entirely
122
+
123
+ [jev-guard.severity]
124
+ JG004 = "HIGH" # bump a rule's severity
125
+
126
+ [jev-guard.extra]
127
+ dangerous_tools = ["wire_transfer", "post_tweet"] # your own high-impact tools
128
+ import_roots = ["my_typesafe_wrapper"] # if you wrap the SDK
129
+ guardrail_calls = ["MyGuardrail"] # your own guardrail factory
130
+ ```
131
+
132
+ Silence a single line inline:
133
+
134
+ ```python
135
+ guardrail = AutoModeMiddleware(tools=["bash"]) # jev-guard: ignore JG001
136
+ another = AutoModeMiddleware(tools=["bash"]) # jev-guard: ignore
137
+ ```
138
+
139
+ jev-guard follows assignments, so indirection is caught — `tools = ["bash"]; create_agent(tools=tools)` and `s = user_msg; classifier.invoke({"state": s})` both flag.
140
+
141
+ ## Scope and honesty
142
+
143
+ This is a **heuristic linter, not a prover.** It reads Python, matches the LangChain `langchain_typesafe` surface, and reasons about obvious patterns. It will miss guardrails assembled dynamically, wrapped in your own abstractions, or written in another language, and it can raise false positives on safe code that names a variable `user_config`. It does not test the model, measure real calibration, or prove exploitability. It tells you *where to look*, not *that you're owned*.
144
+
145
+ For what it can and can't do, and when to reach for something else, read [`GUIDE.md`](GUIDE.md).
146
+
147
+ ## License
148
+
149
+ MIT. Not affiliated with TypeSafe AI or LangChain.
@@ -0,0 +1,13 @@
1
+ jev_guard/__init__.py,sha256=fwXLFrF1cmwtdRr4UZbaQrZbhvXJsSn7XDzEuXV2LIo,221
2
+ jev_guard/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
3
+ jev_guard/cli.py,sha256=BkLOL9i48Q7MWWZPnSdGuxHD1t2Yp3Eeo3cJQObrTHs,2943
4
+ jev_guard/config.py,sha256=it4bw7G-k-DaxQuaSV3yHZwuCcyYSl8sBegUn0GEihs,1803
5
+ jev_guard/findings.py,sha256=krM4aN6Pa7Ta020gE62rpVTltRUlzEnaO6zSSBwuwJs,563
6
+ jev_guard/formats.py,sha256=SR-EQzrbWcu4w-LXlUGCChuSCai_k5BbWAFKFIOs6Dk,1914
7
+ jev_guard/rules.py,sha256=A2xnrzoUTblOt_XkwU1N7jDWd2Vl_BJxsCUgtkZwsMI,4470
8
+ jev_guard/scanner.py,sha256=jNVIuCNpxyLaR8mHyQeu_7VEUz5eziAyWgBPPzezEFc,9085
9
+ jevg-0.3.0.dist-info/METADATA,sha256=yVCTIbsx4lkQD48WYc09SCVQ13Hxv5kGOufRd2rZaAE,6825
10
+ jevg-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ jevg-0.3.0.dist-info/entry_points.txt,sha256=p98gYqrf_bLmgrkOYR8O_spMA0rr_Op3jvGrmHk_obo,75
12
+ jevg-0.3.0.dist-info/licenses/LICENSE,sha256=fVWa5FEmGkZW3a53KYp_Cu7H-_9Jt1Y0QGD88m8hUSw,1068
13
+ jevg-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ jev-guard = jev_guard.cli:main
3
+ jevg = jev_guard.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pradyoth P.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.