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 +9 -0
- verfy-0.2.0/PKG-INFO +150 -0
- verfy-0.2.0/README.md +128 -0
- verfy-0.2.0/pyproject.toml +40 -0
- verfy-0.2.0/setup.cfg +4 -0
- verfy-0.2.0/src/verfy/__init__.py +14 -0
- verfy-0.2.0/src/verfy/__main__.py +4 -0
- verfy-0.2.0/src/verfy/_util.py +191 -0
- verfy-0.2.0/src/verfy/canonical.py +85 -0
- verfy-0.2.0/src/verfy/cli.py +1026 -0
- verfy-0.2.0/src/verfy/data/default_policy.json +104 -0
- verfy-0.2.0/src/verfy/data/patterns.json +737 -0
- verfy-0.2.0/src/verfy/demo_server.py +66 -0
- verfy-0.2.0/src/verfy/discover.py +197 -0
- verfy-0.2.0/src/verfy/ed25519.py +159 -0
- verfy-0.2.0/src/verfy/fileio.py +91 -0
- verfy-0.2.0/src/verfy/gateway.py +295 -0
- verfy-0.2.0/src/verfy/hook.py +150 -0
- verfy-0.2.0/src/verfy/lock.py +188 -0
- verfy-0.2.0/src/verfy/mcp_client.py +80 -0
- verfy-0.2.0/src/verfy/policy.py +53 -0
- verfy-0.2.0/src/verfy/precheck.py +847 -0
- verfy-0.2.0/src/verfy/receipts.py +238 -0
- verfy-0.2.0/src/verfy/scanner.py +211 -0
- verfy-0.2.0/src/verfy/scitt.py +181 -0
- verfy-0.2.0/src/verfy/team.py +203 -0
- verfy-0.2.0/src/verfy.egg-info/PKG-INFO +150 -0
- verfy-0.2.0/src/verfy.egg-info/SOURCES.txt +33 -0
- verfy-0.2.0/src/verfy.egg-info/dependency_links.txt +1 -0
- verfy-0.2.0/src/verfy.egg-info/entry_points.txt +2 -0
- verfy-0.2.0/src/verfy.egg-info/requires.txt +3 -0
- verfy-0.2.0/src/verfy.egg-info/top_level.txt +1 -0
- verfy-0.2.0/tests/test_lock_hook_team.py +512 -0
- verfy-0.2.0/tests/test_scitt.py +99 -0
- verfy-0.2.0/tests/test_verfy.py +335 -0
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,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,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))
|