vitnify 0.2.6__tar.gz → 0.2.8__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.
- {vitnify-0.2.6/src/vitnify.egg-info → vitnify-0.2.8}/PKG-INFO +1 -1
- {vitnify-0.2.6 → vitnify-0.2.8}/pyproject.toml +1 -1
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/certificate.py +14 -1
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/viewer.py +55 -19
- {vitnify-0.2.6 → vitnify-0.2.8/src/vitnify.egg-info}/PKG-INFO +1 -1
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify.egg-info/SOURCES.txt +2 -1
- vitnify-0.2.8/tests/test_viewer.py +133 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/LICENSE +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/NOTICE +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/README.md +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/setup.cfg +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/__init__.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/_vendor/__init__.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/_vendor/pck/__init__.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/_vendor/pck/cas.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/capability.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/deterministic_llm.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/engine.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/events.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/langgraph_adapter.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/mcp_adapter.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/react.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/replayer.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify.egg-info/dependency_links.txt +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify.egg-info/requires.txt +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify.egg-info/top_level.txt +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_adapters.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_attack_matrix.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_capability.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_certificate.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_ed25519.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_events.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_l2_recompute.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_receipt_v2.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_verification.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_verify_hardening.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vitnify"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.8"
|
|
8
8
|
description = "Execution receipts for AI agents: contain, deterministically reconstruct, and cryptographically certify a run."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -32,6 +32,19 @@ FORMAT = "vitnify-receipt v2"
|
|
|
32
32
|
# reconstructs each receipt's signed body per its own `v` (see body()).
|
|
33
33
|
SUPPORTED_FORMATS = frozenset({"vitnify-receipt v1", FORMAT})
|
|
34
34
|
|
|
35
|
+
# The tool decisions that count as GATED -- the call passed through the capability wall
|
|
36
|
+
# (allow) or was refused by it (deny). Any other label ("observed", ...) is watch-only,
|
|
37
|
+
# not enforcement. This is the SINGLE SOURCE OF TRUTH for the containment predicate:
|
|
38
|
+
# verify_certificate and the human-facing viewer both call it, so the rule cannot drift
|
|
39
|
+
# between the dict a machine checks and the page a person reads.
|
|
40
|
+
GATED_DECISIONS = frozenset({"allow", "deny"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def decision_is_gated(decision) -> bool:
|
|
44
|
+
"""True if a tool decision was gated by the capability wall (allow/deny), not merely
|
|
45
|
+
observed. Case- and whitespace-insensitive; fails closed on anything unrecognised."""
|
|
46
|
+
return str(decision).strip().lower() in GATED_DECISIONS
|
|
47
|
+
|
|
35
48
|
|
|
36
49
|
def _now_iso() -> str:
|
|
37
50
|
"""Issuer-asserted UTC timestamp (second precision). Places the receipt in time
|
|
@@ -238,7 +251,7 @@ def verify_certificate(cert: ExecutionCertificate, log: EventLog, key: bytes | N
|
|
|
238
251
|
# analogue of an empty model_digests. Reported separately, NOT folded into ok, so a
|
|
239
252
|
# valid transcript stays valid while a containment PROOF requires ok AND this True.
|
|
240
253
|
checks["containment_enforced"] = all(
|
|
241
|
-
|
|
254
|
+
decision_is_gated(e.payload.get("decision"))
|
|
242
255
|
for e in log.events
|
|
243
256
|
if e.kind == Kind.TOOL_CALL.value
|
|
244
257
|
)
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import os, sys, json, html
|
|
11
11
|
|
|
12
12
|
from ._vendor.pck.cas import MerkleCAS # noqa: E402
|
|
13
|
+
from .certificate import decision_is_gated # single source of truth for the containment rule
|
|
13
14
|
|
|
14
15
|
def _canon(o) -> str:
|
|
15
16
|
return json.dumps(o, sort_keys=True, separators=(",", ":"))
|
|
@@ -88,22 +89,56 @@ def render(run: dict) -> str:
|
|
|
88
89
|
def warn_badge(text):
|
|
89
90
|
return f'<div class="badge warn"><span class="dot"></span>{text}</div>'
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
92
|
+
def neutral_badge(text):
|
|
93
|
+
return f'<div class="badge neutral"><span class="dot"></span>{text}</div>'
|
|
94
|
+
|
|
95
|
+
# Every containment claim on this page is DERIVED from the events with the SAME rule
|
|
96
|
+
# the verifier uses (decision_is_gated) -- never taken from the caller's verdict dict.
|
|
97
|
+
# But a claim is only as strong as the evidence under it, so the page asserts
|
|
98
|
+
# containment POSITIVELY only when both hold:
|
|
99
|
+
# * the certificate verifies -- otherwise the log is UNVERIFIED and may be the very
|
|
100
|
+
# forgery the certificate exists to catch, so nothing derived from it can be
|
|
101
|
+
# trusted, containment included; and
|
|
102
|
+
# * a tool was actually gated -- a run that called no tool has nothing to contain,
|
|
103
|
+
# so "contained" would be vacuously true.
|
|
104
|
+
# verify_certificate reports those cases as containment_enforced=True (a machine
|
|
105
|
+
# predicate over the events alone); the human page is deliberately stricter and never
|
|
106
|
+
# reads as "contained" on unverified or empty evidence.
|
|
107
|
+
tool_events = [e for e in events if e["kind"] == "tool_call"]
|
|
108
|
+
all_gated = all(decision_is_gated(e["payload"].get("decision")) for e in tool_events)
|
|
109
|
+
blocked = any(str(e["payload"].get("decision", "")).strip().lower() == "deny" for e in tool_events)
|
|
110
|
+
cert_ok = bool(v.get("cert_ok"))
|
|
111
|
+
replay_ok = bool(v.get("replay_identical"))
|
|
112
|
+
containment_proven = cert_ok and bool(tool_events) and all_gated
|
|
113
|
+
|
|
114
|
+
if not cert_ok:
|
|
115
|
+
containment_badge = warn_badge("Containment unverified — certificate failed")
|
|
116
|
+
elif not tool_events:
|
|
117
|
+
containment_badge = neutral_badge("No tool calls") # nothing to contain
|
|
118
|
+
elif not all_gated:
|
|
119
|
+
containment_badge = warn_badge("Containment observed — not proven")
|
|
120
|
+
elif blocked:
|
|
121
|
+
containment_badge = badge(True, "Injection contained", "") # an ungranted call was refused
|
|
122
|
+
else:
|
|
123
|
+
containment_badge = badge(True, "Containment enforced", "") # every call gated; none needed blocking
|
|
124
|
+
|
|
125
|
+
badges = (containment_badge
|
|
126
|
+
+ badge(replay_ok, "Replay bit-identical", "Replay diverged")
|
|
127
|
+
+ badge(cert_ok, "Certificate verified", "Certificate FAILED"))
|
|
128
|
+
|
|
129
|
+
# Headline asserts ONLY what the evidence supports: "contained" needs a verified log
|
|
130
|
+
# AND a gated tool; "cryptographically certified" needs the signature to check out.
|
|
131
|
+
claims = (["contained"] if containment_proven else []) \
|
|
132
|
+
+ (["replayed bit-for-bit"] if replay_ok else []) \
|
|
133
|
+
+ (["cryptographically certified"] if cert_ok else [])
|
|
134
|
+
if not claims:
|
|
135
|
+
headline = "Recorded agent run."
|
|
136
|
+
elif len(claims) == 1:
|
|
137
|
+
headline = f"This agent run was {claims[0]}."
|
|
138
|
+
elif len(claims) == 2:
|
|
139
|
+
headline = f"This agent run was {claims[0]} and {claims[1]}."
|
|
140
|
+
else:
|
|
141
|
+
headline = "This agent run was " + ", ".join(claims[:-1]) + f", and {claims[-1]}."
|
|
107
142
|
|
|
108
143
|
cert_rows = "".join(
|
|
109
144
|
f'<div class="crow"><span class="ck">{k}</span><span class="cv mono">{_esc(val)}</span></div>'
|
|
@@ -112,8 +147,8 @@ def render(run: dict) -> str:
|
|
|
112
147
|
("capabilities granted", ", ".join(cert["capabilities"])),
|
|
113
148
|
("event-log root", cert["event_root"]),
|
|
114
149
|
("chain head", cert["head_hash"]),
|
|
115
|
-
("certificate id", cert
|
|
116
|
-
("signature
|
|
150
|
+
("certificate id", cert.get("digest", "—")),
|
|
151
|
+
("signature", cert.get("sig", "—")),
|
|
117
152
|
])
|
|
118
153
|
|
|
119
154
|
return f"""<title>Agent Replay Certificate</title>
|
|
@@ -150,6 +185,7 @@ h1{{font-size:27px;margin:0;letter-spacing:-.01em;text-wrap:balance;}}
|
|
|
150
185
|
.badge.good{{color:var(--ok);}} .badge.good .dot{{background:var(--ok);}}
|
|
151
186
|
.badge.bad{{color:var(--tamper);}} .badge.bad .dot{{background:var(--tamper);}}
|
|
152
187
|
.badge.warn{{color:var(--warn);}} .badge.warn .dot{{background:var(--warn);}}
|
|
188
|
+
.badge.neutral{{color:var(--ink-dim);}} .badge.neutral .dot{{background:var(--ink-dim);}}
|
|
153
189
|
section{{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:22px 22px;margin:16px 0;box-shadow:var(--shadow);}}
|
|
154
190
|
.sec-h{{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin:0 0 14px;}}
|
|
155
191
|
.sec-h h2{{font-size:13px;letter-spacing:.09em;text-transform:uppercase;color:var(--ink-dim);margin:0;font-weight:700;}}
|
|
@@ -192,7 +228,7 @@ footer{{color:var(--ink-dim);font-size:12px;margin-top:26px;text-align:center;}}
|
|
|
192
228
|
<div class="wrap">
|
|
193
229
|
<header class="top">
|
|
194
230
|
<div class="eyebrow">Vitnify · Execution Certificate</div>
|
|
195
|
-
<h1>
|
|
231
|
+
<h1>{_esc(headline)}</h1>
|
|
196
232
|
<p class="task">Task recorded: “{_esc(run.get('task','(agent run)'))}”</p>
|
|
197
233
|
</header>
|
|
198
234
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The viewer renders receipts for humans, so its claims must be exactly as strong as
|
|
2
|
+
the receipt -- no more. These tests assert the HEADLINE (the largest text on the page)
|
|
3
|
+
and the badges are honest about containment, derived from the events rather than taken
|
|
4
|
+
on faith from the caller's verdict dict. The headline used to be an unconditional
|
|
5
|
+
string that asserted containment even for an observe-only run; nothing imported the
|
|
6
|
+
viewer, so that survived. This is the coverage that closes it.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
|
|
12
|
+
from vitnify.events import EventLog, Kind
|
|
13
|
+
from vitnify.certificate import issue_certificate, verify_certificate, gen_ed25519
|
|
14
|
+
from vitnify.viewer import render
|
|
15
|
+
|
|
16
|
+
PRIV, PUB = gen_ed25519()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build(decisions, *, cert_ok=True, replay=True):
|
|
20
|
+
"""A run dict shaped exactly like the demos build (examples/demo_react.py)."""
|
|
21
|
+
log = EventLog()
|
|
22
|
+
log.append_llm_call("ph", [1, 2, 3], seed=0, model_digest="dd")
|
|
23
|
+
for i, d in enumerate(decisions):
|
|
24
|
+
p = {"tool": f"tool{i}", "decision": d}
|
|
25
|
+
dl = str(d).strip().lower()
|
|
26
|
+
if dl == "allow":
|
|
27
|
+
p.update(result="x", result_hash="h") # gated allow: result + hash
|
|
28
|
+
elif dl != "deny":
|
|
29
|
+
p["result"] = "x" # observed / other: watched, result recorded
|
|
30
|
+
# a clean deny carries no result
|
|
31
|
+
log.append(Kind.TOOL_CALL, p)
|
|
32
|
+
cert, _ = issue_certificate("prog", ["tool0"], log, priv=PRIV)
|
|
33
|
+
run = {
|
|
34
|
+
"task": "demo",
|
|
35
|
+
"events": [asdict(e) for e in log.events],
|
|
36
|
+
"certificate": {"program_hash": cert.program_hash, "capabilities": cert.capabilities,
|
|
37
|
+
"event_root": cert.event_root, "head_hash": cert.head_hash,
|
|
38
|
+
"digest": cert.digest(), "sig": cert.sig},
|
|
39
|
+
"verdict": {"contained": True, "replay_identical": replay, "cert_ok": cert_ok},
|
|
40
|
+
}
|
|
41
|
+
return run, log
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _headline(html):
|
|
45
|
+
return re.search(r"<h1>(.*?)</h1>", html, re.S).group(1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _badges(html):
|
|
49
|
+
return re.findall(r'class="badge (\w+)"><span class="dot"></span>([^<]*)', html)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_observe_only_headline_does_not_claim_containment():
|
|
53
|
+
# The regression: an observe-only run must not read as "contained" in the headline.
|
|
54
|
+
run, _ = _build(["observed"])
|
|
55
|
+
html = render(run)
|
|
56
|
+
headline = _headline(html)
|
|
57
|
+
assert "contained" not in headline.lower()
|
|
58
|
+
assert "Containment observed — not proven" in html
|
|
59
|
+
# it may still honestly claim the properties that DO hold
|
|
60
|
+
assert "replayed bit-for-bit" in headline and "cryptographically certified" in headline
|
|
61
|
+
# and the warn badge, not a green containment badge
|
|
62
|
+
assert ("warn", "Containment observed — not proven") in _badges(html)
|
|
63
|
+
assert not any(txt.startswith("Injection contained") or txt.startswith("Containment enforced")
|
|
64
|
+
for _, txt in _badges(html))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_enforced_with_block_claims_containment():
|
|
68
|
+
run, _ = _build(["allow", "deny"])
|
|
69
|
+
html = render(run)
|
|
70
|
+
assert "contained" in _headline(html).lower()
|
|
71
|
+
assert ("good", "Injection contained") in _badges(html)
|
|
72
|
+
assert "Containment observed" not in html
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_enforced_all_allow_claims_containment():
|
|
76
|
+
run, _ = _build(["allow"])
|
|
77
|
+
html = render(run)
|
|
78
|
+
assert "contained" in _headline(html).lower()
|
|
79
|
+
assert ("good", "Containment enforced") in _badges(html)
|
|
80
|
+
assert "Containment observed" not in html
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_cert_failed_drops_all_derived_claims():
|
|
84
|
+
# If the certificate does not verify, the event log is UNVERIFIED -- it may be the
|
|
85
|
+
# very forgery the certificate exists to catch -- so the page must not claim
|
|
86
|
+
# containment (or certification) from it, even though every event reads as gated.
|
|
87
|
+
run, _ = _build(["allow"], cert_ok=False)
|
|
88
|
+
html = render(run)
|
|
89
|
+
headline = _headline(html)
|
|
90
|
+
assert "cryptographically certified" not in headline
|
|
91
|
+
assert "contained" not in headline.lower()
|
|
92
|
+
assert ("warn", "Containment unverified — certificate failed") in _badges(html)
|
|
93
|
+
assert ("bad", "Certificate FAILED") in _badges(html)
|
|
94
|
+
assert not any(t in ("Injection contained", "Containment enforced") for _, t in _badges(html))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_zero_tool_run_is_not_vacuously_contained():
|
|
98
|
+
# all([]) is True, so a tool-free run is containment_enforced at the API level -- but
|
|
99
|
+
# the page must not tell a human the run was "contained" when nothing was ever gated.
|
|
100
|
+
run, _ = _build([]) # llm_call only, no tool calls
|
|
101
|
+
html = render(run)
|
|
102
|
+
assert "contained" not in _headline(html).lower()
|
|
103
|
+
assert ("neutral", "No tool calls") in _badges(html)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_viewer_never_claims_containment_without_verified_gating():
|
|
107
|
+
# Soundness: the headline reads "contained" ONLY when the certificate verifies, a
|
|
108
|
+
# tool was actually gated, and the verifier's own predicate agrees gating held. The
|
|
109
|
+
# page never claims containment on unverified, empty, or ungated evidence.
|
|
110
|
+
cases = [(["allow", "deny"], True), (["observed"], True), (["allow"], True),
|
|
111
|
+
(["PERMIT"], True), (["allow"], False), ([], True)]
|
|
112
|
+
for decisions, cert_ok in cases:
|
|
113
|
+
run, log = _build(decisions, cert_ok=cert_ok)
|
|
114
|
+
says_contained = "contained" in _headline(render(run)).lower()
|
|
115
|
+
enforced = verify_certificate(_recert(run), log)["containment_enforced"]
|
|
116
|
+
if says_contained:
|
|
117
|
+
assert cert_ok and bool(decisions) and enforced, (decisions, cert_ok)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_render_tolerates_missing_digest():
|
|
121
|
+
# render() must not require a `digest` key that cert.to_json() does not emit.
|
|
122
|
+
run, _ = _build(["allow"])
|
|
123
|
+
del run["certificate"]["digest"]
|
|
124
|
+
del run["certificate"]["sig"]
|
|
125
|
+
html = render(run) # must not raise
|
|
126
|
+
assert "—" in html
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _recert(run):
|
|
130
|
+
from vitnify.certificate import ExecutionCertificate
|
|
131
|
+
c = run["certificate"]
|
|
132
|
+
return ExecutionCertificate(c["program_hash"], c["capabilities"], c["event_root"],
|
|
133
|
+
len(run["events"]), c["head_hash"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|