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.
Files changed (36) hide show
  1. {vitnify-0.2.6/src/vitnify.egg-info → vitnify-0.2.8}/PKG-INFO +1 -1
  2. {vitnify-0.2.6 → vitnify-0.2.8}/pyproject.toml +1 -1
  3. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/certificate.py +14 -1
  4. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/viewer.py +55 -19
  5. {vitnify-0.2.6 → vitnify-0.2.8/src/vitnify.egg-info}/PKG-INFO +1 -1
  6. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify.egg-info/SOURCES.txt +2 -1
  7. vitnify-0.2.8/tests/test_viewer.py +133 -0
  8. {vitnify-0.2.6 → vitnify-0.2.8}/LICENSE +0 -0
  9. {vitnify-0.2.6 → vitnify-0.2.8}/NOTICE +0 -0
  10. {vitnify-0.2.6 → vitnify-0.2.8}/README.md +0 -0
  11. {vitnify-0.2.6 → vitnify-0.2.8}/setup.cfg +0 -0
  12. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/__init__.py +0 -0
  13. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/_vendor/__init__.py +0 -0
  14. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/_vendor/pck/__init__.py +0 -0
  15. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/_vendor/pck/cas.py +0 -0
  16. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/capability.py +0 -0
  17. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/deterministic_llm.py +0 -0
  18. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/engine.py +0 -0
  19. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/events.py +0 -0
  20. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/langgraph_adapter.py +0 -0
  21. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/mcp_adapter.py +0 -0
  22. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/react.py +0 -0
  23. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify/replayer.py +0 -0
  24. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify.egg-info/dependency_links.txt +0 -0
  25. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify.egg-info/requires.txt +0 -0
  26. {vitnify-0.2.6 → vitnify-0.2.8}/src/vitnify.egg-info/top_level.txt +0 -0
  27. {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_adapters.py +0 -0
  28. {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_attack_matrix.py +0 -0
  29. {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_capability.py +0 -0
  30. {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_certificate.py +0 -0
  31. {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_ed25519.py +0 -0
  32. {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_events.py +0 -0
  33. {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_l2_recompute.py +0 -0
  34. {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_receipt_v2.py +0 -0
  35. {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_verification.py +0 -0
  36. {vitnify-0.2.6 → vitnify-0.2.8}/tests/test_verify_hardening.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vitnify
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: Execution receipts for AI agents: contain, deterministically reconstruct, and cryptographically certify a run.
5
5
  Author: vitnify
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vitnify"
7
- version = "0.2.6"
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
- str(e.payload.get("decision", "")).strip().lower() in ("allow", "deny")
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
- # Whether the receipt PROVES containment, derived from the events the same way the
92
- # verifier does: every tool decision must be a gated allow/deny. An observe-only
93
- # run (a watching callback that records but does not gate) is a valid transcript but
94
- # proves no containment -- it must NOT render as a plain green "verified" receipt,
95
- # or a non-engineer is handed a badge that claims more than the receipt shows.
96
- containment_enforced = all(
97
- str(e["payload"].get("decision", "")).strip().lower() in ("allow", "deny")
98
- for e in events if e["kind"] == "tool_call")
99
- containment_badge = (badge(True, "Containment enforced", "")
100
- if containment_enforced
101
- else warn_badge("Containment observed — not proven"))
102
-
103
- badges = (badge(v["contained"], "Injection contained", "NOT contained")
104
- + badge(v["replay_identical"], "Replay bit-identical", "Replay diverged")
105
- + badge(v["cert_ok"], "Certificate verified", "Certificate FAILED")
106
- + containment_badge)
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["digest"]),
116
- ("signature (HMAC)", cert["sig"]),
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 &middot; Execution Certificate</div>
195
- <h1>This agent run was contained, replayed bit-for-bit, and cryptographically certified.</h1>
231
+ <h1>{_esc(headline)}</h1>
196
232
  <p class="task">Task recorded: &ldquo;{_esc(run.get('task','(agent run)'))}&rdquo;</p>
197
233
  </header>
198
234
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vitnify
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: Execution receipts for AI agents: contain, deterministically reconstruct, and cryptographically certify a run.
5
5
  Author: vitnify
6
6
  License: Apache-2.0
@@ -30,4 +30,5 @@ tests/test_events.py
30
30
  tests/test_l2_recompute.py
31
31
  tests/test_receipt_v2.py
32
32
  tests/test_verification.py
33
- tests/test_verify_hardening.py
33
+ tests/test_verify_hardening.py
34
+ tests/test_viewer.py
@@ -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