vitnify 0.2.7__tar.gz → 0.2.9__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.7/src/vitnify.egg-info → vitnify-0.2.9}/PKG-INFO +1 -1
- {vitnify-0.2.7 → vitnify-0.2.9}/pyproject.toml +1 -1
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/certificate.py +6 -2
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/viewer.py +26 -10
- {vitnify-0.2.7 → vitnify-0.2.9/src/vitnify.egg-info}/PKG-INFO +1 -1
- {vitnify-0.2.7 → vitnify-0.2.9}/tests/test_verify_hardening.py +15 -1
- {vitnify-0.2.7 → vitnify-0.2.9}/tests/test_viewer.py +30 -11
- {vitnify-0.2.7 → vitnify-0.2.9}/LICENSE +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/NOTICE +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/README.md +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/setup.cfg +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/__init__.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/_vendor/__init__.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/_vendor/pck/__init__.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/_vendor/pck/cas.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/capability.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/deterministic_llm.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/engine.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/events.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/langgraph_adapter.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/mcp_adapter.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/react.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify/replayer.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify.egg-info/SOURCES.txt +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify.egg-info/dependency_links.txt +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify.egg-info/requires.txt +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/src/vitnify.egg-info/top_level.txt +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/tests/test_adapters.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/tests/test_attack_matrix.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/tests/test_capability.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/tests/test_certificate.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/tests/test_ed25519.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/tests/test_events.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/tests/test_l2_recompute.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/tests/test_receipt_v2.py +0 -0
- {vitnify-0.2.7 → vitnify-0.2.9}/tests/test_verification.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.9"
|
|
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"
|
|
@@ -177,7 +177,11 @@ def verify_certificate(cert: ExecutionCertificate, log: EventLog, key: bytes | N
|
|
|
177
177
|
model_digest through vitni-tensor -- is a separate step that additionally
|
|
178
178
|
needs the model weights.)
|
|
179
179
|
"""
|
|
180
|
-
|
|
180
|
+
# An empty event log has no Merkle commitment, so no legitimately issued receipt
|
|
181
|
+
# carries one (issue_certificate commits >=1 event). A verifier must FAIL CLOSED on
|
|
182
|
+
# such degenerate/hostile input, never crash: compute the root only when there are
|
|
183
|
+
# events and treat its absence as a non-match, instead of letting MerkleCAS raise.
|
|
184
|
+
cas_root = MerkleCAS(log.chunks()).root if log.events else None
|
|
181
185
|
# Every event kind must be recognised. The semantic checks below filter events
|
|
182
186
|
# by an EXACT kind match, so an unknown kind ("TOOL_CALL", "toolcall", ...) would
|
|
183
187
|
# slip past them silently -- the same self-declared-label class as an unknown
|
|
@@ -187,7 +191,7 @@ def verify_certificate(cert: ExecutionCertificate, log: EventLog, key: bytes | N
|
|
|
187
191
|
checks = {
|
|
188
192
|
"format": cert.v in SUPPORTED_FORMATS,
|
|
189
193
|
"kinds_known": all(e.kind in known_kinds for e in log.events),
|
|
190
|
-
"root_matches":
|
|
194
|
+
"root_matches": cas_root is not None and cas_root == cert.event_root,
|
|
191
195
|
"head_matches": log.head() == cert.head_hash,
|
|
192
196
|
"count_matches": len(log) == cert.n_events,
|
|
193
197
|
"model_digests_match": log.model_digests() == list(cert.model_digests),
|
|
@@ -89,31 +89,46 @@ def render(run: dict) -> str:
|
|
|
89
89
|
def warn_badge(text):
|
|
90
90
|
return f'<div class="badge warn"><span class="dot"></span>{text}</div>'
|
|
91
91
|
|
|
92
|
+
def neutral_badge(text):
|
|
93
|
+
return f'<div class="badge neutral"><span class="dot"></span>{text}</div>'
|
|
94
|
+
|
|
92
95
|
# Every containment claim on this page is DERIVED from the events with the SAME rule
|
|
93
96
|
# the verifier uses (decision_is_gated) -- never taken from the caller's verdict dict.
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
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.
|
|
97
107
|
tool_events = [e for e in events if e["kind"] == "tool_call"]
|
|
98
|
-
|
|
108
|
+
all_gated = all(decision_is_gated(e["payload"].get("decision")) for e in tool_events)
|
|
99
109
|
blocked = any(str(e["payload"].get("decision", "")).strip().lower() == "deny" for e in tool_events)
|
|
100
110
|
cert_ok = bool(v.get("cert_ok"))
|
|
101
111
|
replay_ok = bool(v.get("replay_identical"))
|
|
112
|
+
containment_proven = cert_ok and bool(tool_events) and all_gated
|
|
102
113
|
|
|
103
|
-
if not
|
|
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:
|
|
104
119
|
containment_badge = warn_badge("Containment observed — not proven")
|
|
105
120
|
elif blocked:
|
|
106
|
-
containment_badge = badge(True, "Injection contained", "")
|
|
121
|
+
containment_badge = badge(True, "Injection contained", "") # an ungranted call was refused
|
|
107
122
|
else:
|
|
108
|
-
containment_badge = badge(True, "Containment enforced", "")
|
|
123
|
+
containment_badge = badge(True, "Containment enforced", "") # every call gated; none needed blocking
|
|
109
124
|
|
|
110
125
|
badges = (containment_badge
|
|
111
126
|
+ badge(replay_ok, "Replay bit-identical", "Replay diverged")
|
|
112
127
|
+ badge(cert_ok, "Certificate verified", "Certificate FAILED"))
|
|
113
128
|
|
|
114
|
-
# Headline asserts ONLY what
|
|
115
|
-
#
|
|
116
|
-
claims = (["contained"] if
|
|
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 []) \
|
|
117
132
|
+ (["replayed bit-for-bit"] if replay_ok else []) \
|
|
118
133
|
+ (["cryptographically certified"] if cert_ok else [])
|
|
119
134
|
if not claims:
|
|
@@ -170,6 +185,7 @@ h1{{font-size:27px;margin:0;letter-spacing:-.01em;text-wrap:balance;}}
|
|
|
170
185
|
.badge.good{{color:var(--ok);}} .badge.good .dot{{background:var(--ok);}}
|
|
171
186
|
.badge.bad{{color:var(--tamper);}} .badge.bad .dot{{background:var(--tamper);}}
|
|
172
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);}}
|
|
173
189
|
section{{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:22px 22px;margin:16px 0;box-shadow:var(--shadow);}}
|
|
174
190
|
.sec-h{{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin:0 0 14px;}}
|
|
175
191
|
.sec-h h2{{font-size:13px;letter-spacing:.09em;text-transform:uppercase;color:var(--ink-dim);margin:0;font-weight:700;}}
|
|
@@ -11,7 +11,8 @@ verification-bypass that must stay closed:
|
|
|
11
11
|
F5 optional key pinning rejects an unpinned (but otherwise valid) signer
|
|
12
12
|
"""
|
|
13
13
|
from vitnify.events import EventLog, Kind
|
|
14
|
-
from vitnify.certificate import
|
|
14
|
+
from vitnify.certificate import (
|
|
15
|
+
ExecutionCertificate, issue_certificate, verify_certificate, gen_ed25519)
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def _log(tool, decision):
|
|
@@ -140,3 +141,16 @@ def test_observe_only_receipt_is_flagged_not_masqueraded():
|
|
|
140
141
|
assert receipt("OBSERVED", ["read_docs"])["ok"] is False # observed ungranted tool ran -> rejected
|
|
141
142
|
r = receipt("ALLOW", ["read_docs", "send_email"]) # enforced allow -> real proof
|
|
142
143
|
assert r["ok"] is True and r["containment_enforced"] is True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_empty_log_fails_closed_not_raises():
|
|
147
|
+
# A verifier must never CRASH on degenerate/hostile input. An empty event log has no
|
|
148
|
+
# Merkle commitment (no legitimately issued receipt has one), so verification must
|
|
149
|
+
# return ok=False -- not raise ValueError from MerkleCAS on the empty chunk list.
|
|
150
|
+
priv, _ = gen_ed25519()
|
|
151
|
+
log = EventLog() # zero events
|
|
152
|
+
# A signed certificate that merely CLAIMS an empty run must still not verify.
|
|
153
|
+
cert = ExecutionCertificate("prog", [], "0" * 64, 0, "genesis").sign_ed25519(priv)
|
|
154
|
+
checks = verify_certificate(cert, log) # must not raise
|
|
155
|
+
assert checks["ok"] is False
|
|
156
|
+
assert checks["root_matches"] is False
|
|
@@ -80,22 +80,41 @@ def test_enforced_all_allow_claims_containment():
|
|
|
80
80
|
assert "Containment observed" not in html
|
|
81
81
|
|
|
82
82
|
|
|
83
|
-
def
|
|
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.
|
|
84
87
|
run, _ = _build(["allow"], cert_ok=False)
|
|
85
|
-
|
|
88
|
+
html = render(run)
|
|
89
|
+
headline = _headline(html)
|
|
86
90
|
assert "cryptographically certified" not in headline
|
|
87
|
-
assert "contained" in headline.lower()
|
|
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))
|
|
88
95
|
|
|
89
96
|
|
|
90
|
-
def
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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()
|
|
96
115
|
enforced = verify_certificate(_recert(run), log)["containment_enforced"]
|
|
97
|
-
|
|
98
|
-
|
|
116
|
+
if says_contained:
|
|
117
|
+
assert cert_ok and bool(decisions) and enforced, (decisions, cert_ok)
|
|
99
118
|
|
|
100
119
|
|
|
101
120
|
def test_render_tolerates_missing_digest():
|
|
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
|