apache-aiops 0.2.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.
- apache_aiops/__init__.py +8 -0
- apache_aiops/cli/__init__.py +9 -0
- apache_aiops/cli/_common.py +118 -0
- apache_aiops/cli/_root.py +206 -0
- apache_aiops/cli/undo.py +66 -0
- apache_aiops/config.py +74 -0
- apache_aiops/connection.py +222 -0
- apache_aiops/governance/__init__.py +50 -0
- apache_aiops/governance/audit.py +526 -0
- apache_aiops/governance/budget.py +222 -0
- apache_aiops/governance/decorators.py +599 -0
- apache_aiops/governance/outcome.py +102 -0
- apache_aiops/governance/paths.py +23 -0
- apache_aiops/governance/patterns.py +396 -0
- apache_aiops/governance/policy.py +67 -0
- apache_aiops/governance/sanitize.py +79 -0
- apache_aiops/governance/undo.py +259 -0
- apache_aiops/mcp_server/__init__.py +1 -0
- apache_aiops/mcp_server/_annotations.py +66 -0
- apache_aiops/mcp_server/_shared.py +67 -0
- apache_aiops/mcp_server/server.py +14 -0
- apache_aiops/mcp_server/tools/__init__.py +1 -0
- apache_aiops/mcp_server/tools/httpd.py +143 -0
- apache_aiops/mcp_server/tools/undo.py +204 -0
- apache_aiops/ops/__init__.py +1 -0
- apache_aiops/ops/httpd.py +81 -0
- apache_aiops-0.2.0.dist-info/METADATA +50 -0
- apache_aiops-0.2.0.dist-info/RECORD +31 -0
- apache_aiops-0.2.0.dist-info/WHEEL +4 -0
- apache_aiops-0.2.0.dist-info/entry_points.txt +3 -0
- apache_aiops-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Unified audit logging engine — all Monitoring skills write to a single SQLite database.
|
|
2
|
+
|
|
3
|
+
Replaces 7 per-skill JSON Lines audit loggers with one shared ``~/.apache-aiops/audit.db``.
|
|
4
|
+
Framework-agnostic: works with Claude, Codex, local agents, or any MCP client.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import getpass
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sqlite3
|
|
14
|
+
import threading
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from datetime import UTC, datetime, timedelta
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from apache_aiops.governance.paths import ops_path
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import fcntl
|
|
24
|
+
except ImportError: # Windows has no fcntl; the in-process lock still applies.
|
|
25
|
+
fcntl = None
|
|
26
|
+
|
|
27
|
+
_log = logging.getLogger("apache-aiops.audit")
|
|
28
|
+
|
|
29
|
+
# Back-compat override hook. When None (default) the path resolves via
|
|
30
|
+
# ops_path("audit.db") (honoring APACHE_AIOPS_HOME). Downstream code/tests may set this
|
|
31
|
+
# to a fixed Path to redirect the default DB — kept so the APACHE_AIOPS_HOME refactor
|
|
32
|
+
# does not break callers that monkeypatch `_DEFAULT_DB`.
|
|
33
|
+
_DEFAULT_DB: Path | None = None
|
|
34
|
+
|
|
35
|
+
_MAX_DB_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB
|
|
36
|
+
_MAX_ARCHIVES = 5
|
|
37
|
+
_BUSY_TIMEOUT_MS = 5000
|
|
38
|
+
_rotation_thread_lock = threading.RLock()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@contextmanager
|
|
42
|
+
def _rotation_guard(path: Path):
|
|
43
|
+
"""Serialize rotation and intent insertion across local processes."""
|
|
44
|
+
with _rotation_thread_lock:
|
|
45
|
+
fd = os.open(path.with_name("audit.lock"), os.O_CREAT | os.O_RDWR, 0o600)
|
|
46
|
+
try:
|
|
47
|
+
if fcntl is not None:
|
|
48
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
49
|
+
yield
|
|
50
|
+
finally:
|
|
51
|
+
if fcntl is not None:
|
|
52
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
53
|
+
os.close(fd)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuditUnavailable(RuntimeError):
|
|
57
|
+
"""A write was stopped because its durable audit intent could not be recorded."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_CREATE_TABLE = """\
|
|
61
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
62
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
|
+
ts TEXT NOT NULL,
|
|
64
|
+
skill TEXT NOT NULL,
|
|
65
|
+
tool TEXT NOT NULL,
|
|
66
|
+
params TEXT NOT NULL DEFAULT '{}',
|
|
67
|
+
result TEXT NOT NULL DEFAULT '{}',
|
|
68
|
+
status TEXT NOT NULL DEFAULT 'ok',
|
|
69
|
+
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
agent TEXT NOT NULL DEFAULT 'unknown',
|
|
71
|
+
workflow_id TEXT NOT NULL DEFAULT '',
|
|
72
|
+
user TEXT NOT NULL DEFAULT 'unknown',
|
|
73
|
+
risk_level TEXT NOT NULL DEFAULT 'low',
|
|
74
|
+
rationale TEXT NOT NULL DEFAULT '',
|
|
75
|
+
approved_by TEXT NOT NULL DEFAULT '',
|
|
76
|
+
risk_tier TEXT NOT NULL DEFAULT ''
|
|
77
|
+
)
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
# Columns added after the original schema shipped. Each is applied with an
|
|
81
|
+
# idempotent ALTER TABLE so pre-existing audit.db files migrate in place (a
|
|
82
|
+
# fresh DB already has them via _CREATE_TABLE). Accountability fields per the
|
|
83
|
+
# SOC2 / 等保 "who authorized this, and why" requirement.
|
|
84
|
+
_MIGRATIONS = (
|
|
85
|
+
("rationale", "TEXT NOT NULL DEFAULT ''"),
|
|
86
|
+
("approved_by", "TEXT NOT NULL DEFAULT ''"),
|
|
87
|
+
("risk_tier", "TEXT NOT NULL DEFAULT ''"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
_INSERT = """\
|
|
91
|
+
INSERT INTO audit_log (ts, skill, tool, params, result, status, duration_ms, agent, workflow_id, user, risk_level, rationale, approved_by, risk_tier)
|
|
92
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AuditEngine:
|
|
97
|
+
"""Append-only audit logger backed by SQLite with WAL mode.
|
|
98
|
+
|
|
99
|
+
Thread-safe for concurrent writes from multiple skill processes.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, db_path: Path | str | None = None) -> None:
|
|
103
|
+
if db_path:
|
|
104
|
+
self._path = Path(db_path).expanduser()
|
|
105
|
+
else:
|
|
106
|
+
self._path = _DEFAULT_DB or ops_path("audit.db")
|
|
107
|
+
self._ok = False
|
|
108
|
+
self._write_blocked = False
|
|
109
|
+
self._write_failures = 0
|
|
110
|
+
try:
|
|
111
|
+
self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
112
|
+
self._init_db()
|
|
113
|
+
self._harden_permissions()
|
|
114
|
+
self._ok = True
|
|
115
|
+
except Exception:
|
|
116
|
+
_log.warning("Cannot initialize audit DB at %s", self._path, exc_info=True)
|
|
117
|
+
|
|
118
|
+
def _init_db(self) -> None:
|
|
119
|
+
conn = self._connect()
|
|
120
|
+
conn.execute(_CREATE_TABLE)
|
|
121
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
122
|
+
conn.execute("PRAGMA synchronous=FULL")
|
|
123
|
+
self._migrate(conn)
|
|
124
|
+
conn.commit()
|
|
125
|
+
conn.close()
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _migrate(conn: sqlite3.Connection) -> None:
|
|
129
|
+
"""Add columns introduced after the original schema, in place.
|
|
130
|
+
|
|
131
|
+
Idempotent: only adds a column the table is missing, so existing
|
|
132
|
+
audit.db files gain rationale/approved_by/risk_tier without losing rows.
|
|
133
|
+
"""
|
|
134
|
+
existing = {row[1] for row in conn.execute("PRAGMA table_info(audit_log)")}
|
|
135
|
+
for name, decl in _MIGRATIONS:
|
|
136
|
+
if name not in existing:
|
|
137
|
+
conn.execute(f"ALTER TABLE audit_log ADD COLUMN {name} {decl}") # nosec B608
|
|
138
|
+
|
|
139
|
+
def _harden_permissions(self) -> None:
|
|
140
|
+
"""Restrict the audit dir to 0700 and DB files (incl. WAL/SHM) to 0600.
|
|
141
|
+
|
|
142
|
+
``mkdir(mode=...)`` is masked by umask, so set permissions explicitly.
|
|
143
|
+
Best-effort: never raises (audit must not break the tool call)."""
|
|
144
|
+
try:
|
|
145
|
+
os.chmod(self._path.parent, 0o700)
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
for suffix in ("", "-wal", "-shm"):
|
|
149
|
+
candidate = self._path.with_name(self._path.name + suffix)
|
|
150
|
+
try:
|
|
151
|
+
if candidate.exists():
|
|
152
|
+
os.chmod(candidate, 0o600)
|
|
153
|
+
except OSError:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
def _connect(self) -> sqlite3.Connection:
|
|
157
|
+
conn = sqlite3.connect(str(self._path), timeout=_BUSY_TIMEOUT_MS / 1000)
|
|
158
|
+
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
|
|
159
|
+
conn.execute("PRAGMA synchronous=FULL")
|
|
160
|
+
return conn
|
|
161
|
+
|
|
162
|
+
def log(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
skill: str,
|
|
166
|
+
tool: str,
|
|
167
|
+
params: dict[str, Any] | None = None,
|
|
168
|
+
result: Any = None,
|
|
169
|
+
status: str = "ok",
|
|
170
|
+
duration_ms: int = 0,
|
|
171
|
+
agent: str = "unknown",
|
|
172
|
+
workflow_id: str = "",
|
|
173
|
+
user: str = "",
|
|
174
|
+
risk_level: str = "low",
|
|
175
|
+
rationale: str = "",
|
|
176
|
+
approved_by: str = "",
|
|
177
|
+
risk_tier: str = "",
|
|
178
|
+
strict: bool = False,
|
|
179
|
+
) -> int | None:
|
|
180
|
+
"""Append a row. Strict mode refuses writes if the intent cannot persist.
|
|
181
|
+
|
|
182
|
+
rationale / approved_by / risk_tier carry the accountability trail:
|
|
183
|
+
*why* a change was made, *who* signed off, and the *approval tier* the
|
|
184
|
+
policy engine assigned (none/confirm/dual/review).
|
|
185
|
+
"""
|
|
186
|
+
if not self._ok:
|
|
187
|
+
if strict:
|
|
188
|
+
self._write_blocked = True
|
|
189
|
+
self._write_failures += 1
|
|
190
|
+
raise AuditUnavailable("Audit storage is unavailable; write was not started")
|
|
191
|
+
self._write_failures += 1
|
|
192
|
+
return None
|
|
193
|
+
try:
|
|
194
|
+
with _rotation_guard(self._path):
|
|
195
|
+
self._maybe_rotate()
|
|
196
|
+
conn = self._connect()
|
|
197
|
+
try:
|
|
198
|
+
cursor = conn.execute(
|
|
199
|
+
_INSERT,
|
|
200
|
+
(
|
|
201
|
+
datetime.now(tz=UTC).isoformat(),
|
|
202
|
+
skill,
|
|
203
|
+
tool,
|
|
204
|
+
_safe_json(params),
|
|
205
|
+
_safe_json(_summarize_result(result)),
|
|
206
|
+
status,
|
|
207
|
+
duration_ms,
|
|
208
|
+
agent,
|
|
209
|
+
workflow_id,
|
|
210
|
+
user or _current_user(),
|
|
211
|
+
risk_level,
|
|
212
|
+
rationale,
|
|
213
|
+
approved_by,
|
|
214
|
+
risk_tier,
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
conn.commit()
|
|
218
|
+
audit_id = cursor.lastrowid
|
|
219
|
+
finally:
|
|
220
|
+
conn.close()
|
|
221
|
+
_log.debug("[AUDIT] %s.%s -> %s (%dms)", skill, tool, status, duration_ms)
|
|
222
|
+
return audit_id
|
|
223
|
+
except Exception:
|
|
224
|
+
self._write_failures += 1
|
|
225
|
+
_log.warning("Failed to write audit log", exc_info=True)
|
|
226
|
+
if strict:
|
|
227
|
+
self._write_blocked = True
|
|
228
|
+
raise AuditUnavailable(
|
|
229
|
+
"Audit storage is unavailable; write was not started"
|
|
230
|
+
) from None
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def begin_write(self, **metadata: Any) -> int:
|
|
234
|
+
"""Commit a durable intent before the caller sends an upstream write."""
|
|
235
|
+
if self._write_blocked:
|
|
236
|
+
raise AuditUnavailable("Audit storage failed; further writes are blocked")
|
|
237
|
+
audit_id = self.log(status="pending", strict=True, **metadata)
|
|
238
|
+
assert audit_id is not None
|
|
239
|
+
return audit_id
|
|
240
|
+
|
|
241
|
+
def finish_write(self, audit_id: int, *, result: Any, status: str, duration_ms: int) -> None:
|
|
242
|
+
"""Resolve a write intent; on failure leave a recoverable pending row."""
|
|
243
|
+
try:
|
|
244
|
+
conn = self._connect()
|
|
245
|
+
try:
|
|
246
|
+
cursor = conn.execute(
|
|
247
|
+
"UPDATE audit_log SET result = ?, status = ?, duration_ms = ? "
|
|
248
|
+
"WHERE id = ? AND status = 'pending'",
|
|
249
|
+
(_safe_json(_summarize_result(result)), status, duration_ms, audit_id),
|
|
250
|
+
)
|
|
251
|
+
if cursor.rowcount != 1:
|
|
252
|
+
raise RuntimeError(f"audit intent {audit_id} is missing")
|
|
253
|
+
conn.commit()
|
|
254
|
+
finally:
|
|
255
|
+
conn.close()
|
|
256
|
+
except Exception:
|
|
257
|
+
self._write_blocked = True
|
|
258
|
+
self._write_failures += 1
|
|
259
|
+
_log.warning("Failed to finalize audit intent %s", audit_id, exc_info=True)
|
|
260
|
+
raise AuditUnavailable(
|
|
261
|
+
f"Audit intent {audit_id} remains pending; operation may have completed"
|
|
262
|
+
) from None
|
|
263
|
+
|
|
264
|
+
def reconcile_pending(self, audit_id: int) -> bool:
|
|
265
|
+
"""Mark an orphaned intent unknown after an operator checks the process.
|
|
266
|
+
|
|
267
|
+
Never run this automatically: another process may still be executing
|
|
268
|
+
the write. The row remains visible until explicitly reconciled.
|
|
269
|
+
"""
|
|
270
|
+
conn = self._connect()
|
|
271
|
+
try:
|
|
272
|
+
cursor = conn.execute(
|
|
273
|
+
"UPDATE audit_log SET status = 'unknown', result = ? "
|
|
274
|
+
"WHERE id = ? AND status = 'pending'",
|
|
275
|
+
(_safe_json({"auditInterrupted": True}), audit_id),
|
|
276
|
+
)
|
|
277
|
+
conn.commit()
|
|
278
|
+
return cursor.rowcount == 1
|
|
279
|
+
finally:
|
|
280
|
+
conn.close()
|
|
281
|
+
|
|
282
|
+
def health(self) -> dict[str, Any]:
|
|
283
|
+
"""Safe local health counters for operators and telemetry exporters."""
|
|
284
|
+
pending: int | None = None
|
|
285
|
+
if self._ok:
|
|
286
|
+
try:
|
|
287
|
+
conn = self._connect()
|
|
288
|
+
try:
|
|
289
|
+
pending = conn.execute(
|
|
290
|
+
"SELECT COUNT(*) FROM audit_log WHERE status = 'pending'"
|
|
291
|
+
).fetchone()[0]
|
|
292
|
+
finally:
|
|
293
|
+
conn.close()
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
return {
|
|
297
|
+
"available": self._ok and pending is not None and not self._write_blocked,
|
|
298
|
+
"writeBlocked": self._write_blocked,
|
|
299
|
+
"auditWriteFailures": self._write_failures,
|
|
300
|
+
"pendingWrites": pending,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# ── Rotation ──────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
def _maybe_rotate(self) -> None:
|
|
306
|
+
"""Archive the DB if it exceeds size limit."""
|
|
307
|
+
try:
|
|
308
|
+
if not self._path.exists():
|
|
309
|
+
return
|
|
310
|
+
if self._path.stat().st_size < _MAX_DB_SIZE_BYTES:
|
|
311
|
+
return
|
|
312
|
+
pending_conn = self._connect()
|
|
313
|
+
try:
|
|
314
|
+
if pending_conn.execute(
|
|
315
|
+
"SELECT 1 FROM audit_log WHERE status = 'pending' LIMIT 1"
|
|
316
|
+
).fetchone():
|
|
317
|
+
return
|
|
318
|
+
finally:
|
|
319
|
+
pending_conn.close()
|
|
320
|
+
archive_name = self._path.with_suffix(
|
|
321
|
+
f".{datetime.now(tz=UTC).strftime('%Y%m%d-%H%M%S')}.db"
|
|
322
|
+
)
|
|
323
|
+
# Checkpoint the WAL into the main file before renaming, otherwise
|
|
324
|
+
# un-checkpointed rows in audit.db-wal are silently lost (the
|
|
325
|
+
# sidecars belong to the OLD path and never follow the archive).
|
|
326
|
+
conn = self._connect()
|
|
327
|
+
try:
|
|
328
|
+
checkpoint = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
|
|
329
|
+
if checkpoint is None or checkpoint[0]:
|
|
330
|
+
raise RuntimeError(f"WAL checkpoint incomplete: {checkpoint}")
|
|
331
|
+
finally:
|
|
332
|
+
conn.close()
|
|
333
|
+
self._path.rename(archive_name)
|
|
334
|
+
# Drop now-stale sidecars (empty after a successful checkpoint).
|
|
335
|
+
for suffix in ("-wal", "-shm"):
|
|
336
|
+
sidecar = self._path.with_name(self._path.name + suffix)
|
|
337
|
+
try:
|
|
338
|
+
sidecar.unlink(missing_ok=True)
|
|
339
|
+
except OSError:
|
|
340
|
+
pass
|
|
341
|
+
self._init_db()
|
|
342
|
+
self._harden_permissions()
|
|
343
|
+
self._cleanup_archives()
|
|
344
|
+
_log.info("Audit DB rotated → %s", archive_name.name)
|
|
345
|
+
except Exception:
|
|
346
|
+
_log.warning("Audit DB rotation failed", exc_info=True)
|
|
347
|
+
|
|
348
|
+
def _cleanup_archives(self) -> None:
|
|
349
|
+
"""Keep only the most recent N archive files."""
|
|
350
|
+
archives = sorted(
|
|
351
|
+
self._path.parent.glob("audit.*.db"),
|
|
352
|
+
key=lambda p: p.stat().st_mtime,
|
|
353
|
+
reverse=True,
|
|
354
|
+
)
|
|
355
|
+
for old in archives[_MAX_ARCHIVES:]:
|
|
356
|
+
old.unlink(missing_ok=True)
|
|
357
|
+
|
|
358
|
+
# ── Query helpers (used by CLI) ───────────────────────────────────
|
|
359
|
+
|
|
360
|
+
def query(
|
|
361
|
+
self,
|
|
362
|
+
*,
|
|
363
|
+
skill: str | None = None,
|
|
364
|
+
tool: str | None = None,
|
|
365
|
+
status: str | None = None,
|
|
366
|
+
workflow_id: str | None = None,
|
|
367
|
+
since: str | None = None,
|
|
368
|
+
limit: int = 50,
|
|
369
|
+
) -> list[dict[str, Any]]:
|
|
370
|
+
"""Query audit records with optional filters."""
|
|
371
|
+
clauses: list[str] = []
|
|
372
|
+
values: list[Any] = []
|
|
373
|
+
|
|
374
|
+
if skill:
|
|
375
|
+
clauses.append("skill = ?")
|
|
376
|
+
values.append(skill)
|
|
377
|
+
if tool:
|
|
378
|
+
clauses.append("tool = ?")
|
|
379
|
+
values.append(tool)
|
|
380
|
+
if status:
|
|
381
|
+
clauses.append("status = ?")
|
|
382
|
+
values.append(status)
|
|
383
|
+
if workflow_id:
|
|
384
|
+
clauses.append("workflow_id = ?")
|
|
385
|
+
values.append(workflow_id)
|
|
386
|
+
if since:
|
|
387
|
+
clauses.append("ts >= ?")
|
|
388
|
+
values.append(since)
|
|
389
|
+
|
|
390
|
+
# ``clauses`` are hardcoded "col = ?" fragments; every user value is a
|
|
391
|
+
# bound parameter in ``values``. No user input is interpolated here.
|
|
392
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
393
|
+
sql = f"SELECT * FROM audit_log {where} ORDER BY id DESC LIMIT ?" # nosec B608
|
|
394
|
+
values.append(limit)
|
|
395
|
+
|
|
396
|
+
conn = self._connect()
|
|
397
|
+
try:
|
|
398
|
+
conn.row_factory = sqlite3.Row
|
|
399
|
+
rows = conn.execute(sql, values).fetchall()
|
|
400
|
+
return [dict(r) for r in rows]
|
|
401
|
+
finally:
|
|
402
|
+
conn.close()
|
|
403
|
+
|
|
404
|
+
def stats(self, days: int = 7) -> dict[str, Any]:
|
|
405
|
+
"""Aggregate statistics over the last N days."""
|
|
406
|
+
since = (datetime.now(tz=UTC) - timedelta(days=days)).isoformat()
|
|
407
|
+
conn = self._connect()
|
|
408
|
+
try:
|
|
409
|
+
conn.row_factory = sqlite3.Row
|
|
410
|
+
|
|
411
|
+
total = conn.execute(
|
|
412
|
+
"SELECT COUNT(*) as c FROM audit_log WHERE ts >= ?", (since,)
|
|
413
|
+
).fetchone()["c"]
|
|
414
|
+
|
|
415
|
+
by_status = {
|
|
416
|
+
r["status"]: r["c"]
|
|
417
|
+
for r in conn.execute(
|
|
418
|
+
"SELECT status, COUNT(*) as c FROM audit_log WHERE ts >= ? GROUP BY status",
|
|
419
|
+
(since,),
|
|
420
|
+
).fetchall()
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
by_skill = {
|
|
424
|
+
r["skill"]: r["c"]
|
|
425
|
+
for r in conn.execute(
|
|
426
|
+
"SELECT skill, COUNT(*) as c FROM audit_log WHERE ts >= ? GROUP BY skill",
|
|
427
|
+
(since,),
|
|
428
|
+
).fetchall()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {"total": total, "by_status": by_status, "by_skill": by_skill, "days": days}
|
|
432
|
+
finally:
|
|
433
|
+
conn.close()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ── Module-level helpers ──────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _current_user() -> str:
|
|
440
|
+
try:
|
|
441
|
+
return getpass.getuser()
|
|
442
|
+
except Exception:
|
|
443
|
+
return "unknown"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _safe_json(obj: Any) -> str:
|
|
447
|
+
"""Serialize to JSON, falling back to str() for non-serializable objects."""
|
|
448
|
+
if obj is None:
|
|
449
|
+
return "{}"
|
|
450
|
+
try:
|
|
451
|
+
return json.dumps(obj, ensure_ascii=False, default=str)
|
|
452
|
+
except Exception:
|
|
453
|
+
return json.dumps({"_raw": str(obj)})
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _summarize_result(result: Any) -> dict[str, Any]:
|
|
457
|
+
"""Persist only outcome metadata; never copy a tool's business payload."""
|
|
458
|
+
if isinstance(result, dict):
|
|
459
|
+
summary: dict[str, Any] = {"kind": "object"}
|
|
460
|
+
if result.get("error"):
|
|
461
|
+
summary["error"] = True
|
|
462
|
+
if result.get("outcomeUnknown") is True:
|
|
463
|
+
summary["outcomeUnknown"] = True
|
|
464
|
+
if result.get("_undo_id"):
|
|
465
|
+
summary["undoRecorded"] = True
|
|
466
|
+
if result.get("_pattern_id"):
|
|
467
|
+
summary["patternMatched"] = True
|
|
468
|
+
for key in ("truncated", "returned", "limit"):
|
|
469
|
+
value = result.get(key)
|
|
470
|
+
if type(value) in (bool, int):
|
|
471
|
+
summary[key] = value
|
|
472
|
+
return summary
|
|
473
|
+
if isinstance(result, (list, tuple)):
|
|
474
|
+
return {"kind": "list", "count": len(result)}
|
|
475
|
+
return {"kind": type(result).__name__}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def detect_agent() -> str:
|
|
479
|
+
"""Infer the calling AI agent from environment variables."""
|
|
480
|
+
if os.environ.get("CLAUDE_SESSION_ID") or os.environ.get("CLAUDE_CODE"):
|
|
481
|
+
return "claude"
|
|
482
|
+
# Note: OPENAI_API_KEY is deliberately NOT used as a codex marker — it is
|
|
483
|
+
# commonly set in shells for unrelated tooling and misattributes the agent.
|
|
484
|
+
if os.environ.get("CODEX_SESSION"):
|
|
485
|
+
return "codex"
|
|
486
|
+
if os.environ.get("OLLAMA_HOST"):
|
|
487
|
+
return "local"
|
|
488
|
+
if os.environ.get("DEERFLOW_SESSION"):
|
|
489
|
+
return "deerflow"
|
|
490
|
+
return "unknown"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# Singleton — shared across all skills in the same process
|
|
494
|
+
_engine: AuditEngine | None = None
|
|
495
|
+
_engine_lock = threading.Lock()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def get_engine(db_path: Path | str | None = None) -> AuditEngine:
|
|
499
|
+
"""Return the global AuditEngine singleton (lazy, lock-guarded).
|
|
500
|
+
|
|
501
|
+
A ``db_path`` differing from the one the singleton was created with is
|
|
502
|
+
ignored with a warning — call :func:`reset_engine` first to rebind.
|
|
503
|
+
"""
|
|
504
|
+
global _engine
|
|
505
|
+
if _engine is None:
|
|
506
|
+
with _engine_lock:
|
|
507
|
+
if _engine is None:
|
|
508
|
+
_engine = AuditEngine(db_path)
|
|
509
|
+
return _engine
|
|
510
|
+
if db_path is not None:
|
|
511
|
+
requested = Path(db_path).expanduser()
|
|
512
|
+
if requested != _engine._path:
|
|
513
|
+
_log.warning(
|
|
514
|
+
"get_engine(%s) ignored — singleton already initialized at %s; "
|
|
515
|
+
"call reset_engine() first to rebind.",
|
|
516
|
+
requested,
|
|
517
|
+
_engine._path,
|
|
518
|
+
)
|
|
519
|
+
return _engine
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def reset_engine() -> None:
|
|
523
|
+
"""Reset the singleton. Mirrors patterns.reset_pattern_engine()."""
|
|
524
|
+
global _engine
|
|
525
|
+
with _engine_lock:
|
|
526
|
+
_engine = None
|