dcl-core 0.3.0__tar.gz → 0.3.2__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 (29) hide show
  1. dcl_core-0.3.2/PKG-INFO +297 -0
  2. dcl_core-0.3.2/README.md +270 -0
  3. dcl_core-0.3.2/dcl/__init__.py +18 -0
  4. dcl_core-0.3.2/dcl/guard.py +512 -0
  5. dcl_core-0.3.2/dcl/sandbox.py +149 -0
  6. dcl_core-0.3.2/dcl/signed_envelope.py +278 -0
  7. {dcl_core-0.3.0 → dcl_core-0.3.2}/dcl_core/__init__.py +1 -1
  8. {dcl_core-0.3.0 → dcl_core-0.3.2}/dcl_core/chain.py +255 -242
  9. dcl_core-0.3.2/dcl_core.egg-info/PKG-INFO +297 -0
  10. {dcl_core-0.3.0 → dcl_core-0.3.2}/dcl_core.egg-info/SOURCES.txt +8 -0
  11. dcl_core-0.3.2/dcl_core.egg-info/requires.txt +7 -0
  12. {dcl_core-0.3.0 → dcl_core-0.3.2}/dcl_core.egg-info/top_level.txt +1 -0
  13. {dcl_core-0.3.0 → dcl_core-0.3.2}/pyproject.toml +36 -33
  14. dcl_core-0.3.2/tests/test_dcl_guard.py +867 -0
  15. dcl_core-0.3.2/tests/test_production_web2_proof.py +816 -0
  16. dcl_core-0.3.2/tests/test_signed_oracle_envelope_guard.py +321 -0
  17. dcl_core-0.3.2/tests/test_signed_oracle_envelope_vector.py +160 -0
  18. {dcl_core-0.3.0 → dcl_core-0.3.2}/tests/test_verify_matches_chain_state.py +101 -98
  19. dcl_core-0.3.0/PKG-INFO +0 -113
  20. dcl_core-0.3.0/README.md +0 -90
  21. dcl_core-0.3.0/dcl_core.egg-info/PKG-INFO +0 -113
  22. dcl_core-0.3.0/dcl_core.egg-info/requires.txt +0 -3
  23. {dcl_core-0.3.0 → dcl_core-0.3.2}/LICENSE +0 -0
  24. {dcl_core-0.3.0 → dcl_core-0.3.2}/dcl_core/consensus.py +0 -0
  25. {dcl_core-0.3.0 → dcl_core-0.3.2}/dcl_core/seal.py +0 -0
  26. {dcl_core-0.3.0 → dcl_core-0.3.2}/dcl_core/verify.py +0 -0
  27. {dcl_core-0.3.0 → dcl_core-0.3.2}/dcl_core.egg-info/dependency_links.txt +0 -0
  28. {dcl_core-0.3.0 → dcl_core-0.3.2}/setup.cfg +0 -0
  29. {dcl_core-0.3.0 → dcl_core-0.3.2}/tests/test_fixes.py +0 -0
