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.
Files changed (36) hide show
  1. {vitnify-0.2.6/src/vitnify.egg-info → vitnify-0.2.7}/PKG-INFO +1 -1
  2. {vitnify-0.2.6 → vitnify-0.2.7}/pyproject.toml +1 -1
  3. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/certificate.py +14 -1
  4. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/viewer.py +39 -19
  5. {vitnify-0.2.6 → vitnify-0.2.7/src/vitnify.egg-info}/PKG-INFO +1 -1
  6. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify.egg-info/SOURCES.txt +2 -1
  7. vitnify-0.2.7/tests/test_viewer.py +114 -0
  8. {vitnify-0.2.6 → vitnify-0.2.7}/LICENSE +0 -0
  9. {vitnify-0.2.6 → vitnify-0.2.7}/NOTICE +0 -0
  10. {vitnify-0.2.6 → vitnify-0.2.7}/README.md +0 -0
  11. {vitnify-0.2.6 → vitnify-0.2.7}/setup.cfg +0 -0
  12. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/__init__.py +0 -0
  13. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/_vendor/__init__.py +0 -0
  14. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/_vendor/pck/__init__.py +0 -0
  15. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/_vendor/pck/cas.py +0 -0
  16. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/capability.py +0 -0
  17. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/deterministic_llm.py +0 -0
  18. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/engine.py +0 -0
  19. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/events.py +0 -0
  20. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/langgraph_adapter.py +0 -0
  21. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/mcp_adapter.py +0 -0
  22. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/react.py +0 -0
  23. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify/replayer.py +0 -0
  24. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify.egg-info/dependency_links.txt +0 -0
  25. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify.egg-info/requires.txt +0 -0
  26. {vitnify-0.2.6 → vitnify-0.2.7}/src/vitnify.egg-info/top_level.txt +0 -0
  27. {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_adapters.py +0 -0
  28. {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_attack_matrix.py +0 -0
  29. {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_capability.py +0 -0
  30. {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_certificate.py +0 -0
  31. {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_ed25519.py +0 -0
  32. {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_events.py +0 -0
  33. {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_l2_recompute.py +0 -0
  34. {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_receipt_v2.py +0 -0
  35. {vitnify-0.2.6 → vitnify-0.2.7}/tests/test_verification.py +0 -0
  36. {vitnify-0.2.6 → vitnify-0.2.7}/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.7
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.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
- 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,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
- # 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
+ # 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["digest"]),
116
- ("signature (HMAC)", cert["sig"]),
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 &middot; Execution Certificate</div>
195
- <h1>This agent run was contained, replayed bit-for-bit, and cryptographically certified.</h1>
215
+ <h1>{_esc(headline)}</h1>
196
216
  <p class="task">Task recorded: &ldquo;{_esc(run.get('task','(agent run)'))}&rdquo;</p>
197
217
  </header>
198
218
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vitnify
3
- Version: 0.2.6
3
+ Version: 0.2.7
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,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