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 +40 -0
- verdictkit/checks.py +92 -0
- verdictkit/cli.py +54 -0
- verdictkit/client.py +124 -0
- verdictkit/contracts.py +37 -0
- verdictkit/evidence.py +166 -0
- verdictkit/verdict.py +63 -0
- verdictkit-0.1.0.dist-info/METADATA +191 -0
- verdictkit-0.1.0.dist-info/RECORD +12 -0
- verdictkit-0.1.0.dist-info/WHEEL +5 -0
- verdictkit-0.1.0.dist-info/entry_points.txt +2 -0
- verdictkit-0.1.0.dist-info/top_level.txt +1 -0
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
|
verdictkit/contracts.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
verdictkit
|