verfy 0.2.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.
verfy/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Verfy: security, policy and verifiable evidence for coding agents and MCP tools.
2
+
3
+ Guard every tool call with deterministic rules, scan and lock MCP tool
4
+ definitions, and sign a tamper-evident receipt for everything that happened.
5
+ """
6
+
7
+ from .canonical import canonical, digest
8
+ from .policy import DEFAULT_POLICY, load_policy, policy_digest
9
+ from .precheck import check, classify
10
+ from .scanner import scan, tool_pin
11
+
12
+ __version__ = "0.2.0"
13
+
14
+ __all__ = ["canonical", "digest", "DEFAULT_POLICY", "load_policy", "policy_digest", "check", "classify", "scan", "tool_pin", "__version__"]
verfy/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+ import sys
3
+
4
+ sys.exit(main())
verfy/_util.py ADDED
@@ -0,0 +1,191 @@
1
+ """Small helpers shared by the scanner and the pre-check.
2
+
3
+ Every function here has a twin in ``web/verfy-engine.js``. Keep them in step:
4
+ the parity tests run the same fixtures through both.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ import re
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+ from typing import Any, Iterable
15
+
16
+ PATTERNS: dict = json.loads((Path(__file__).parent / "data" / "patterns.json").read_text("utf-8"))
17
+
18
+ SEVERITY_RANK = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}
19
+ VERDICT_RANK = {"deny": 2, "ask": 1, "allow": 0}
20
+
21
+
22
+ @lru_cache(maxsize=None)
23
+ def rx(pattern: str, flags: str = "") -> "re.Pattern[str]":
24
+ f = 0
25
+ if "i" in flags:
26
+ f |= re.IGNORECASE
27
+ return re.compile(pattern, f)
28
+
29
+
30
+ def words(name: str) -> list[str]:
31
+ """Split ``readFile``, ``read_file`` and ``read-file`` into lowercase words."""
32
+ s = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name or "")
33
+ return [w for w in re.split(r"[^A-Za-z0-9]+", s.lower()) if w]
34
+
35
+
36
+ def is_invisible(cp: int) -> bool:
37
+ return any(a <= cp <= b for a, b in PATTERNS["invisible_ranges"])
38
+
39
+
40
+ def entropy(v: str) -> float:
41
+ if not v:
42
+ return 0.0
43
+ counts: dict[str, int] = {}
44
+ for ch in v:
45
+ counts[ch] = counts.get(ch, 0) + 1
46
+ n = len(v)
47
+ return -sum(c / n * math.log2(c / n) for c in counts.values())
48
+
49
+
50
+ def scan_secrets(text: str) -> list[dict]:
51
+ out: list[dict] = []
52
+ if not text or not isinstance(text, str):
53
+ return out
54
+ seen = set()
55
+ for rid, label, pat, flags, sev in PATTERNS["secrets"]:
56
+ for m in rx(pat, flags).finditer(text):
57
+ value = m.group(1) if m.groups() and m.group(1) is not None else m.group(0)
58
+ low = value.lower()
59
+ if any(h in low for h in PATTERNS["placeholders"]):
60
+ continue
61
+ if sev == "medium" and entropy(value) < 3.0:
62
+ continue
63
+ if rid in seen:
64
+ continue
65
+ seen.add(rid)
66
+ out.append({"id": rid, "label": label, "severity": sev, "start": m.start(), "end": m.end()})
67
+ return out
68
+
69
+
70
+ def redact(text: str) -> str:
71
+ hits = scan_secrets(text)
72
+ for h in sorted(hits, key=lambda h: -h["start"]):
73
+ text = text[: h["start"]] + "[REDACTED:" + h["id"] + "]" + text[h["end"]:]
74
+ return text
75
+
76
+
77
+ def redact_value(v: Any) -> Any:
78
+ if isinstance(v, str):
79
+ return redact(v) if scan_secrets(v) else v
80
+ if isinstance(v, list):
81
+ return [redact_value(x) for x in v]
82
+ if isinstance(v, dict):
83
+ return {k: redact_value(x) for k, x in v.items()}
84
+ return v
85
+
86
+
87
+ def flatten_strings(v: Any, path: str = "") -> list[tuple[str, str]]:
88
+ """Every string inside ``v`` with its dotted key path."""
89
+ out: list[tuple[str, str]] = []
90
+ if isinstance(v, str):
91
+ out.append((path, v))
92
+ elif isinstance(v, list):
93
+ for i, x in enumerate(v):
94
+ out.extend(flatten_strings(x, f"{path}[{i}]"))
95
+ elif isinstance(v, dict):
96
+ for k, x in v.items():
97
+ out.extend(flatten_strings(x, f"{path}.{k}" if path else k))
98
+ return out
99
+
100
+
101
+ # ---------------------------------------------------------------- paths
102
+
103
+ def normpath(p: str) -> str:
104
+ """POSIX normpath that never touches the real filesystem."""
105
+ absolute = p.startswith("/")
106
+ parts: list[str] = []
107
+ for part in p.split("/"):
108
+ if part in ("", "."):
109
+ continue
110
+ if part == "..":
111
+ if parts and parts[-1] != "..":
112
+ parts.pop()
113
+ elif not absolute:
114
+ parts.append("..")
115
+ continue
116
+ parts.append(part)
117
+ s = "/".join(parts)
118
+ return ("/" + s) if absolute else (s or ".")
119
+
120
+
121
+ def resolve(p: str, home: str, cwd: str) -> str:
122
+ p = (p or "").strip()
123
+ if p == "~" or p.startswith("~/"):
124
+ p = home + p[1:]
125
+ elif p.startswith("$HOME") or p.startswith("${HOME}"):
126
+ p = home + p[(5 if p.startswith("$HOME") else 7):]
127
+ if not p.startswith("/"):
128
+ p = cwd.rstrip("/") + "/" + p
129
+ return normpath(p)
130
+
131
+
132
+ def is_within(path: str, root: str) -> bool:
133
+ root = root.rstrip("/") or "/"
134
+ return path == root or path.startswith(root + "/") or root == "/"
135
+
136
+
137
+ @lru_cache(maxsize=None)
138
+ def _glob_rx(pattern: str) -> "re.Pattern[str]":
139
+ i, out = 0, ""
140
+ if pattern.startswith("**/"):
141
+ out = "(?:.*/)?"
142
+ i = 3
143
+ while i < len(pattern):
144
+ c = pattern[i]
145
+ if pattern.startswith("/**/", i):
146
+ out += "/(?:.*/)?"
147
+ i += 4
148
+ continue
149
+ if pattern.startswith("/**", i) and i + 3 == len(pattern):
150
+ out += "(?:/.*)?"
151
+ i += 3
152
+ continue
153
+ if pattern.startswith("**", i):
154
+ out += ".*"
155
+ i += 2
156
+ continue
157
+ if c == "*":
158
+ out += "[^/]*"
159
+ elif c == "?":
160
+ out += "[^/]"
161
+ else:
162
+ out += re.escape(c)
163
+ i += 1
164
+ return re.compile("^" + out + "$")
165
+
166
+
167
+ def glob_match(pattern: str, path: str, home: str) -> bool:
168
+ if pattern == "~" or pattern.startswith("~/"):
169
+ pattern = home + pattern[1:]
170
+ if pattern.startswith("**/"):
171
+ return bool(_glob_rx(pattern).match(path.lstrip("/"))) or bool(_glob_rx(pattern).match(path))
172
+ return bool(_glob_rx(pattern).match(path))
173
+
174
+
175
+ def first_match(patterns: Iterable[str], path: str, home: str) -> str | None:
176
+ for pat in patterns or []:
177
+ if glob_match(pat, path, home):
178
+ return pat
179
+ return None
180
+
181
+
182
+ def name_match(pattern: str, name: str) -> bool:
183
+ """Glob for tool names (``*`` matches anything, including ``/``)."""
184
+ return bool(re.match("^" + re.escape(pattern).replace(r"\*", ".*") + "$", name or ""))
185
+
186
+
187
+ def host_match(pattern: str, host: str) -> bool:
188
+ pattern, host = pattern.lower(), host.lower()
189
+ if pattern.startswith("*."):
190
+ return host == pattern[2:] or host.endswith(pattern[1:])
191
+ return host == pattern
verfy/canonical.py ADDED
@@ -0,0 +1,85 @@
1
+ """Canonical JSON: the exact bytes Verfy hashes and signs.
2
+
3
+ Keys are sorted, there is no whitespace and numbers are written the way
4
+ JavaScript's ``JSON.stringify`` writes them, so the Python and browser
5
+ engines produce identical bytes for identical data.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import math
13
+ from typing import Any
14
+
15
+
16
+ def _number(x: float) -> str:
17
+ if isinstance(x, bool):
18
+ raise TypeError("bool is not a number")
19
+ if isinstance(x, int):
20
+ return str(x)
21
+ if math.isnan(x) or math.isinf(x):
22
+ raise ValueError("NaN and Infinity cannot be signed")
23
+ if x == int(x) and abs(x) < 1e21:
24
+ return str(int(x))
25
+ # ECMAScript Number::toString, built from Python's shortest repr digits.
26
+ r = repr(abs(x))
27
+ mant, _, exp = r.partition("e")
28
+ e = int(exp) if exp else 0
29
+ if "." in mant:
30
+ ip, fp = mant.split(".")
31
+ else:
32
+ ip, fp = mant, ""
33
+ digits = (ip + fp).lstrip("0")
34
+ point = len(ip) + e # position of the decimal point relative to digits start
35
+ lead = len(ip + fp) - len((ip + fp).lstrip("0"))
36
+ point -= lead
37
+ digits = digits.rstrip("0") or "0"
38
+ k, n = len(digits), point
39
+ sign = "-" if x < 0 else ""
40
+ if k <= n <= 21:
41
+ return sign + digits + "0" * (n - k)
42
+ if 0 < n <= 21:
43
+ return sign + digits[:n] + "." + digits[n:]
44
+ if -6 < n <= 0:
45
+ return sign + "0." + "0" * (-n) + digits
46
+ e2 = n - 1
47
+ es = ("+" if e2 >= 0 else "-") + str(abs(e2))
48
+ if k == 1:
49
+ return sign + digits + "e" + es
50
+ return sign + digits[0] + "." + digits[1:] + "e" + es
51
+
52
+
53
+ def canonical(obj: Any) -> str:
54
+ if obj is None:
55
+ return "null"
56
+ if obj is True:
57
+ return "true"
58
+ if obj is False:
59
+ return "false"
60
+ if isinstance(obj, (int, float)):
61
+ return _number(obj)
62
+ if isinstance(obj, str):
63
+ return json.dumps(obj, ensure_ascii=False)
64
+ if isinstance(obj, (list, tuple)):
65
+ return "[" + ",".join(canonical(v) for v in obj) + "]"
66
+ if isinstance(obj, dict):
67
+ for k in obj:
68
+ if not isinstance(k, str):
69
+ raise TypeError("object keys must be strings")
70
+ keys = sorted(obj, key=lambda s: s.encode("utf-16-be"))
71
+ return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonical(obj[k]) for k in keys) + "}"
72
+ raise TypeError(f"cannot canonicalize {type(obj).__name__}")
73
+
74
+
75
+ def canonical_bytes(obj: Any) -> bytes:
76
+ return canonical(obj).encode("utf-8")
77
+
78
+
79
+ def sha256_hex(data: bytes) -> str:
80
+ return hashlib.sha256(data).hexdigest()
81
+
82
+
83
+ def digest(obj: Any) -> str:
84
+ """``sha256:<hex>`` of the canonical form of ``obj``."""
85
+ return "sha256:" + sha256_hex(canonical_bytes(obj))