verdictkit 0.1.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.
verdictkit/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """verdictkit -- non-custodial verification for agent work.
2
+
3
+ Local mode (free, unlimited, no API key, no network):
4
+
5
+ import verdictkit as vk
6
+
7
+ contract = vk.define_contract(
8
+ task_id="research-042",
9
+ agent_id="agent-7f3a",
10
+ intended_action="Summarize the Q3 incident reports into incidents.md",
11
+ success_criteria=[
12
+ {"id": "c1", "type": "file_exists", "path": "incidents.md",
13
+ "description": "summary file was written"},
14
+ ],
15
+ )
16
+ result = vk.verify_run(contract, base_path="./output") # PASS | FLAG | FAIL
17
+
18
+ Hosted mode (reputation ledger + cross-machine re-verification):
19
+
20
+ client = vk.Client(api_key="vk_live_...")
21
+ contract_id = client.create_contract(contract)
22
+ bundle = vk.collect_evidence(contract, base_path="./output")
23
+ verdict = client.submit_run(contract_id, bundle)
24
+ """
25
+ from .contracts import define_contract
26
+ from .verdict import verify_run, Verdict
27
+ from .evidence import collect_evidence, verify_bundle_hash
28
+ from .client import Client, VerdictkitError, AuthError
29
+
30
+ __version__ = "0.1.0"
31
+ __all__ = [
32
+ "define_contract",
33
+ "verify_run",
34
+ "Verdict",
35
+ "collect_evidence",
36
+ "verify_bundle_hash",
37
+ "Client",
38
+ "VerdictkitError",
39
+ "AuthError",
40
+ ]
verdictkit/checks.py ADDED
@@ -0,0 +1,92 @@
1
+ """Deterministic checks (stdlib only). Never raise; errors are results."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import sqlite3
8
+
9
+
10
+ def _expand(p): return os.path.expanduser(os.path.expandvars(p))
11
+ def _r(c, s, e): return {"check_id": c["id"], "type": c["type"], "status": s, "evidence": e}
12
+
13
+
14
+ def _file_exists(c):
15
+ p = _expand(c["path"])
16
+ ok = os.path.isfile(p)
17
+ return _r(c, "pass" if ok else "fail", f"{'found' if ok else 'MISSING'}: {p}")
18
+
19
+ def _file_nonempty(c):
20
+ p = _expand(c["path"])
21
+ if not os.path.isfile(p): return _r(c, "fail", f"MISSING: {p}")
22
+ n = os.path.getsize(p)
23
+ return _r(c, "pass" if n > 0 else "fail", f"{p} size={n}")
24
+
25
+ def _file_contains(c):
26
+ p = _expand(c["path"])
27
+ try: rx = re.compile(c["pattern"])
28
+ except re.error as e: return _r(c, "error", f"bad regex: {e}")
29
+ try:
30
+ with open(p, errors="replace") as f: text = f.read()
31
+ except OSError as e: return _r(c, "fail", f"cannot read {p}: {e}")
32
+ hit = rx.search(text) is not None
33
+ return _r(c, "pass" if hit else "fail", f"pattern {'found in' if hit else 'NOT FOUND in'} {p}")
34
+
35
+ def _dir_exists(c):
36
+ p = _expand(c["path"])
37
+ ok = os.path.isdir(p)
38
+ return _r(c, "pass" if ok else "fail", f"{'found dir' if ok else 'MISSING dir'}: {p}")
39
+
40
+ def _json_valid(c):
41
+ p = _expand(c["path"])
42
+ try:
43
+ with open(p, errors="replace") as f: json.load(f)
44
+ return _r(c, "pass", f"valid JSON: {p}")
45
+ except OSError as e: return _r(c, "fail", f"cannot read {p}: {e}")
46
+ except json.JSONDecodeError as e: return _r(c, "fail", f"invalid JSON: {e}")
47
+
48
+ def _line_count_gte(c):
49
+ p = _expand(c["path"]); m = int(c.get("min_lines", 1))
50
+ try:
51
+ with open(p, errors="replace") as f: n = sum(1 for _ in f)
52
+ except OSError as e: return _r(c, "fail", f"cannot read {p}: {e}")
53
+ return _r(c, "pass" if n >= m else "fail", f"{p}: {n} lines, need >={m}")
54
+
55
+ def _sqlite_table_has_rows(c):
56
+ db, table, m = _expand(c["db"]), c["table"], int(c.get("min_rows", 1))
57
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", table):
58
+ return _r(c, "error", f"unsafe table name {table!r}")
59
+ try:
60
+ con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
61
+ n = con.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
62
+ con.close()
63
+ except Exception as e: return _r(c, "error", f"cannot query: {e}")
64
+ return _r(c, "pass" if n >= m else "fail", f"{table}: {n} rows, need >={m}")
65
+
66
+ def _path_absent(c):
67
+ p = _expand(c["path"]); gone = not os.path.exists(p)
68
+ return _r(c, "pass" if gone else "fail", f"{'absent' if gone else 'PRESENT (scope violation)'}: {p}")
69
+
70
+ def _pattern_count_gte(c):
71
+ p = _expand(c["path"]); m = int(c.get("min_count", 1))
72
+ try: rx = re.compile(c["pattern"], re.MULTILINE)
73
+ except re.error as e: return _r(c, "error", f"bad regex: {e}")
74
+ try:
75
+ with open(p, errors="replace") as f: n = len(rx.findall(f.read()))
76
+ except OSError as e: return _r(c, "fail", f"cannot read {p}: {e}")
77
+ return _r(c, "pass" if n >= m else "fail", f"{n} matches in {p}, need >={m}")
78
+
79
+ RUNNERS = {
80
+ "file_exists": _file_exists, "file_nonempty": _file_nonempty,
81
+ "file_contains": _file_contains, "dir_exists": _dir_exists,
82
+ "json_valid": _json_valid, "line_count_gte": _line_count_gte,
83
+ "sqlite_table_has_rows": _sqlite_table_has_rows,
84
+ "path_absent": _path_absent, "pattern_count_gte": _pattern_count_gte,
85
+ }
86
+
87
+ def run_check(criterion):
88
+ r = RUNNERS.get(criterion["type"])
89
+ if r is None:
90
+ return _r(criterion, "error", f"no runner for {criterion['type']!r}")
91
+ try: return r(criterion)
92
+ except Exception as e: return _r(criterion, "error", f"runner crashed: {e}")
verdictkit/cli.py ADDED
@@ -0,0 +1,54 @@
1
+ """Minimal CLI: `verdictkit verify <contract.json> [--base-path DIR]`."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+
8
+ from . import verify_run, Verdict, __version__
9
+
10
+ EXIT = {Verdict.PASS: 0, Verdict.FAIL: 1, Verdict.FLAG: 2}
11
+
12
+
13
+ def cmd_verify(args) -> int:
14
+ try:
15
+ with open(args.contract, encoding="utf-8") as f:
16
+ contract = json.load(f)
17
+ except (OSError, json.JSONDecodeError) as e:
18
+ print(f"error: cannot read contract {args.contract}: {e}",
19
+ file=sys.stderr)
20
+ return 3
21
+ result = verify_run(contract, base_path=args.base_path)
22
+ print(f"verdict: {result['verdict']}")
23
+ print(f"reason: {result['reason']}")
24
+ if args.verbose:
25
+ for r in result["check_results"]:
26
+ print(f" [{r['status']:>5}] {r['check_id']} ({r['type']}): {r['evidence']}")
27
+ for claim in result["unverifiable_claims"]:
28
+ print(f" [claim] {claim}")
29
+ return EXIT.get(result["verdict"], 3)
30
+
31
+
32
+ def build_parser() -> argparse.ArgumentParser:
33
+ p = argparse.ArgumentParser(
34
+ prog="verdictkit",
35
+ description="Non-custodial verification for agent work (local, free).")
36
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
37
+ sub = p.add_subparsers(dest="command", required=True)
38
+ v = sub.add_parser("verify", help="Verify a contract JSON file locally.")
39
+ v.add_argument("contract", help="Path to a contract JSON file.")
40
+ v.add_argument("--base-path", default=None,
41
+ help="Resolve relative criterion paths against this directory.")
42
+ v.add_argument("-v", "--verbose", action="store_true",
43
+ help="Show per-check evidence.")
44
+ v.set_defaults(func=cmd_verify)
45
+ return p
46
+
47
+
48
+ def main(argv=None) -> int:
49
+ args = build_parser().parse_args(argv)
50
+ return args.func(args)
51
+
52
+
53
+ if __name__ == "__main__":
54
+ sys.exit(main())
verdictkit/client.py ADDED
@@ -0,0 +1,124 @@
1
+ """Client for the hosted verdictkit API.
2
+
3
+ The hosted tier adds what local verification cannot: cross-machine
4
+ evidence re-verification, a third-party-queryable verdict registry
5
+ (reputation ledger), webhooks on FAIL, and pay-per-verification billing.
6
+
7
+ Local verification (verdictkit.verify_run) is always free, unlimited, and
8
+ needs no API key and no network. This client is only for the hosted tier.
9
+
10
+ import verdictkit as vk
11
+ client = vk.Client(api_key="vk_live_...") # or base_url=...
12
+ contract_id = client.create_contract(contract)
13
+ verdict = client.submit_run(contract_id, evidence_bundle)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import requests
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # PLACEHOLDER: production API URL is not yet assigned. Update this constant
21
+ # (and release 0.2.0) once the hosted API ships. Until then, Client can be
22
+ # pointed at a dev/staging host via the base_url argument.
23
+ # ---------------------------------------------------------------------------
24
+ DEFAULT_BASE_URL = "https://api.verdictkit.dev/v1"
25
+
26
+
27
+ class VerdictkitError(Exception):
28
+ """Base error for hosted-API failures (network, timeout, bad response)."""
29
+
30
+
31
+ class AuthError(VerdictkitError):
32
+ """Raised on HTTP 401/403 -- the API key was rejected."""
33
+
34
+
35
+ class Client:
36
+ """Thin HTTP client for the hosted verdictkit API."""
37
+
38
+ def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL,
39
+ timeout: float = 30.0):
40
+ if not api_key:
41
+ raise ValueError("api_key is required for the hosted verdictkit API. "
42
+ "For local verification with no key and no network, "
43
+ "use verdictkit.verify_run() instead.")
44
+ self.api_key = api_key
45
+ self.base_url = base_url.rstrip("/")
46
+ self.timeout = timeout
47
+ self._session = requests.Session()
48
+ self._session.headers.update({
49
+ "Authorization": f"Bearer {api_key}",
50
+ "Content-Type": "application/json",
51
+ "User-Agent": f"verdictkit-py/{__import__('verdictkit').__version__}",
52
+ })
53
+
54
+ # -- internals ----------------------------------------------------------
55
+ def _request(self, method: str, path: str, **kwargs):
56
+ url = f"{self.base_url}{path}"
57
+ try:
58
+ resp = self._session.request(method, url,
59
+ timeout=self.timeout, **kwargs)
60
+ except requests.RequestException as e:
61
+ raise VerdictkitError(
62
+ f"could not reach verdictkit API at {url}: {e}") from e
63
+ if resp.status_code in (401, 403):
64
+ raise AuthError(
65
+ f"verdictkit API rejected the credentials (HTTP {resp.status_code}). "
66
+ "Check your API key.")
67
+ if not resp.ok:
68
+ detail = self._safe_error_detail(resp)
69
+ raise VerdictkitError(
70
+ f"verdictkit API returned HTTP {resp.status_code}: {detail}")
71
+ try:
72
+ return resp.json()
73
+ except ValueError as e:
74
+ raise VerdictkitError(
75
+ f"verdictkit API returned non-JSON response (HTTP {resp.status_code})"
76
+ ) from e
77
+
78
+ @staticmethod
79
+ def _safe_error_detail(resp) -> str:
80
+ try:
81
+ data = resp.json()
82
+ if isinstance(data, dict):
83
+ return str(data.get("error") or data.get("message") or data)[:300]
84
+ except ValueError:
85
+ pass
86
+ return (resp.text or "(empty body)")[:300]
87
+
88
+ # -- public API ----------------------------------------------------------
89
+ def create_contract(self, contract: dict) -> str:
90
+ """Register a contract with the hosted API. Returns the contract_id."""
91
+ data = self._request("POST", "/contracts", json=contract)
92
+ try:
93
+ return data["contract_id"]
94
+ except (TypeError, KeyError) as e:
95
+ raise VerdictkitError(
96
+ f"verdictkit API /contracts response missing 'contract_id': {data!r}"
97
+ ) from e
98
+
99
+ def submit_run(self, contract_id: str, evidence_bundle: dict) -> dict:
100
+ """Submit an evidence bundle for a run; returns the server verdict.
101
+
102
+ The server re-runs the deterministic checks against the
103
+ content-addressed evidence in the bundle -- it never trusts the
104
+ agent's claims alone.
105
+ """
106
+ return self._request(
107
+ "POST", f"/contracts/{contract_id}/runs",
108
+ json={"evidence_bundle": evidence_bundle})
109
+
110
+ def get_verdict(self, contract_id: str) -> dict:
111
+ """Fetch the latest verdict for a contract."""
112
+ return self._request("GET", f"/contracts/{contract_id}/verdict")
113
+
114
+ def list_verdicts(self, agent_id: str | None = None,
115
+ limit: int = 100) -> list:
116
+ """List recent verdicts, optionally filtered to one agent."""
117
+ params = {"limit": limit}
118
+ if agent_id is not None:
119
+ params["agent_id"] = agent_id
120
+ data = self._request("GET", "/verdicts", params=params)
121
+ if not isinstance(data, list):
122
+ raise VerdictkitError(
123
+ f"verdictkit API /verdicts response was not a list: {data!r}")
124
+ return data
@@ -0,0 +1,37 @@
1
+ """Task contracts: the machine-readable promise an agent run is checked against."""
2
+ from __future__ import annotations
3
+
4
+ import datetime
5
+ import uuid
6
+
7
+ CHECK_TYPES = {
8
+ "file_exists", "file_nonempty", "file_contains", "dir_exists",
9
+ "json_valid", "line_count_gte", "sqlite_table_has_rows", "path_absent",
10
+ "pattern_count_gte",
11
+ }
12
+
13
+
14
+ def define_contract(task_id, agent_id, intended_action, success_criteria,
15
+ scope=None, unverifiable_claims=None, principal="unknown"):
16
+ """Create a validated contract dict.
17
+
18
+ success_criteria: list of {"id", "type", "description", ...check params}.
19
+ Anything that cannot be checked deterministically belongs in
20
+ unverifiable_claims (yields FLAG, never silent PASS).
21
+ """
22
+ for i, c in enumerate(success_criteria):
23
+ if not isinstance(c, dict) or "type" not in c or "id" not in c:
24
+ raise ValueError(f"criterion {i} needs 'id' and 'type'")
25
+ if c["type"] not in CHECK_TYPES:
26
+ raise ValueError(f"unknown check type {c['type']!r}")
27
+ return {
28
+ "contract_id": f"vc_{uuid.uuid4().hex[:12]}",
29
+ "task_id": task_id,
30
+ "agent_id": agent_id,
31
+ "principal": principal,
32
+ "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
33
+ "intended_action": intended_action,
34
+ "scope": scope or {},
35
+ "success_criteria": success_criteria,
36
+ "unverifiable_claims": unverifiable_claims or [],
37
+ }
verdictkit/evidence.py ADDED
@@ -0,0 +1,166 @@
1
+ """Evidence bundles: the trust model of hosted verification.
2
+
3
+ The hosted API never trusts the agent's claims. Instead, the agent (or its
4
+ harness) captures *content-addressed evidence* locally -- file bytes with
5
+ their sha256, directory listings, parsed JSON -- into a bundle, and the
6
+ server re-runs the deterministic checks against the bundle's content.
7
+
8
+ Integrity: every artifact carries a sha256 of its full bytes, and
9
+ ``bundle_hash`` is a sha256 over the canonical JSON of all artifacts. The
10
+ server recomputes both; tampering breaks the hashes.
11
+
12
+ Limits: per-artifact byte caps keep bundles small. Truncated artifacts are
13
+ marked ``truncated: true`` -- the server treats checks it cannot fully
14
+ re-run as FLAG, never as PASS. A missing or unreadable artifact is
15
+ recorded honestly (``exists: false`` / ``collection_error``); absence of
16
+ evidence can only downgrade a verdict, never upgrade it.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import datetime
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import sqlite3
26
+
27
+ EVIDENCE_SCHEMA = "verdictkit-evidence/1"
28
+ MAX_ARTIFACT_BYTES = 256 * 1024 # 256 KiB of file content per artifact
29
+ MAX_DIR_ENTRIES = 2000
30
+
31
+
32
+ def _sha256_bytes(data: bytes) -> str:
33
+ return hashlib.sha256(data).hexdigest()
34
+
35
+
36
+ def _resolve(path: str, base_path: str | None) -> str:
37
+ p = os.path.expanduser(os.path.expandvars(path))
38
+ if base_path and not os.path.isabs(p):
39
+ p = os.path.join(base_path, p)
40
+ return os.path.normpath(p)
41
+
42
+
43
+ def _file_artifact(path: str) -> dict:
44
+ art: dict = {"kind": "file", "path": path, "exists": False}
45
+ if not os.path.isfile(path):
46
+ return art
47
+ art["exists"] = True
48
+ size = os.path.getsize(path)
49
+ art["size"] = size
50
+ # Full-file hash by streaming, even when content is truncated below.
51
+ h = hashlib.sha256()
52
+ with open(path, "rb") as f:
53
+ for chunk in iter(lambda: f.read(65536), b""):
54
+ h.update(chunk)
55
+ art["full_sha256"] = h.hexdigest()
56
+ art["truncated"] = size > MAX_ARTIFACT_BYTES
57
+ with open(path, "rb") as f:
58
+ raw = f.read(MAX_ARTIFACT_BYTES)
59
+ art["sha256"] = _sha256_bytes(raw)
60
+ try:
61
+ art["content"] = raw.decode("utf-8")
62
+ art["content_encoding"] = "text"
63
+ art["line_count"] = art["content"].count("\n") + (1 if art["content"] else 0)
64
+ except UnicodeDecodeError:
65
+ art["content"] = base64.b64encode(raw).decode("ascii")
66
+ art["content_encoding"] = "base64"
67
+ art["line_count"] = None
68
+ return art
69
+
70
+
71
+ def _dir_artifact(path: str) -> dict:
72
+ art: dict = {"kind": "dir", "path": path, "exists": os.path.isdir(path)}
73
+ if art["exists"]:
74
+ try:
75
+ names = sorted(os.listdir(path))
76
+ except OSError as e:
77
+ return {**art, "collection_error": str(e)}
78
+ art["listing_truncated"] = len(names) > MAX_DIR_ENTRIES
79
+ art["listing"] = names[:MAX_DIR_ENTRIES]
80
+ return art
81
+
82
+
83
+ def _sqlite_artifact(db: str, table: str) -> dict:
84
+ art: dict = {"kind": "sqlite", "db": db, "table": table,
85
+ "db_exists": os.path.isfile(db), "row_count": None}
86
+ if not art["db_exists"]:
87
+ return art
88
+ if not table.replace("_", "").isalnum() or table[0].isdigit():
89
+ art["collection_error"] = f"unsafe table name {table!r}"
90
+ return art
91
+ try:
92
+ con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
93
+ art["row_count"] = con.execute(
94
+ f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
95
+ art["tables"] = [r[0] for r in
96
+ con.execute("SELECT name FROM sqlite_master "
97
+ "WHERE type='table' ORDER BY name")]
98
+ con.close()
99
+ except Exception as e: # recorded, never raised
100
+ art["collection_error"] = str(e)
101
+ return art
102
+
103
+
104
+ def _absence_artifact(path: str) -> dict:
105
+ return {"kind": "absence", "path": path,
106
+ "exists": os.path.exists(path)}
107
+
108
+
109
+ _FILE_CHECK_TYPES = {"file_exists", "file_nonempty", "file_contains",
110
+ "json_valid", "line_count_gte", "pattern_count_gte"}
111
+
112
+
113
+ def collect_evidence(contract: dict, base_path: str | None = None) -> dict:
114
+ """Walk the contract's success_criteria and capture artifacts locally.
115
+
116
+ Returns a bundle dict the hosted API can re-verify:
117
+
118
+ {"schema": "verdictkit-evidence/1", "contract_id": ..., "task_id": ...,
119
+ "agent_id": ..., "success_criteria": [...], "artifacts": {check_id: ...},
120
+ "created_at": ..., "bundle_hash": "sha256..."}
121
+
122
+ Relative paths in criteria are resolved against ``base_path``.
123
+ Collects only -- never runs the verdict rubric and never touches the
124
+ network.
125
+ """
126
+ criteria = contract.get("success_criteria", [])
127
+ artifacts = {}
128
+ for c in criteria:
129
+ ctype = c.get("type")
130
+ cid = c.get("id")
131
+ if ctype in _FILE_CHECK_TYPES:
132
+ artifacts[cid] = _file_artifact(_resolve(c.get("path", ""), base_path))
133
+ elif ctype == "dir_exists":
134
+ artifacts[cid] = _dir_artifact(_resolve(c.get("path", ""), base_path))
135
+ elif ctype == "sqlite_table_has_rows":
136
+ artifacts[cid] = _sqlite_artifact(
137
+ _resolve(c.get("db", ""), base_path), c.get("table", ""))
138
+ elif ctype == "path_absent":
139
+ artifacts[cid] = _absence_artifact(_resolve(c.get("path", ""), base_path))
140
+ else:
141
+ artifacts[cid] = {"kind": "unknown",
142
+ "collection_error": f"no collector for {ctype!r}"}
143
+ canonical = json.dumps(artifacts, sort_keys=True, separators=(",", ":"),
144
+ default=str)
145
+ bundle = {
146
+ "schema": EVIDENCE_SCHEMA,
147
+ "contract_id": contract.get("contract_id"),
148
+ "task_id": contract.get("task_id"),
149
+ "agent_id": contract.get("agent_id"),
150
+ "success_criteria": criteria,
151
+ "base_path": os.path.abspath(base_path) if base_path else None,
152
+ "artifacts": artifacts,
153
+ "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
154
+ "bundle_hash": _sha256_bytes(canonical.encode("utf-8")),
155
+ }
156
+ return bundle
157
+
158
+
159
+ def verify_bundle_hash(bundle: dict) -> bool:
160
+ """Recompute the bundle hash; False means the bundle was tampered with."""
161
+ artifacts = bundle.get("artifacts")
162
+ if not isinstance(artifacts, dict):
163
+ return False
164
+ canonical = json.dumps(artifacts, sort_keys=True, separators=(",", ":"),
165
+ default=str)
166
+ return _sha256_bytes(canonical.encode("utf-8")) == bundle.get("bundle_hash")
verdictkit/verdict.py ADDED
@@ -0,0 +1,63 @@
1
+ """Rubric: FAIL if any check fails; FLAG if anything is unverifiable or
2
+ errored (or there was nothing checkable); PASS only on clean checks.
3
+
4
+ verify_run is local-first: no API key, no network, no cost. Pass
5
+ ``base_path`` to resolve relative criterion paths against a directory
6
+ instead of the current working directory.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from .checks import run_check
13
+
14
+ _PATH_KEYS = ("path", "db")
15
+
16
+
17
+ def _resolve_criteria(contract, base_path):
18
+ """Copy criteria with relative paths resolved against base_path."""
19
+ resolved = []
20
+ for c in contract.get("success_criteria", []):
21
+ c = dict(c)
22
+ for key in _PATH_KEYS:
23
+ p = c.get(key)
24
+ if isinstance(p, str) and not os.path.isabs(
25
+ os.path.expanduser(os.path.expandvars(p))):
26
+ c[key] = os.path.normpath(os.path.join(base_path, p))
27
+ resolved.append(c)
28
+ return resolved
29
+
30
+
31
+ class Verdict:
32
+ PASS = "PASS"
33
+ FLAG = "FLAG"
34
+ FAIL = "FAIL"
35
+
36
+
37
+ def verify_run(contract, base_path=None):
38
+ """Run the deterministic checks locally and return the verdict dict.
39
+
40
+ No API key, no network, free and unlimited. ``base_path`` resolves
41
+ relative criterion paths against a directory (default: cwd).
42
+ """
43
+ criteria = (_resolve_criteria(contract, base_path)
44
+ if base_path else contract.get("success_criteria", []))
45
+ results = [run_check(c) for c in criteria]
46
+ unverifiable = contract.get("unverifiable_claims", [])
47
+ failed = [r for r in results if r["status"] == "fail"]
48
+ errored = [r for r in results if r["status"] == "error"]
49
+ if failed:
50
+ verdict, reason = Verdict.FAIL, "; ".join(r["evidence"] for r in failed)
51
+ elif errored or unverifiable or not results:
52
+ parts = []
53
+ if not results: parts.append("no checkable success criteria")
54
+ if errored: parts.append("; ".join(r["evidence"] for r in errored))
55
+ if unverifiable: parts.append(f"{len(unverifiable)} unverifiable claim(s)")
56
+ verdict, reason = Verdict.FLAG, ". ".join(parts)
57
+ else:
58
+ verdict = Verdict.PASS
59
+ reason = f"all {len(results)} checks passed"
60
+ return {"contract_id": contract["contract_id"], "task_id": contract["task_id"],
61
+ "agent_id": contract["agent_id"], "verdict": verdict,
62
+ "reason": reason, "check_results": results,
63
+ "unverifiable_claims": unverifiable}
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: verdictkit
3
+ Version: 0.1.0
4
+ Summary: Non-custodial verification for agent work: did the agent do what it said?
5
+ Author: agentbuilt
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mattedwardseo/verdictkit
8
+ Project-URL: Documentation, https://github.com/mattedwardseo/verdictkit#readme
9
+ Project-URL: Repository, https://github.com/mattedwardseo/verdictkit
10
+ Project-URL: Issues, https://github.com/mattedwardseo/verdictkit/issues
11
+ Keywords: agents,verification,ai-safety,auditing,deterministic-checks
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Classifier: Topic :: Security
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: requests>=2.28
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7; extra == "dev"
27
+ Requires-Dist: build; extra == "dev"
28
+
29
+ # verdictkit
30
+
31
+ Non-custodial verification for agent work. Answers one question: **did the
32
+ agent do what it said it would?**
33
+
34
+ The product is the *verification layer*, not custody: a machine-readable
35
+ contract (intended action + checkable success criteria), deterministic
36
+ checks against the world, and a rubric verdict — `PASS` / `FLAG` / `FAIL`
37
+ — with evidence. No money moves through it, so there is no money-transmitter
38
+ licensing wall. That is the whole strategic point.
39
+
40
+ **Status: 0.1.0, launch-ready, NOT published to PyPI.** Do not run the
41
+ publish command without approval.
42
+
43
+ ## Quickstart
44
+
45
+ ```bash
46
+ pip install verdictkit # not on PyPI yet; use: pip install .
47
+ ```
48
+
49
+ ### Local verification (free, unlimited, no key, no network)
50
+
51
+ ```python
52
+ import verdictkit as vk
53
+
54
+ contract = vk.define_contract(
55
+ task_id="research-042",
56
+ agent_id="agent-7f3a",
57
+ intended_action="Summarize the Q3 incident reports into incidents.md",
58
+ success_criteria=[
59
+ {"id": "c1", "type": "file_exists",
60
+ "path": "incidents.md",
61
+ "description": "summary file was written"},
62
+ {"id": "c2", "type": "pattern_count_gte",
63
+ "path": "incidents.md",
64
+ "pattern": r"^## Incident",
65
+ "min_count": 3,
66
+ "description": "covers at least 3 incidents"},
67
+ ],
68
+ unverifiable_claims=["tone is executive-appropriate"], # -> FLAG, honestly
69
+ )
70
+
71
+ result = vk.verify_run(contract, base_path="./output")
72
+ print(result["verdict"]) # PASS | FLAG | FAIL
73
+ print(result["reason"]) # evidence, always
74
+ ```
75
+
76
+ Or from the shell:
77
+
78
+ ```bash
79
+ verdictkit verify contract.json --base-path ./output -v
80
+ # exit code: 0 = PASS, 1 = FAIL, 2 = FLAG, 3 = usage/IO error
81
+ ```
82
+
83
+ ### Hosted verification (reputation ledger + re-verification)
84
+
85
+ Local mode checks *your* machine. The hosted tier lets a third party trust
86
+ the verdict: you capture evidence locally, the server **re-runs the
87
+ deterministic checks against the evidence** — it never trusts the agent's
88
+ claims alone.
89
+
90
+ ```python
91
+ import verdictkit as vk
92
+
93
+ client = vk.Client(api_key="vk_live_...") # base_url=... to override
94
+ contract_id = client.create_contract(contract) # -> "vc_..."
95
+ bundle = vk.collect_evidence(contract, base_path="./output")
96
+ verdict = client.submit_run(contract_id, bundle) # server-side verdict dict
97
+
98
+ client.get_verdict(contract_id) # latest verdict
99
+ client.list_verdicts(agent_id="agent-7f3a") # reputation ledger
100
+ ```
101
+
102
+ Errors are clean: `vk.AuthError` on bad credentials (401/403),
103
+ `vk.VerdictkitError` on network trouble, timeouts, or bad responses.
104
+
105
+ ## API reference
106
+
107
+ | Symbol | Kind | Description |
108
+ |---|---|---|
109
+ | `vk.define_contract(task_id, agent_id, intended_action, success_criteria, scope=None, unverifiable_claims=None, principal="unknown")` | function | Build a validated contract dict. Raises `ValueError` on unknown check types or criteria missing `id`/`type`. |
110
+ | `vk.verify_run(contract, base_path=None)` | function | Run all checks locally, return verdict dict. No key, no network. `base_path` resolves relative criterion paths. |
111
+ | `vk.Verdict` | class | Constants `PASS`, `FLAG`, `FAIL`. |
112
+ | `vk.collect_evidence(contract, base_path=None)` | function | Capture a content-addressed evidence bundle (file bytes + sha256, dir listings, parsed JSON, sqlite row counts). Local only, no network. |
113
+ | `vk.verify_bundle_hash(bundle)` | function | Recompute `bundle_hash`; `False` means the bundle was tampered with. |
114
+ | `vk.Client(api_key, base_url=..., timeout=30.0)` | class | Hosted API client. `create_contract(contract) -> contract_id`, `submit_run(contract_id, evidence_bundle) -> verdict dict`, `get_verdict(contract_id) -> dict`, `list_verdicts(agent_id=None, limit=100) -> list`. Raises `ValueError` without an API key. |
115
+ | `vk.VerdictkitError` | exception | Base error: network, timeout, bad server response. |
116
+ | `vk.AuthError(VerdictkitError)` | exception | 401/403 from the API. |
117
+
118
+ ### Verdict shape
119
+
120
+ ```python
121
+ {"contract_id": ..., "task_id": ..., "agent_id": ...,
122
+ "verdict": "PASS" | "FLAG" | "FAIL",
123
+ "reason": "human-readable evidence summary",
124
+ "check_results": [{"check_id": ..., "type": ..., "status": "pass"|"fail"|"error",
125
+ "evidence": ...}, ...],
126
+ "unverifiable_claims": [...]}
127
+ ```
128
+
129
+ ### Check types
130
+
131
+ `file_exists` · `file_nonempty` · `file_contains` (regex) ·
132
+ `pattern_count_gte` · `dir_exists` · `json_valid` · `line_count_gte` ·
133
+ `sqlite_table_has_rows` · `path_absent` (scope guard)
134
+
135
+ ## The rubric
136
+
137
+ - **FAIL** — any check fails. Fail dominates everything else.
138
+ - **FLAG** — no failures, but something couldn't be verified: a check
139
+ errored, a claim was declared unverifiable, or there was nothing
140
+ checkable at all.
141
+ - **PASS** — every check passed cleanly, and nothing was left unverifiable.
142
+
143
+ Design rule: a check that cannot run is an *error*, never a pass. A run
144
+ with nothing checkable can at best be *flagged*. Absence of evidence is
145
+ never evidence of success.
146
+
147
+ ## Trust model
148
+
149
+ 1. **Contracts are machine-readable promises.** What the agent intended to
150
+ do, written before the run, with deterministic success criteria.
151
+ 2. **Evidence is content-addressed.** `collect_evidence` captures file
152
+ bytes with their sha256, directory listings, and query results. The
153
+ bundle carries a `bundle_hash` over canonical JSON — the server
154
+ recomputes every hash; tampering breaks them.
155
+ 3. **The server re-runs checks; it never trusts claims.** Hosted
156
+ verification executes the same deterministic check engine against the
157
+ bundle's content. Claims without evidence stay in
158
+ `unverifiable_claims` and can only produce FLAG.
159
+ 4. **Truncation is honest.** Artifacts are capped (256 KiB file content,
160
+ 2000 dir entries). Truncated artifacts are marked, and checks the
161
+ server cannot fully re-run become FLAG — never PASS.
162
+
163
+ ## What verification is NOT
164
+
165
+ - **Not a guarantee of quality.** PASS means the deterministic criteria
166
+ were met — the file exists, has ≥3 sections, parses as JSON. It says
167
+ nothing about whether the writing is good, the code is correct, or the
168
+ summary is accurate. Put anything subjective in `unverifiable_claims`
169
+ and accept the FLAG honestly.
170
+ - **Not an oracle.** Checks only see what they can read: files, dirs,
171
+ sqlite. They cannot verify "the user is happy" or "the bug is fixed"
172
+ unless that was operationalized into a checkable artifact.
173
+ - **Not custody, not escrow.** No money moves through verdictkit; it
174
+ attests to work done, it does not hold funds or release payment.
175
+ - **Not tamper-proof on a compromised machine.** Local verification trusts
176
+ the local filesystem. The hosted tier raises the bar (content-addressed
177
+ bundles, server-side re-runs, reputation ledger) but a fully
178
+ compromised agent host can still fake the inputs. Verification makes
179
+ lying *auditable*, not impossible.
180
+
181
+ ## Development
182
+
183
+ ```bash
184
+ pip install -e ".[dev]" # dev extras: pytest, build
185
+ python3 -m pytest # 59 tests, all local, no network
186
+ python3 -m build # wheel/sdist -- DO NOT upload without approval
187
+ ```
188
+
189
+ ## License
190
+
191
+ MIT. See `LICENSE`.
@@ -0,0 +1,12 @@
1
+ verdictkit/__init__.py,sha256=IqtA15Mh7kji3dk2mbD-rqaiVfhEYpfdFeYJcZmVwik,1253
2
+ verdictkit/checks.py,sha256=57nfWtMUL7XaXEBckkV5o0Bt0PJ5ij-3mkFZiVNoBS0,3696
3
+ verdictkit/cli.py,sha256=11WvxAJr5QGrDAShg-3orkarmCCrDtgfo1k2E2EGeIo,1907
4
+ verdictkit/client.py,sha256=4Pod_lJRyC9pwt73ZFhM08mnKgW5MEnnVgVA341OwDI,5215
5
+ verdictkit/contracts.py,sha256=G81Ix1wrgXJZtixu7Q6f4zOe5oJDSkoDuPGeqoNMkaM,1456
6
+ verdictkit/evidence.py,sha256=t4qEAgfQU7Uf4oW7v5-E-ksAF1IM7-6Yg7ukfxsn4j0,6498
7
+ verdictkit/verdict.py,sha256=KzxDfzLRwmw0mt8yjl2ZCfAtH4aXWZ7ad8h6-VXorJQ,2418
8
+ verdictkit-0.1.0.dist-info/METADATA,sha256=OelZtbFloIqqy6F2rurTJGx0ufww12_G4ns06nez6Pg,8275
9
+ verdictkit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ verdictkit-0.1.0.dist-info/entry_points.txt,sha256=pL3SfNWtPRuAO43FEX3skoULazvSFM_HVTJzNw0o3vI,51
11
+ verdictkit-0.1.0.dist-info/top_level.txt,sha256=iv3wlK6trZ6ak0yC5q60eCj8COQGtMiNNOq8CMW8-v0,11
12
+ verdictkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ verdictkit = verdictkit.cli:main
@@ -0,0 +1 @@
1
+ verdictkit