vitnify 0.2.4__tar.gz → 0.2.6__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 (35) hide show
  1. {vitnify-0.2.4/src/vitnify.egg-info → vitnify-0.2.6}/PKG-INFO +8 -3
  2. {vitnify-0.2.4 → vitnify-0.2.6}/README.md +7 -2
  3. {vitnify-0.2.4 → vitnify-0.2.6}/pyproject.toml +1 -1
  4. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/_vendor/pck/cas.py +15 -15
  5. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/certificate.py +34 -15
  6. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/engine.py +6 -8
  7. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/events.py +8 -8
  8. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/viewer.py +22 -5
  9. {vitnify-0.2.4 → vitnify-0.2.6/src/vitnify.egg-info}/PKG-INFO +8 -3
  10. {vitnify-0.2.4 → vitnify-0.2.6}/tests/test_verify_hardening.py +20 -0
  11. {vitnify-0.2.4 → vitnify-0.2.6}/LICENSE +0 -0
  12. {vitnify-0.2.4 → vitnify-0.2.6}/NOTICE +0 -0
  13. {vitnify-0.2.4 → vitnify-0.2.6}/setup.cfg +0 -0
  14. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/__init__.py +0 -0
  15. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/_vendor/__init__.py +0 -0
  16. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/_vendor/pck/__init__.py +0 -0
  17. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/capability.py +0 -0
  18. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/deterministic_llm.py +0 -0
  19. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/langgraph_adapter.py +0 -0
  20. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/mcp_adapter.py +0 -0
  21. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/react.py +0 -0
  22. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify/replayer.py +0 -0
  23. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify.egg-info/SOURCES.txt +0 -0
  24. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify.egg-info/dependency_links.txt +0 -0
  25. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify.egg-info/requires.txt +0 -0
  26. {vitnify-0.2.4 → vitnify-0.2.6}/src/vitnify.egg-info/top_level.txt +0 -0
  27. {vitnify-0.2.4 → vitnify-0.2.6}/tests/test_adapters.py +0 -0
  28. {vitnify-0.2.4 → vitnify-0.2.6}/tests/test_attack_matrix.py +0 -0
  29. {vitnify-0.2.4 → vitnify-0.2.6}/tests/test_capability.py +0 -0
  30. {vitnify-0.2.4 → vitnify-0.2.6}/tests/test_certificate.py +0 -0
  31. {vitnify-0.2.4 → vitnify-0.2.6}/tests/test_ed25519.py +0 -0
  32. {vitnify-0.2.4 → vitnify-0.2.6}/tests/test_events.py +0 -0
  33. {vitnify-0.2.4 → vitnify-0.2.6}/tests/test_l2_recompute.py +0 -0
  34. {vitnify-0.2.4 → vitnify-0.2.6}/tests/test_receipt_v2.py +0 -0
  35. {vitnify-0.2.4 → vitnify-0.2.6}/tests/test_verification.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vitnify
3
- Version: 0.2.4
3
+ Version: 0.2.6
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
@@ -64,8 +64,13 @@ log.append(Kind.TOOL_CALL, {"tool": "send_email", "decision": "deny"}) # ungran
64
64
  priv, pub = gen_ed25519()
65
65
  cert, _ = issue_certificate("program_hash", ["read_docs"], log, priv=priv)
66
66
 
67
- verify_certificate(cert, log) # level 1: offline integrity — no model, no secret
68
- # level 2: re-run each step through the engine; every model_digest reproduces bit-for-bit
67
+ checks = verify_certificate(cert, log) # level 1: offline integrity — no model, no secret
68
+ assert checks["ok"] # signed, unaltered, and no ungranted tool ran
69
+ assert checks["containment_enforced"] # every tool call was GATED, not merely observed
70
+ # A receipt can be ok=True yet containment_enforced=False — a valid transcript from a
71
+ # watch-only integration proves what ran, not that anything was contained. A containment
72
+ # claim requires BOTH. (level 2: re-run each step through the engine; every model_digest
73
+ # reproduces bit-for-bit.)
69
74
  ```
70
75
 
71
76
  See [`vitnify-receipt-v2.md`](vitnify-receipt-v2.md) for the receipt format, and
@@ -38,8 +38,13 @@ log.append(Kind.TOOL_CALL, {"tool": "send_email", "decision": "deny"}) # ungran
38
38
  priv, pub = gen_ed25519()
39
39
  cert, _ = issue_certificate("program_hash", ["read_docs"], log, priv=priv)
40
40
 
41
- verify_certificate(cert, log) # level 1: offline integrity — no model, no secret
42
- # level 2: re-run each step through the engine; every model_digest reproduces bit-for-bit
41
+ checks = verify_certificate(cert, log) # level 1: offline integrity — no model, no secret
42
+ assert checks["ok"] # signed, unaltered, and no ungranted tool ran
43
+ assert checks["containment_enforced"] # every tool call was GATED, not merely observed
44
+ # A receipt can be ok=True yet containment_enforced=False — a valid transcript from a
45
+ # watch-only integration proves what ran, not that anything was contained. A containment
46
+ # claim requires BOTH. (level 2: re-run each step through the engine; every model_digest
47
+ # reproduces bit-for-bit.)
43
48
  ```
