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 +6 -0
- jev_guard/__main__.py +4 -0
- jev_guard/cli.py +88 -0
- jev_guard/config.py +56 -0
- jev_guard/findings.py +31 -0
- jev_guard/formats.py +68 -0
- jev_guard/rules.py +97 -0
- jev_guard/scanner.py +265 -0
- jevg-0.3.0.dist-info/METADATA +149 -0
- jevg-0.3.0.dist-info/RECORD +13 -0
- jevg-0.3.0.dist-info/WHEEL +4 -0
- jevg-0.3.0.dist-info/entry_points.txt +3 -0
- jevg-0.3.0.dist-info/licenses/LICENSE +21 -0
jev_guard/__init__.py
ADDED
jev_guard/__main__.py
ADDED
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
|
+
[](https://github.com/ppradyoth/jev-guard/actions/workflows/ci.yml) [](LICENSE) 
|
|
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,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.
|