@@ -0,0 +1,297 @@
1
+ Metadata-Version: 2.4
2
+ Name: dcl-core
3
+ Version: 0.3.2
4
+ Summary: Tamper-evident record chain, offline chain verification, and multi-party consensus primitives for AI agent audit systems (DCL / Leibniz Layer protocol).
5
+ Author: Fronesis Labs
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://fronesislabs.com
8
+ Project-URL: MCP, https://mcp.fronesislabs.com/mcp
9
+ Project-URL: Repository, https://github.com/Fronesis-Labs/dcl-core
10
+ Project-URL: Protocol, https://github.com/Fronesis-Labs/dcl-sdk/blob/main/PROTOCOL.md
11
+ Keywords: mcp,model-context-protocol,audit,hash-chain,ai-agents,x402
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Security :: Cryptography
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: cryptography>=41
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0; extra == "dev"
24
+ Provides-Extra: proof
25
+ Requires-Dist: x402[evm,requests]>=2.25.0; extra == "proof"
26
+ Dynamic: license-file
27
+
28
+ # DCL Core
29
+
30
+ [![PyPI version](https://img.shields.io/pypi/v/dcl-core)](https://pypi.org/project/dcl-core/)
31
+ [![Python versions](https://img.shields.io/pypi/pyversions/dcl-core)](https://pypi.org/project/dcl-core/)
32
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/Fronesis-Labs/dcl-core/blob/main/LICENSE)
33
+
34
+ **Don't trust the agent. Trust the proof.**
35
+
36
+ Python reference implementation of the tamper-evident record chain and
37
+ multi-party consensus primitives behind DCL — the audit layer for
38
+ autonomous AI agent decisions. Given a chain record, `dcl-core` recomputes
39
+ its hash locally and tells you if it was tampered with. No API key, no
40
+ network call, no trust placed in whoever is showing you the record.
41
+ That claim is `verify_chain()` on a full chain record. The Web2 proof
42
+ later in this document only confirms the sequence this client observed.
43
+
44
+ Part of the Deterministic Commitment Layer / Leibniz Layer™ ecosystem by
45
+ Fronesis Labs.
46
+
47
+ ## Why This Package Is Free
48
+
49
+ Verifying that an audit record hasn't been altered is a different problem
50
+ from generating the verdict in the first place — and it's the one part of
51
+ an audit system that *must* be independently checkable, or the audit trail
52
+ isn't really independent. If you can only verify a record by paying the
53
+ same party who might have edited it, that's not verification, it's trust
54
+ with extra steps.
55
+
56
+ So the protocol itself is open, Apache-2.0, and has no server dependency
57
+ for its core function. `dcl-core` is the reference implementation for
58
+ Python; [`@fronesis-labs/dcl-sdk`](https://github.com/Fronesis-Labs/dcl-sdk)
59
+ is the byte-for-byte-compatible equivalent for TypeScript/JavaScript. DCL's
60
+ paid layer — running an agent's output through policy and getting a
61
+ verdict — lives separately in
62
+ [`dcl-webhook`](https://github.com/Fronesis-Labs/dcl-webhook).
63
+
64
+ This is a clean-history consolidation: the single-agent chain logic
65
+ previously duplicated inside `dcl-webhook`, and the multi-agent consensus
66
+ logic from `dcl-v2`, unified into one module with two known issues fixed.
67
+
68
+ ## Two Integrity Fixes Worth Knowing About
69
+
70
+ ### 1. `ChainState.verify()` now catches content tampering, not just broken links
71
+
72
+ The chain logic previously embedded in `dcl-webhook/dcl_core.py` only
73
+ checked that `prev_hash` pointers linked correctly between rows. It never
74
+ recomputed a row's hash from its own stored fields. That meant a party with
75
+ direct database access could edit `verdict`, `confidence`, or `reason` on
76
+ an existing row — without touching `tx_hash`/`prev_hash` — and `verify()`
77
+ would still report the chain as clean. That defeated the core tamper-evident
78
+ claim.
79
+
80
+ `verify()` here recomputes each row's hash from all of its stored fields
81
+ and compares it against the stored `tx_hash`. Editing any field now breaks
82
+ verification. See
83
+ `tests/test_fixes.py::test_detects_content_tamper_not_just_link_break`.
84
+
85
+ ### 2. Multi-party consensus no longer forgeable via XOR
86
+
87
+ `dcl-v2`'s original formula was `H*_t = hash(h_t1 ⊕ h_t2 ⊕ ... ⊕ h_tM)`.
88
+ XOR is commutative and reversible: a party submitting last — or able to
89
+ observe the running combination — could solve for a contribution that
90
+ forces the final Super-Hash to any target value, without breaking any hash
91
+ function. Cheap arithmetic forgery, not a cryptographic break, but a real
92
+ one.
93
+
94
+ `compute_super_hash()` instead sorts contributions by party ID and hashes
95
+ their length-prefixed concatenation. Order no longer matters (no
96
+ last-mover advantage) and the XOR-forgery trick no longer works. See
97
+ `tests/test_fixes.py::test_super_hash_xor_forgery_no_longer_trivial`.
98
+
99
+ ## Structure
100
+
101
+ ```
102
+ dcl_core/
103
+ ├── __init__.py
104
+ ├── chain.py ChainState — append-only chain, content-hash verify()
105
+ ├── consensus.py ConsensusRound, compute_super_hash — multi-party consensus
106
+ ├── seal.py format_seal() — human-readable "Verified by..." seal, mirrors dcl-sdk's seal.ts
107
+ └── verify.py verify_chain() — standalone offline chain verification, mirrors dcl-sdk's verify.ts
108
+ tests/
109
+ └── test_fixes.py Regression tests for both fixes above
110
+ ```
111
+
112
+ ## Quick Start
113
+
114
+ ```bash
115
+ pip install -e .
116
+ python -m pytest tests/ -v
117
+ ```
118
+
119
+ ```python
120
+ from dcl_core import ChainState
121
+
122
+ chain = ChainState("audit.db")
123
+ tx_hash, idx = chain.append(
124
+ verdict="COMMIT", input_hash="0xabc...", policy_hash="0xdef...",
125
+ agent_id="agent-1", reason="policy checks passed", confidence=0.95,
126
+ task_type="generation",
127
+ )
128
+
129
+ clean, bad_index, reason = chain.verify()
130
+ ```
131
+
132
+ ```python
133
+ from dcl_core import ConsensusRound, verify_super_hash
134
+
135
+ round_ = ConsensusRound(expected_parties={"org-a", "org-b", "org-c"})
136
+ round_.submit("org-a", h_a)
137
+ round_.submit("org-b", h_b)
138
+ round_.submit("org-c", h_c)
139
+ super_hash, contributions = round_.seal()
140
+ assert verify_super_hash(contributions, super_hash)
141
+ ```
142
+
143
+ ## Scope
144
+
145
+ This repo publishes the **protocol layer only** — record format, hash-chain
146
+ verification, and consensus combination. Scoring/policy engines (behavioral
147
+ fingerprinting, epistemic validation thresholds, reputation weighting) stay
148
+ in separate, closed modules that build on top of this.
149
+
150
+ ## Web2 HTTP side effect
151
+
152
+ Web2 agents that need a COMMIT / NO_COMMIT gate in front of an HTTP side effect can use `DCLGuard`:
153
+
154
+ ```python
155
+ from dcl import DCLGuard
156
+
157
+ guard = DCLGuard(oracle_url="https://webhook.fronesislabs.com")
158
+
159
+ result = guard.post(
160
+ "https://api.example.com/send",
161
+ json={"to": "user@example.com", "subject": "Hello"},
162
+ )
163
+
164
+ # result.executed is True only for exact COMMIT, a matching digest,
165
+ # and a non-redirect response from that URL.
166
+ # A target redirect sets request_sent True and executed False:
167
+ # the digested host may already have seen the POST.
168
+ ```
169
+
170
+ `from dcl import DCLGuard` is the public guard import. `from dcl_core import ChainState` is the chain import. This distribution publishes both packages (`pyproject.toml` includes `dcl` and `dcl_core*`). They are not the same module.
171
+
172
+ `post()` accepts `json=`. It does not accept `data=` or `files=`. A missing JSON body is hashed as an empty string, not as JSON `null`.
173
+
174
+ ### Request binding
175
+
176
+ `dcl.guard.request_digest()` is SHA-256 (hex, no `0x`) of this preimage, UTF-8:
177
+
178
+ ```text
179
+ METHOD
180
+ canonical URL
181
+ canonical JSON body
182
+ ```
183
+
184
+ `METHOD` is stripped and uppercased. The digest includes the HTTP method, the canonical URL, and the canonical JSON body.
185
+
186
+ The digest does not include HTTP headers, `Authorization`, `Content-Type`, x402 headers, cookies, `User-Agent`, other transport metadata, TLS metadata, or the network connection. `post()` can still send `headers=` to the target after COMMIT. Those headers are not sent to the Oracle and are not in the digest.
187
+
188
+ The Oracle receives the action, the target, and the JSON body in the `response` field, not only the digest, because checks such as jailbreak evaluate that text. On the production Oracle that text is sent to `https://webhook.fronesislabs.com`, which is the same disclosure as publishing the decision on the public audit board.
189
+
190
+ If a header changes what the request means, the Oracle decision is not cryptographically bound to that header.
191
+
192
+ ### Canonicalization
193
+
194
+ This is this package's own deterministic scheme. It is not RFC 8785 JCS.
195
+
196
+ - `dcl.guard.canonical_json()`: sorted object keys, compact separators `( ",", ":" )`, `ensure_ascii=False`. Array order is preserved. No Unicode normalization.
197
+ - `dcl.guard.canonical_url()`: strip surrounding whitespace, lowercase scheme and hostname, drop the fragment, omit default ports 80 and 443, keep any other port, sort decoded query pairs and encode them again, keep the path, and use `/` when the path is empty.
198
+
199
+ A published vector is tested by hashing that exact preimage, not by calling `request_digest()` twice. Method `POST`, URL `https://API.Example.com:443/orders?b=2&a=1#fragment`, JSON `{"z":1,"a":"é"}` canonicalizes to:
200
+
201
+ ```text
202
+ POST
203
+ https://api.example.com/orders?a=1&b=2
204
+ {"a":"é","z":1}
205
+ ```
206
+
207
+ SHA-256: `102854ec6909e5be3774fffbfc7bee1922341a2dede88277f7e30f12bc3a8123`.
208
+
209
+ Semantically similar URLs or JSON values that this scheme does not normalize (path percent-encoding, object key duplicates, `1` versus `1.0`) produce different digests. That is the current scheme, not an accident to paper over.
210
+
211
+ ### Enforcement boundary
212
+
213
+ This is library-level enforcement inside the process that calls `DCLGuard.post()`. It does not enforce anything at the network. An application can still POST the same URL with another client and skip the guard.
214
+
215
+ ### Oracle trust boundary
216
+
217
+ Three different things:
218
+
219
+ 1. Request-digest binding: the guard compares the digest it computed with the `request_digest` string in the Oracle JSON.
220
+ 2. Oracle transport and trust: the default client uses HTTPS/TLS to the configured Oracle URL and does not follow Oracle redirects. Unsigned JSON remains supported. When OracleTrustConfig is configured, the guard verifies the Oracle signed wrapper before accepting the response.
221
+ 3. Audit-chain verification: `dcl_core.verify_chain()` recomputes a chain record hash. The guard does not do that.
222
+
223
+ Python `DCLGuard` with `trust=OracleTrustConfig(...)` verifies a signed wrapper. Without `trust`, it uses the current unsigned path. The TypeScript guard is still unsigned. Nonce replay protection is not implemented. HTTPS/TLS to the configured Oracle endpoint is still required. This does not mean every Oracle response is cryptographically signed.
224
+
225
+ ### Signed decision envelope
226
+
227
+ [Signed Oracle decision envelope v1](docs/SIGNED_ORACLE_DECISION_ENVELOPE_V1.md) is the contract. Python `DCLGuard` verifies it only when the caller passes `trust=OracleTrustConfig(...)`. `trust=None`, the default, keeps the current unsigned Oracle JSON. Passing `trust` does not reject that unsigned JSON. A signed wrapper without `trust` stays a denial. The TypeScript guard does not verify envelopes yet.
228
+
229
+ The signed message is the UTF-8 canonical bytes of the ten-field envelope, not the HTTP headers and not `oracle.tx_hash`. The public key comes from `trust.keys`, not from the Oracle body. Unknown `key_id` is a denial. `COMMIT` after a signed wrapper requires a valid Ed25519 signature plus the envelope's `request_digest`, `trace_id`, `issued_at`, and `expires_at` checks. Nonce format is checked and is not stored; that is not replay protection. The signature does not replace HTTPS/TLS. The request-digest algorithm, Audit Event v1.0, and the production proof JSON stay as they are. `oracle.tx_hash` remains the audit-chain hash, not a Base transaction.
230
+
231
+ `verdict` must be exactly `COMMIT` or exactly `NO_COMMIT`. `COMMITTED`, `commit`, `COMMIT `, and any other string are denials. `allowed` is true only when `verdict == "COMMIT"`.
232
+
233
+ ### Failure behavior
234
+
235
+ These outcomes are fail-closed in `tests/test_dcl_guard.py` and `packages/dcl/test/guard.test.ts`. `result.executed` is false and the target server used by the test receives no request, except the target-redirect case noted below:
236
+
237
+ - Oracle HTTP 402, including a body that says `COMMIT`
238
+ - Oracle HTTP 404 and HTTP 500
239
+ - Oracle timeout
240
+ - Oracle connection failure
241
+ - any other exception from the Oracle transport
242
+ - malformed JSON, JSON that is not an object, missing verdict, unknown verdict, missing or non-string `reason`
243
+ - `COMMIT` with no `request_digest`, an empty digest, or a different digest
244
+ - `NO_COMMIT`
245
+ - Oracle HTTP redirect (not followed)
246
+ - target HTTP redirect (not followed; see below)
247
+
248
+ ### Redirect behavior
249
+
250
+ The default Oracle client (`_urllib_transport`, and `redirect: "manual"` in TypeScript) does not follow a 3xx from the Oracle. The guard then denies the side effect. A custom `transport` is responsible for its own Oracle requests; the guard still will not call the target unless that transport returns exact `COMMIT` plus the matching digest.
251
+
252
+ The target POST also does not follow redirects. `urllib` redirect codes are turned into `TargetRedirectRefused` (TypeScript uses `redirect: "manual"`). `Location` is not requested.
253
+
254
+ Known case: `request_sent` is true and `executed` is false (`requestSent` in TypeScript). The POST to the digested URL has already been sent, so that host may have seen the request. `executed: false` does not mean the request never left this process. The redirect destination is not contacted.
255
+
256
+ ### Production proof
257
+
258
+ Live runs against `https://webhook.fronesislabs.com` and `https://httpbin.org/post`, from commit `79390fc93f9ecf0baebe96e750d25e133ff1ef3e`:
259
+
260
+ [Production proof](examples/production_web2_proof.py)
261
+
262
+ Positive path, `2026-10-02T06:45:16Z`: Oracle 402 → x402 payment → `COMMIT` with `request_digest` `841bae2cf5392e1c55c4be5bae4187722df1af40166b1f8fc5c485d515b70019` → target POST 200.
263
+
264
+ Negative path, `2026-10-02T06:46:17Z`: Oracle 402 → x402 payment → `NO_COMMIT` (`forbidden: 'jailbreak'`) with `request_digest` `bc6bc2e1d5e9101a177d0d07469670d81699305e41e23b6fe6ff46d70b8b806e` → the guard did not call the target.
265
+
266
+ Evidence:
267
+
268
+ - [production_web2_proof.json](examples/production_web2_proof.json)
269
+ - [production_web2_negative_proof.json](examples/production_web2_negative_proof.json)
270
+
271
+ `oracle.tx_hash` is the identifier the Oracle returned. In the DCL chain, `ChainState.append` sets that field to `0x` plus the SHA-256 of the canonical audit-chain record. It is not a Base blockchain transaction hash. Positive: `0xaa13b48b6089eb31c9050719b61705d2a33dce911e0299b32c5244cb8821db83`. Negative: `0xa244dccca0682a89441f99bc982ec1f260a2493f667e80c72bd1c4a22d846689`.
272
+
273
+ `payment.tx_hash` on the positive proof is the x402 settlement transaction: `0x33f8af305b255b1a5d15b891f036b5940a47afa90fe9742201ea35af28ce2ad1`.
274
+
275
+ These two JSON files, from commit `79390fc93f9ecf0baebe96e750d25e133ff1ef3e` at `2026-10-02T06:45:16Z` and `2026-10-02T06:46:17Z`, are the current evidence. This repository does not contain an earlier proof note. A write-up that cites commit `8b620d0` or different hashes is a previous run, not a second canonical proof. The identifier that note called an Oracle transaction reference is the audit-chain hash (`oracle.tx_hash`), not a Base transaction.
276
+
277
+ ### Evidence limitations
278
+
279
+ The production proof shows what this client observed: the 402, one Oracle payment, the Oracle JSON, the digest comparison, the COMMIT or NO_COMMIT branch, and whether `DCLGuard.post()` then called the target.
280
+
281
+ The negative proof's `target.called: false` means this client did not run the target POST after `NO_COMMIT`. `httpbin.org` does not provide an independent call counter in this proof. The proof does not show that no other client reached httpbin.
282
+
283
+ The JSON does not contain the audit-chain fields `verify_chain()` needs (`index`, `prev_hash`, `input_hash`, `policy_hash`, `confidence`, `timestamp`, `drift_context`, and the rest of the canonical record). It is not an offline cryptographic proof of the Oracle verdict, and it does not authenticate the Oracle response.
284
+
285
+ It also does not provide network-level enforcement or stop another code path from skipping `DCLGuard`.
286
+
287
+ ### Payment
288
+
289
+ x402 payment is not part of `DCLGuard`. The proof script passes `examples/oracle_x402_transport.py` as the Oracle `transport`. That adapter reads `DCL_PAYER_PRIVATE_KEY`, or `X402_PRIVATE_KEY` as its only alias. A general `PRIVATE_KEY` is ignored, even when it is set for another wallet. If neither accepted variable is set, the adapter raises before any network call or signature and names the missing variable. It will not pay more than `DCL_MAX_PAYMENT_USDC` (default `0.01`). The guard never sees the key and never signs a payment. The target POST stays unpaid.
290
+
291
+ Do not commit a private key or a seed phrase. Use a dedicated wallet for automated x402 payments, keep only a small operational balance, and do not point this adapter at a treasury or personal wallet. Rotate a credential that has been exposed.
292
+
293
+ See [Protect an HTTP side effect with DCL](docs/PROTECT_HTTP_SIDE_EFFECT.md) and [examples/README.md](examples/README.md).
294
+
295
+ ## License
296
+
297
+ Apache License 2.0 — see [LICENSE](LICENSE).
@@ -0,0 +1,270 @@
1
+ # DCL Core
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/dcl-core)](https://pypi.org/project/dcl-core/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/dcl-core)](https://pypi.org/project/dcl-core/)
5
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/Fronesis-Labs/dcl-core/blob/main/LICENSE)
6
+
7
+ **Don't trust the agent. Trust the proof.**
8
+
9
+ Python reference implementation of the tamper-evident record chain and
10
+ multi-party consensus primitives behind DCL — the audit layer for
11
+ autonomous AI agent decisions. Given a chain record, `dcl-core` recomputes
12
+ its hash locally and tells you if it was tampered with. No API key, no
13
+ network call, no trust placed in whoever is showing you the record.
14
+ That claim is `verify_chain()` on a full chain record. The Web2 proof
15
+ later in this document only confirms the sequence this client observed.
16
+
17
+ Part of the Deterministic Commitment Layer / Leibniz Layer™ ecosystem by
18
+ Fronesis Labs.
19
+
20
+ ## Why This Package Is Free
21
+
22
+ Verifying that an audit record hasn't been altered is a different problem
23
+ from generating the verdict in the first place — and it's the one part of
24
+ an audit system that *must* be independently checkable, or the audit trail
25
+ isn't really independent. If you can only verify a record by paying the
26
+ same party who might have edited it, that's not verification, it's trust
27
+ with extra steps.
28
+
29
+ So the protocol itself is open, Apache-2.0, and has no server dependency
30
+ for its core function. `dcl-core` is the reference implementation for
31
+ Python; [`@fronesis-labs/dcl-sdk`](https://github.com/Fronesis-Labs/dcl-sdk)
32
+ is the byte-for-byte-compatible equivalent for TypeScript/JavaScript. DCL's
33
+ paid layer — running an agent's output through policy and getting a
34
+ verdict — lives separately in
35
+ [`dcl-webhook`](https://github.com/Fronesis-Labs/dcl-webhook).
36
+
37
+ This is a clean-history consolidation: the single-agent chain logic
38
+ previously duplicated inside `dcl-webhook`, and the multi-agent consensus
39
+ logic from `dcl-v2`, unified into one module with two known issues fixed.
40
+
41
+ ## Two Integrity Fixes Worth Knowing About
42
+
43
+ ### 1. `ChainState.verify()` now catches content tampering, not just broken links
44
+
45
+ The chain logic previously embedded in `dcl-webhook/dcl_core.py` only
46
+ checked that `prev_hash` pointers linked correctly between rows. It never
47
+ recomputed a row's hash from its own stored fields. That meant a party with
48
+ direct database access could edit `verdict`, `confidence`, or `reason` on
49
+ an existing row — without touching `tx_hash`/`prev_hash` — and `verify()`
50
+ would still report the chain as clean. That defeated the core tamper-evident
51
+ claim.
52
+
53
+ `verify()` here recomputes each row's hash from all of its stored fields
54
+ and compares it against the stored `tx_hash`. Editing any field now breaks
55
+ verification. See
56
+ `tests/test_fixes.py::test_detects_content_tamper_not_just_link_break`.
57
+
58
+ ### 2. Multi-party consensus no longer forgeable via XOR
59
+
60
+ `dcl-v2`'s original formula was `H*_t = hash(h_t1 ⊕ h_t2 ⊕ ... ⊕ h_tM)`.
61
+ XOR is commutative and reversible: a party submitting last — or able to
62
+ observe the running combination — could solve for a contribution that
63
+ forces the final Super-Hash to any target value, without breaking any hash
64
+ function. Cheap arithmetic forgery, not a cryptographic break, but a real
65
+ one.
66
+
67
+ `compute_super_hash()` instead sorts contributions by party ID and hashes
68
+ their length-prefixed concatenation. Order no longer matters (no
69
+ last-mover advantage) and the XOR-forgery trick no longer works. See
70
+ `tests/test_fixes.py::test_super_hash_xor_forgery_no_longer_trivial`.
71
+
72
+ ## Structure
73
+
74
+ ```
75
+ dcl_core/
76
+ ├── __init__.py
77
+ ├── chain.py ChainState — append-only chain, content-hash verify()
78
+ ├── consensus.py ConsensusRound, compute_super_hash — multi-party consensus
79
+ ├── seal.py format_seal() — human-readable "Verified by..." seal, mirrors dcl-sdk's seal.ts
80
+ └── verify.py verify_chain() — standalone offline chain verification, mirrors dcl-sdk's verify.ts
81
+ tests/
82
+ └── test_fixes.py Regression tests for both fixes above
83
+ ```
84
+
85
+ ## Quick Start
86
+
87
+ ```bash
88
+ pip install -e .
89
+ python -m pytest tests/ -v
90
+ ```
91
+
92
+ ```python
93
+ from dcl_core import ChainState
94
+
95
+ chain = ChainState("audit.db")
96
+ tx_hash, idx = chain.append(
97
+ verdict="COMMIT", input_hash="0xabc...", policy_hash="0xdef...",
98
+ agent_id="agent-1", reason="policy checks passed", confidence=0.95,
99
+ task_type="generation",
100
+ )
101
+
102
+ clean, bad_index, reason = chain.verify()
103
+ ```
104
+
105
+ ```python
106
+ from dcl_core import ConsensusRound, verify_super_hash
107
+
108
+ round_ = ConsensusRound(expected_parties={"org-a", "org-b", "org-c"})
109
+ round_.submit("org-a", h_a)
110
+ round_.submit("org-b", h_b)
111
+ round_.submit("org-c", h_c)
112
+ super_hash, contributions = round_.seal()
113
+ assert verify_super_hash(contributions, super_hash)
114
+ ```
115
+
116
+ ## Scope
117
+
118
+ This repo publishes the **protocol layer only** — record format, hash-chain
119
+ verification, and consensus combination. Scoring/policy engines (behavioral
120
+ fingerprinting, epistemic validation thresholds, reputation weighting) stay
121
+ in separate, closed modules that build on top of this.
122
+
123
+ ## Web2 HTTP side effect
124
+
125
+ Web2 agents that need a COMMIT / NO_COMMIT gate in front of an HTTP side effect can use `DCLGuard`:
126
+
127
+ ```python
128
+ from dcl import DCLGuard
129
+
130
+ guard = DCLGuard(oracle_url="https://webhook.fronesislabs.com")
131
+
132
+ result = guard.post(
133
+ "https://api.example.com/send",
134
+ json={"to": "user@example.com", "subject": "Hello"},
135
+ )
136
+
137
+ # result.executed is True only for exact COMMIT, a matching digest,
138
+ # and a non-redirect response from that URL.
139
+ # A target redirect sets request_sent True and executed False:
140
+ # the digested host may already have seen the POST.
141
+ ```
142
+
143
+ `from dcl import DCLGuard` is the public guard import. `from dcl_core import ChainState` is the chain import. This distribution publishes both packages (`pyproject.toml` includes `dcl` and `dcl_core*`). They are not the same module.
144
+
145
+ `post()` accepts `json=`. It does not accept `data=` or `files=`. A missing JSON body is hashed as an empty string, not as JSON `null`.
146
+
147
+ ### Request binding
148
+
149
+ `dcl.guard.request_digest()` is SHA-256 (hex, no `0x`) of this preimage, UTF-8:
150
+
151
+ ```text
152
+ METHOD
153
+ canonical URL
154
+ canonical JSON body
155
+ ```
156
+
157
+ `METHOD` is stripped and uppercased. The digest includes the HTTP method, the canonical URL, and the canonical JSON body.
158
+
159
+ The digest does not include HTTP headers, `Authorization`, `Content-Type`, x402 headers, cookies, `User-Agent`, other transport metadata, TLS metadata, or the network connection. `post()` can still send `headers=` to the target after COMMIT. Those headers are not sent to the Oracle and are not in the digest.
160
+
161
+ The Oracle receives the action, the target, and the JSON body in the `response` field, not only the digest, because checks such as jailbreak evaluate that text. On the production Oracle that text is sent to `https://webhook.fronesislabs.com`, which is the same disclosure as publishing the decision on the public audit board.
162
+
163
+ If a header changes what the request means, the Oracle decision is not cryptographically bound to that header.
164
+
165
+ ### Canonicalization
166
+
167
+ This is this package's own deterministic scheme. It is not RFC 8785 JCS.
168
+
169
+ - `dcl.guard.canonical_json()`: sorted object keys, compact separators `( ",", ":" )`, `ensure_ascii=False`. Array order is preserved. No Unicode normalization.
170
+ - `dcl.guard.canonical_url()`: strip surrounding whitespace, lowercase scheme and hostname, drop the fragment, omit default ports 80 and 443, keep any other port, sort decoded query pairs and encode them again, keep the path, and use `/` when the path is empty.
171
+
172
+ A published vector is tested by hashing that exact preimage, not by calling `request_digest()` twice. Method `POST`, URL `https://API.Example.com:443/orders?b=2&a=1#fragment`, JSON `{"z":1,"a":"é"}` canonicalizes to:
173
+
174
+ ```text
175
+ POST
176
+ https://api.example.com/orders?a=1&b=2
177
+ {"a":"é","z":1}
178
+ ```
179
+
180
+ SHA-256: `102854ec6909e5be3774fffbfc7bee1922341a2dede88277f7e30f12bc3a8123`.
181
+
182
+ Semantically similar URLs or JSON values that this scheme does not normalize (path percent-encoding, object key duplicates, `1` versus `1.0`) produce different digests. That is the current scheme, not an accident to paper over.
183
+
184
+ ### Enforcement boundary
185
+
186
+ This is library-level enforcement inside the process that calls `DCLGuard.post()`. It does not enforce anything at the network. An application can still POST the same URL with another client and skip the guard.
187
+
188
+ ### Oracle trust boundary
189
+
190
+ Three different things:
191
+
192
+ 1. Request-digest binding: the guard compares the digest it computed with the `request_digest` string in the Oracle JSON.
193
+ 2. Oracle transport and trust: the default client uses HTTPS/TLS to the configured Oracle URL and does not follow Oracle redirects. Unsigned JSON remains supported. When OracleTrustConfig is configured, the guard verifies the Oracle signed wrapper before accepting the response.
194
+ 3. Audit-chain verification: `dcl_core.verify_chain()` recomputes a chain record hash. The guard does not do that.
195
+
196
+ Python `DCLGuard` with `trust=OracleTrustConfig(...)` verifies a signed wrapper. Without `trust`, it uses the current unsigned path. The TypeScript guard is still unsigned. Nonce replay protection is not implemented. HTTPS/TLS to the configured Oracle endpoint is still required. This does not mean every Oracle response is cryptographically signed.
197
+
198
+ ### Signed decision envelope
199
+
200
+ [Signed Oracle decision envelope v1](docs/SIGNED_ORACLE_DECISION_ENVELOPE_V1.md) is the contract. Python `DCLGuard` verifies it only when the caller passes `trust=OracleTrustConfig(...)`. `trust=None`, the default, keeps the current unsigned Oracle JSON. Passing `trust` does not reject that unsigned JSON. A signed wrapper without `trust` stays a denial. The TypeScript guard does not verify envelopes yet.
201
+
202
+ The signed message is the UTF-8 canonical bytes of the ten-field envelope, not the HTTP headers and not `oracle.tx_hash`. The public key comes from `trust.keys`, not from the Oracle body. Unknown `key_id` is a denial. `COMMIT` after a signed wrapper requires a valid Ed25519 signature plus the envelope's `request_digest`, `trace_id`, `issued_at`, and `expires_at` checks. Nonce format is checked and is not stored; that is not replay protection. The signature does not replace HTTPS/TLS. The request-digest algorithm, Audit Event v1.0, and the production proof JSON stay as they are. `oracle.tx_hash` remains the audit-chain hash, not a Base transaction.
203
+
204
+ `verdict` must be exactly `COMMIT` or exactly `NO_COMMIT`. `COMMITTED`, `commit`, `COMMIT `, and any other string are denials. `allowed` is true only when `verdict == "COMMIT"`.
205
+
206
+ ### Failure behavior
207
+
208
+ These outcomes are fail-closed in `tests/test_dcl_guard.py` and `packages/dcl/test/guard.test.ts`. `result.executed` is false and the target server used by the test receives no request, except the target-redirect case noted below:
209
+
210
+ - Oracle HTTP 402, including a body that says `COMMIT`
211
+ - Oracle HTTP 404 and HTTP 500
212
+ - Oracle timeout
213
+ - Oracle connection failure
214
+ - any other exception from the Oracle transport
215
+ - malformed JSON, JSON that is not an object, missing verdict, unknown verdict, missing or non-string `reason`
216
+ - `COMMIT` with no `request_digest`, an empty digest, or a different digest
217
+ - `NO_COMMIT`
218
+ - Oracle HTTP redirect (not followed)
219
+ - target HTTP redirect (not followed; see below)
220
+
221
+ ### Redirect behavior
222
+
223
+ The default Oracle client (`_urllib_transport`, and `redirect: "manual"` in TypeScript) does not follow a 3xx from the Oracle. The guard then denies the side effect. A custom `transport` is responsible for its own Oracle requests; the guard still will not call the target unless that transport returns exact `COMMIT` plus the matching digest.
224
+
225
+ The target POST also does not follow redirects. `urllib` redirect codes are turned into `TargetRedirectRefused` (TypeScript uses `redirect: "manual"`). `Location` is not requested.
226
+
227
+ Known case: `request_sent` is true and `executed` is false (`requestSent` in TypeScript). The POST to the digested URL has already been sent, so that host may have seen the request. `executed: false` does not mean the request never left this process. The redirect destination is not contacted.
228
+
229
+ ### Production proof
230
+
231
+ Live runs against `https://webhook.fronesislabs.com` and `https://httpbin.org/post`, from commit `79390fc93f9ecf0baebe96e750d25e133ff1ef3e`:
232
+
233
+ [Production proof](examples/production_web2_proof.py)
234
+
235
+ Positive path, `2026-10-02T06:45:16Z`: Oracle 402 → x402 payment → `COMMIT` with `request_digest` `841bae2cf5392e1c55c4be5bae4187722df1af40166b1f8fc5c485d515b70019` → target POST 200.
236
+
237
+ Negative path, `2026-10-02T06:46:17Z`: Oracle 402 → x402 payment → `NO_COMMIT` (`forbidden: 'jailbreak'`) with `request_digest` `bc6bc2e1d5e9101a177d0d07469670d81699305e41e23b6fe6ff46d70b8b806e` → the guard did not call the target.
238
+
239
+ Evidence:
240
+
241
+ - [production_web2_proof.json](examples/production_web2_proof.json)
242
+ - [production_web2_negative_proof.json](examples/production_web2_negative_proof.json)
243
+
244
+ `oracle.tx_hash` is the identifier the Oracle returned. In the DCL chain, `ChainState.append` sets that field to `0x` plus the SHA-256 of the canonical audit-chain record. It is not a Base blockchain transaction hash. Positive: `0xaa13b48b6089eb31c9050719b61705d2a33dce911e0299b32c5244cb8821db83`. Negative: `0xa244dccca0682a89441f99bc982ec1f260a2493f667e80c72bd1c4a22d846689`.
245
+
246
+ `payment.tx_hash` on the positive proof is the x402 settlement transaction: `0x33f8af305b255b1a5d15b891f036b5940a47afa90fe9742201ea35af28ce2ad1`.
247
+
248
+ These two JSON files, from commit `79390fc93f9ecf0baebe96e750d25e133ff1ef3e` at `2026-10-02T06:45:16Z` and `2026-10-02T06:46:17Z`, are the current evidence. This repository does not contain an earlier proof note. A write-up that cites commit `8b620d0` or different hashes is a previous run, not a second canonical proof. The identifier that note called an Oracle transaction reference is the audit-chain hash (`oracle.tx_hash`), not a Base transaction.
249
+
250
+ ### Evidence limitations
251
+
252
+ The production proof shows what this client observed: the 402, one Oracle payment, the Oracle JSON, the digest comparison, the COMMIT or NO_COMMIT branch, and whether `DCLGuard.post()` then called the target.
253
+
254
+ The negative proof's `target.called: false` means this client did not run the target POST after `NO_COMMIT`. `httpbin.org` does not provide an independent call counter in this proof. The proof does not show that no other client reached httpbin.
255
+
256
+ The JSON does not contain the audit-chain fields `verify_chain()` needs (`index`, `prev_hash`, `input_hash`, `policy_hash`, `confidence`, `timestamp`, `drift_context`, and the rest of the canonical record). It is not an offline cryptographic proof of the Oracle verdict, and it does not authenticate the Oracle response.
257
+
258
+ It also does not provide network-level enforcement or stop another code path from skipping `DCLGuard`.
259
+
260
+ ### Payment
261
+
262
+ x402 payment is not part of `DCLGuard`. The proof script passes `examples/oracle_x402_transport.py` as the Oracle `transport`. That adapter reads `DCL_PAYER_PRIVATE_KEY`, or `X402_PRIVATE_KEY` as its only alias. A general `PRIVATE_KEY` is ignored, even when it is set for another wallet. If neither accepted variable is set, the adapter raises before any network call or signature and names the missing variable. It will not pay more than `DCL_MAX_PAYMENT_USDC` (default `0.01`). The guard never sees the key and never signs a payment. The target POST stays unpaid.
263
+
264
+ Do not commit a private key or a seed phrase. Use a dedicated wallet for automated x402 payments, keep only a small operational balance, and do not point this adapter at a treasury or personal wallet. Rotate a credential that has been exposed.
265
+
266
+ See [Protect an HTTP side effect with DCL](docs/PROTECT_HTTP_SIDE_EFFECT.md) and [examples/README.md](examples/README.md).
267
+
268
+ ## License
269
+
270
+ Apache License 2.0 — see [LICENSE](LICENSE).
@@ -0,0 +1,18 @@
1
+ """Developer-facing gate in front of an existing DCL Trust Oracle.
2
+
3
+ Ask the Oracle whether an HTTP side effect may run. Execute the call only
4
+ when the verdict is COMMIT. Timeouts, malformed replies, server errors, and
5
+ unpaid checks fail closed.
6
+ """
7
+
8
+ from dcl.guard import DCLGuard, Decision, OracleTrustConfig, SideEffectResult, request_digest
9
+ from dcl.sandbox import LocalSandbox
10
+
11
+ __all__ = [
12
+ "DCLGuard",
13
+ "Decision",
14
+ "LocalSandbox",
15
+ "OracleTrustConfig",
16
+ "SideEffectResult",
17
+ "request_digest",
18
+ ]