verfy 0.2.0__tar.gz

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-0.2.0/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javohir
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
verfy-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: verfy
3
+ Version: 0.2.0
4
+ Summary: Verfy: guard coding-agent tool calls, scan and lock MCP tools, and sign a verifiable receipt for every action.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://verfy-tech.vercel.app
7
+ Project-URL: Documentation, https://verfy-tech.vercel.app/docs
8
+ Keywords: mcp,ai-agents,security,model-context-protocol,claude-code,audit,receipts
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Security
15
+ Classifier: Operating System :: OS Independent
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Provides-Extra: fast
20
+ Requires-Dist: cryptography>=43; extra == "fast"
21
+ Dynamic: license-file
22
+
23
+ # Verfy CLI
24
+
25
+ Guard coding-agent tool calls, scan and lock MCP tools, and sign a verifiable receipt for every action. Plain Python (≥ 3.10), no required dependencies, MIT.
26
+
27
+ ```bash
28
+ pip install verfy # Python ≥ 3.10, no dependencies
29
+ pip install "verfy[fast]" # optional: faster Ed25519 via `cryptography`
30
+ ```
31
+
32
+ ## Guard Claude Code
33
+
34
+ ```bash
35
+ verfy init # ~/.verfy: signing key (0600), starter policy
36
+ verfy hook install # shows the change to ~/.claude/settings.json
37
+ verfy hook install --write # saves it (a backup is kept); --project for ./.claude/settings.json
38
+ ```
39
+
40
+ Claude Code then runs `verfy hook pre-tool-use` before every tool call. Verfy answers **deny** or **ask** with the rule and reason, or stays silent so Claude Code's own permissions decide; it never answers allow itself. Any error becomes `ask` (or `deny` with `"fail_mode": "deny"`). The hook never touches the network.
41
+
42
+ ## Guard MCP servers (gateway)
43
+
44
+ ```bash
45
+ claude mcp add filesystem -- verfy proxy --name filesystem -- npx -y @modelcontextprotocol/server-filesystem@2025.8.21 ~/project
46
+ verfy wrap ~/.cursor/mcp.json --write # wrap every server in a config (backup kept)
47
+ ```
48
+
49
+ The gateway scans each `tools/list` answer, hides poisoned or drifted tools, checks every `tools/call`, can ask the user through MCP elicitation (`"on_ask": "elicit"`), and writes a receipt for every call.
50
+
51
+ ## Scan
52
+
53
+ ```bash
54
+ verfy scan --discover # static check of Claude Code, Cursor, Claude Desktop configs
55
+ verfy scan --discover --live # also start each stdio server and scan its tools
56
+ verfy scan tools.json --fail-under C # a saved tools/list answer, for CI
57
+ ```
58
+
59
+ Exit codes: config scan `0` ok, `1` medium, `2` high or worse; tool scan `1` with `--fail-under` when a server grades worse.
60
+
61
+ ## Lock
62
+
63
+ ```bash
64
+ verfy lock status # 0 ok · 1 new/removed · 2 drift
65
+ verfy lock diff filesystem # what changed, invisible characters spelled out
66
+ verfy lock approve filesystem # review, then accept (--yes after reviewing in CI)
67
+ verfy lock add tools.json --server remote-api
68
+ verfy lock verify # verfy.lock is well formed and untampered
69
+ ```
70
+
71
+ A project-level `./verfy.lock` is used when present, otherwise `~/.verfy/verfy.lock`.
72
+
73
+ ### In CI
74
+
75
+ ```bash
76
+ verfy lock check --update # start the servers in .mcp.json, review every definition, write ./verfy.lock
77
+ verfy lock check # 0 ok · 1 new/removed · 2 drift · 3 a server did not answer
78
+ ```
79
+
80
+ Commit `verfy.lock`; a pull request that changes a tool then fails until someone reviews the diff and runs `--update`. Inside GitHub Actions the command also writes annotations and a step summary. The repository root has an `action.yml` that installs this CLI and runs the check:
81
+
82
+ ```yaml
83
+ - uses: actions/setup-python@v7
84
+ with: { python-version: "3.13" }
85
+ - uses: ./ # or <owner>/verfy@<ref> once the repository is public
86
+ with: { config: .mcp.json, lock: verfy.lock }
87
+ ```
88
+
89
+ `lock check` starts each stdio server command from the config, as your MCP client would: pin package versions and pass keys as CI secrets. Remote servers are skipped.
90
+
91
+ ## Check and verify
92
+
93
+ ```bash
94
+ verfy check "git push --force" # 0 allow · 1 ask · 2 deny
95
+ verfy check --tool send_payment --args '{"amount": 500}'
96
+ verfy ledger show
97
+ verfy verify # your own ledger
98
+ verfy verify --ledger receipts.jsonl --pub <key> --kid <chain> [--start-after <hash>] # an evidence export
99
+ ```
100
+
101
+ ## Verfy Team
102
+
103
+ ```bash
104
+ verfy team enroll --url https://verfy.example.com --code atle_… # code from Dashboard → Devices
105
+ verfy sync # upload new receipts (run from cron/CI, never in the hook)
106
+ verfy team pull # team policy + team-approved locks
107
+ verfy team propose SERVER TOOL # ask admins to approve a changed tool
108
+ verfy team status
109
+ ```
110
+
111
+ Only the public key is sent at enrollment. The device token is stored in `~/.verfy/team.json` (0600). Receipts are uploaded exactly as signed; they contain only hashes of arguments unless you enabled `receipts.include_args`, in which case `sync` needs `--allow-args`.
112
+
113
+ ## Policy
114
+
115
+ JSON merged over `src/verfy/data/default_policy.json`. Precedence: `--policy FILE`, `./verfy.policy.json`, `~/.verfy/team/policy.json`, `~/.verfy/policy.json`.
116
+
117
+ ```json
118
+ {
119
+ "on_ask": "deny",
120
+ "fail_mode": "ask",
121
+ "tools": {"deny": ["github/delete_*"], "ask": ["merge_pull_request"]},
122
+ "network": {"allow": ["github.com", "*.python.org"], "unknown": "ask"},
123
+ "rules": [{"id": "no-prod-db", "pattern": "prod-db", "verdict": "deny", "message": "Production is off-limits."}],
124
+ "lock": {"on_new": "ask"},
125
+ "receipts": {"include_args": false}
126
+ }
127
+ ```
128
+
129
+ ## Receipt format (`verfy.receipt/1`)
130
+
131
+ One JSON object per line in `~/.verfy/ledger.jsonl`: `v, id, seq, prev, issued_at, agent, session, action{server, tool, kind, args_sha256, args?}, decision{verdict, rules, policy_sha256, approved_by?}, outcome{status, result_sha256}, tool_sha256, kid, sig`. `sig` is Ed25519 over the canonical JSON of all other fields; `prev` is the SHA-256 of the previous receipt. Writes are serialized with an OS file lock, so parallel sessions never fork the chain.
132
+
133
+ ### SCITT signed statements
134
+
135
+ ```bash
136
+ verfy scitt export --out statements/ [--issuer URL] # one COSE_Sign1 per receipt
137
+ verfy scitt verify statements/*.cose --pub signer.json # COSE signature, CWT subject and the receipt's own signature
138
+ ```
139
+
140
+ Each statement is a COSE_Sign1 (RFC 9052) with alg EdDSA, content type `application/json`, the key id and CWT claims (`iss`, `sub` = the receipt hash) in the protected header, and the canonical receipt as payload: the signed-statement envelope of the IETF SCITT architecture draft. Verfy does not run a transparency service or issue SCITT receipts.
141
+
142
+ ## Tests
143
+
144
+ ```bash
145
+ cd verfy-cli && python -m unittest discover -s tests # needs Node for the cross-engine tests
146
+ ```
147
+
148
+ ## Limits
149
+
150
+ Rules are pattern matching, not a sandbox. Verfy sees only calls that pass through its hook or gateway. A receipt proves what Verfy decided and that the log was not edited, not what a tool did internally. Keep `~/.verfy/key.json` private.
verfy-0.2.0/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # Verfy CLI
2
+
3
+ Guard coding-agent tool calls, scan and lock MCP tools, and sign a verifiable receipt for every action. Plain Python (≥ 3.10), no required dependencies, MIT.
4
+
5
+ ```bash
6
+ pip install verfy # Python ≥ 3.10, no dependencies
7
+ pip install "verfy[fast]" # optional: faster Ed25519 via `cryptography`
8
+ ```
9
+
10
+ ## Guard Claude Code
11
+
12
+ ```bash
13
+ verfy init # ~/.verfy: signing key (0600), starter policy
14
+ verfy hook install # shows the change to ~/.claude/settings.json
15
+ verfy hook install --write # saves it (a backup is kept); --project for ./.claude/settings.json
16
+ ```
17
+
18
+ Claude Code then runs `verfy hook pre-tool-use` before every tool call. Verfy answers **deny** or **ask** with the rule and reason, or stays silent so Claude Code's own permissions decide; it never answers allow itself. Any error becomes `ask` (or `deny` with `"fail_mode": "deny"`). The hook never touches the network.
19
+
20
+ ## Guard MCP servers (gateway)
21
+
22
+ ```bash
23
+ claude mcp add filesystem -- verfy proxy --name filesystem -- npx -y @modelcontextprotocol/server-filesystem@2025.8.21 ~/project
24
+ verfy wrap ~/.cursor/mcp.json --write # wrap every server in a config (backup kept)
25
+ ```
26
+
27
+ The gateway scans each `tools/list` answer, hides poisoned or drifted tools, checks every `tools/call`, can ask the user through MCP elicitation (`"on_ask": "elicit"`), and writes a receipt for every call.
28
+
29
+ ## Scan
30
+
31
+ ```bash
32
+ verfy scan --discover # static check of Claude Code, Cursor, Claude Desktop configs
33
+ verfy scan --discover --live # also start each stdio server and scan its tools
34
+ verfy scan tools.json --fail-under C # a saved tools/list answer, for CI
35
+ ```
36
+
37
+ Exit codes: config scan `0` ok, `1` medium, `2` high or worse; tool scan `1` with `--fail-under` when a server grades worse.
38
+
39
+ ## Lock
40
+
41
+ ```bash
42
+ verfy lock status # 0 ok · 1 new/removed · 2 drift
43
+ verfy lock diff filesystem # what changed, invisible characters spelled out
44
+ verfy lock approve filesystem # review, then accept (--yes after reviewing in CI)
45
+ verfy lock add tools.json --server remote-api
46
+ verfy lock verify # verfy.lock is well formed and untampered
47
+ ```
48
+
49
+ A project-level `./verfy.lock` is used when present, otherwise `~/.verfy/verfy.lock`.
50
+
51
+ ### In CI
52
+
53
+ ```bash
54
+ verfy lock check --update # start the servers in .mcp.json, review every definition, write ./verfy.lock
55
+ verfy lock check # 0 ok · 1 new/removed · 2 drift · 3 a server did not answer
56
+ ```
57
+
58
+ Commit `verfy.lock`; a pull request that changes a tool then fails until someone reviews the diff and runs `--update`. Inside GitHub Actions the command also writes annotations and a step summary. The repository root has an `action.yml` that installs this CLI and runs the check:
59
+
60
+ ```yaml
61
+ - uses: actions/setup-python@v7
62
+ with: { python-version: "3.13" }
63
+ - uses: ./ # or <owner>/verfy@<ref> once the repository is public
64
+ with: { config: .mcp.json, lock: verfy.lock }
65
+ ```
66
+
67
+ `lock check` starts each stdio server command from the config, as your MCP client would: pin package versions and pass keys as CI secrets. Remote servers are skipped.
68
+
69
+ ## Check and verify
70
+
71
+ ```bash
72
+ verfy check "git push --force" # 0 allow · 1 ask · 2 deny
73
+ verfy check --tool send_payment --args '{"amount": 500}'
74
+ verfy ledger show
75
+ verfy verify # your own ledger
76
+ verfy verify --ledger receipts.jsonl --pub <key> --kid <chain> [--start-after <hash>] # an evidence export
77
+ ```
78
+
79
+ ## Verfy Team
80
+
81
+ ```bash
82
+ verfy team enroll --url https://verfy.example.com --code atle_… # code from Dashboard → Devices
83
+ verfy sync # upload new receipts (run from cron/CI, never in the hook)
84
+ verfy team pull # team policy + team-approved locks
85
+ verfy team propose SERVER TOOL # ask admins to approve a changed tool
86
+ verfy team status
87
+ ```
88
+
89
+ Only the public key is sent at enrollment. The device token is stored in `~/.verfy/team.json` (0600). Receipts are uploaded exactly as signed; they contain only hashes of arguments unless you enabled `receipts.include_args`, in which case `sync` needs `--allow-args`.
90
+
91
+ ## Policy
92
+
93
+ JSON merged over `src/verfy/data/default_policy.json`. Precedence: `--policy FILE`, `./verfy.policy.json`, `~/.verfy/team/policy.json`, `~/.verfy/policy.json`.
94
+
95
+ ```json
96
+ {
97
+ "on_ask": "deny",
98
+ "fail_mode": "ask",
99
+ "tools": {"deny": ["github/delete_*"], "ask": ["merge_pull_request"]},
100
+ "network": {"allow": ["github.com", "*.python.org"], "unknown": "ask"},
101
+ "rules": [{"id": "no-prod-db", "pattern": "prod-db", "verdict": "deny", "message": "Production is off-limits."}],
102
+ "lock": {"on_new": "ask"},
103
+ "receipts": {"include_args": false}
104
+ }
105
+ ```
106
+
107
+ ## Receipt format (`verfy.receipt/1`)
108
+
109
+ One JSON object per line in `~/.verfy/ledger.jsonl`: `v, id, seq, prev, issued_at, agent, session, action{server, tool, kind, args_sha256, args?}, decision{verdict, rules, policy_sha256, approved_by?}, outcome{status, result_sha256}, tool_sha256, kid, sig`. `sig` is Ed25519 over the canonical JSON of all other fields; `prev` is the SHA-256 of the previous receipt. Writes are serialized with an OS file lock, so parallel sessions never fork the chain.
110
+
111
+ ### SCITT signed statements
112
+
113
+ ```bash
114
+ verfy scitt export --out statements/ [--issuer URL] # one COSE_Sign1 per receipt
115
+ verfy scitt verify statements/*.cose --pub signer.json # COSE signature, CWT subject and the receipt's own signature
116
+ ```
117
+
118
+ Each statement is a COSE_Sign1 (RFC 9052) with alg EdDSA, content type `application/json`, the key id and CWT claims (`iss`, `sub` = the receipt hash) in the protected header, and the canonical receipt as payload: the signed-statement envelope of the IETF SCITT architecture draft. Verfy does not run a transparency service or issue SCITT receipts.
119
+
120
+ ## Tests
121
+
122
+ ```bash
123
+ cd verfy-cli && python -m unittest discover -s tests # needs Node for the cross-engine tests
124
+ ```
125
+
126
+ ## Limits
127
+
128
+ Rules are pattern matching, not a sandbox. Verfy sees only calls that pass through its hook or gateway. A receipt proves what Verfy decided and that the log was not edited, not what a tool did internally. Keep `~/.verfy/key.json` private.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "verfy"
7
+ version = "0.2.0"
8
+ description = "Verfy: guard coding-agent tool calls, scan and lock MCP tools, and sign a verifiable receipt for every action."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ keywords = ["mcp", "ai-agents", "security", "model-context-protocol", "claude-code", "audit", "receipts"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Security",
21
+ "Operating System :: OS Independent",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = "https://verfy-tech.vercel.app"
27
+ Documentation = "https://verfy-tech.vercel.app/docs"
28
+
29
+ [project.optional-dependencies]
30
+ fast = ["cryptography>=43"]
31
+
32
+ [project.scripts]
33
+ verfy = "verfy.cli:main"
34
+
35
+ [tool.setuptools]
36
+ package-dir = {"" = "src"}
37
+ packages = ["verfy", "verfy.data"]
38
+
39
+ [tool.setuptools.package-data]
40
+ verfy = ["data/*.json"]
verfy-0.2.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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__"]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+ import sys
3
+
4
+ sys.exit(main())
@@ -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
@@ -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))