auditgate 0.2.1__tar.gz → 0.3.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.
- auditgate-0.3.0/CLAUDE.md +81 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/PKG-INFO +1 -1
- {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/__init__.py +4 -2
- {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/cli.py +8 -5
- {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/core.py +5 -6
- {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/emitter.py +2 -1
- auditgate-0.3.0/auditgate/engine.py +385 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/store.py +94 -7
- {auditgate-0.2.1 → auditgate-0.3.0}/pyproject.toml +2 -1
- auditgate-0.3.0/tests/test_async.py +492 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/tests/test_auditgate.py +32 -31
- {auditgate-0.2.1 → auditgate-0.3.0}/tests/test_cli.py +24 -23
- {auditgate-0.2.1 → auditgate-0.3.0}/tests/test_golden_vectors.py +5 -2
- auditgate-0.2.1/auditgate/engine.py +0 -209
- {auditgate-0.2.1 → auditgate-0.3.0}/.gitignore +0 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/CANONICAL_JSON.md +0 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/LICENSE +0 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/README.md +0 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/SEMANTICS.md +0 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/__main__.py +0 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/py.typed +0 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/examples/basic_audit.py +0 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/examples/bench_auditgate.py +0 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/examples/compose_with_gates.py +0 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/examples/fail_closed_guarantee.py +0 -0
- {auditgate-0.2.1 → auditgate-0.3.0}/schemas/auditgate-entry.schema.json +0 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
## Identity
|
|
4
|
+
AuditGate — tamper-evident audit trail primitive for AI agents.
|
|
5
|
+
Hash-chained logging with severity filtering and retention pruning. Part of the ActionGate governance stack. Apache-2.0. Zero dependencies.
|
|
6
|
+
|
|
7
|
+
## Stack
|
|
8
|
+
Python 3.12+ · Hatchling · mypy strict · ruff · PyPI: `auditgate` @ `actiongate-oss`
|
|
9
|
+
|
|
10
|
+
## Layout
|
|
11
|
+
```
|
|
12
|
+
auditgate/ # core.py, engine.py, emitter.py, store.py, cli.py, __main__.py
|
|
13
|
+
tests/ # pytest suite, test_golden_vectors.py, test_cli.py
|
|
14
|
+
SEMANTICS.md # normative spec (governs over implementation)
|
|
15
|
+
pyproject.toml # package config
|
|
16
|
+
benchmarks/ # reproducible perf tests
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
```bash
|
|
21
|
+
pytest tests/ -v # test
|
|
22
|
+
mypy auditgate/ --strict # typecheck
|
|
23
|
+
ruff check auditgate/ # lint
|
|
24
|
+
python -m build && twine upload dist/* # publish
|
|
25
|
+
git log --oneline -5 && git status # session start check
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Inviolable Rules
|
|
29
|
+
- NEVER modify SEMANTICS.md without explicit approval
|
|
30
|
+
- NEVER force-push to main
|
|
31
|
+
- Tests pass before every push
|
|
32
|
+
|
|
33
|
+
## Code Standards
|
|
34
|
+
**Architecture:**
|
|
35
|
+
- Frozen dataclasses with slots: `@dataclass(frozen=True, slots=True)`
|
|
36
|
+
- Enums use `auto()`
|
|
37
|
+
- `_Missing` sentinel pattern with `MISSING` constant and `Result[T]`
|
|
38
|
+
- Engine uses `__slots__`, accepts `store: Store | None`, `clock: Callable`
|
|
39
|
+
- Store is a `Protocol`, MemoryStore uses threading locks
|
|
40
|
+
- Emitter mixin: `on_decision()`, `_emit()`, `listener_errors`
|
|
41
|
+
- Integrity modes: NONE, HASH, CHAIN (tamper-evident)
|
|
42
|
+
- Severity filtering via AuditPolicy
|
|
43
|
+
- Verdict enum: ALLOW, BLOCK, ERROR, OVERRIDE
|
|
44
|
+
|
|
45
|
+
**Style:**
|
|
46
|
+
- Every public symbol has type annotations. No exceptions.
|
|
47
|
+
- No `Any` unless wrapping genuinely dynamic external input
|
|
48
|
+
- Prefer `collections.abc` over `typing` for abstract types
|
|
49
|
+
- Single responsibility: one class, one job. If a docstring needs "and", split it.
|
|
50
|
+
- Functions ≤25 lines. Classes ≤200 lines. Files ≤400 lines. Exceed = refactor.
|
|
51
|
+
- No magic numbers. Named constants or enum members only.
|
|
52
|
+
- Error paths return typed errors, never raise except for programmer bugs
|
|
53
|
+
- Guard clauses at top, happy path unindented. No deep nesting.
|
|
54
|
+
|
|
55
|
+
**Naming:**
|
|
56
|
+
- `snake_case` for functions/variables, `PascalCase` for classes, `UPPER_SNAKE` for constants
|
|
57
|
+
- Boolean vars/params: `is_`, `has_`, `should_`, `can_` prefixes
|
|
58
|
+
- Private: single underscore. No dunder methods unless implementing a protocol.
|
|
59
|
+
|
|
60
|
+
**Testing:**
|
|
61
|
+
- Every public method has at least one test
|
|
62
|
+
- Test names: `test_<method>_<scenario>_<expected>`
|
|
63
|
+
- No mocks unless testing I/O boundaries. Prefer real objects.
|
|
64
|
+
- Edge cases: empty input, boundary values, hash chain verification, retention pruning, CLI output
|
|
65
|
+
|
|
66
|
+
**Performance:**
|
|
67
|
+
- Hot path (record) must be allocation-minimal
|
|
68
|
+
- SHA-256 hashing on hot path is acceptable (required for integrity)
|
|
69
|
+
- Prefer `__slots__` on all frequently instantiated classes
|
|
70
|
+
- Store operations: O(1) lookup, O(n) scan only for admin ops (clear_all)
|
|
71
|
+
|
|
72
|
+
## Cross-Gate Invariants
|
|
73
|
+
All four gates (actiongate, budgetgate, rulegate, auditgate) share identical patterns:
|
|
74
|
+
- Same Engine API: register, check, enforce, guard, guard_result, clear, clear_all
|
|
75
|
+
- Same Store protocol: get, set, delete, keys
|
|
76
|
+
- Same Emitter: on_decision, _emit, listener_errors
|
|
77
|
+
- Same Decision/Result types with __bool__, to_dict
|
|
78
|
+
- Same pyproject.toml: hatchling, >=3.12, Apache-2.0, same extras pattern
|
|
79
|
+
|
|
80
|
+
## Commit Convention
|
|
81
|
+
Imperative mood, scoped: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: auditgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Compliance-grade audit logging for agent systems.
|
|
5
5
|
Project-URL: Homepage, https://github.com/actiongate-oss/auditgate
|
|
6
6
|
Project-URL: Documentation, https://github.com/actiongate-oss/auditgate#readme
|
|
@@ -25,7 +25,7 @@ from .core import (
|
|
|
25
25
|
)
|
|
26
26
|
from .emitter import Emitter
|
|
27
27
|
from .engine import AuditError, Engine
|
|
28
|
-
from .store import MemoryStore, QueryFilter, Store
|
|
28
|
+
from .store import AsyncMemoryStore, AsyncStore, MemoryStore, QueryFilter, Store
|
|
29
29
|
|
|
30
30
|
__all__ = [
|
|
31
31
|
"Trail",
|
|
@@ -47,8 +47,10 @@ __all__ = [
|
|
|
47
47
|
"AuditError",
|
|
48
48
|
"Emitter",
|
|
49
49
|
"Store",
|
|
50
|
+
"AsyncStore",
|
|
50
51
|
"MemoryStore",
|
|
52
|
+
"AsyncMemoryStore",
|
|
51
53
|
"QueryFilter",
|
|
52
54
|
]
|
|
53
55
|
|
|
54
|
-
__version__ = "0.
|
|
56
|
+
__version__ = "0.3.0"
|
|
@@ -19,11 +19,12 @@ import argparse
|
|
|
19
19
|
import json
|
|
20
20
|
import sys
|
|
21
21
|
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
22
23
|
|
|
23
24
|
from .core import compute_hash
|
|
24
25
|
|
|
25
26
|
|
|
26
|
-
def _verify_log(entries: list[dict], verbose: bool = False) -> tuple[bool, list[str]]:
|
|
27
|
+
def _verify_log(entries: list[dict[str, Any]], verbose: bool = False) -> tuple[bool, list[str]]:
|
|
27
28
|
"""Verify a list of serialized audit entries.
|
|
28
29
|
|
|
29
30
|
Works with raw dicts from JSON (no AuditEntry objects needed).
|
|
@@ -35,7 +36,7 @@ def _verify_log(entries: list[dict], verbose: bool = False) -> tuple[bool, list[
|
|
|
35
36
|
return True, ["Empty log — nothing to verify."]
|
|
36
37
|
|
|
37
38
|
# Group by trail
|
|
38
|
-
trails: dict[str, list[dict]] = {}
|
|
39
|
+
trails: dict[str, list[dict[str, Any]]] = {}
|
|
39
40
|
for entry in entries:
|
|
40
41
|
trail = entry.get("trail", "<unknown>")
|
|
41
42
|
trails.setdefault(trail, []).append(entry)
|
|
@@ -78,8 +79,10 @@ def _verify_log(entries: list[dict], verbose: bool = False) -> tuple[bool, list[
|
|
|
78
79
|
prev_entry_hash = prev_entry.get("entry_hash")
|
|
79
80
|
if prev_entry_hash != prev_hash:
|
|
80
81
|
all_valid = False
|
|
82
|
+
prev_seq = prev_entry.get('sequence', '?')
|
|
81
83
|
messages.append(
|
|
82
|
-
f" [{seq}] FAIL — chain break
|
|
84
|
+
f" [{seq}] FAIL — chain break"
|
|
85
|
+
f" (prev_hash does not match entry [{prev_seq}])"
|
|
83
86
|
)
|
|
84
87
|
if verbose:
|
|
85
88
|
messages.append(f" expected prev: {prev_entry_hash}")
|
|
@@ -99,7 +102,7 @@ def _verify_log(entries: list[dict], verbose: bool = False) -> tuple[bool, list[
|
|
|
99
102
|
messages.append(f" [{seq}] OK — {verdict} hash:{entry_hash[:12]}...")
|
|
100
103
|
|
|
101
104
|
if all_valid:
|
|
102
|
-
messages.append(
|
|
105
|
+
messages.append(" VALID — all hashes verified")
|
|
103
106
|
|
|
104
107
|
return all_valid, messages
|
|
105
108
|
|
|
@@ -145,7 +148,7 @@ def _cmd_verify(args: argparse.Namespace) -> int:
|
|
|
145
148
|
if valid:
|
|
146
149
|
print(f"RESULT: VALID ({len(entries)} entries verified)")
|
|
147
150
|
else:
|
|
148
|
-
print(
|
|
151
|
+
print("RESULT: INVALID (chain integrity broken)")
|
|
149
152
|
|
|
150
153
|
return 0 if valid else 1
|
|
151
154
|
|
|
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
import hashlib
|
|
12
12
|
import json
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
|
-
from datetime import
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
15
|
from enum import Enum, auto
|
|
16
16
|
from typing import Any
|
|
17
17
|
|
|
@@ -170,12 +170,12 @@ class Result[T]:
|
|
|
170
170
|
raise RuntimeError(
|
|
171
171
|
f"unwrap() called on dropped audit: {self.decision.reason}"
|
|
172
172
|
)
|
|
173
|
-
return self._value
|
|
173
|
+
return self._value
|
|
174
174
|
|
|
175
175
|
def unwrap_or(self, default: T) -> T:
|
|
176
176
|
if isinstance(self._value, _Missing):
|
|
177
177
|
return default
|
|
178
|
-
return self._value
|
|
178
|
+
return self._value
|
|
179
179
|
|
|
180
180
|
|
|
181
181
|
# ── Integrity ──
|
|
@@ -224,8 +224,7 @@ def verify_chain(entries: list[AuditEntry]) -> tuple[bool, int | None]:
|
|
|
224
224
|
if entry.entry_hash != expected:
|
|
225
225
|
return False, entry.sequence
|
|
226
226
|
|
|
227
|
-
if entry.prev_hash is not None and i > 0:
|
|
228
|
-
if entries[i - 1].entry_hash != entry.prev_hash:
|
|
227
|
+
if entry.prev_hash is not None and i > 0 and entries[i - 1].entry_hash != entry.prev_hash:
|
|
229
228
|
return False, entry.sequence
|
|
230
229
|
|
|
231
230
|
return True, None
|
|
@@ -233,4 +232,4 @@ def verify_chain(entries: list[AuditEntry]) -> tuple[bool, int | None]:
|
|
|
233
232
|
|
|
234
233
|
def wall_clock() -> str:
|
|
235
234
|
"""Current wall-clock time as ISO 8601 UTC."""
|
|
236
|
-
return datetime.now(
|
|
235
|
+
return datetime.now(UTC).isoformat()
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# Copyright 2026 actiongate-oss
|
|
2
|
+
# Licensed under the Apache License, Version 2.0;
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License in the LICENSE file at the
|
|
5
|
+
# root of this repository.
|
|
6
|
+
|
|
7
|
+
"""AuditGate engine for compliance-grade audit logging."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable, Coroutine
|
|
14
|
+
from functools import wraps
|
|
15
|
+
from typing import Any, ParamSpec, TypeVar
|
|
16
|
+
|
|
17
|
+
from .core import (
|
|
18
|
+
AuditEntry,
|
|
19
|
+
AuditPolicy,
|
|
20
|
+
Decision,
|
|
21
|
+
IntegrityMode,
|
|
22
|
+
Mode,
|
|
23
|
+
Result,
|
|
24
|
+
Severity,
|
|
25
|
+
Status,
|
|
26
|
+
StoreErrorMode,
|
|
27
|
+
Trail,
|
|
28
|
+
Verdict,
|
|
29
|
+
compute_hash,
|
|
30
|
+
wall_clock,
|
|
31
|
+
)
|
|
32
|
+
from .emitter import Emitter
|
|
33
|
+
from .store import AsyncMemoryStore, AsyncStore, MemoryStore, Store
|
|
34
|
+
|
|
35
|
+
P = ParamSpec("P")
|
|
36
|
+
T = TypeVar("T")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuditError(RuntimeError):
|
|
40
|
+
"""Raised when audit logging fails in HARD mode."""
|
|
41
|
+
def __init__(self, decision: Decision) -> None:
|
|
42
|
+
super().__init__(decision.reason or f"Audit failure: {decision.status}")
|
|
43
|
+
self.decision = decision
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Engine:
|
|
47
|
+
"""AuditGate engine for compliance-grade audit logging."""
|
|
48
|
+
|
|
49
|
+
__slots__ = (
|
|
50
|
+
"_store", "_async_store", "_clock", "_wall_clock", "_recorded_by",
|
|
51
|
+
"_policies", "_sequences", "_emitter",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
store: Store | None = None,
|
|
57
|
+
clock: Callable[[], float] | None = None,
|
|
58
|
+
wall_clock_fn: Callable[[], str] | None = None,
|
|
59
|
+
recorded_by: str = "",
|
|
60
|
+
emitter: Emitter | None = None,
|
|
61
|
+
async_store: AsyncStore | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self._store: Store = store or MemoryStore()
|
|
64
|
+
self._async_store: AsyncStore = async_store or AsyncMemoryStore()
|
|
65
|
+
self._clock = clock or time.monotonic
|
|
66
|
+
self._wall_clock = wall_clock_fn or wall_clock
|
|
67
|
+
self._recorded_by = recorded_by
|
|
68
|
+
self._policies: dict[Trail, AuditPolicy] = {}
|
|
69
|
+
self._sequences: dict[Trail, int] = {}
|
|
70
|
+
self._emitter = emitter or Emitter()
|
|
71
|
+
|
|
72
|
+
def register(self, trail: Trail, policy: AuditPolicy) -> None:
|
|
73
|
+
self._policies[trail] = policy
|
|
74
|
+
|
|
75
|
+
def policy_for(self, trail: Trail) -> AuditPolicy | None:
|
|
76
|
+
return self._policies.get(trail)
|
|
77
|
+
|
|
78
|
+
def on_decision(self, listener: Callable[[Decision], None]) -> None:
|
|
79
|
+
self._emitter.add(listener)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def listener_errors(self) -> int:
|
|
83
|
+
return self._emitter.error_count
|
|
84
|
+
|
|
85
|
+
def record(
|
|
86
|
+
self,
|
|
87
|
+
trail: Trail,
|
|
88
|
+
*,
|
|
89
|
+
verdict: Verdict,
|
|
90
|
+
severity: Severity,
|
|
91
|
+
gate_type: str,
|
|
92
|
+
gate_identity: str,
|
|
93
|
+
reason: str | None = None,
|
|
94
|
+
detail: dict[str, Any] | None = None,
|
|
95
|
+
policy: AuditPolicy | None = None,
|
|
96
|
+
) -> Decision:
|
|
97
|
+
"""Record an audit entry for a gate decision."""
|
|
98
|
+
if policy is not None:
|
|
99
|
+
self._policies[trail] = policy
|
|
100
|
+
p = self._policies.get(trail)
|
|
101
|
+
if p is None:
|
|
102
|
+
p = AuditPolicy()
|
|
103
|
+
self._policies[trail] = p
|
|
104
|
+
if _sev_rank(severity) < _sev_rank(p.min_severity):
|
|
105
|
+
return self._decide(trail, p, Status.DROPPED,
|
|
106
|
+
reason=f"Below min_severity ({p.min_severity.name})")
|
|
107
|
+
now = self._clock()
|
|
108
|
+
try:
|
|
109
|
+
entry = self._build_entry(trail, p, now, verdict, severity,
|
|
110
|
+
gate_type, gate_identity, reason, detail or {})
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
return self._on_store_error(trail, p, f"Entry build failed: {exc}")
|
|
113
|
+
try:
|
|
114
|
+
self._store.append(entry)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
return self._on_store_error(trail, p, f"Store append failed: {exc}")
|
|
117
|
+
if p.retention_seconds is not None:
|
|
118
|
+
with contextlib.suppress(Exception):
|
|
119
|
+
self._store.prune(trail, now - p.retention_seconds)
|
|
120
|
+
return self._decide(trail, p, Status.RECORDED, entry=entry)
|
|
121
|
+
|
|
122
|
+
def enforce(self, decision: Decision) -> None:
|
|
123
|
+
if decision.dropped and decision.policy.mode == Mode.HARD:
|
|
124
|
+
raise AuditError(decision)
|
|
125
|
+
|
|
126
|
+
def clear(self, trail: Trail) -> None:
|
|
127
|
+
self._store.clear(trail)
|
|
128
|
+
self._sequences.pop(trail, None)
|
|
129
|
+
|
|
130
|
+
def clear_all(self) -> None:
|
|
131
|
+
self._store.clear_all()
|
|
132
|
+
self._sequences.clear()
|
|
133
|
+
|
|
134
|
+
def guard(
|
|
135
|
+
self, trail: Trail, *, policy: AuditPolicy | None = None,
|
|
136
|
+
verdict: Verdict = Verdict.ALLOW, severity: Severity = Severity.INFO,
|
|
137
|
+
gate_type: str = "auditgate", meta: dict[str, Any] | None = None,
|
|
138
|
+
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
|
139
|
+
if policy is not None:
|
|
140
|
+
self.register(trail, policy)
|
|
141
|
+
def decorator(fn: Callable[P, T]) -> Callable[P, T]:
|
|
142
|
+
@wraps(fn)
|
|
143
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
144
|
+
d = self._guard_record(trail, verdict, severity, gate_type, fn, meta)
|
|
145
|
+
if d.dropped:
|
|
146
|
+
self.enforce(d)
|
|
147
|
+
return fn(*args, **kwargs)
|
|
148
|
+
return wrapper
|
|
149
|
+
return decorator
|
|
150
|
+
|
|
151
|
+
def guard_result(
|
|
152
|
+
self, trail: Trail, *, policy: AuditPolicy | None = None,
|
|
153
|
+
verdict: Verdict = Verdict.ALLOW, severity: Severity = Severity.INFO,
|
|
154
|
+
gate_type: str = "auditgate", meta: dict[str, Any] | None = None,
|
|
155
|
+
) -> Callable[[Callable[P, T]], Callable[P, Result[T]]]:
|
|
156
|
+
if policy is not None:
|
|
157
|
+
self.register(trail, policy)
|
|
158
|
+
def decorator(fn: Callable[P, T]) -> Callable[P, Result[T]]:
|
|
159
|
+
@wraps(fn)
|
|
160
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T]:
|
|
161
|
+
d = self._guard_record(trail, verdict, severity, gate_type, fn, meta,
|
|
162
|
+
prefix="guard_result")
|
|
163
|
+
if d.dropped:
|
|
164
|
+
return Result(decision=d)
|
|
165
|
+
return Result(decision=d, _value=fn(*args, **kwargs))
|
|
166
|
+
return wrapper
|
|
167
|
+
return decorator
|
|
168
|
+
|
|
169
|
+
# ─────────────────────────────────────────────────────────────
|
|
170
|
+
# Async API
|
|
171
|
+
# ─────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
async def async_record(
|
|
174
|
+
self,
|
|
175
|
+
trail: Trail,
|
|
176
|
+
*,
|
|
177
|
+
verdict: Verdict,
|
|
178
|
+
severity: Severity,
|
|
179
|
+
gate_type: str,
|
|
180
|
+
gate_identity: str,
|
|
181
|
+
reason: str | None = None,
|
|
182
|
+
detail: dict[str, Any] | None = None,
|
|
183
|
+
policy: AuditPolicy | None = None,
|
|
184
|
+
) -> Decision:
|
|
185
|
+
"""Async version of record(). Uses async_store."""
|
|
186
|
+
if policy is not None:
|
|
187
|
+
self._policies[trail] = policy
|
|
188
|
+
p = self._policies.get(trail)
|
|
189
|
+
if p is None:
|
|
190
|
+
p = AuditPolicy()
|
|
191
|
+
self._policies[trail] = p
|
|
192
|
+
if _sev_rank(severity) < _sev_rank(p.min_severity):
|
|
193
|
+
return self._decide(trail, p, Status.DROPPED,
|
|
194
|
+
reason=f"Below min_severity ({p.min_severity.name})")
|
|
195
|
+
now = self._clock()
|
|
196
|
+
try:
|
|
197
|
+
entry = await self._async_build_entry(
|
|
198
|
+
trail, p, now, verdict, severity,
|
|
199
|
+
gate_type, gate_identity, reason, detail or {},
|
|
200
|
+
)
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
return self._on_store_error(trail, p, f"Entry build failed: {exc}")
|
|
203
|
+
try:
|
|
204
|
+
await self._async_store.append(entry)
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
return self._on_store_error(trail, p, f"Store append failed: {exc}")
|
|
207
|
+
if p.retention_seconds is not None:
|
|
208
|
+
with contextlib.suppress(Exception):
|
|
209
|
+
await self._async_store.prune(trail, now - p.retention_seconds)
|
|
210
|
+
return self._decide(trail, p, Status.RECORDED, entry=entry)
|
|
211
|
+
|
|
212
|
+
async def async_enforce(self, decision: Decision) -> None:
|
|
213
|
+
"""Async version of enforce(). Raises AuditError in HARD mode."""
|
|
214
|
+
if decision.dropped and decision.policy.mode == Mode.HARD:
|
|
215
|
+
raise AuditError(decision)
|
|
216
|
+
|
|
217
|
+
def async_guard(
|
|
218
|
+
self, trail: Trail, *, policy: AuditPolicy | None = None,
|
|
219
|
+
verdict: Verdict = Verdict.ALLOW, severity: Severity = Severity.INFO,
|
|
220
|
+
gate_type: str = "auditgate", meta: dict[str, Any] | None = None,
|
|
221
|
+
) -> Callable[
|
|
222
|
+
[Callable[P, Coroutine[object, object, T]]],
|
|
223
|
+
Callable[P, Coroutine[object, object, T]],
|
|
224
|
+
]:
|
|
225
|
+
"""Async decorator that raises AuditError on dropped + HARD."""
|
|
226
|
+
if policy is not None:
|
|
227
|
+
self.register(trail, policy)
|
|
228
|
+
|
|
229
|
+
def decorator(
|
|
230
|
+
fn: Callable[P, Coroutine[object, object, T]],
|
|
231
|
+
) -> Callable[P, Coroutine[object, object, T]]:
|
|
232
|
+
@wraps(fn)
|
|
233
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
234
|
+
d = await self._async_guard_record(
|
|
235
|
+
trail, verdict, severity, gate_type, fn, meta,
|
|
236
|
+
)
|
|
237
|
+
if d.dropped:
|
|
238
|
+
await self.async_enforce(d)
|
|
239
|
+
return await fn(*args, **kwargs)
|
|
240
|
+
return wrapper
|
|
241
|
+
return decorator
|
|
242
|
+
|
|
243
|
+
def async_guard_result(
|
|
244
|
+
self, trail: Trail, *, policy: AuditPolicy | None = None,
|
|
245
|
+
verdict: Verdict = Verdict.ALLOW, severity: Severity = Severity.INFO,
|
|
246
|
+
gate_type: str = "auditgate", meta: dict[str, Any] | None = None,
|
|
247
|
+
) -> Callable[
|
|
248
|
+
[Callable[P, Coroutine[object, object, T]]],
|
|
249
|
+
Callable[P, Coroutine[object, object, Result[T]]],
|
|
250
|
+
]:
|
|
251
|
+
"""Async decorator that returns Result[T] (never raises)."""
|
|
252
|
+
if policy is not None:
|
|
253
|
+
self.register(trail, policy)
|
|
254
|
+
|
|
255
|
+
def decorator(
|
|
256
|
+
fn: Callable[P, Coroutine[object, object, T]],
|
|
257
|
+
) -> Callable[P, Coroutine[object, object, Result[T]]]:
|
|
258
|
+
@wraps(fn)
|
|
259
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T]:
|
|
260
|
+
d = await self._async_guard_record(
|
|
261
|
+
trail, verdict, severity, gate_type, fn, meta,
|
|
262
|
+
prefix="async_guard_result",
|
|
263
|
+
)
|
|
264
|
+
if d.dropped:
|
|
265
|
+
return Result(decision=d)
|
|
266
|
+
return Result(decision=d, _value=await fn(*args, **kwargs))
|
|
267
|
+
return wrapper
|
|
268
|
+
return decorator
|
|
269
|
+
|
|
270
|
+
async def _async_guard_record(
|
|
271
|
+
self, trail: Trail, verdict: Verdict, severity: Severity,
|
|
272
|
+
gate_type: str, fn: Any, meta: dict[str, Any] | None,
|
|
273
|
+
prefix: str = "async_guard",
|
|
274
|
+
) -> Decision:
|
|
275
|
+
return await self.async_record(
|
|
276
|
+
trail=trail, verdict=verdict, severity=severity,
|
|
277
|
+
gate_type=gate_type, gate_identity=str(trail),
|
|
278
|
+
reason=f"{prefix}:{fn.__qualname__}", detail=meta or {},
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async def _async_build_entry(
|
|
282
|
+
self, trail: Trail, policy: AuditPolicy, now: float,
|
|
283
|
+
verdict: Verdict, severity: Severity, gate_type: str,
|
|
284
|
+
gate_identity: str, reason: str | None, detail: dict[str, Any],
|
|
285
|
+
) -> AuditEntry:
|
|
286
|
+
seq = await self._async_next_seq(trail)
|
|
287
|
+
base = {
|
|
288
|
+
"trail": str(trail), "ts": now, "verdict": verdict.name,
|
|
289
|
+
"severity": severity.name, "gate_type": gate_type,
|
|
290
|
+
"gate_identity": gate_identity, "reason": reason,
|
|
291
|
+
"detail": detail, "sequence": seq,
|
|
292
|
+
}
|
|
293
|
+
entry_hash = None
|
|
294
|
+
prev_hash = None
|
|
295
|
+
if policy.integrity == IntegrityMode.HASH:
|
|
296
|
+
entry_hash = compute_hash(base)
|
|
297
|
+
elif policy.integrity == IntegrityMode.CHAIN:
|
|
298
|
+
prev = await self._async_store.last_entry(trail)
|
|
299
|
+
prev_hash = prev.entry_hash if prev is not None else None
|
|
300
|
+
entry_hash = compute_hash(base, prev_hash=prev_hash)
|
|
301
|
+
return AuditEntry(
|
|
302
|
+
trail=trail, ts=now, wall_ts=self._wall_clock(),
|
|
303
|
+
verdict=verdict, severity=severity, gate_type=gate_type,
|
|
304
|
+
gate_identity=gate_identity, recorded_by=self._recorded_by,
|
|
305
|
+
reason=reason, detail=detail, entry_hash=entry_hash,
|
|
306
|
+
prev_hash=prev_hash, sequence=seq,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
async def _async_next_seq(self, trail: Trail) -> int:
|
|
310
|
+
if trail not in self._sequences:
|
|
311
|
+
last = await self._async_store.last_entry(trail)
|
|
312
|
+
self._sequences[trail] = (last.sequence + 1) if last is not None else 0
|
|
313
|
+
seq = self._sequences[trail]
|
|
314
|
+
self._sequences[trail] = seq + 1
|
|
315
|
+
return seq
|
|
316
|
+
|
|
317
|
+
# ─────────────────────────────────────────────────────────────
|
|
318
|
+
# Internal (sync)
|
|
319
|
+
# ─────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
def _guard_record(
|
|
322
|
+
self, trail: Trail, verdict: Verdict, severity: Severity,
|
|
323
|
+
gate_type: str, fn: Any, meta: dict[str, Any] | None,
|
|
324
|
+
prefix: str = "guard",
|
|
325
|
+
) -> Decision:
|
|
326
|
+
return self.record(trail=trail, verdict=verdict, severity=severity,
|
|
327
|
+
gate_type=gate_type, gate_identity=str(trail),
|
|
328
|
+
reason=f"{prefix}:{fn.__qualname__}", detail=meta or {})
|
|
329
|
+
|
|
330
|
+
def _build_entry(
|
|
331
|
+
self, trail: Trail, policy: AuditPolicy, now: float,
|
|
332
|
+
verdict: Verdict, severity: Severity, gate_type: str,
|
|
333
|
+
gate_identity: str, reason: str | None, detail: dict[str, Any],
|
|
334
|
+
) -> AuditEntry:
|
|
335
|
+
seq = self._next_seq(trail)
|
|
336
|
+
base = {
|
|
337
|
+
"trail": str(trail), "ts": now, "verdict": verdict.name,
|
|
338
|
+
"severity": severity.name, "gate_type": gate_type,
|
|
339
|
+
"gate_identity": gate_identity, "reason": reason,
|
|
340
|
+
"detail": detail, "sequence": seq,
|
|
341
|
+
}
|
|
342
|
+
entry_hash = None
|
|
343
|
+
prev_hash = None
|
|
344
|
+
if policy.integrity == IntegrityMode.HASH:
|
|
345
|
+
entry_hash = compute_hash(base)
|
|
346
|
+
elif policy.integrity == IntegrityMode.CHAIN:
|
|
347
|
+
prev = self._store.last_entry(trail)
|
|
348
|
+
prev_hash = prev.entry_hash if prev is not None else None
|
|
349
|
+
entry_hash = compute_hash(base, prev_hash=prev_hash)
|
|
350
|
+
return AuditEntry(
|
|
351
|
+
trail=trail, ts=now, wall_ts=self._wall_clock(),
|
|
352
|
+
verdict=verdict, severity=severity, gate_type=gate_type,
|
|
353
|
+
gate_identity=gate_identity, recorded_by=self._recorded_by,
|
|
354
|
+
reason=reason, detail=detail, entry_hash=entry_hash,
|
|
355
|
+
prev_hash=prev_hash, sequence=seq,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _next_seq(self, trail: Trail) -> int:
|
|
359
|
+
if trail not in self._sequences:
|
|
360
|
+
last = self._store.last_entry(trail)
|
|
361
|
+
self._sequences[trail] = (last.sequence + 1) if last is not None else 0
|
|
362
|
+
seq = self._sequences[trail]
|
|
363
|
+
self._sequences[trail] = seq + 1
|
|
364
|
+
return seq
|
|
365
|
+
|
|
366
|
+
def _on_store_error(
|
|
367
|
+
self, trail: Trail, policy: AuditPolicy, msg: str,
|
|
368
|
+
) -> Decision:
|
|
369
|
+
decision = self._decide(trail, policy, Status.DROPPED, reason=msg)
|
|
370
|
+
if policy.on_store_error == StoreErrorMode.FAIL_CLOSED and policy.mode == Mode.HARD:
|
|
371
|
+
raise AuditError(decision)
|
|
372
|
+
return decision
|
|
373
|
+
|
|
374
|
+
def _decide(
|
|
375
|
+
self, trail: Trail, policy: AuditPolicy, status: Status,
|
|
376
|
+
entry: AuditEntry | None = None, reason: str | None = None,
|
|
377
|
+
) -> Decision:
|
|
378
|
+
d = Decision(status=status, trail=trail, policy=policy, entry=entry, reason=reason)
|
|
379
|
+
self._emitter.emit(d)
|
|
380
|
+
return d
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _sev_rank(severity: Severity) -> int:
|
|
384
|
+
return {Severity.DEBUG: 0, Severity.INFO: 1, Severity.WARN: 2,
|
|
385
|
+
Severity.ERROR: 3, Severity.CRITICAL: 4}[severity]
|