44
49
 
45
50
  See [`vitnify-receipt-v2.md`](vitnify-receipt-v2.md) for the receipt format, and
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vitnify"
7
- version = "0.2.4"
7
+ version = "0.2.6"
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"
@@ -13,29 +13,24 @@ root. The guarantees this buys, and why they matter for AI answers:
13
13
  hallucination: a grounded claim points at a chunk with a valid proof; an invented
14
14
  one cannot.
15
15
 
16
- This mirrors the BLAKE3 + Merkle design of a native runtime's ``cas_store`` (whose real
17
- Rust code is exercised in ``research/pck_provenance/``); here we keep a dependency-light
18
- pure-Python implementation that uses ``blake3`` when available and falls back to
19
- ``hashlib.blake2b`` otherwise. The wire format of a proof is identical either way.
16
+ This mirrors the BLAKE3 + Merkle design of a native runtime's ``cas_store``; here we
17
+ keep a dependency-light pure-Python implementation over ``blake3``. BLAKE3 is a hard
18
+ dependency and DEFINES the wire format: a ``blake2b`` digest is a different value under
19
+ the same ``hash_name``, so there is NO silent stdlib fallback -- one would make honest
20
+ proofs indistinguishable from forgeries. A missing blake3 fails loudly (matching
21
+ ``events.py`` / ``certificate.py``), never silently downgrades.
20
22
  """
21
23
  from __future__ import annotations
22
24
 
23
- import hashlib
24
25
  from dataclasses import dataclass, field
25
26
  from typing import Optional
26
27
 
27
- try: # BLAKE3 if the user has it (matches the substrate); else a stdlib fallback.
28
- import blake3 as _blake3 # type: ignore
28
+ import blake3 as _blake3
29
29
 
30
- _HASH_NAME = "blake3"
30
+ _HASH_NAME = "blake3"
31
31
 
32
- def _digest(b: bytes) -> str:
33
- return _blake3.blake3(b).hexdigest()
34
- except Exception: # pragma: no cover - depends on environment
35
- _HASH_NAME = "blake2b-256"
36
-
37
- def _digest(b: bytes) -> str:
38
- return hashlib.blake2b(b, digest_size=32).hexdigest()
32
+ def _digest(b: bytes) -> str:
33
+ return _blake3.blake3(b).hexdigest()
39
34
 
40
35
 
41
36
  HASH_NAME = _HASH_NAME
@@ -87,6 +82,11 @@ class InclusionProof:
87
82
  hash_name: str = HASH_NAME
88
83
 
89
84
  def verify(self, root: str) -> bool:
85
+ # A proof carries the hash suite it was built under. This module only speaks
86
+ # BLAKE3, so a proof committed under any other suite cannot be recomputed here
87
+ # -- reject rather than silently recompute under the wrong hash.
88
+ if self.hash_name != HASH_NAME:
89
+ return False
90
90
  return verify_proof(self.leaf, self.path, root)
91
91
 
92
92
  def to_json(self) -> dict:
@@ -41,17 +41,16 @@ def _now_iso() -> str:
41
41
  from datetime import datetime, timezone
42
42
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
43
43
 
44
- try:
45
- import blake3 as _blake3
46
- def _digest32(b: bytes) -> str:
47
- return _blake3.blake3(b).hexdigest()
48
- except ImportError:
49
- import hashlib
50
- def _digest32(b: bytes) -> str:
51
- return hashlib.blake2b(b, digest_size=32).hexdigest()
44
+ # BLAKE3 is a hard dependency and DEFINES the wire format: a blake2b digest is a
45
+ # different value, so there is no silent stdlib fallback (see events.py). A missing
46
+ # blake3 fails loudly rather than producing incompatible digests.
47
+ import blake3 as _blake3
48
+ def _digest32(b: bytes) -> str:
49
+ return _blake3.blake3(b).hexdigest()
52
50
 
53
- # cryptography is imported lazily inside the ed25519 helpers so the fallback path
54
- # still runs on a stock Python without the dependency.
51
+ # `cryptography` is imported lazily inside the ed25519 helpers, so HMAC-only signing
52
+ # works without it installed. (blake3, above, is a hard dependency -- there is no
53
+ # hash-free path; the module will not import without it.)
55
54
 
56
55
 
57
56
  def _canon(o) -> str:
@@ -121,10 +120,18 @@ def issue_certificate(program_hash, capabilities, log: EventLog, priv=None, key:
121
120
  """Build and sign a vitnify-receipt for a completed run.
