vitnify 0.2.6__tar.gz → 0.2.7__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.7}/PKG-INFO +1 -1
- {vitnify-0.2.6 → vitnify-0.2.7}/pyproject.toml +1 -1
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/certificate.py +14 -1
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/viewer.py +39 -19
- {vitnify-0.2.6 → vitnify-0.2.7/src/vitnify.egg-info}/PKG-INFO +1 -1
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify.egg-info/SOURCES.txt +2 -1
- vitnify-0.2.7/tests/test_viewer.py +114 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/LICENSE +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/NOTICE +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/README.md +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/setup.cfg +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/__init__.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/_vendor/__init__.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/_vendor/pck/__init__.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/_vendor/pck/cas.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/capability.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/deterministic_llm.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/engine.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/events.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/langgraph_adapter.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/mcp_adapter.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/react.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/replayer.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify.egg-info/dependency_links.txt +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify.egg-info/requires.txt +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify.egg-info/top_level.txt +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_adapters.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_attack_matrix.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_capability.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_certificate.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_ed25519.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_events.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_l2_recompute.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_receipt_v2.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_verification.py +0 -0
- {vitnify-0.2.6 → vitnify-0.2.7}/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.7"
|
|
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,41 @@ 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
|
-
# verifier
|
|
93
|
-
# run (a watching callback that records but does not gate) is a valid
|
|
94
|
-
# proves no containment
|
|
95
|
-
# or a non-engineer is handed a
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
92
|
+
# Every containment claim on this page is DERIVED from the events with the SAME rule
|
|
93
|
+
# the verifier uses (decision_is_gated) -- never taken from the caller's verdict dict.
|
|
94
|
+
# An observe-only run (a watching callback that records but does not gate) is a valid
|
|
95
|
+
# transcript that proves no containment; it must not read as "contained" anywhere,
|
|
96
|
+
# headline included, or a non-engineer is handed a claim stronger than the receipt.
|
|
97
|
+
tool_events = [e for e in events if e["kind"] == "tool_call"]
|
|
98
|
+
containment_enforced = all(decision_is_gated(e["payload"].get("decision")) for e in tool_events)
|
|
99
|
+
blocked = any(str(e["payload"].get("decision", "")).strip().lower() == "deny" for e in tool_events)
|
|
100
|
+
cert_ok = bool(v.get("cert_ok"))
|
|
101
|
+
replay_ok = bool(v.get("replay_identical"))
|
|
102
|
+
|
|
103
|
+
if not containment_enforced:
|
|
104
|
+
containment_badge = warn_badge("Containment observed — not proven")
|
|
105
|
+
elif blocked:
|
|
106
|
+
containment_badge = badge(True, "Injection contained", "") # an ungranted call was refused
|
|
107
|
+
else:
|
|
108
|
+
containment_badge = badge(True, "Containment enforced", "") # every call gated; none needed blocking
|
|
109
|
+
|
|
110
|
+
badges = (containment_badge
|
|
111
|
+
+ badge(replay_ok, "Replay bit-identical", "Replay diverged")
|
|
112
|
+
+ badge(cert_ok, "Certificate verified", "Certificate FAILED"))
|
|
113
|
+
|
|
114
|
+
# Headline asserts ONLY what holds. "contained" tracks containment_enforced (not the
|
|
115
|
+
# caller), so the observe-only page drops the word instead of retracting it below.
|
|
116
|
+
claims = (["contained"] if containment_enforced else []) \
|
|
117
|
+
+ (["replayed bit-for-bit"] if replay_ok else []) \
|
|
118
|
+
+ (["cryptographically certified"] if cert_ok else [])
|
|
119
|
+
if not claims:
|
|
120
|
+
headline = "Recorded agent run."
|
|
121
|
+
elif len(claims) == 1:
|
|
122
|
+
headline = f"This agent run was {claims[0]}."
|
|
123
|
+
elif len(claims) == 2:
|
|
124
|
+
headline = f"This agent run was {claims[0]} and {claims[1]}."
|
|
125
|
+
else:
|
|
126
|
+
headline = "This agent run was " + ", ".join(claims[:-1]) + f", and {claims[-1]}."
|
|
107
127
|
|
|
108
128
|
cert_rows = "".join(
|
|
109
129
|
f'<div class="crow"><span class="ck">{k}</span><span class="cv mono">{_esc(val)}</span></div>'
|
|
@@ -112,8 +132,8 @@ def render(run: dict) -> str:
|
|
|
112
132
|
("capabilities granted", ", ".join(cert["capabilities"])),
|
|
113
133
|
("event-log root", cert["event_root"]),
|
|
114
134
|
("chain head", cert["head_hash"]),
|
|
115
|
-
("certificate id", cert
|
|
116
|
-
("signature
|
|
135
|
+
("certificate id", cert.get("digest", "—")),
|
|
136
|
+
("signature", cert.get("sig", "—")),
|
|
117
137
|
])
|
|
118
138
|
|
|
119
139
|
return f"""<title>Agent Replay Certificate</title>
|
|
@@ -192,7 +212,7 @@ footer{{color:var(--ink-dim);font-size:12px;margin-top:26px;text-align:center;}}
|
|
|
192
212
|
<div class="wrap">
|
|
193
213
|
<header class="top">
|
|
194
214
|
<div class="eyebrow">Vitnify · Execution Certificate</div>
|
|
195
|
-
<h1>
|
|
215
|
+
<h1>{_esc(headline)}</h1>
|
|
196
216
|
<p class="task">Task recorded: “{_esc(run.get('task','(agent run)'))}”</p>
|
|
197
217
|
</header>
|
|
198
218
|
|
|
@@ -0,0 +1,114 @@
|
|
|
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_headline_drops_uncertified_claim():
|
|
84
|
+
run, _ = _build(["allow"], cert_ok=False)
|
|
85
|
+
headline = _headline(render(run))
|
|
86
|
+
assert "cryptographically certified" not in headline
|
|
87
|
+
assert "contained" in headline.lower() # containment still holds
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_viewer_containment_matches_verifier():
|
|
91
|
+
# The viewer and the verifier must never disagree about containment -- they share
|
|
92
|
+
# one predicate (decision_is_gated). Assert the page's warn badge appears iff the
|
|
93
|
+
# verifier reports containment_enforced=False, for both gated and observed runs.
|
|
94
|
+
for decisions in (["allow", "deny"], ["observed"], ["allow"], ["PERMIT"]):
|
|
95
|
+
run, log = _build(decisions)
|
|
96
|
+
enforced = verify_certificate(_recert(run), log)["containment_enforced"]
|
|
97
|
+
warned = "Containment observed — not proven" in render(run)
|
|
98
|
+
assert warned == (not enforced), decisions
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_render_tolerates_missing_digest():
|
|
102
|
+
# render() must not require a `digest` key that cert.to_json() does not emit.
|
|
103
|
+
run, _ = _build(["allow"])
|
|
104
|
+
del run["certificate"]["digest"]
|
|
105
|
+
del run["certificate"]["sig"]
|
|
106
|
+
html = render(run) # must not raise
|
|
107
|
+
assert "—" in html
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _recert(run):
|
|
111
|
+
from vitnify.certificate import ExecutionCertificate
|
|
112
|
+
c = run["certificate"]
|
|
113
|
+
return ExecutionCertificate(c["program_hash"], c["capabilities"], c["event_root"],
|
|
114
|
+
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
|