skillpool 4.3.0__py3-none-any.whl
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.
- skillpool/__init__.py +74 -0
- skillpool/__main__.py +6 -0
- skillpool/adapters/__init__.py +8 -0
- skillpool/adapters/base.py +41 -0
- skillpool/adapters/claude_adapter.py +36 -0
- skillpool/adapters/codex_adapter.py +92 -0
- skillpool/adapters/hermes_adapter.py +38 -0
- skillpool/audit/__init__.py +651 -0
- skillpool/bridge/__init__.py +16 -0
- skillpool/bridge/freeze_detector.py +134 -0
- skillpool/bridge/maintenance.py +119 -0
- skillpool/bridge/wal_manager.py +136 -0
- skillpool/clawmem_client.py +176 -0
- skillpool/cli.py +700 -0
- skillpool/combiner/__init__.py +31 -0
- skillpool/combiner/lifecycle.py +453 -0
- skillpool/combiner/models.py +99 -0
- skillpool/config.py +34 -0
- skillpool/cost/__init__.py +111 -0
- skillpool/cost/audit_hash.py +51 -0
- skillpool/cost/budget_tracker.py +66 -0
- skillpool/cost/dashboard.py +189 -0
- skillpool/cost/models.py +129 -0
- skillpool/cost/token_governor.py +264 -0
- skillpool/cost/trace_ceiling.py +38 -0
- skillpool/csdf.py +126 -0
- skillpool/evolver/__init__.py +978 -0
- skillpool/gain/__init__.py +285 -0
- skillpool/gate.py +282 -0
- skillpool/gate_policy/__init__.py +31 -0
- skillpool/gate_policy/incremental.py +157 -0
- skillpool/gate_policy/parser.py +258 -0
- skillpool/gate_policy/state_machine.py +432 -0
- skillpool/graph/__init__.py +14 -0
- skillpool/graph/ppr.py +279 -0
- skillpool/health/__init__.py +73 -0
- skillpool/health/check.py +85 -0
- skillpool/health/degradation.py +90 -0
- skillpool/health/models.py +43 -0
- skillpool/hooks/__init__.py +4 -0
- skillpool/hooks/security_scanner.py +288 -0
- skillpool/lifecycle.py +150 -0
- skillpool/materializer/__init__.py +124 -0
- skillpool/materializer/budget_cropper.py +178 -0
- skillpool/materializer/csdf_loader.py +114 -0
- skillpool/materializer/lazy_loader.py +265 -0
- skillpool/materializer/lifecycle_filter.py +93 -0
- skillpool/materializer/mapper.py +178 -0
- skillpool/materializer/models.py +66 -0
- skillpool/mcp_server.py +2005 -0
- skillpool/monitor/__init__.py +576 -0
- skillpool/monitor/bug_collector.py +392 -0
- skillpool/monitor/defect_classifier.py +218 -0
- skillpool/monitor/self_healing.py +530 -0
- skillpool/monitor/telemetry_bridge.py +197 -0
- skillpool/paradigm/__init__.py +312 -0
- skillpool/paradigm/override.py +285 -0
- skillpool/profile.py +94 -0
- skillpool/quality.py +254 -0
- skillpool/registry/__init__.py +509 -0
- skillpool/registry/models.py +98 -0
- skillpool/resolver/__init__.py +320 -0
- skillpool/resolver/cache.py +103 -0
- skillpool/resolver/circuit_breaker.py +103 -0
- skillpool/resolver/conflict_detector.py +111 -0
- skillpool/resolver/health_filter.py +38 -0
- skillpool/resolver/models.py +154 -0
- skillpool/resolver/rate_limiter.py +48 -0
- skillpool/resolver/skill_graph.py +183 -0
- skillpool/review/__init__.py +242 -0
- skillpool/review/async_queue.py +96 -0
- skillpool/review/checkpoint_runner.py +345 -0
- skillpool/review/models.py +164 -0
- skillpool/review/suspect_marker.py +39 -0
- skillpool/review/veto_evaluator.py +94 -0
- skillpool/router/__init__.py +481 -0
- skillpool/schemas.py +119 -0
- skillpool/synergy/__init__.py +240 -0
- skillpool/synergy/detector.py +5 -0
- skillpool/telemetry.py +126 -0
- skillpool/utils/__init__.py +21 -0
- skillpool/utils/changelog.py +218 -0
- skillpool/utils/logger.py +273 -0
- skillpool/utils/runtime_audit.py +163 -0
- skillpool/utils/time_utils.py +13 -0
- skillpool-4.3.0.dist-info/METADATA +21 -0
- skillpool-4.3.0.dist-info/RECORD +90 -0
- skillpool-4.3.0.dist-info/WHEEL +5 -0
- skillpool-4.3.0.dist-info/entry_points.txt +3 -0
- skillpool-4.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""Audit Layer V4.0 — Immutable audit evidence with OpenTelemetry alignment.
|
|
2
|
+
|
|
3
|
+
Upgraded from V3.4 (17 fields) to V4.0 (34 fields) per V1.1 Section 8.12.
|
|
4
|
+
OpenTelemetry-compatible: trace_id, span_id, parent_span_id, event_id.
|
|
5
|
+
Hash chain integrity with append-only design.
|
|
6
|
+
|
|
7
|
+
Architecture constraint:
|
|
8
|
+
- Audit MUST NOT be bypassed
|
|
9
|
+
- Audit is append-only integrity-protected
|
|
10
|
+
- Audit unavailable = fail closed for mutations
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AuditLayer",
|
|
17
|
+
"AuditRecord",
|
|
18
|
+
"AuditUnavailableError",
|
|
19
|
+
"log_event",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import uuid
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from skillpool.config import get_data_dir
|
|
32
|
+
from skillpool.utils.time_utils import utc_now
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def isoformat_z(dt) -> str:
|
|
36
|
+
"""Return ISO 8601 format with Z suffix for UTC."""
|
|
37
|
+
if dt.tzinfo is None:
|
|
38
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
39
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S") + "Z"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AuditRecord:
|
|
44
|
+
"""Immutable audit record — 34-field OTel-aligned schema (V4.0)."""
|
|
45
|
+
|
|
46
|
+
# ── Core identity (OTel-aligned) ──
|
|
47
|
+
audit_id: str
|
|
48
|
+
event_id: str = ""
|
|
49
|
+
trace_id: str = ""
|
|
50
|
+
span_id: str = ""
|
|
51
|
+
parent_span_id: str = ""
|
|
52
|
+
|
|
53
|
+
# ── Temporal ──
|
|
54
|
+
event_type: str = "skill_pool_event"
|
|
55
|
+
event_time: Any = field(default_factory=utc_now)
|
|
56
|
+
created_at: str = ""
|
|
57
|
+
updated_at: str = ""
|
|
58
|
+
duration_ms: float = 0.0
|
|
59
|
+
|
|
60
|
+
# ── Actor & session ──
|
|
61
|
+
actor: str = "system"
|
|
62
|
+
agent_id: str = "" # Required by audit_record.v4.json schema (SPIFFE SVID or agent identifier)
|
|
63
|
+
session_id: str = ""
|
|
64
|
+
tenant_id: str = "default"
|
|
65
|
+
|
|
66
|
+
# ── Source & target ──
|
|
67
|
+
source_component: str = "SkillPool"
|
|
68
|
+
action: str = ""
|
|
69
|
+
resource_type: str = "skill"
|
|
70
|
+
resource_id: str = ""
|
|
71
|
+
|
|
72
|
+
# ── Request routing ──
|
|
73
|
+
request_id: str = ""
|
|
74
|
+
correlation_id: str = ""
|
|
75
|
+
|
|
76
|
+
# ── Decision & result ──
|
|
77
|
+
policy_decision: str = "allow"
|
|
78
|
+
decision: str = ""
|
|
79
|
+
result: str = "success"
|
|
80
|
+
reason: str = ""
|
|
81
|
+
severity: str = "INFO"
|
|
82
|
+
|
|
83
|
+
# ── Integrity (hash chain) ──
|
|
84
|
+
previous_hash: str = ""
|
|
85
|
+
current_hash: str = ""
|
|
86
|
+
chain_index: int = 0
|
|
87
|
+
signature: str = ""
|
|
88
|
+
|
|
89
|
+
# ── Content hashes ──
|
|
90
|
+
input_hash: str = ""
|
|
91
|
+
output_hash: str = ""
|
|
92
|
+
|
|
93
|
+
# ── Metadata & compliance ──
|
|
94
|
+
metadata_json: str = "{}"
|
|
95
|
+
retention_class: str = "hot"
|
|
96
|
+
compliance_tags: str = ""
|
|
97
|
+
|
|
98
|
+
# ── Geo (optional) ──
|
|
99
|
+
geo_location: str = ""
|
|
100
|
+
|
|
101
|
+
# ── Trace provenance ──
|
|
102
|
+
trace_source: str = "" # "w3c_passthrough" | "skillpool_internal" | "test"
|
|
103
|
+
|
|
104
|
+
# ── Backward compat aliases ──
|
|
105
|
+
@property
|
|
106
|
+
def hash(self) -> str:
|
|
107
|
+
"""Backward compat: hash → current_hash."""
|
|
108
|
+
return self.current_hash
|
|
109
|
+
|
|
110
|
+
@hash.setter
|
|
111
|
+
def hash(self, value: str) -> None:
|
|
112
|
+
self.current_hash = value
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def object_type(self) -> str:
|
|
116
|
+
"""Backward compat: object_type → resource_type."""
|
|
117
|
+
return self.resource_type
|
|
118
|
+
|
|
119
|
+
@object_type.setter
|
|
120
|
+
def object_type(self, value: str) -> None:
|
|
121
|
+
self.resource_type = value
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def object_id(self) -> str:
|
|
125
|
+
"""Backward compat: object_id → resource_id."""
|
|
126
|
+
return self.resource_id
|
|
127
|
+
|
|
128
|
+
@object_id.setter
|
|
129
|
+
def object_id(self, value: str) -> None:
|
|
130
|
+
self.resource_id = value
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def audit_ref(self) -> str:
|
|
134
|
+
"""Backward compat: audit_ref → audit_id."""
|
|
135
|
+
return self.audit_id
|
|
136
|
+
|
|
137
|
+
@audit_ref.setter
|
|
138
|
+
def audit_ref(self, value: str) -> None:
|
|
139
|
+
self.audit_id = value
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def log_event(
|
|
143
|
+
action: str,
|
|
144
|
+
actor: str = "system",
|
|
145
|
+
resource_type: str = "skill",
|
|
146
|
+
resource_id: str = "",
|
|
147
|
+
result: str = "success",
|
|
148
|
+
reason: str = "",
|
|
149
|
+
severity: str = "INFO",
|
|
150
|
+
tenant_id: str = "default",
|
|
151
|
+
source_component: str = "SkillPool",
|
|
152
|
+
duration_ms: float = 0.0,
|
|
153
|
+
metadata: dict[str, Any] | None = None,
|
|
154
|
+
trace_id: str | None = None,
|
|
155
|
+
request_id: str = "",
|
|
156
|
+
session_id: str = "",
|
|
157
|
+
agent_id: str = "",
|
|
158
|
+
**kwargs: Any,
|
|
159
|
+
) -> AuditRecord:
|
|
160
|
+
"""
|
|
161
|
+
Convenience function to create an audit event with auto-filled fields.
|
|
162
|
+
|
|
163
|
+
Generates event_id (UUID7-like), trace_id (if not provided), span_id,
|
|
164
|
+
and input/output hashes automatically. Suitable for one-liner audit logging.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
action: Action performed (e.g., "register_skill", "approve_evolution")
|
|
168
|
+
actor: Identity performing the action
|
|
169
|
+
resource_type: Type of resource acted upon
|
|
170
|
+
resource_id: ID of resource acted upon
|
|
171
|
+
result: "success" / "failure" / "denied"
|
|
172
|
+
reason: Human-readable explanation
|
|
173
|
+
severity: INFO / WARN / ERROR / CRITICAL
|
|
174
|
+
tenant_id: Tenant identifier
|
|
175
|
+
source_component: Originating component
|
|
176
|
+
duration_ms: Operation duration in milliseconds
|
|
177
|
+
metadata: Arbitrary key-value metadata
|
|
178
|
+
trace_id: OTel trace ID (auto-generated if empty)
|
|
179
|
+
request_id: Request correlation ID
|
|
180
|
+
session_id: User session ID
|
|
181
|
+
**kwargs: Additional fields passed to AuditRecord
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Fully populated AuditRecord
|
|
185
|
+
"""
|
|
186
|
+
now = utc_now()
|
|
187
|
+
event_id = kwargs.pop("event_id", str(uuid.uuid4()))
|
|
188
|
+
span_id = kwargs.pop("span_id", uuid.uuid4().hex[:16])
|
|
189
|
+
parent_span_id = kwargs.pop("parent_span_id", "")
|
|
190
|
+
|
|
191
|
+
# Hash inputs for integrity
|
|
192
|
+
payload = json.dumps(
|
|
193
|
+
{
|
|
194
|
+
"action": action,
|
|
195
|
+
"actor": actor,
|
|
196
|
+
"resource_type": resource_type,
|
|
197
|
+
"resource_id": resource_id,
|
|
198
|
+
"result": result,
|
|
199
|
+
"timestamp": isoformat_z(now),
|
|
200
|
+
},
|
|
201
|
+
sort_keys=True,
|
|
202
|
+
)
|
|
203
|
+
input_hash = hashlib.sha256(payload.encode()).hexdigest()
|
|
204
|
+
|
|
205
|
+
# Determine trace provenance
|
|
206
|
+
if trace_id:
|
|
207
|
+
resolved_trace_id = trace_id
|
|
208
|
+
trace_source = "w3c_passthrough"
|
|
209
|
+
else:
|
|
210
|
+
resolved_trace_id = os.urandom(16).hex()
|
|
211
|
+
trace_source = "skillpool_internal"
|
|
212
|
+
|
|
213
|
+
record = AuditRecord(
|
|
214
|
+
audit_id=f"audit-{event_id}",
|
|
215
|
+
event_id=event_id,
|
|
216
|
+
trace_id=resolved_trace_id,
|
|
217
|
+
span_id=span_id,
|
|
218
|
+
parent_span_id=parent_span_id,
|
|
219
|
+
event_type="skill_pool_event",
|
|
220
|
+
event_time=now,
|
|
221
|
+
created_at=isoformat_z(now),
|
|
222
|
+
updated_at=isoformat_z(now),
|
|
223
|
+
duration_ms=duration_ms,
|
|
224
|
+
actor=actor,
|
|
225
|
+
agent_id=agent_id or actor, # Default agent_id to actor if not provided
|
|
226
|
+
session_id=session_id,
|
|
227
|
+
tenant_id=tenant_id,
|
|
228
|
+
source_component=source_component,
|
|
229
|
+
action=action,
|
|
230
|
+
resource_type=resource_type,
|
|
231
|
+
resource_id=resource_id,
|
|
232
|
+
request_id=request_id or f"req-{uuid.uuid4().hex[:12]}",
|
|
233
|
+
correlation_id=kwargs.pop("correlation_id", ""),
|
|
234
|
+
policy_decision=kwargs.pop("policy_decision", "allow"),
|
|
235
|
+
decision=kwargs.pop("decision", result),
|
|
236
|
+
result=result,
|
|
237
|
+
reason=reason,
|
|
238
|
+
severity=severity,
|
|
239
|
+
input_hash=input_hash,
|
|
240
|
+
output_hash="",
|
|
241
|
+
metadata_json=json.dumps(metadata or {}, sort_keys=True, ensure_ascii=False),
|
|
242
|
+
retention_class=kwargs.pop("retention_class", "hot"),
|
|
243
|
+
compliance_tags=kwargs.pop("compliance_tags", ""),
|
|
244
|
+
geo_location=kwargs.pop("geo_location", ""),
|
|
245
|
+
trace_source=kwargs.pop("trace_source", trace_source),
|
|
246
|
+
signature="",
|
|
247
|
+
chain_index=0,
|
|
248
|
+
previous_hash="",
|
|
249
|
+
current_hash="",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Compute final hash over all non-hash fields
|
|
253
|
+
all_fields = {
|
|
254
|
+
"event_id": record.event_id,
|
|
255
|
+
"trace_id": record.trace_id,
|
|
256
|
+
"span_id": record.span_id,
|
|
257
|
+
"action": record.action,
|
|
258
|
+
"actor": record.actor,
|
|
259
|
+
"resource_type": record.resource_type,
|
|
260
|
+
"resource_id": record.resource_id,
|
|
261
|
+
"result": record.result,
|
|
262
|
+
"timestamp": record.created_at,
|
|
263
|
+
"previous_hash": record.previous_hash,
|
|
264
|
+
}
|
|
265
|
+
record.current_hash = hashlib.sha256(json.dumps(all_fields, sort_keys=True).encode()).hexdigest()
|
|
266
|
+
|
|
267
|
+
return record
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class AuditUnavailableError(Exception):
|
|
271
|
+
"""Audit unavailable — fail closed."""
|
|
272
|
+
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class AuditLayer:
|
|
277
|
+
"""
|
|
278
|
+
Audit layer — immutable evidence ledger (V4.0).
|
|
279
|
+
|
|
280
|
+
Hard rules:
|
|
281
|
+
- MUST NOT be bypassed
|
|
282
|
+
- MUST NOT be rewritten
|
|
283
|
+
- MUST NOT be replaced by logs
|
|
284
|
+
- Unavailable = fail closed for mutations
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
GENESIS_HASH = "0" * 64
|
|
288
|
+
|
|
289
|
+
def __init__(self, available: bool = True, max_entries: int = 10000, data_dir: Path | None = None) -> None:
|
|
290
|
+
self._available = available
|
|
291
|
+
self._records: list[AuditRecord] = []
|
|
292
|
+
self._last_hash = self.GENESIS_HASH
|
|
293
|
+
self._max_entries = max_entries
|
|
294
|
+
self._data_dir = data_dir or get_data_dir()
|
|
295
|
+
self._jsonl_path = self._data_dir / "audit" / "audit.jsonl"
|
|
296
|
+
self._load_from_jsonl()
|
|
297
|
+
|
|
298
|
+
def _rotate(self) -> None:
|
|
299
|
+
"""Rotate audit log when entries exceed max_entries.
|
|
300
|
+
|
|
301
|
+
Keeps the most recent max_entries records, preserving hash chain integrity
|
|
302
|
+
by carrying forward the last pre-rotation hash as the new anchor and
|
|
303
|
+
recomputing hashes for all retained records.
|
|
304
|
+
"""
|
|
305
|
+
if len(self._records) <= self._max_entries:
|
|
306
|
+
return
|
|
307
|
+
# Capture the hash of the last record being discarded
|
|
308
|
+
cutoff = len(self._records) - self._max_entries
|
|
309
|
+
last_discarded_hash = self._records[cutoff - 1].current_hash if cutoff > 0 else self.GENESIS_HASH
|
|
310
|
+
self._records = self._records[cutoff:]
|
|
311
|
+
# Re-anchor: carry forward the pre-rotation chain tail hash
|
|
312
|
+
# and recompute hashes for all retained records
|
|
313
|
+
if self._records:
|
|
314
|
+
prev_hash = last_discarded_hash
|
|
315
|
+
for idx, record in enumerate(self._records):
|
|
316
|
+
record.previous_hash = prev_hash
|
|
317
|
+
record.chain_index = idx
|
|
318
|
+
chain_payload = json.dumps(
|
|
319
|
+
{
|
|
320
|
+
"event_id": record.event_id,
|
|
321
|
+
"trace_id": record.trace_id,
|
|
322
|
+
"span_id": record.span_id,
|
|
323
|
+
"action": record.action,
|
|
324
|
+
"actor": record.actor,
|
|
325
|
+
"resource_type": record.resource_type,
|
|
326
|
+
"resource_id": record.resource_id,
|
|
327
|
+
"result": record.result,
|
|
328
|
+
"timestamp": record.created_at,
|
|
329
|
+
"chain_index": idx,
|
|
330
|
+
"previous_hash": prev_hash,
|
|
331
|
+
},
|
|
332
|
+
sort_keys=True,
|
|
333
|
+
)
|
|
334
|
+
record.current_hash = hashlib.sha256(chain_payload.encode()).hexdigest()
|
|
335
|
+
record.signature = record.current_hash
|
|
336
|
+
prev_hash = record.current_hash
|
|
337
|
+
self._last_hash = prev_hash
|
|
338
|
+
|
|
339
|
+
def is_available(self) -> bool:
|
|
340
|
+
"""Check if Audit layer is available."""
|
|
341
|
+
return self._available
|
|
342
|
+
|
|
343
|
+
def set_available(self, available: bool) -> None:
|
|
344
|
+
"""Set availability (for testing)."""
|
|
345
|
+
self._available = available
|
|
346
|
+
|
|
347
|
+
def append(
|
|
348
|
+
self,
|
|
349
|
+
action: str,
|
|
350
|
+
object_id: str = "",
|
|
351
|
+
result: str = "success",
|
|
352
|
+
actor: str = "system",
|
|
353
|
+
tenant_id: str = "default",
|
|
354
|
+
source_component: str = "SkillPool",
|
|
355
|
+
**kwargs: Any,
|
|
356
|
+
) -> str:
|
|
357
|
+
"""
|
|
358
|
+
Append immutable audit record (backward-compatible interface).
|
|
359
|
+
|
|
360
|
+
Uses the upgraded log_event() internally to produce 34-field records.
|
|
361
|
+
|
|
362
|
+
Returns audit_ref for traceability.
|
|
363
|
+
|
|
364
|
+
Raises:
|
|
365
|
+
AuditUnavailableError if Audit is unavailable
|
|
366
|
+
"""
|
|
367
|
+
if not self._available:
|
|
368
|
+
raise AuditUnavailableError("Audit unavailable — cannot append record")
|
|
369
|
+
|
|
370
|
+
chain_index = len(self._records)
|
|
371
|
+
previous_hash = self._last_hash
|
|
372
|
+
|
|
373
|
+
record = log_event(
|
|
374
|
+
action=action,
|
|
375
|
+
actor=actor,
|
|
376
|
+
resource_type=kwargs.pop("object_type", "skill"),
|
|
377
|
+
resource_id=object_id,
|
|
378
|
+
result=result,
|
|
379
|
+
reason=kwargs.pop("reason", ""),
|
|
380
|
+
severity=kwargs.pop("severity", "INFO"),
|
|
381
|
+
tenant_id=tenant_id,
|
|
382
|
+
source_component=source_component,
|
|
383
|
+
duration_ms=kwargs.pop("duration_ms", 0.0),
|
|
384
|
+
metadata=kwargs.pop("metadata", None),
|
|
385
|
+
trace_id=kwargs.pop("trace_id", ""),
|
|
386
|
+
request_id=kwargs.pop("request_id", ""),
|
|
387
|
+
session_id=kwargs.pop("session_id", ""),
|
|
388
|
+
agent_id=kwargs.pop("agent_id", ""),
|
|
389
|
+
**kwargs,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
record.previous_hash = previous_hash
|
|
393
|
+
record.chain_index = chain_index
|
|
394
|
+
|
|
395
|
+
# Recompute hash with chain position
|
|
396
|
+
chain_payload = json.dumps(
|
|
397
|
+
{
|
|
398
|
+
"event_id": record.event_id,
|
|
399
|
+
"trace_id": record.trace_id,
|
|
400
|
+
"span_id": record.span_id,
|
|
401
|
+
"action": record.action,
|
|
402
|
+
"actor": record.actor,
|
|
403
|
+
"resource_type": record.resource_type,
|
|
404
|
+
"resource_id": record.resource_id,
|
|
405
|
+
"result": record.result,
|
|
406
|
+
"timestamp": record.created_at,
|
|
407
|
+
"chain_index": chain_index,
|
|
408
|
+
"previous_hash": previous_hash,
|
|
409
|
+
},
|
|
410
|
+
sort_keys=True,
|
|
411
|
+
)
|
|
412
|
+
record.current_hash = hashlib.sha256(chain_payload.encode()).hexdigest()
|
|
413
|
+
record.signature = record.current_hash
|
|
414
|
+
|
|
415
|
+
self._records.append(record)
|
|
416
|
+
self._last_hash = record.current_hash
|
|
417
|
+
|
|
418
|
+
self._rotate()
|
|
419
|
+
self._write_to_jsonl(record)
|
|
420
|
+
return record.audit_id
|
|
421
|
+
|
|
422
|
+
def log_event_record(self, action: str, **kwargs: Any) -> AuditRecord:
|
|
423
|
+
"""
|
|
424
|
+
Log a fully-detailed event (new V4.0 interface).
|
|
425
|
+
|
|
426
|
+
Thin wrapper around the module-level log_event() that also
|
|
427
|
+
appends to the chain.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
action: Action name
|
|
431
|
+
**kwargs: All AuditRecord fields
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
The created AuditRecord
|
|
435
|
+
"""
|
|
436
|
+
if not self._available:
|
|
437
|
+
raise AuditUnavailableError("Audit unavailable — cannot log event")
|
|
438
|
+
|
|
439
|
+
record = log_event(action=action, **kwargs)
|
|
440
|
+
chain_index = len(self._records)
|
|
441
|
+
record.previous_hash = self._last_hash
|
|
442
|
+
record.chain_index = chain_index
|
|
443
|
+
|
|
444
|
+
chain_payload = json.dumps(
|
|
445
|
+
{
|
|
446
|
+
"event_id": record.event_id,
|
|
447
|
+
"trace_id": record.trace_id,
|
|
448
|
+
"span_id": record.span_id,
|
|
449
|
+
"action": record.action,
|
|
450
|
+
"actor": record.actor,
|
|
451
|
+
"resource_type": record.resource_type,
|
|
452
|
+
"resource_id": record.resource_id,
|
|
453
|
+
"result": record.result,
|
|
454
|
+
"timestamp": record.created_at,
|
|
455
|
+
"chain_index": chain_index,
|
|
456
|
+
"previous_hash": self._last_hash,
|
|
457
|
+
},
|
|
458
|
+
sort_keys=True,
|
|
459
|
+
)
|
|
460
|
+
record.current_hash = hashlib.sha256(chain_payload.encode()).hexdigest()
|
|
461
|
+
record.signature = record.current_hash
|
|
462
|
+
|
|
463
|
+
self._records.append(record)
|
|
464
|
+
self._last_hash = record.current_hash
|
|
465
|
+
|
|
466
|
+
self._write_to_jsonl(record)
|
|
467
|
+
return record
|
|
468
|
+
|
|
469
|
+
def get_records(self, object_id: str | None = None) -> list[AuditRecord]:
|
|
470
|
+
"""Get audit records, optionally filtered by resource_id (or object_id compat)."""
|
|
471
|
+
if object_id:
|
|
472
|
+
return [r for r in self._records if r.resource_id == object_id]
|
|
473
|
+
return list(self._records)
|
|
474
|
+
|
|
475
|
+
def get_record_count(self) -> int:
|
|
476
|
+
"""Return total number of audit records."""
|
|
477
|
+
return len(self._records)
|
|
478
|
+
|
|
479
|
+
def verify_integrity(self) -> bool:
|
|
480
|
+
"""
|
|
481
|
+
Verify hash chain integrity of in-memory audit records.
|
|
482
|
+
|
|
483
|
+
Checks:
|
|
484
|
+
1. Each record's current_hash matches recomputed hash from its content
|
|
485
|
+
2. Each record's previous_hash matches the preceding record's current_hash
|
|
486
|
+
|
|
487
|
+
After rotation, the first record's previous_hash may reference a
|
|
488
|
+
pre-rotation chain tail rather than GENESIS_HASH — we accept it
|
|
489
|
+
as a valid anchor.
|
|
490
|
+
"""
|
|
491
|
+
if not self._records:
|
|
492
|
+
return True
|
|
493
|
+
|
|
494
|
+
for idx, record in enumerate(self._records):
|
|
495
|
+
# Recompute hash from record content
|
|
496
|
+
chain_payload = json.dumps(
|
|
497
|
+
{
|
|
498
|
+
"event_id": record.event_id,
|
|
499
|
+
"trace_id": record.trace_id,
|
|
500
|
+
"span_id": record.span_id,
|
|
501
|
+
"action": record.action,
|
|
502
|
+
"actor": record.actor,
|
|
503
|
+
"resource_type": record.resource_type,
|
|
504
|
+
"resource_id": record.resource_id,
|
|
505
|
+
"result": record.result,
|
|
506
|
+
"timestamp": record.created_at,
|
|
507
|
+
"chain_index": record.chain_index,
|
|
508
|
+
"previous_hash": record.previous_hash,
|
|
509
|
+
},
|
|
510
|
+
sort_keys=True,
|
|
511
|
+
)
|
|
512
|
+
expected_hash = hashlib.sha256(chain_payload.encode()).hexdigest()
|
|
513
|
+
|
|
514
|
+
if record.current_hash != expected_hash:
|
|
515
|
+
return False
|
|
516
|
+
|
|
517
|
+
# Verify chain link (skip first record — its previous_hash
|
|
518
|
+
# may reference pre-rotation tail)
|
|
519
|
+
if idx > 0:
|
|
520
|
+
if record.previous_hash != self._records[idx - 1].current_hash:
|
|
521
|
+
return False
|
|
522
|
+
|
|
523
|
+
return True
|
|
524
|
+
|
|
525
|
+
def _write_to_jsonl(self, record: AuditRecord) -> None:
|
|
526
|
+
"""Append an audit record to JSONL file with fsync for durability."""
|
|
527
|
+
try:
|
|
528
|
+
self._jsonl_path.parent.mkdir(parents=True, exist_ok=True)
|
|
529
|
+
import dataclasses
|
|
530
|
+
|
|
531
|
+
d = dataclasses.asdict(record)
|
|
532
|
+
# Convert datetime objects to ISO strings
|
|
533
|
+
et = d.get("event_time")
|
|
534
|
+
if isinstance(et, datetime):
|
|
535
|
+
d["event_time"] = isoformat_z(et)
|
|
536
|
+
line = json.dumps(d, ensure_ascii=False, sort_keys=True) + "\n"
|
|
537
|
+
with open(self._jsonl_path, "a", encoding="utf-8") as f:
|
|
538
|
+
f.write(line)
|
|
539
|
+
f.flush()
|
|
540
|
+
os.fsync(f.fileno())
|
|
541
|
+
except OSError:
|
|
542
|
+
pass # Disk persistence is best-effort
|
|
543
|
+
|
|
544
|
+
def _load_from_jsonl(self) -> None:
|
|
545
|
+
"""Load audit records from JSONL file on startup, rebuilding hash chain."""
|
|
546
|
+
if not self._jsonl_path.exists():
|
|
547
|
+
return
|
|
548
|
+
try:
|
|
549
|
+
with open(self._jsonl_path, "r", encoding="utf-8") as f:
|
|
550
|
+
for line in f:
|
|
551
|
+
line = line.strip()
|
|
552
|
+
if not line:
|
|
553
|
+
continue
|
|
554
|
+
data = json.loads(line)
|
|
555
|
+
record = AuditRecord(
|
|
556
|
+
audit_id=data.get("audit_id", ""),
|
|
557
|
+
event_id=data.get("event_id", ""),
|
|
558
|
+
trace_id=data.get("trace_id", ""),
|
|
559
|
+
span_id=data.get("span_id", ""),
|
|
560
|
+
parent_span_id=data.get("parent_span_id", ""),
|
|
561
|
+
event_type=data.get("event_type", "skill_pool_event"),
|
|
562
|
+
event_time=data.get("event_time"),
|
|
563
|
+
created_at=data.get("created_at", ""),
|
|
564
|
+
updated_at=data.get("updated_at", ""),
|
|
565
|
+
duration_ms=data.get("duration_ms", 0.0),
|
|
566
|
+
actor=data.get("actor", "system"),
|
|
567
|
+
agent_id=data.get("agent_id", ""),
|
|
568
|
+
session_id=data.get("session_id", ""),
|
|
569
|
+
tenant_id=data.get("tenant_id", "default"),
|
|
570
|
+
source_component=data.get("source_component", "SkillPool"),
|
|
571
|
+
action=data.get("action", ""),
|
|
572
|
+
resource_type=data.get("resource_type", "skill"),
|
|
573
|
+
resource_id=data.get("resource_id", ""),
|
|
574
|
+
request_id=data.get("request_id", ""),
|
|
575
|
+
correlation_id=data.get("correlation_id", ""),
|
|
576
|
+
policy_decision=data.get("policy_decision", "allow"),
|
|
577
|
+
decision=data.get("decision", ""),
|
|
578
|
+
result=data.get("result", "success"),
|
|
579
|
+
reason=data.get("reason", ""),
|
|
580
|
+
severity=data.get("severity", "INFO"),
|
|
581
|
+
previous_hash=data.get("previous_hash", ""),
|
|
582
|
+
current_hash=data.get("current_hash", ""),
|
|
583
|
+
chain_index=data.get("chain_index", 0),
|
|
584
|
+
signature=data.get("signature", ""),
|
|
585
|
+
input_hash=data.get("input_hash", ""),
|
|
586
|
+
output_hash=data.get("output_hash", ""),
|
|
587
|
+
metadata_json=data.get("metadata_json", "{}"),
|
|
588
|
+
retention_class=data.get("retention_class", "hot"),
|
|
589
|
+
compliance_tags=data.get("compliance_tags", ""),
|
|
590
|
+
geo_location=data.get("geo_location", ""),
|
|
591
|
+
trace_source=data.get("trace_source", ""),
|
|
592
|
+
)
|
|
593
|
+
self._records.append(record)
|
|
594
|
+
if record.current_hash:
|
|
595
|
+
self._last_hash = record.current_hash
|
|
596
|
+
# Enforce max_entries after load
|
|
597
|
+
if len(self._records) > self._max_entries:
|
|
598
|
+
cutoff = len(self._records) - self._max_entries
|
|
599
|
+
self._records = self._records[cutoff:]
|
|
600
|
+
if self._records:
|
|
601
|
+
self._last_hash = self._records[-1].current_hash
|
|
602
|
+
except (json.JSONDecodeError, OSError):
|
|
603
|
+
pass # Disk load is best-effort
|
|
604
|
+
|
|
605
|
+
def export_otel_traces(self) -> list[dict[str, Any]]:
|
|
606
|
+
"""
|
|
607
|
+
Export records in OpenTelemetry-compatible format.
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
List of dicts with OTel standard fields: traceId, spanId, name, attributes, etc.
|
|
611
|
+
"""
|
|
612
|
+
from datetime import timezone
|
|
613
|
+
|
|
614
|
+
traces = []
|
|
615
|
+
for record in self._records:
|
|
616
|
+
event_time = record.event_time
|
|
617
|
+
if isinstance(event_time, str):
|
|
618
|
+
# Loaded from JSONL — parse back to datetime
|
|
619
|
+
try:
|
|
620
|
+
event_time = datetime.fromisoformat(event_time.replace("Z", "+00:00"))
|
|
621
|
+
except (ValueError, AttributeError):
|
|
622
|
+
event_time = datetime.now(UTC)
|
|
623
|
+
if event_time.tzinfo is None:
|
|
624
|
+
event_time = event_time.replace(tzinfo=timezone.utc)
|
|
625
|
+
|
|
626
|
+
traces.append(
|
|
627
|
+
{
|
|
628
|
+
"traceId": record.trace_id,
|
|
629
|
+
"spanId": record.span_id,
|
|
630
|
+
"parentSpanId": record.parent_span_id or None,
|
|
631
|
+
"name": f"{record.source_component}.{record.action}",
|
|
632
|
+
"kind": "INTERNAL",
|
|
633
|
+
"startTimeUnixNano": str(int(event_time.timestamp() * 1e9)),
|
|
634
|
+
"endTimeUnixNano": str(int((event_time.timestamp() + record.duration_ms / 1000) * 1e9)),
|
|
635
|
+
"attributes": {
|
|
636
|
+
"audit.id": record.audit_id,
|
|
637
|
+
"event.id": record.event_id,
|
|
638
|
+
"actor": record.actor,
|
|
639
|
+
"action": record.action,
|
|
640
|
+
"resource.type": record.resource_type,
|
|
641
|
+
"resource.id": record.resource_id,
|
|
642
|
+
"decision": record.decision,
|
|
643
|
+
"result": record.result,
|
|
644
|
+
"severity": record.severity,
|
|
645
|
+
"tenant.id": record.tenant_id,
|
|
646
|
+
"compliance.tags": record.compliance_tags,
|
|
647
|
+
},
|
|
648
|
+
"status": {"code": 1 if record.result == "success" else 2},
|
|
649
|
+
}
|
|
650
|
+
)
|
|
651
|
+
return traces
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""SkillPool Bridge — WAL, freeze detection, and maintenance for registry integrity."""
|
|
2
|
+
|
|
3
|
+
from .freeze_detector import FreezeDetector, FreezeReport, FreezeStatus
|
|
4
|
+
from .maintenance import MaintenanceCron, MaintenanceResult
|
|
5
|
+
from .wal_manager import WALEntry, WALEntryType, WALManager
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"WALManager",
|
|
9
|
+
"WALEntry",
|
|
10
|
+
"WALEntryType",
|
|
11
|
+
"FreezeDetector",
|
|
12
|
+
"FreezeReport",
|
|
13
|
+
"FreezeStatus",
|
|
14
|
+
"MaintenanceCron",
|
|
15
|
+
"MaintenanceResult",
|
|
16
|
+
]
|