omega-audit 0.1.0__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.
- omega_audit-0.1.0/.gitignore +6 -0
- omega_audit-0.1.0/LICENSE +201 -0
- omega_audit-0.1.0/PKG-INFO +233 -0
- omega_audit-0.1.0/README.md +204 -0
- omega_audit-0.1.0/omega_audit/__init__.py +56 -0
- omega_audit-0.1.0/omega_audit/__main__.py +8 -0
- omega_audit-0.1.0/omega_audit/canonical.py +137 -0
- omega_audit-0.1.0/omega_audit/cli.py +104 -0
- omega_audit-0.1.0/omega_audit/ledger.py +109 -0
- omega_audit-0.1.0/omega_audit/verify.py +97 -0
- omega_audit-0.1.0/omega_audit/wrap.py +114 -0
- omega_audit-0.1.0/pyproject.toml +70 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to the Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2024-2026 Jesse Mahoney
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: omega-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tamper-evident audit + provenance for AI agents — an offline, zero-dependency, hash-chained append-only ledger. Wrap your agent's tool calls, then prove what it did: any edit, reorder, insertion, or deletion fails verification.
|
|
5
|
+
Project-URL: Homepage, https://omegaengine.ai
|
|
6
|
+
Project-URL: Documentation, https://github.com/TheArkhitect/Omegaengine/tree/main/packages/omega-audit-py#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/TheArkhitect/Omegaengine/tree/main/packages/omega-audit-py
|
|
8
|
+
Project-URL: Issues, https://github.com/TheArkhitect/Omegaengine/issues
|
|
9
|
+
Author-email: OmegaEngine <team@omegaengine.ai>
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-agents,append-only,audit-log,compliance,eu-ai-act,hash-chain,omegaengine,provenance,sha256,tamper-evident
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# omega-audit
|
|
31
|
+
|
|
32
|
+
**Tamper-evident audit + provenance for AI agents — prove what your agent did, hash-chained, verifiable, auditor-ready.**
|
|
33
|
+
|
|
34
|
+
Wrap your agent's tool calls. Every call is appended to a local, append-only, SHA-256 hash-chained ledger. Later, anyone can recompute the chain and detect *any* edit, reorder, insertion, or deletion — offline, in one command, with no signup and no trust in us.
|
|
35
|
+
|
|
36
|
+
- **Zero runtime dependencies.** Python's standard library only (`hashlib`, `json`, `base64`). No SDK, no account, no network.
|
|
37
|
+
- **Offline and local.** The ledger is a plain JSONL file on your disk. You own it.
|
|
38
|
+
- **Framework-agnostic.** Wrap any callable — sync or async — plain tools, OpenAI/LangChain/LlamaIndex tool defs, MCP handlers.
|
|
39
|
+
- **Independently verifiable.** `omega-audit verify` recomputes the whole chain. Break one byte and it fails.
|
|
40
|
+
- **Byte-compatible with the TypeScript twin** [`@omegaengine/audit`](https://github.com/TheArkhitect/Omegaengine/tree/main/packages/omega-audit): a ledger written in Python verifies under the TS CLI and vice versa — same bytes, same hashes.
|
|
41
|
+
- **Apache-2.0.**
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Why now
|
|
46
|
+
|
|
47
|
+
The **EU AI Act's record-keeping obligation (Article 12) begins to apply on August 2, 2026** for high-risk AI systems: providers must ensure their systems automatically record events (logs) over their lifetime, to a degree appropriate to the system's purpose. A policy document that says "we log agent actions" is not evidence. **Evidence is a log you can hand an auditor that provably has not been altered.**
|
|
48
|
+
|
|
49
|
+
Append-only, cryptographically hash-chained logs are the well-established way to make an audit trail tamper-evident: each record commits to the one before it, so the chain itself proves nothing was quietly changed after the fact. That's the same construction behind Git commit history and RFC 6962 Certificate Transparency. This package gives you that construction for your agent's actions, in about twenty minutes.
|
|
50
|
+
|
|
51
|
+
> This is honest scope: `omega-audit` produces **tamper-evident** local logs — you can *detect* tampering by recomputing the chain. It is not, by itself, a certification, a signature from us, or proof to a third party who doesn't have your file. For cross-organization, independently-anchored proof, see [Anchoring to the transparency log](#anchor-to-the-transparency-log-optional) below.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 20-minute quickstart
|
|
56
|
+
|
|
57
|
+
### 1. Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install omega-audit
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 2. Wrap a tool
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from omega_audit import Ledger, wrap_tool
|
|
67
|
+
|
|
68
|
+
# One local, append-only ledger file. Offline — no signup, no network.
|
|
69
|
+
ledger = Ledger("ledger.jsonl")
|
|
70
|
+
|
|
71
|
+
# Your existing tool — any callable, sync or async.
|
|
72
|
+
def send_email(to: str, subject: str):
|
|
73
|
+
# ... your real implementation ...
|
|
74
|
+
return {"delivered": True}
|
|
75
|
+
|
|
76
|
+
# Wrap it. Same signature, same return value — it just records every call.
|
|
77
|
+
tracked_send_email = wrap_tool(ledger, "send_email", send_email)
|
|
78
|
+
|
|
79
|
+
tracked_send_email("auditor@example.com", "Q3 evidence")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Each call appends one record: `{tool, args, ok, result | error, duration_ms}`. Errors are recorded too (with `ok=False` and the message) and then re-raised unchanged, so wrapping never swallows a failure.
|
|
83
|
+
|
|
84
|
+
Async tools work the same way — if the wrapped function is a coroutine, the wrapper is too:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import asyncio
|
|
88
|
+
from omega_audit import Ledger, wrap_tool
|
|
89
|
+
|
|
90
|
+
ledger = Ledger("ledger.jsonl")
|
|
91
|
+
|
|
92
|
+
async def fetch_row(row_id: str):
|
|
93
|
+
# ... await your real async implementation ...
|
|
94
|
+
return {"id": row_id}
|
|
95
|
+
|
|
96
|
+
tracked = wrap_tool(ledger, "fetch_row", fetch_row)
|
|
97
|
+
asyncio.run(tracked("abc")) # the record is written when the awaited call settles
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
This composes with agent frameworks: an OpenAI or LangChain tool is just a callable with a schema, so wrap its implementation and register the wrapped version. Nothing about your agent loop changes.
|
|
101
|
+
|
|
102
|
+
### 3. Look at the ledger
|
|
103
|
+
|
|
104
|
+
`ledger.jsonl` is one JSON object per line — readable, greppable, diffable:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{"seq":0,"ts":"2026-08-02T09:00:00.000Z","action":{"tool":"send_email","args":["auditor@example.com","Q3 evidence"],"ok":true,"result":{"delivered":true},"durationMs":501},"prevHash":"0000000000000000000000000000000000000000000000000000000000000000","hash":"44dab7b4…"}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Every record's `hash` is `sha256(canonical_json({seq, ts, action, prevHash}))`, and its `prevHash` is the previous record's `hash` (the first record links to 64 zeros). That chain is what makes tampering detectable. The field is stored on disk as `prevHash` (camelCase) so the bytes are identical to the TypeScript twin.
|
|
111
|
+
|
|
112
|
+
### 4. Verify it
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
omega-audit verify ledger.jsonl
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
✓ 3 records — chain intact & tamper-evident
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Exit code `0` when intact, `1` when tampered. Drop it in CI to fail a build if an agent's audit trail was touched.
|
|
123
|
+
|
|
124
|
+
You can also verify in code:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from omega_audit import verify
|
|
128
|
+
|
|
129
|
+
result = verify("ledger.jsonl")
|
|
130
|
+
# VerifyResult(valid=True, records=3, broken_at=None, reason=None)
|
|
131
|
+
# or VerifyResult(valid=False, records=3, broken_at=2, reason="record 2 was edited — …")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## See it break: the tamper demo
|
|
137
|
+
|
|
138
|
+
One command builds a small ledger, tampers a record, and shows verification catch it:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
omega-audit demo
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
omega-audit demo — tamper-evident agent audit in one command
|
|
146
|
+
|
|
147
|
+
1. An agent runs three tool calls. Each one is appended to a hash-chained ledger:
|
|
148
|
+
|
|
149
|
+
#0 search_web hash=ce4ca688b665… prev=000000000000…
|
|
150
|
+
#1 read_file hash=5c7f4ae5891f… prev=ce4ca688b665…
|
|
151
|
+
#2 send_email hash=a0c640028849… prev=5c7f4ae5891f…
|
|
152
|
+
|
|
153
|
+
2. Verify the untouched ledger:
|
|
154
|
+
|
|
155
|
+
$ omega-audit verify ledger.jsonl
|
|
156
|
+
✓ 3 records — chain intact & tamper-evident
|
|
157
|
+
|
|
158
|
+
3. Now someone quietly edits record #2 to hide who the email went to:
|
|
159
|
+
|
|
160
|
+
before: send_email → auditor@example.com
|
|
161
|
+
after: send_email → attacker@evil.example (hash left unchanged to hide the edit)
|
|
162
|
+
|
|
163
|
+
4. Verify again — the chain no longer recomputes:
|
|
164
|
+
|
|
165
|
+
$ omega-audit verify ledger.jsonl
|
|
166
|
+
✗ TAMPER DETECTED at record 2: record 2 was edited — its stored hash a0c64002…30cc ≠ the recomputed hash 6e1be9ab…e0ef
|
|
167
|
+
|
|
168
|
+
That ✗ is the whole point: the record was changed, and verification proved it.
|
|
169
|
+
Nothing here touched the network. You can run the same check on your own ledger.
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The `demo` command exits `1` — its whole job is to prove that a tampered ledger fails verification. (The hashes vary run to run because each record's timestamp is real; the chain is self-consistent every time.)
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Enforce vs. prove
|
|
177
|
+
|
|
178
|
+
These are two different jobs, and you want both:
|
|
179
|
+
|
|
180
|
+
- **A policy layer *enforces*** — it decides, at run time, whether an action is allowed and blocks the ones that aren't.
|
|
181
|
+
- **`omega-audit` *proves*** — it produces the independent, tamper-evident evidence trail of what actually happened, which you can verify after the fact.
|
|
182
|
+
|
|
183
|
+
Enforcement without evidence is a claim. Evidence without enforcement is a bystander. Run your guardrails to stop bad actions; run `omega-audit` to prove — to yourself, to an auditor, to a customer — exactly what your agent did and that the record wasn't altered.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Anchor to the transparency log (optional)
|
|
188
|
+
|
|
189
|
+
Your local ledger is tamper-evident to *anyone who has the file*. To make it verifiable *across organizations* — so a customer or auditor who does **not** have your machine can still confirm the record — you anchor the ledger's root to a public, append-only transparency log.
|
|
190
|
+
|
|
191
|
+
The record hashing here is byte-compatible with the OmegaEngine platform's canonical JSON + SHA-256 **and with the TypeScript twin** — a ledger written by this Python package verifies under `@omegaengine/audit` and vice versa, so it is already in the shape the hosted log expects. [OmegaEngine](https://omegaengine.ai) offers, as a hosted backend:
|
|
192
|
+
|
|
193
|
+
- **Anchoring** your ledger root into a public RFC 6962 transparency log for cross-org, independently-checkable proof of inclusion.
|
|
194
|
+
- **Long-retention storage** to support multi-year record-keeping regimes (for example SEC Rule 17a-4 or HIPAA retention).
|
|
195
|
+
- **Auditor exports** — signed, verifiable bundles an auditor can check with the open-source [`@omegaengine/verify`](https://github.com/TheArkhitect/Omegaengine/tree/main/packages/omega-verify) tool, no OmegaEngine account required.
|
|
196
|
+
|
|
197
|
+
The local package is fully useful on its own and stays free and open source. The hosted log is the optional upgrade when "verifiable on my disk" needs to become "verifiable by someone else."
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## API
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
Ledger(path: str)
|
|
205
|
+
ledger.append(action) -> dict # append one record; returns it
|
|
206
|
+
ledger.size() -> int
|
|
207
|
+
ledger.tip_hash() -> str # hash of the last record (chain tip)
|
|
208
|
+
|
|
209
|
+
wrap_tool(ledger, name, fn) -> fn # wrap a named tool (sync or async); records every call
|
|
210
|
+
capture(ledger, fn) -> fn # like wrap_tool, using fn.__name__
|
|
211
|
+
|
|
212
|
+
verify(path: str) -> VerifyResult
|
|
213
|
+
# VerifyResult(valid, records, broken_at, reason)
|
|
214
|
+
|
|
215
|
+
read_ledger(path) -> list[dict] # parse JSONL into records (no chain check)
|
|
216
|
+
verify_entries(entries) -> VerifyResult # verify already-parsed records
|
|
217
|
+
canonical_json(value) -> str # the deterministic serializer used for hashing
|
|
218
|
+
sha256_hex(s) -> str
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
A ledger entry is a dict `{seq, ts, action, prevHash, hash}`.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Design partners
|
|
226
|
+
|
|
227
|
+
We're looking for teams putting agents in front of real users or real money who need to *prove* what those agents did. If you're wiring agent actions into an audit or compliance story — EU AI Act, financial record-keeping, healthcare — we'd like to build with you. Open an issue on [github.com/TheArkhitect/Omegaengine](https://github.com/TheArkhitect/Omegaengine/discussions) or reach out via [omegaengine.ai](https://omegaengine.ai).
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
Apache-2.0. See the repository root for the full text.
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# omega-audit
|
|
2
|
+
|
|
3
|
+
**Tamper-evident audit + provenance for AI agents — prove what your agent did, hash-chained, verifiable, auditor-ready.**
|
|
4
|
+
|
|
5
|
+
Wrap your agent's tool calls. Every call is appended to a local, append-only, SHA-256 hash-chained ledger. Later, anyone can recompute the chain and detect *any* edit, reorder, insertion, or deletion — offline, in one command, with no signup and no trust in us.
|
|
6
|
+
|
|
7
|
+
- **Zero runtime dependencies.** Python's standard library only (`hashlib`, `json`, `base64`). No SDK, no account, no network.
|
|
8
|
+
- **Offline and local.** The ledger is a plain JSONL file on your disk. You own it.
|
|
9
|
+
- **Framework-agnostic.** Wrap any callable — sync or async — plain tools, OpenAI/LangChain/LlamaIndex tool defs, MCP handlers.
|
|
10
|
+
- **Independently verifiable.** `omega-audit verify` recomputes the whole chain. Break one byte and it fails.
|
|
11
|
+
- **Byte-compatible with the TypeScript twin** [`@omegaengine/audit`](https://github.com/TheArkhitect/Omegaengine/tree/main/packages/omega-audit): a ledger written in Python verifies under the TS CLI and vice versa — same bytes, same hashes.
|
|
12
|
+
- **Apache-2.0.**
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Why now
|
|
17
|
+
|
|
18
|
+
The **EU AI Act's record-keeping obligation (Article 12) begins to apply on August 2, 2026** for high-risk AI systems: providers must ensure their systems automatically record events (logs) over their lifetime, to a degree appropriate to the system's purpose. A policy document that says "we log agent actions" is not evidence. **Evidence is a log you can hand an auditor that provably has not been altered.**
|
|
19
|
+
|
|
20
|
+
Append-only, cryptographically hash-chained logs are the well-established way to make an audit trail tamper-evident: each record commits to the one before it, so the chain itself proves nothing was quietly changed after the fact. That's the same construction behind Git commit history and RFC 6962 Certificate Transparency. This package gives you that construction for your agent's actions, in about twenty minutes.
|
|
21
|
+
|
|
22
|
+
> This is honest scope: `omega-audit` produces **tamper-evident** local logs — you can *detect* tampering by recomputing the chain. It is not, by itself, a certification, a signature from us, or proof to a third party who doesn't have your file. For cross-organization, independently-anchored proof, see [Anchoring to the transparency log](#anchor-to-the-transparency-log-optional) below.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 20-minute quickstart
|
|
27
|
+
|
|
28
|
+
### 1. Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install omega-audit
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 2. Wrap a tool
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from omega_audit import Ledger, wrap_tool
|
|
38
|
+
|
|
39
|
+
# One local, append-only ledger file. Offline — no signup, no network.
|
|
40
|
+
ledger = Ledger("ledger.jsonl")
|
|
41
|
+
|
|
42
|
+
# Your existing tool — any callable, sync or async.
|
|
43
|
+
def send_email(to: str, subject: str):
|
|
44
|
+
# ... your real implementation ...
|
|
45
|
+
return {"delivered": True}
|
|
46
|
+
|
|
47
|
+
# Wrap it. Same signature, same return value — it just records every call.
|
|
48
|
+
tracked_send_email = wrap_tool(ledger, "send_email", send_email)
|
|
49
|
+
|
|
50
|
+
tracked_send_email("auditor@example.com", "Q3 evidence")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Each call appends one record: `{tool, args, ok, result | error, duration_ms}`. Errors are recorded too (with `ok=False` and the message) and then re-raised unchanged, so wrapping never swallows a failure.
|
|
54
|
+
|
|
55
|
+
Async tools work the same way — if the wrapped function is a coroutine, the wrapper is too:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio
|
|
59
|
+
from omega_audit import Ledger, wrap_tool
|
|
60
|
+
|
|
61
|
+
ledger = Ledger("ledger.jsonl")
|
|
62
|
+
|
|
63
|
+
async def fetch_row(row_id: str):
|
|
64
|
+
# ... await your real async implementation ...
|
|
65
|
+
return {"id": row_id}
|
|
66
|
+
|
|
67
|
+
tracked = wrap_tool(ledger, "fetch_row", fetch_row)
|
|
68
|
+
asyncio.run(tracked("abc")) # the record is written when the awaited call settles
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This composes with agent frameworks: an OpenAI or LangChain tool is just a callable with a schema, so wrap its implementation and register the wrapped version. Nothing about your agent loop changes.
|
|
72
|
+
|
|
73
|
+
### 3. Look at the ledger
|
|
74
|
+
|
|
75
|
+
`ledger.jsonl` is one JSON object per line — readable, greppable, diffable:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{"seq":0,"ts":"2026-08-02T09:00:00.000Z","action":{"tool":"send_email","args":["auditor@example.com","Q3 evidence"],"ok":true,"result":{"delivered":true},"durationMs":501},"prevHash":"0000000000000000000000000000000000000000000000000000000000000000","hash":"44dab7b4…"}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Every record's `hash` is `sha256(canonical_json({seq, ts, action, prevHash}))`, and its `prevHash` is the previous record's `hash` (the first record links to 64 zeros). That chain is what makes tampering detectable. The field is stored on disk as `prevHash` (camelCase) so the bytes are identical to the TypeScript twin.
|
|
82
|
+
|
|
83
|
+
### 4. Verify it
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
omega-audit verify ledger.jsonl
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
✓ 3 records — chain intact & tamper-evident
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Exit code `0` when intact, `1` when tampered. Drop it in CI to fail a build if an agent's audit trail was touched.
|
|
94
|
+
|
|
95
|
+
You can also verify in code:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from omega_audit import verify
|
|
99
|
+
|
|
100
|
+
result = verify("ledger.jsonl")
|
|
101
|
+
# VerifyResult(valid=True, records=3, broken_at=None, reason=None)
|
|
102
|
+
# or VerifyResult(valid=False, records=3, broken_at=2, reason="record 2 was edited — …")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## See it break: the tamper demo
|
|
108
|
+
|
|
109
|
+
One command builds a small ledger, tampers a record, and shows verification catch it:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
omega-audit demo
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
omega-audit demo — tamper-evident agent audit in one command
|
|
117
|
+
|
|
118
|
+
1. An agent runs three tool calls. Each one is appended to a hash-chained ledger:
|
|
119
|
+
|
|
120
|
+
#0 search_web hash=ce4ca688b665… prev=000000000000…
|
|
121
|
+
#1 read_file hash=5c7f4ae5891f… prev=ce4ca688b665…
|
|
122
|
+
#2 send_email hash=a0c640028849… prev=5c7f4ae5891f…
|
|
123
|
+
|
|
124
|
+
2. Verify the untouched ledger:
|
|
125
|
+
|
|
126
|
+
$ omega-audit verify ledger.jsonl
|
|
127
|
+
✓ 3 records — chain intact & tamper-evident
|
|
128
|
+
|
|
129
|
+
3. Now someone quietly edits record #2 to hide who the email went to:
|
|
130
|
+
|
|
131
|
+
before: send_email → auditor@example.com
|
|
132
|
+
after: send_email → attacker@evil.example (hash left unchanged to hide the edit)
|
|
133
|
+
|
|
134
|
+
4. Verify again — the chain no longer recomputes:
|
|
135
|
+
|
|
136
|
+
$ omega-audit verify ledger.jsonl
|
|
137
|
+
✗ TAMPER DETECTED at record 2: record 2 was edited — its stored hash a0c64002…30cc ≠ the recomputed hash 6e1be9ab…e0ef
|
|
138
|
+
|
|
139
|
+
That ✗ is the whole point: the record was changed, and verification proved it.
|
|
140
|
+
Nothing here touched the network. You can run the same check on your own ledger.
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The `demo` command exits `1` — its whole job is to prove that a tampered ledger fails verification. (The hashes vary run to run because each record's timestamp is real; the chain is self-consistent every time.)
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Enforce vs. prove
|
|
148
|
+
|
|
149
|
+
These are two different jobs, and you want both:
|
|
150
|
+
|
|
151
|
+
- **A policy layer *enforces*** — it decides, at run time, whether an action is allowed and blocks the ones that aren't.
|
|
152
|
+
- **`omega-audit` *proves*** — it produces the independent, tamper-evident evidence trail of what actually happened, which you can verify after the fact.
|
|
153
|
+
|
|
154
|
+
Enforcement without evidence is a claim. Evidence without enforcement is a bystander. Run your guardrails to stop bad actions; run `omega-audit` to prove — to yourself, to an auditor, to a customer — exactly what your agent did and that the record wasn't altered.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Anchor to the transparency log (optional)
|
|
159
|
+
|
|
160
|
+
Your local ledger is tamper-evident to *anyone who has the file*. To make it verifiable *across organizations* — so a customer or auditor who does **not** have your machine can still confirm the record — you anchor the ledger's root to a public, append-only transparency log.
|
|
161
|
+
|
|
162
|
+
The record hashing here is byte-compatible with the OmegaEngine platform's canonical JSON + SHA-256 **and with the TypeScript twin** — a ledger written by this Python package verifies under `@omegaengine/audit` and vice versa, so it is already in the shape the hosted log expects. [OmegaEngine](https://omegaengine.ai) offers, as a hosted backend:
|
|
163
|
+
|
|
164
|
+
- **Anchoring** your ledger root into a public RFC 6962 transparency log for cross-org, independently-checkable proof of inclusion.
|
|
165
|
+
- **Long-retention storage** to support multi-year record-keeping regimes (for example SEC Rule 17a-4 or HIPAA retention).
|
|
166
|
+
- **Auditor exports** — signed, verifiable bundles an auditor can check with the open-source [`@omegaengine/verify`](https://github.com/TheArkhitect/Omegaengine/tree/main/packages/omega-verify) tool, no OmegaEngine account required.
|
|
167
|
+
|
|
168
|
+
The local package is fully useful on its own and stays free and open source. The hosted log is the optional upgrade when "verifiable on my disk" needs to become "verifiable by someone else."
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## API
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
Ledger(path: str)
|
|
176
|
+
ledger.append(action) -> dict # append one record; returns it
|
|
177
|
+
ledger.size() -> int
|
|
178
|
+
ledger.tip_hash() -> str # hash of the last record (chain tip)
|
|
179
|
+
|
|
180
|
+
wrap_tool(ledger, name, fn) -> fn # wrap a named tool (sync or async); records every call
|
|
181
|
+
capture(ledger, fn) -> fn # like wrap_tool, using fn.__name__
|
|
182
|
+
|
|
183
|
+
verify(path: str) -> VerifyResult
|
|
184
|
+
# VerifyResult(valid, records, broken_at, reason)
|
|
185
|
+
|
|
186
|
+
read_ledger(path) -> list[dict] # parse JSONL into records (no chain check)
|
|
187
|
+
verify_entries(entries) -> VerifyResult # verify already-parsed records
|
|
188
|
+
canonical_json(value) -> str # the deterministic serializer used for hashing
|
|
189
|
+
sha256_hex(s) -> str
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
A ledger entry is a dict `{seq, ts, action, prevHash, hash}`.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Design partners
|
|
197
|
+
|
|
198
|
+
We're looking for teams putting agents in front of real users or real money who need to *prove* what those agents did. If you're wiring agent actions into an audit or compliance story — EU AI Act, financial record-keeping, healthcare — we'd like to build with you. Open an issue on [github.com/TheArkhitect/Omegaengine](https://github.com/TheArkhitect/Omegaengine/discussions) or reach out via [omegaengine.ai](https://omegaengine.ai).
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
Apache-2.0. See the repository root for the full text.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""omega-audit — tamper-evident audit + provenance for AI agents (Python twin).
|
|
2
|
+
|
|
3
|
+
A local, offline, append-only, hash-chained ledger: wrap your agent's tool
|
|
4
|
+
calls, then PROVE what it did. Any edit, reorder, insertion, or deletion of a
|
|
5
|
+
record breaks the chain and fails verification — you confirm it yourself, with
|
|
6
|
+
no network, no signup, no trust in OmegaEngine.
|
|
7
|
+
|
|
8
|
+
Zero runtime dependencies: only Python's stdlib (``hashlib``, ``json``, ``base64``,
|
|
9
|
+
``datetime``, ``time``).
|
|
10
|
+
|
|
11
|
+
The record hashing is BYTE-IDENTICAL to the OmegaEngine platform's canonical
|
|
12
|
+
JSON + SHA-256 and to the TypeScript twin ``@omegaengine/audit``. A ledger you
|
|
13
|
+
capture locally in Python verifies against a TS one and can be anchored,
|
|
14
|
+
unchanged, to the hosted transparency log for cross-org verifiability.
|
|
15
|
+
|
|
16
|
+
Public API (mirrors the TS twin 1:1, Pythonic names):
|
|
17
|
+
|
|
18
|
+
Ledger(path) .append(action) -> dict, .size(), .tip_hash()
|
|
19
|
+
wrap_tool(ledger, name, fn) wrap a named tool (sync or async)
|
|
20
|
+
capture(ledger, fn) like wrap_tool, using fn.__name__
|
|
21
|
+
verify(path) -> VerifyResult
|
|
22
|
+
verify_entries(entries) -> VerifyResult
|
|
23
|
+
read_ledger(path) -> list[dict]
|
|
24
|
+
canonical_json(value) -> str
|
|
25
|
+
sha256_hex(s) -> str
|
|
26
|
+
GENESIS_PREV_HASH
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from .canonical import (
|
|
32
|
+
GENESIS_PREV_HASH,
|
|
33
|
+
canonical_json,
|
|
34
|
+
hash_entry,
|
|
35
|
+
sha256_hex,
|
|
36
|
+
)
|
|
37
|
+
from .ledger import Ledger, read_ledger
|
|
38
|
+
from .verify import VerifyResult, verify, verify_entries
|
|
39
|
+
from .wrap import capture, wrap_tool
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"Ledger",
|
|
45
|
+
"read_ledger",
|
|
46
|
+
"verify",
|
|
47
|
+
"verify_entries",
|
|
48
|
+
"VerifyResult",
|
|
49
|
+
"wrap_tool",
|
|
50
|
+
"capture",
|
|
51
|
+
"canonical_json",
|
|
52
|
+
"sha256_hex",
|
|
53
|
+
"hash_entry",
|
|
54
|
+
"GENESIS_PREV_HASH",
|
|
55
|
+
"__version__",
|
|
56
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Canonical JSON + SHA-256 — byte-identical to the TypeScript twin.
|
|
2
|
+
|
|
3
|
+
This is the load-bearing compatibility layer. The record ``hash`` is
|
|
4
|
+
``sha256(canonical_json({seq, ts, action, prev_hash}))``, and it MUST produce
|
|
5
|
+
the exact same bytes as:
|
|
6
|
+
|
|
7
|
+
* the OmegaEngine platform (``lib/crypto/canonicalJson.ts``),
|
|
8
|
+
* ``@omegaengine/verify``,
|
|
9
|
+
* the TypeScript twin ``@omegaengine/audit`` (``packages/omega-audit/src/index.ts``).
|
|
10
|
+
|
|
11
|
+
If it does not, a Python-captured ledger will not verify against a TS one and
|
|
12
|
+
cannot be anchored to the hosted transparency log — which is the whole point of
|
|
13
|
+
the cross-language design.
|
|
14
|
+
|
|
15
|
+
Canonicalization rules (mirroring the TS ``canonicalJson``):
|
|
16
|
+
|
|
17
|
+
* objects: keys sorted lexicographically, recursively;
|
|
18
|
+
* arrays: order preserved;
|
|
19
|
+
* ``None`` -> ``null`` (kept);
|
|
20
|
+
* ``undefined`` has no Python analogue — object keys whose value normalizes to
|
|
21
|
+
"drop" are omitted (see below); inside arrays a dropped element becomes null,
|
|
22
|
+
matching ``JSON.stringify``;
|
|
23
|
+
* bool: kept (``True`` -> ``true``); note bools are checked BEFORE ints because
|
|
24
|
+
``isinstance(True, int)`` is ``True`` in Python;
|
|
25
|
+
* numbers: non-finite (NaN/inf) -> ``null`` (matches ``JSON.stringify``);
|
|
26
|
+
* bytes / bytearray -> base64 (matches Buffer/Uint8Array -> base64);
|
|
27
|
+
* strings kept verbatim (non-ASCII NOT escaped, matching ``ensure_ascii=False``).
|
|
28
|
+
|
|
29
|
+
Zero runtime dependencies: only ``hashlib``, ``json``, ``base64`` from stdlib.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import base64
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
import math
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
# The genesis link: 64 hex zeros (the "prev_hash" of the very first record).
|
|
41
|
+
GENESIS_PREV_HASH = "0" * 64
|
|
42
|
+
|
|
43
|
+
# Sentinel meaning "this value has no JSON representation and should be dropped
|
|
44
|
+
# from an object" — the Python analogue of JavaScript's `undefined`. It is never
|
|
45
|
+
# emitted; in arrays it is replaced by null, exactly like `JSON.stringify`.
|
|
46
|
+
_DROP = object()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _normalize(value: Any, seen: set[int]) -> Any:
|
|
50
|
+
"""Recursively normalize a value into JSON-canonical primitives.
|
|
51
|
+
|
|
52
|
+
Returns the sentinel ``_DROP`` for values that ``JSON.stringify`` would omit
|
|
53
|
+
from an object (functions/undefined have no Python equivalent, so the only
|
|
54
|
+
droppable case here is a plain, unserializable object — we raise on those
|
|
55
|
+
instead to fail loud rather than silently lose data).
|
|
56
|
+
"""
|
|
57
|
+
if value is None:
|
|
58
|
+
return None
|
|
59
|
+
# bool BEFORE int: isinstance(True, int) is True in Python.
|
|
60
|
+
if isinstance(value, bool):
|
|
61
|
+
return value
|
|
62
|
+
if isinstance(value, str):
|
|
63
|
+
return value
|
|
64
|
+
if isinstance(value, int):
|
|
65
|
+
return value
|
|
66
|
+
if isinstance(value, float):
|
|
67
|
+
# JSON.stringify turns NaN/Infinity into null; match that.
|
|
68
|
+
return value if math.isfinite(value) else None
|
|
69
|
+
if isinstance(value, (bytes, bytearray)):
|
|
70
|
+
# Buffer/Uint8Array -> base64 in the TS twin.
|
|
71
|
+
return base64.b64encode(bytes(value)).decode("ascii")
|
|
72
|
+
if isinstance(value, (list, tuple)):
|
|
73
|
+
out = []
|
|
74
|
+
for item in value:
|
|
75
|
+
n = _normalize(item, seen)
|
|
76
|
+
# In arrays, JSON.stringify turns undefined/dropped into null.
|
|
77
|
+
out.append(None if n is _DROP else n)
|
|
78
|
+
return out
|
|
79
|
+
if isinstance(value, dict):
|
|
80
|
+
obj_id = id(value)
|
|
81
|
+
if obj_id in seen:
|
|
82
|
+
raise ValueError("[canonical_json] cycle detected")
|
|
83
|
+
seen.add(obj_id)
|
|
84
|
+
out_obj: dict[str, Any] = {}
|
|
85
|
+
# Sort keys lexicographically. Keys must be strings for JSON; coerce
|
|
86
|
+
# non-str keys via str() the way json.dumps(sort_keys=True) would reject,
|
|
87
|
+
# so we normalize to str first to keep sorting well-defined.
|
|
88
|
+
for k in sorted(value.keys(), key=lambda x: str(x)):
|
|
89
|
+
n = _normalize(value[k], seen)
|
|
90
|
+
if n is _DROP:
|
|
91
|
+
continue
|
|
92
|
+
out_obj[str(k)] = n
|
|
93
|
+
seen.discard(obj_id)
|
|
94
|
+
return out_obj
|
|
95
|
+
# Anything else (a custom object, a set, etc.) has no defined canonical form.
|
|
96
|
+
# Fail loud rather than emit bytes that would not match the TS twin.
|
|
97
|
+
raise TypeError(
|
|
98
|
+
f"[canonical_json] value of type {type(value).__name__!r} is not JSON-canonicalizable"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def canonical_json(value: Any) -> str:
|
|
103
|
+
"""Deterministically serialize ``value`` to a canonical JSON string.
|
|
104
|
+
|
|
105
|
+
Byte-identical to the TS ``canonicalJson``. Equivalent to
|
|
106
|
+
``json.dumps(normalized, sort_keys=True, separators=(",", ":"),
|
|
107
|
+
ensure_ascii=False)`` where ``normalized`` has already had object keys
|
|
108
|
+
recursively sorted, non-finite numbers folded to null, and unserializable
|
|
109
|
+
values dropped/base64-encoded per the rules above.
|
|
110
|
+
|
|
111
|
+
We still pass ``sort_keys=True`` to ``json.dumps`` as belt-and-suspenders,
|
|
112
|
+
but the recursive normalization above is what guarantees the byte match.
|
|
113
|
+
"""
|
|
114
|
+
norm = _normalize(value, set())
|
|
115
|
+
return json.dumps(
|
|
116
|
+
norm,
|
|
117
|
+
sort_keys=True,
|
|
118
|
+
ensure_ascii=False,
|
|
119
|
+
separators=(",", ":"),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def sha256_hex(s: str) -> str:
|
|
124
|
+
"""SHA-256 hex digest of a string (UTF-8) — the platform's record primitive."""
|
|
125
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def hash_entry(seq: int, ts: str, action: Any, prev_hash: str) -> str:
|
|
129
|
+
"""Compute an entry's canonical hash from its content (everything but ``hash``).
|
|
130
|
+
|
|
131
|
+
``hash = sha256(canonical_json({seq, ts, action, prev_hash}))``. The key
|
|
132
|
+
names in the hashed object use the platform's camelCase (``prevHash``) so the
|
|
133
|
+
bytes match the TS twin exactly — this differs from the Pythonic
|
|
134
|
+
``prev_hash`` field name used on the returned dict.
|
|
135
|
+
"""
|
|
136
|
+
content = {"seq": seq, "ts": ts, "action": action, "prevHash": prev_hash}
|
|
137
|
+
return sha256_hex(canonical_json(content))
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""omega-audit — tamper-evident audit for AI agents (CLI).
|
|
2
|
+
|
|
3
|
+
omega-audit verify <ledger.jsonl> check a ledger's chain (exit 0 intact, 1 tampered)
|
|
4
|
+
omega-audit demo build a 3-record ledger, tamper it, show verify FAIL
|
|
5
|
+
|
|
6
|
+
Pure, offline, zero-dependency (stdlib only). Exit codes:
|
|
7
|
+
0 = chain intact 1 = tamper detected 2 = usage error.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
from .ledger import Ledger, read_ledger
|
|
18
|
+
from .verify import verify
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _usage() -> int:
|
|
22
|
+
sys.stderr.write("usage:\n")
|
|
23
|
+
sys.stderr.write(" omega-audit verify <ledger.jsonl> verify a ledger's hash-chain\n")
|
|
24
|
+
sys.stderr.write(" omega-audit demo see a tampered record fail verification\n")
|
|
25
|
+
return 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _print_verify(path: str) -> int:
|
|
29
|
+
r = verify(path)
|
|
30
|
+
if r.valid:
|
|
31
|
+
print(f"✓ {r.records} records — chain intact & tamper-evident")
|
|
32
|
+
return 0
|
|
33
|
+
if (r.reason or "").startswith("no such file"):
|
|
34
|
+
# Missing file ≠ tamper — but it must still fail closed (exit 1).
|
|
35
|
+
print(f"✗ CANNOT VERIFY: {r.reason}")
|
|
36
|
+
return 1
|
|
37
|
+
print(f"✗ TAMPER DETECTED at record {r.broken_at}: {r.reason}")
|
|
38
|
+
return 1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run_demo() -> int:
|
|
42
|
+
print("omega-audit demo — tamper-evident agent audit in one command\n")
|
|
43
|
+
|
|
44
|
+
tmp_dir = tempfile.mkdtemp(prefix="omega-audit-demo-")
|
|
45
|
+
path = os.path.join(tmp_dir, "ledger.jsonl")
|
|
46
|
+
ledger = Ledger(path)
|
|
47
|
+
|
|
48
|
+
# 1) Capture three sample agent actions into a hash-chained ledger.
|
|
49
|
+
print("1. An agent runs three tool calls. Each one is appended to a hash-chained ledger:\n")
|
|
50
|
+
ledger.append({"tool": "search_web", "args": {"query": "EU AI Act Article 12"}, "ok": True, "durationMs": 214})
|
|
51
|
+
ledger.append({"tool": "read_file", "args": {"path": "/reports/q3.md"}, "ok": True, "durationMs": 8})
|
|
52
|
+
ledger.append({"tool": "send_email", "args": {"to": "auditor@example.com", "subject": "Q3 evidence"}, "ok": True, "durationMs": 501})
|
|
53
|
+
|
|
54
|
+
for e in read_ledger(path):
|
|
55
|
+
tool = e["action"].get("tool") if isinstance(e["action"], dict) else None
|
|
56
|
+
print(f" #{e['seq']} {tool} hash={e['hash'][:12]}… prev={e['prevHash'][:12]}…")
|
|
57
|
+
|
|
58
|
+
# 2) Verify the untouched ledger — it passes.
|
|
59
|
+
print("\n2. Verify the untouched ledger:\n")
|
|
60
|
+
print(" $ omega-audit verify ledger.jsonl")
|
|
61
|
+
sys.stdout.write(" ")
|
|
62
|
+
_print_verify(path)
|
|
63
|
+
|
|
64
|
+
# 3) Tamper with one record — change what the agent claims it did.
|
|
65
|
+
print("\n3. Now someone quietly edits record #2 to hide who the email went to:\n")
|
|
66
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
67
|
+
lines = fh.read().strip().split("\n")
|
|
68
|
+
tampered = json.loads(lines[2])
|
|
69
|
+
before = tampered["action"]["args"].get("to")
|
|
70
|
+
print(f" before: send_email → {before}")
|
|
71
|
+
tampered["action"]["args"]["to"] = "attacker@evil.example" # edit payload, leave the hash
|
|
72
|
+
print(f" after: send_email → {tampered['action']['args']['to']} (hash left unchanged to hide the edit)")
|
|
73
|
+
lines[2] = json.dumps(tampered, ensure_ascii=False, separators=(",", ":"))
|
|
74
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
75
|
+
fh.write("\n".join(lines) + "\n")
|
|
76
|
+
|
|
77
|
+
# 4) Verify again — the recomputed hash no longer matches. Tamper is caught.
|
|
78
|
+
print("\n4. Verify again — the chain no longer recomputes:\n")
|
|
79
|
+
print(" $ omega-audit verify ledger.jsonl")
|
|
80
|
+
sys.stdout.write(" ")
|
|
81
|
+
code = _print_verify(path)
|
|
82
|
+
|
|
83
|
+
print("\nThat ✗ is the whole point: the record was changed, and verification proved it.")
|
|
84
|
+
print("Nothing here touched the network. You can run the same check on your own ledger.")
|
|
85
|
+
return code # 1 when the tamper was caught — that is the expected demo result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv: list[str] | None = None) -> int:
|
|
89
|
+
args = sys.argv[1:] if argv is None else argv
|
|
90
|
+
cmd = args[0] if args else None
|
|
91
|
+
|
|
92
|
+
if cmd == "verify":
|
|
93
|
+
if len(args) < 2 or not args[1]:
|
|
94
|
+
return _usage()
|
|
95
|
+
return _print_verify(args[1])
|
|
96
|
+
if cmd == "demo":
|
|
97
|
+
# `demo` exits 1: its job is to prove a tampered ledger FAILS verification,
|
|
98
|
+
# and the resulting exit-1 is the signal a CI check or a reader can rely on.
|
|
99
|
+
return _run_demo()
|
|
100
|
+
return _usage()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
sys.exit(main())
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The append-only, hash-chained ledger.
|
|
2
|
+
|
|
3
|
+
A ledger is a plain JSONL file: one JSON object per line. Each line is an entry
|
|
4
|
+
|
|
5
|
+
{"seq", "ts", "action", "prevHash", "hash"}
|
|
6
|
+
|
|
7
|
+
written with the SAME field names the TypeScript twin writes (note ``prevHash``
|
|
8
|
+
in camelCase on disk — this is deliberate: it makes a Python-written ledger and
|
|
9
|
+
a TS-written ledger the same bytes, so they cross-verify and can be anchored to
|
|
10
|
+
the same transparency log). The genesis entry's ``prevHash`` is 64 zeros.
|
|
11
|
+
|
|
12
|
+
Offline: no network, no signup. Each ``append`` reads the tail hash, links the
|
|
13
|
+
new record to it, hashes the record, and writes one line.
|
|
14
|
+
|
|
15
|
+
Zero runtime dependencies: only ``json``/``time`` from stdlib (plus ``canonical``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from typing import Any, Dict, List
|
|
24
|
+
|
|
25
|
+
from .canonical import GENESIS_PREV_HASH, hash_entry
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _iso_now() -> str:
|
|
29
|
+
"""UTC timestamp in the same shape the TS twin uses: ``…T…Z`` with millis.
|
|
30
|
+
|
|
31
|
+
``new Date().toISOString()`` yields e.g. ``2026-08-02T09:00:00.000Z``. We
|
|
32
|
+
match that format (milliseconds, ``Z`` suffix) so timestamps look identical
|
|
33
|
+
across the two implementations.
|
|
34
|
+
"""
|
|
35
|
+
now = datetime.now(timezone.utc)
|
|
36
|
+
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_ledger(path: str) -> List[Dict[str, Any]]:
|
|
40
|
+
"""Read a JSONL ledger file into a list of entry dicts.
|
|
41
|
+
|
|
42
|
+
Ignores blank lines; raises ``ValueError`` on a line that is not valid JSON
|
|
43
|
+
(a structurally corrupt file). Does NOT check the chain — use ``verify``.
|
|
44
|
+
Each returned dict has the on-disk keys: ``seq, ts, action, prevHash, hash``.
|
|
45
|
+
"""
|
|
46
|
+
if not os.path.exists(path):
|
|
47
|
+
return []
|
|
48
|
+
out: List[Dict[str, Any]] = []
|
|
49
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
50
|
+
for i, raw in enumerate(fh.read().split("\n")):
|
|
51
|
+
line = raw.strip()
|
|
52
|
+
if line == "":
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
parsed = json.loads(line)
|
|
56
|
+
except json.JSONDecodeError as exc:
|
|
57
|
+
raise ValueError(f"[read_ledger] line {i + 1} is not valid JSON") from exc
|
|
58
|
+
out.append(parsed)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Ledger:
|
|
63
|
+
"""An append-only, hash-chained ledger persisted as JSONL at ``path``.
|
|
64
|
+
|
|
65
|
+
Mirrors the TS ``Ledger`` interface: ``append(action) -> dict``, ``size()``,
|
|
66
|
+
``tip_hash()``.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, path: str) -> None:
|
|
70
|
+
self.path = path
|
|
71
|
+
|
|
72
|
+
def _entries(self) -> List[Dict[str, Any]]:
|
|
73
|
+
return read_ledger(self.path)
|
|
74
|
+
|
|
75
|
+
def size(self) -> int:
|
|
76
|
+
"""Number of records currently in the ledger file."""
|
|
77
|
+
return len(self._entries())
|
|
78
|
+
|
|
79
|
+
def tip_hash(self) -> str:
|
|
80
|
+
"""Hash of the last record (or ``GENESIS_PREV_HASH`` if empty) — the chain tip."""
|
|
81
|
+
entries = self._entries()
|
|
82
|
+
return GENESIS_PREV_HASH if not entries else entries[-1]["hash"]
|
|
83
|
+
|
|
84
|
+
def append(self, action: Any) -> Dict[str, Any]:
|
|
85
|
+
"""Append an ``action``.
|
|
86
|
+
|
|
87
|
+
Reads the current tail hash, builds the next linked+hashed record, and
|
|
88
|
+
writes one JSON line. Returns the persisted entry dict, keyed with the
|
|
89
|
+
on-disk field names ``{seq, ts, action, prevHash, hash}`` (so it matches
|
|
90
|
+
the TS twin's returned entry byte-for-byte when re-serialized).
|
|
91
|
+
"""
|
|
92
|
+
entries = self._entries()
|
|
93
|
+
seq = len(entries)
|
|
94
|
+
prev_hash = GENESIS_PREV_HASH if not entries else entries[-1]["hash"]
|
|
95
|
+
ts = _iso_now()
|
|
96
|
+
entry_hash = hash_entry(seq, ts, action, prev_hash)
|
|
97
|
+
entry = {
|
|
98
|
+
"seq": seq,
|
|
99
|
+
"ts": ts,
|
|
100
|
+
"action": action,
|
|
101
|
+
"prevHash": prev_hash,
|
|
102
|
+
"hash": entry_hash,
|
|
103
|
+
}
|
|
104
|
+
# One JSON line, no whitespace, non-ASCII preserved — same shape the TS
|
|
105
|
+
# twin writes via JSON.stringify.
|
|
106
|
+
line = json.dumps(entry, ensure_ascii=False, separators=(",", ":"))
|
|
107
|
+
with open(self.path, "a", encoding="utf-8") as fh:
|
|
108
|
+
fh.write(line + "\n")
|
|
109
|
+
return entry
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Chain verification — recompute the whole chain and report any tampering.
|
|
2
|
+
|
|
3
|
+
Detects ANY tampering — an edit to any field, a reordering, an inserted record,
|
|
4
|
+
or a deleted record — because each record's ``hash`` covers its content and its
|
|
5
|
+
``prevHash`` must equal the previous record's ``hash``. Pure and offline. This
|
|
6
|
+
is the same logic as the TS twin's ``verify`` / ``verifyEntries``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from .canonical import GENESIS_PREV_HASH, hash_entry
|
|
16
|
+
from .ledger import read_ledger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class VerifyResult:
|
|
21
|
+
"""Outcome of verifying a ledger.
|
|
22
|
+
|
|
23
|
+
``broken_at`` is the zero-based index of the first record that fails the
|
|
24
|
+
chain (``None`` when valid). ``reason`` is a human-readable explanation,
|
|
25
|
+
present iff ``not valid``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
valid: bool
|
|
29
|
+
records: int
|
|
30
|
+
broken_at: Optional[int] = None
|
|
31
|
+
reason: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _short(h: Any) -> str:
|
|
35
|
+
if isinstance(h, str) and len(h) > 12:
|
|
36
|
+
return f"{h[:8]}…{h[-4:]}"
|
|
37
|
+
return str(h)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def verify_entries(entries: List[Dict[str, Any]]) -> VerifyResult:
|
|
41
|
+
"""Verify already-parsed entry dicts (same logic as ``verify`` without I/O)."""
|
|
42
|
+
expected_prev = GENESIS_PREV_HASH
|
|
43
|
+
for i, e in enumerate(entries):
|
|
44
|
+
if not isinstance(e, dict):
|
|
45
|
+
return VerifyResult(False, len(entries), i, f"record {i} is not an object")
|
|
46
|
+
|
|
47
|
+
if e.get("seq") != i:
|
|
48
|
+
return VerifyResult(
|
|
49
|
+
False,
|
|
50
|
+
len(entries),
|
|
51
|
+
i,
|
|
52
|
+
f"record {i} has seq {e.get('seq')} — expected {i} "
|
|
53
|
+
"(a record was reordered, inserted, or deleted)",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
prev = e.get("prevHash")
|
|
57
|
+
if prev != expected_prev:
|
|
58
|
+
if i == 0:
|
|
59
|
+
reason = (
|
|
60
|
+
f"record 0 must link to the genesis hash "
|
|
61
|
+
f"({GENESIS_PREV_HASH[:8]}…) but links to {_short(prev)}"
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
reason = (
|
|
65
|
+
f"record {i} does not link to record {i - 1} "
|
|
66
|
+
f"(prevHash {_short(prev)} ≠ {_short(expected_prev)}) "
|
|
67
|
+
"— a record was altered, inserted, or deleted"
|
|
68
|
+
)
|
|
69
|
+
return VerifyResult(False, len(entries), i, reason)
|
|
70
|
+
|
|
71
|
+
recomputed = hash_entry(e.get("seq"), e.get("ts"), e.get("action"), e.get("prevHash"))
|
|
72
|
+
if recomputed != e.get("hash"):
|
|
73
|
+
return VerifyResult(
|
|
74
|
+
False,
|
|
75
|
+
len(entries),
|
|
76
|
+
i,
|
|
77
|
+
f"record {i} was edited — its stored hash {_short(e.get('hash'))} "
|
|
78
|
+
f"≠ the recomputed hash {_short(recomputed)}",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
expected_prev = e["hash"]
|
|
82
|
+
|
|
83
|
+
return VerifyResult(True, len(entries))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def verify(path: str) -> VerifyResult:
|
|
87
|
+
"""Recompute the entire chain from a JSONL ledger file and report integrity."""
|
|
88
|
+
# Fail closed: read_ledger treats a missing file as an empty ledger (right for
|
|
89
|
+
# the append path), but for verification "file not found" must never report
|
|
90
|
+
# "chain intact" — an auditor with a typo'd path would get a green check.
|
|
91
|
+
if not os.path.exists(path):
|
|
92
|
+
return VerifyResult(False, 0, 0, f"no such file: {path}")
|
|
93
|
+
try:
|
|
94
|
+
entries = read_ledger(path)
|
|
95
|
+
except Exception as exc: # a structurally corrupt file is itself a failure
|
|
96
|
+
return VerifyResult(False, 0, 0, str(exc))
|
|
97
|
+
return verify_entries(entries)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Wrapping tool calls — record every invocation to the ledger.
|
|
2
|
+
|
|
3
|
+
``wrap_tool(ledger, name, fn)`` returns a wrapper that appends a
|
|
4
|
+
``{"tool", "args", "ok", "result"|"error", "duration_ms"}`` record for every
|
|
5
|
+
call, then returns or raises EXACTLY as the original did.
|
|
6
|
+
|
|
7
|
+
Python agents are frequently async, so both sync and async callables are
|
|
8
|
+
supported: if ``fn`` is a coroutine function the wrapper is itself a coroutine
|
|
9
|
+
function (so ``await wrapped(...)`` works and the record is written when the
|
|
10
|
+
awaited call settles); otherwise the wrapper is an ordinary function.
|
|
11
|
+
|
|
12
|
+
The recorded ``args`` mirror the TS twin: positional arguments as a list. When
|
|
13
|
+
keyword arguments are also passed, they are recorded under a ``kwargs`` key so
|
|
14
|
+
nothing about the call is lost.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import functools
|
|
20
|
+
import inspect
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
from .ledger import Ledger
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _record_args(args: tuple, kwargs: dict) -> Any:
|
|
28
|
+
"""Shape call arguments for the ledger.
|
|
29
|
+
|
|
30
|
+
Positional-only calls record ``args`` as a plain list (identical to the TS
|
|
31
|
+
twin, where a tool is called positionally). If keyword arguments are present
|
|
32
|
+
we record ``{"args": [...], "kwargs": {...}}`` so the record stays complete.
|
|
33
|
+
"""
|
|
34
|
+
if kwargs:
|
|
35
|
+
return {"args": list(args), "kwargs": dict(kwargs)}
|
|
36
|
+
return list(args)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def wrap_tool(ledger: Ledger, name: str, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
40
|
+
"""Wrap a named tool so every call is appended to ``ledger``.
|
|
41
|
+
|
|
42
|
+
Works for sync and async ``fn``. Returns/raises unchanged; on a raised
|
|
43
|
+
exception the record is written with ``ok=False`` and the error message, and
|
|
44
|
+
the exception is re-raised (never swallowed).
|
|
45
|
+
"""
|
|
46
|
+
if inspect.iscoroutinefunction(fn):
|
|
47
|
+
|
|
48
|
+
@functools.wraps(fn)
|
|
49
|
+
async def async_wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
50
|
+
start = time.monotonic()
|
|
51
|
+
try:
|
|
52
|
+
result = await fn(*args, **kwargs)
|
|
53
|
+
ledger.append(
|
|
54
|
+
{
|
|
55
|
+
"tool": name,
|
|
56
|
+
"args": _record_args(args, kwargs),
|
|
57
|
+
"ok": True,
|
|
58
|
+
"result": result,
|
|
59
|
+
"duration_ms": int((time.monotonic() - start) * 1000),
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
return result
|
|
63
|
+
except Exception as err: # noqa: BLE001 — record then re-raise unchanged
|
|
64
|
+
ledger.append(
|
|
65
|
+
{
|
|
66
|
+
"tool": name,
|
|
67
|
+
"args": _record_args(args, kwargs),
|
|
68
|
+
"ok": False,
|
|
69
|
+
"error": str(err),
|
|
70
|
+
"duration_ms": int((time.monotonic() - start) * 1000),
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
return async_wrapped
|
|
76
|
+
|
|
77
|
+
@functools.wraps(fn)
|
|
78
|
+
def sync_wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
79
|
+
start = time.monotonic()
|
|
80
|
+
try:
|
|
81
|
+
result = fn(*args, **kwargs)
|
|
82
|
+
ledger.append(
|
|
83
|
+
{
|
|
84
|
+
"tool": name,
|
|
85
|
+
"args": _record_args(args, kwargs),
|
|
86
|
+
"ok": True,
|
|
87
|
+
"result": result,
|
|
88
|
+
"duration_ms": int((time.monotonic() - start) * 1000),
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
return result
|
|
92
|
+
except Exception as err: # noqa: BLE001 — record then re-raise unchanged
|
|
93
|
+
ledger.append(
|
|
94
|
+
{
|
|
95
|
+
"tool": name,
|
|
96
|
+
"args": _record_args(args, kwargs),
|
|
97
|
+
"ok": False,
|
|
98
|
+
"error": str(err),
|
|
99
|
+
"duration_ms": int((time.monotonic() - start) * 1000),
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
return sync_wrapped
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def capture(ledger: Ledger, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
108
|
+
"""Convenience alias: wrap a tool under its own ``__name__``.
|
|
109
|
+
|
|
110
|
+
Equivalent to ``wrap_tool(ledger, fn.__name__, fn)``. Handy for wrapping
|
|
111
|
+
named functions inline.
|
|
112
|
+
"""
|
|
113
|
+
name = getattr(fn, "__name__", "") or "anonymous"
|
|
114
|
+
return wrap_tool(ledger, name, fn)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "omega-audit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Tamper-evident audit + provenance for AI agents — an offline, zero-dependency, hash-chained append-only ledger. Wrap your agent's tool calls, then prove what it did: any edit, reorder, insertion, or deletion fails verification."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "OmegaEngine", email = "team@omegaengine.ai" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"ai-agents",
|
|
17
|
+
"audit-log",
|
|
18
|
+
"tamper-evident",
|
|
19
|
+
"hash-chain",
|
|
20
|
+
"provenance",
|
|
21
|
+
"append-only",
|
|
22
|
+
"eu-ai-act",
|
|
23
|
+
"compliance",
|
|
24
|
+
"sha256",
|
|
25
|
+
"omegaengine",
|
|
26
|
+
]
|
|
27
|
+
classifiers = [
|
|
28
|
+
"Development Status :: 4 - Beta",
|
|
29
|
+
"Intended Audience :: Developers",
|
|
30
|
+
"License :: OSI Approved :: Apache Software License",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.9",
|
|
33
|
+
"Programming Language :: Python :: 3.10",
|
|
34
|
+
"Programming Language :: Python :: 3.11",
|
|
35
|
+
"Programming Language :: Python :: 3.12",
|
|
36
|
+
"Programming Language :: Python :: 3.13",
|
|
37
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
38
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
39
|
+
"Topic :: Security",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Zero runtime dependencies — stdlib only (hashlib / json / base64 / datetime).
|
|
43
|
+
dependencies = []
|
|
44
|
+
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=7.0",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[project.scripts]
|
|
51
|
+
omega-audit = "omega_audit.cli:main"
|
|
52
|
+
|
|
53
|
+
[project.urls]
|
|
54
|
+
Homepage = "https://omegaengine.ai"
|
|
55
|
+
Documentation = "https://github.com/TheArkhitect/Omegaengine/tree/main/packages/omega-audit-py#readme"
|
|
56
|
+
Repository = "https://github.com/TheArkhitect/Omegaengine/tree/main/packages/omega-audit-py"
|
|
57
|
+
Issues = "https://github.com/TheArkhitect/Omegaengine/issues"
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.sdist]
|
|
60
|
+
include = [
|
|
61
|
+
"/omega_audit",
|
|
62
|
+
"/README.md",
|
|
63
|
+
"/LICENSE",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.hatch.build.targets.wheel]
|
|
67
|
+
packages = ["omega_audit"]
|
|
68
|
+
|
|
69
|
+
[tool.pytest.ini_options]
|
|
70
|
+
testpaths = ["tests"]
|