122
121
 
123
122
  The model-computation digests are read from the log's llm_call events, so the
124
- receipt binds exactly the model steps that were recorded. Each receipt is
125
- stamped with an issuer-asserted UTC time, a random nonce, and a run id (pass
126
- `run_id` to set your own; a random one is used otherwise) so distinct runs
127
- produce distinct, time-placeable receipts. ed25519 (self-verifying) is the
123
+ receipt binds exactly the model steps that were recorded. `model_digests` is
124
+ EMPTY for a run with no recomputable model step (a hosted API, or a torch /
125
+ non-deterministic backend) -- such a receipt is integrity-only: it binds the
126
+ transcript, not the computation.
127
+
128
+ `program_hash` is CALLER-ASSERTED -- the receipt binds whatever string you pass;
129
+ it does not itself hash your agent's code. Pass a hash of the exact program or
130
+ config that ran if you want the receipt to pin it.
131
+
132
+ Each receipt is stamped with an issuer-asserted UTC time, a random nonce, and a
133
+ run id (pass `run_id` to set your own; a random one is used otherwise) so distinct
134
+ runs produce distinct, time-placeable receipts. ed25519 (self-verifying) is the
128
135
  canonical signing path; pass `priv`.
129
136
  """
130
137
  cas = MerkleCAS(log.chunks())
@@ -224,5 +231,17 @@ def verify_certificate(cert: ExecutionCertificate, log: EventLog, key: bytes | N
224
231
  checks["signer_pinned"] = (
225
232
  sig_valid and cert.sig_alg == "ed25519" and cert.pubkey in set(pinned_pubkeys))
226
233
 
227
- checks["ok"] = all(checks.values())
234
+ # Enforcement vs observation. An "allow"/"deny" decision came from the capability
235
+ # wall -- the call was GATED. Any other value (e.g. "observed" from the record-only
236
+ # callback adapter) means it was WATCHED, not enforced, so the receipt is a
237
+ # tamper-evident transcript, not proof containment was applied -- the containment
238
+ # analogue of an empty model_digests. Reported separately, NOT folded into ok, so a
239
+ # valid transcript stays valid while a containment PROOF requires ok AND this True.
240
+ checks["containment_enforced"] = all(
241
+ str(e.payload.get("decision", "")).strip().lower() in ("allow", "deny")
242
+ for e in log.events
243
+ if e.kind == Kind.TOOL_CALL.value
244
+ )
245
+
246
+ checks["ok"] = all(v for k, v in checks.items() if k != "containment_enforced")
228
247
  return checks
@@ -10,14 +10,12 @@ The engine is the `vitni-receipt` binary (built from the vitni-tensor crate). Po
10
10
  from __future__ import annotations
11
11
  import os, json, subprocess
12
12
 
13
- try:
14
- import blake3 as _blake3
15
- def _h32(b: bytes) -> str:
16
- return _blake3.blake3(b).hexdigest()
17
- except ImportError:
18
- import hashlib
19
- def _h32(b: bytes) -> str:
20
- return hashlib.blake2b(b, digest_size=32).hexdigest()
13
+ # BLAKE3 is a hard dependency and DEFINES the wire format (see events.py): the prompt
14
+ # hash bound into a receipt must be blake3, so there is no silent blake2b fallback -- a
15
+ # missing blake3 fails loudly rather than binding an incompatible digest.
16
+ import blake3 as _blake3
17
+ def _h32(b: bytes) -> str:
18
+ return _blake3.blake3(b).hexdigest()
21
19
 
22
20
  DEFAULT_BIN = os.environ.get("VITNI_RECEIPT_BIN", "vitni-receipt")
23
21
 
@@ -13,14 +13,14 @@ import json
13
13
  from dataclasses import dataclass, asdict
14
14
  from enum import Enum
15
15
 
16
- try:
17
- import blake3 as _blake3
18
- def _h32(b: bytes) -> str:
19
- return _blake3.blake3(b).hexdigest()
20
- except ImportError: # stdlib fallback; wire format is identical either way
21
- import hashlib
22
- def _h32(b: bytes) -> str:
23
- return hashlib.blake2b(b, digest_size=32).hexdigest()
16
+ # BLAKE3 is a hard dependency and DEFINES the wire format: a blake2b digest is a
17
+ # different value, so there is no silent stdlib fallback -- one would make honest
18
+ # receipts indistinguishable from forgeries across implementations (and the receipt
19
+ # carries no hash-suite identifier to tell them apart). A normal `pip install
20
+ # vitnify` always resolves the wheel; a missing blake3 fails loudly, not silently.
21
+ import blake3 as _blake3
22
+ def _h32(b: bytes) -> str:
23
+ return _blake3.blake3(b).hexdigest()
24
24
 
25
25
 
26
26
  def canon(obj) -> str:
@@ -85,9 +85,25 @@ def render(run: dict) -> str:
85
85
  def badge(ok, yes, no):
86
86
  return f'<div class="badge {"good" if ok else "bad"}"><span class="dot"></span>{yes if ok else no}</div>'
87
87
 
88
+ def warn_badge(text):
89
+ return f'<div class="badge warn"><span class="dot"></span>{text}</div>'
90
+
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
+
88
103
  badges = (badge(v["contained"], "Injection contained", "NOT contained")
89
104
  + badge(v["replay_identical"], "Replay bit-identical", "Replay diverged")
90
- + badge(v["cert_ok"], "Certificate verified", "Certificate FAILED"))
105
+ + badge(v["cert_ok"], "Certificate verified", "Certificate FAILED")
106
+ + containment_badge)
91
107
 
92
108
  cert_rows = "".join(
93
109
  f'<div class="crow"><span class="ck">{k}</span><span class="cv mono">{_esc(val)}</span></div>'
@@ -105,17 +121,17 @@ def render(run: dict) -> str:
105
121
  :root {{
106
122
  --bg:#FBFBFD; --surface:#FFFFFF; --surface-2:#F4F5F8; --ink:#1b1f27; --ink-dim:#5a6270;
107
123
  --border:#E4E7ED; --accent:#0FB5A6; --ok:#1F9D63; --ok-bg:#E7F5EE; --deny:#D6532E; --deny-bg:#FBECE6;
108
- --tamper:#E5484D; --mono-bg:#F2F4F7; --shadow:0 1px 2px rgba(20,26,40,.05),0 8px 24px rgba(20,26,40,.05);
124
+ --warn:#B7791F; --tamper:#E5484D; --mono-bg:#F2F4F7; --shadow:0 1px 2px rgba(20,26,40,.05),0 8px 24px rgba(20,26,40,.05);
109
125
  }}
110
126
  @media (prefers-color-scheme:dark){{ :root:not([data-theme="light"]){{
111
127
  --bg:#0E1116; --surface:#161A21; --surface-2:#1C222B; --ink:#E6E9EF; --ink-dim:#98A2B3;
112
128
  --border:#262D38; --accent:#25C6B6; --ok:#3FBE7C; --ok-bg:#12291E; --deny:#EE7A54; --deny-bg:#2A1811;
113
- --tamper:#F2585B; --mono-bg:#12161C; --shadow:0 1px 2px rgba(0,0,0,.4),0 12px 32px rgba(0,0,0,.35);
129
+ --warn:#E0A93B; --tamper:#F2585B; --mono-bg:#12161C; --shadow:0 1px 2px rgba(0,0,0,.4),0 12px 32px rgba(0,0,0,.35);
114
130
  }} }}
115
131
  :root[data-theme="dark"] {{
116
132
  --bg:#0E1116; --surface:#161A21; --surface-2:#1C222B; --ink:#E6E9EF; --ink-dim:#98A2B3;
117
133
  --border:#262D38; --accent:#25C6B6; --ok:#3FBE7C; --ok-bg:#12291E; --deny:#EE7A54; --deny-bg:#2A1811;
118
- --tamper:#F2585B; --mono-bg:#12161C; --shadow:0 1px 2px rgba(0,0,0,.4),0 12px 32px rgba(0,0,0,.35);
134
+ --warn:#E0A93B; --tamper:#F2585B; --mono-bg:#12161C; --shadow:0 1px 2px rgba(0,0,0,.4),0 12px 32px rgba(0,0,0,.35);
119
135
  }}
120
136
  *{{box-sizing:border-box}}
121
137
  body{{margin:0;background:var(--bg);color:var(--ink);
@@ -133,6 +149,7 @@ h1{{font-size:27px;margin:0;letter-spacing:-.01em;text-wrap:balance;}}
133
149
  .badge .dot{{width:8px;height:8px;border-radius:50%;}}
134
150
  .badge.good{{color:var(--ok);}} .badge.good .dot{{background:var(--ok);}}
135
151
  .badge.bad{{color:var(--tamper);}} .badge.bad .dot{{background:var(--tamper);}}
152
+ .badge.warn{{color:var(--warn);}} .badge.warn .dot{{background:var(--warn);}}
136
153
  section{{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:22px 22px;margin:16px 0;box-shadow:var(--shadow);}}
137
154
  .sec-h{{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin:0 0 14px;}}
138
155
  .sec-h h2{{font-size:13px;letter-spacing:.09em;text-transform:uppercase;color:var(--ink-dim);margin:0;font-weight:700;}}
@@ -174,7 +191,7 @@ footer{{color:var(--ink-dim);font-size:12px;margin-top:26px;text-align:center;}}
174
191
 
175
192
  <div class="wrap">
176
193
  <header class="top">
177
- <div class="eyebrow">VitniReplay &middot; Execution Certificate</div>
194
+ <div class="eyebrow">Vitnify &middot; Execution Certificate</div>
178
195
  <h1>This agent run was contained, replayed bit-for-bit, and cryptographically certified.</h1>
179
196
  <p class="task">Task recorded: &ldquo;{_esc(run.get('task','(agent run)'))}&rdquo;</p>
180
197
  </header>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vitnify
3
- Version: 0.2.4
3
+ Version: 0.2.6
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
@@ -64,8 +64,13 @@ log.append(Kind.TOOL_CALL, {"tool": "send_email", "decision": "deny"}) # ungran
64
64
  priv, pub = gen_ed25519()
65
65
  cert, _ = issue_certificate("program_hash", ["read_docs"], log, priv=priv)
66
66
 
67
- verify_certificate(cert, log) # level 1: offline integrity — no model, no secret
68
- # level 2: re-run each step through the engine; every model_digest reproduces bit-for-bit
67
+ checks = verify_certificate(cert, log) # level 1: offline integrity — no model, no secret
68
+ assert checks["ok"] # signed, unaltered, and no ungranted tool ran
69
+ assert checks["containment_enforced"] # every tool call was GATED, not merely observed
70
+ # A receipt can be ok=True yet containment_enforced=False — a valid transcript from a
71
+ # watch-only integration proves what ran, not that anything was contained. A containment
72
+ # claim requires BOTH. (level 2: re-run each step through the engine; every model_digest
73
+ # reproduces bit-for-bit.)
69
74
  ```
70
75
 
71
76
  See [`vitnify-receipt-v2.md`](vitnify-receipt-v2.md) for the receipt format, and
@@ -120,3 +120,23 @@ def test_f12_unknown_event_kind_is_rejected():
120
120
  log.append(Kind.AGENT_STEP, {"state": "done"})
121
121
  cert, _ = issue_certificate("prog", ["read_docs"], log, priv=priv)
122
122
  assert verify_certificate(cert, log)["ok"] is True
123
+
124
+
125
+ def test_observe_only_receipt_is_flagged_not_masqueraded():
126
+ # A record-only adapter records tool calls with decision "observed" -- watched,
127
+ # not gated. Such a receipt is a valid transcript (ok=True) but must be FLAGGED
128
+ # containment_enforced=False so it can't pass as a containment proof; an enforced
129
+ # allow/deny receipt reads containment_enforced=True.
130
+ priv, _ = gen_ed25519()
131
+
132
+ def receipt(decision, caps):
133
+ log = EventLog()
134
+ log.append(Kind.TOOL_CALL, {"tool": "send_email", "args": ["x"], "decision": decision, "result": "sent"})
135
+ cert, _ = issue_certificate("prog", caps, log, priv=priv)
136
+ return verify_certificate(cert, log)
137
+
138
+ r = receipt("OBSERVED", ["read_docs", "send_email"]) # observed + granted
139
+ assert r["ok"] is True and r["containment_enforced"] is False
140
+ assert receipt("OBSERVED", ["read_docs"])["ok"] is False # observed ungranted tool ran -> rejected
141
+ r = receipt("ALLOW", ["read_docs", "send_email"]) # enforced allow -> real proof
142
+ assert r["ok"] is True and r["containment_enforced"] is True
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