Option C Tools
Loading...
Searching...
No Matches
audit.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/git/audit.py
4
5"""
6Purpose
7-------
8Audit JSONL logger and AI-Trace decorator for Phase 4B ``oct git``
9commands. Writes one JSON object per line to
10``<project>/logs/git-audit-YYYYMMDD-HHMMSS.jsonl`` with size + count
11rotation, and provides the :func:`audited` decorator that every
12``oct git`` Click command is wrapped in so that entry/exit trace events
13and a persistent audit row are recorded even if the command crashes.
14
15Responsibilities
16----------------
17- Define :class:`AuditRecord` — the exact on-disk schema (blueprint
18 §12.2, verbatim).
19- Implement :class:`AuditLogger` with size-based rotation (5 MB) and
20 count-based pruning (keep the 20 newest files).
21- Redact credentials from any string value written to the log by
22 deferring to :func:`oct.core.git._redact_git_url`.
23- Emit AI-Trace shadow events via ``_dbg("GIT", msg, 4)`` — file-only,
24 off by default; see blueprint §12.3 and Phase 4B design decision D7.
25
26Diagnostics
27-----------
28Domain: GIT
29Levels:
30 L1 — errors: rotation IO failures, directory-create failures
31 L2 — lifecycle: file rotated, files pruned
32 L4 — deep trace: every ``write()`` call and every command start/end
33
34Contracts
35---------
36- This module does not import ``click``, does not shell out, and does
37 not touch any git state. It only reads config-less arguments passed
38 in from the caller.
39- :meth:`AuditLogger.write` must never raise. On any IO failure it
40 logs an error via ``_dbg`` and returns silently — the audit log is
41 *observability*, not a correctness path.
42- Filenames follow ``git-audit-YYYYMMDD-HHMMSS.jsonl`` strictly so that
43 lexical ordering matches chronological ordering for pruning.
44- Every ``AuditRecord`` field is JSON-serialisable. ``args`` is always
45 passed through credential redaction before serialisation.
46"""
47
48from __future__ import annotations
49
50import functools
51import json
52import time
53from dataclasses import asdict, dataclass, field
54from datetime import datetime, timezone
55from pathlib import Path
56from typing import Any, Callable
57
58from oct.core.diagnostics import _dbg
59from oct.core.git import _redact_git_url
60from oct.core.option_c_dir import resolve_logs_dir
61
62
63# ---------------------------------------------------------------------
64# Constants
65# ---------------------------------------------------------------------
66
67#: Maximum number of ``git-audit-*.jsonl`` files kept on disk. When the
68#: 21st file is created by a rotation event, the oldest is deleted.
69AUDIT_MAX_FILES: int = 20
70
71#: Rotation threshold. When the current audit file reaches this size in
72#: bytes, the next :meth:`AuditLogger.write` call closes it and opens a
73#: new timestamped file.
74AUDIT_MAX_SIZE_BYTES: int = 5 * 1024 * 1024 # 5 MB
75
76#: ``strftime`` format for the timestamp embedded in each rotated
77#: filename. Lexical sort order is identical to chronological order.
78_AUDIT_TS_FORMAT: str = "%Y%m%d-%H%M%S"
79
80#: Filename prefix and suffix used for glob matching during pruning.
81_AUDIT_FILENAME_PREFIX: str = "git-audit-"
82_AUDIT_FILENAME_SUFFIX: str = ".jsonl"
83_AUDIT_GLOB: str = f"{_AUDIT_FILENAME_PREFIX}*{_AUDIT_FILENAME_SUFFIX}"
84
85#: Directory (relative to project root) where audit logs are written —
86#: legacy fallback only. FS-539 resolves the actual path dynamically via
87#: :func:`oct.core.option_c_dir.resolve_logs_dir`, which prefers
88#: ``.option_c/logs/`` on migrated projects and falls back to this
89#: subdir on legacy ones. Kept as a module constant for any external
90#: test that imports it.
91_AUDIT_SUBDIR: str = "logs"
92
93
94# ---------------------------------------------------------------------
95# Data model (blueprint §12.2)
96# ---------------------------------------------------------------------
97
98
99@dataclass
101 """Schema for one audit row. Serialised via :func:`dataclasses.asdict`.
102
103 Every field is either JSON-primitive or a list/dict of JSON-primitives
104 so that ``json.dumps(asdict(record))`` is guaranteed to succeed.
105 ``args`` is credential-redacted by the caller before being assigned
106 (see :func:`_redact_args`).
107
108 Fields whose value is unknown at record-construction time may be
109 left at their default; missing fields are emitted as ``null`` in
110 the JSON output.
111 """
112
113 timestamp: str = "" # ISO 8601 UTC
114 command: str = "" # e.g. "oct git check"
115 args: list[str] = field(default_factory=list)
116 branch: str | None = None
117 staged_files: int = 0
118 checks_passed: bool = False
119 secrets_detected: bool = False
120 lint_violations: int = 0
121 format_violations: int = 0
122 exit_code: int | None = None
123 duration_ms: int = 0
124 #: F1.3: per-subproject quality-gate results when oct git commit
125 #: ran a workspace fan-out. Empty list when fan-out was not used.
126 #: Each entry: {name, profile, files, lint_violations,
127 #: format_violations, secrets}.
128 per_subproject_results: list[dict] = field(default_factory=list)
129
130
131def _utc_now_iso() -> str:
132 """Return the current UTC time as an ISO 8601 string.
133
134 Uses ``datetime.now(timezone.utc)`` rather than ``datetime.utcnow``
135 (deprecated in 3.12+) and appends a literal ``Z`` so readers can
136 round-trip the value without timezone ambiguity.
137 """
138 return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
139
140
141def _redact_args(argv: list[str] | tuple[str, ...] | None) -> list[str]:
142 """Run each argv element through :func:`_redact_git_url`.
143
144 This is defensive: an ``oct git`` command rarely receives a remote
145 URL as a positional arg, but a user *could* pass one via future
146 subcommands (e.g. ``oct git init --remote``). Applying the redactor
147 unconditionally costs a regex match per arg and closes the hole.
148 """
149 if not argv:
150 return []
151 out: list[str] = []
152 for item in argv:
153 if isinstance(item, str):
154 out.append(_redact_git_url(item))
155 else:
156 out.append(str(item))
157 return out
158
159
160# ---------------------------------------------------------------------
161# Rotating JSONL logger
162# ---------------------------------------------------------------------
163
164
166 """Append-only rotating JSONL logger for ``oct git`` commands.
167
168 One instance corresponds to one project root. The constructor does
169 not open a file — the first :meth:`write` call lazily materialises
170 the ``logs/`` directory and the current audit file. This lets a
171 command that never reaches the write stage (e.g. a CLI parse error)
172 leave no side-effects on disk.
173
174 Rotation strategy
175 -----------------
176 - **Size rotation:** before each write, if the current file is at or
177 above :data:`AUDIT_MAX_SIZE_BYTES`, close it and open a new
178 timestamped file. The rotating write itself goes into the new
179 file, not the old one.
180 - **Count pruning:** after any rotation, if the number of
181 ``git-audit-*.jsonl`` files in the logs directory exceeds
182 :data:`AUDIT_MAX_FILES`, delete the oldest (lexical order) until
183 the count is within the limit.
184
185 Error handling
186 --------------
187 Any IO error during :meth:`write` — creating the directory, opening
188 the file, serialising the record, writing the line, or rotating —
189 is caught and logged via ``_dbg("GIT", ..., 1)``. The method never
190 raises; audit logging is best-effort observability and must not
191 mask command failures.
192 """
193
194 def __init__(self, project_root: Path) -> None:
195 func = "AuditLogger.__init__"
196 self._project_root: Path = Path(project_root)
197 # FS-539: .option_c/logs/ wins when present, else root/logs/ (legacy).
198 # On a fresh project the resolver returns the modern path so the
199 # first write lands under .option_c/.
200 self._logs_dir: Path = resolve_logs_dir(self._project_root)
201 self._current_path: Path | None = None
202 _dbg("GIT", func, f"project_root={self._project_root}", 4)
203
204 # -- internal helpers ------------------------------------------------
205
206 def _ensure_logs_dir(self) -> None:
207 """Create ``<project>/logs/`` on demand. Idempotent."""
208 if not self._logs_dir.exists():
209 self._logs_dir.mkdir(parents=True, exist_ok=True)
210
211 def _new_filename(self) -> Path:
212 """Return a fresh ``git-audit-YYYYMMDD-HHMMSS.jsonl`` path.
213
214 Uses the current UTC timestamp. If the resulting filename
215 already exists (unlikely but possible at sub-second rotation or
216 in a fast loop), disambiguate by appending a monotonic suffix
217 ``-<counter>`` so no two rotations collide.
218 """
219 ts = datetime.now(timezone.utc).strftime(_AUDIT_TS_FORMAT)
220 base = f"{_AUDIT_FILENAME_PREFIX}{ts}{_AUDIT_FILENAME_SUFFIX}"
221 candidate = self._logs_dir / base
222 counter = 1
223 while candidate.exists():
224 candidate = (
225 self._logs_dir
226 / f"{_AUDIT_FILENAME_PREFIX}{ts}-{counter}{_AUDIT_FILENAME_SUFFIX}"
227 )
228 counter += 1
229 return candidate
230
231 def _current_file_size(self) -> int:
232 """Return the current audit file's size in bytes, 0 if missing."""
233 if self._current_path is None:
234 return 0
235 try:
236 return self._current_path.stat().st_size
237 except OSError:
238 return 0
239
240 def _rotate_if_needed(self) -> None:
241 """Open a fresh file if the current one is missing or full.
242
243 Called at the top of :meth:`write`. After opening a new file,
244 delegates to :meth:`_prune_old_files` so count-based rotation
245 runs at the same moment size-based rotation does (cheapest
246 common path).
247 """
248 func = "AuditLogger._rotate_if_needed"
249 if self._current_path is None:
250 self._current_path = self._new_filename()
251 self._current_path.touch() # create on disk so prune counts it
252 _dbg("GIT", func, f"opened initial file={self._current_path.name}", 2)
253 self._prune_old_files()
254 return
255 if self._current_file_size() >= AUDIT_MAX_SIZE_BYTES:
256 _dbg("GIT", func, f"size rotation from={self._current_path.name}", 2)
257 self._current_path = self._new_filename()
258 self._current_path.touch() # create on disk so prune counts it
259 self._prune_old_files()
260
261 def _list_existing_files(self) -> list[Path]:
262 """Return all ``git-audit-*.jsonl`` files in sorted order.
263
264 Sort is lexical, which matches chronological thanks to the
265 fixed-width ``YYYYMMDD-HHMMSS`` timestamp format.
266 """
267 if not self._logs_dir.exists():
268 return []
269 return sorted(self._logs_dir.glob(_AUDIT_GLOB))
270
271 def _prune_old_files(self) -> None:
272 """Delete the oldest files until count <= :data:`AUDIT_MAX_FILES`.
273
274 Deletion is best-effort: an ``OSError`` from a concurrent
275 reader holding a handle on Windows is logged but not re-raised.
276 """
277 func = "AuditLogger._prune_old_files"
278 files = self._list_existing_files()
279 excess = len(files) - AUDIT_MAX_FILES
280 if excess <= 0:
281 return
282 victims = files[:excess]
283 for path in victims:
284 try:
285 path.unlink()
286 _dbg("GIT", func, f"pruned {path.name}", 2)
287 except OSError as exc: # pragma: no cover - best-effort
288 _dbg("GIT", func, f"prune failed {path.name}: {exc}", 1)
289
290 # -- public API ------------------------------------------------------
291
292 def write(self, record: AuditRecord) -> None:
293 """Serialise ``record`` and append it as one JSONL line.
294
295 Never raises. On IO failure, logs an error via ``_dbg`` at
296 level 1 and returns. The caller is never blocked by audit log
297 misbehaviour.
298 """
299 func = "AuditLogger.write"
300 try:
301 self._ensure_logs_dir()
302 self._rotate_if_needed()
303 assert self._current_path is not None # set by _rotate_if_needed
304 # Defensive redaction at the boundary. Even though callers
305 # should already have redacted, applying again is idempotent.
306 record.args = _redact_args(record.args)
307 line = json.dumps(asdict(record), ensure_ascii=False)
308 with self._current_path.open("a", encoding="utf-8") as fh:
309 fh.write(line)
310 fh.write("\n")
311 _dbg("GIT", func, f"wrote record exit_code={record.exit_code}", 4)
312 except OSError as exc:
313 _dbg("GIT", func, f"audit write failed: {exc}", 1)
314 except (TypeError, ValueError) as exc:
315 _dbg("GIT", func, f"audit serialize failed: {exc}", 1)
316
317 @property
318 def current_path(self) -> Path | None:
319 """Current audit filename (``None`` before first write)."""
320 return self._current_path
321
322
323# ---------------------------------------------------------------------
324# @audited decorator (AI-Trace + finally-write audit row)
325# ---------------------------------------------------------------------
326
327
328def audited(cmd_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
329 """Decorator that wraps an ``oct git`` Click command.
330
331 Responsibilities:
332
333 1. Emit ``_dbg("GIT", "command_start ...", 4)`` before the command.
334 2. Run the wrapped function.
335 3. In a ``finally`` block, emit ``_dbg("GIT", "command_end ...", 4)``
336 and write an :class:`AuditRecord` into the project's audit log.
337
338 The wrapped command is responsible for populating
339 ``ctx.obj["_audit_record"]`` during its run — the decorator reads
340 that slot at the end and writes whatever is there, falling back to
341 a minimal record (with ``exit_code=None`` and ``checks_passed=False``)
342 if the command crashed before populating it. This guarantees every
343 command invocation leaves exactly one audit row.
344
345 Parameters
346 ----------
347 cmd_name
348 Human-readable command identifier for the trace and audit row,
349 e.g. ``"oct git check"``.
350 """
351
352 def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
353 @functools.wraps(fn)
354 def wrapper(ctx, *args, **kwargs):
355 import click # lazy import so this module stays Click-free at top level
356
357 func = f"audited::{cmd_name}"
358 start = time.monotonic()
359 _dbg("GIT", func, f"command_start cmd={cmd_name}", 4)
360
361 # Resolve project root from ctx; fall back to cwd.
362 project_root = _resolve_project_root_from_ctx(ctx)
363
364 # Make sure ctx.obj exists so the command can stash its record.
365 if not isinstance(getattr(ctx, "obj", None), dict):
366 ctx.obj = {}
367 ctx.obj.setdefault("_audit_record", AuditRecord(
368 timestamp=_utc_now_iso(),
369 command=cmd_name,
370 ))
371
372 logger = AuditLogger(project_root)
373 try:
374 return fn(ctx, *args, **kwargs)
375 finally:
376 duration_ms = int((time.monotonic() - start) * 1000)
377 record: AuditRecord = ctx.obj.get("_audit_record") or AuditRecord(
378 timestamp=_utc_now_iso(),
379 command=cmd_name,
380 )
381 if not record.timestamp:
382 record.timestamp = _utc_now_iso()
383 if not record.command:
384 record.command = cmd_name
385 record.duration_ms = duration_ms
386 # F1.3: pull per-subproject results from ctx.obj if the
387 # command stashed them there. The command code populates
388 # ctx.obj["_per_subproject_results"]; the audit record
389 # owns the canonical list.
390 psr = ctx.obj.get("_per_subproject_results")
391 if isinstance(psr, list):
392 record.per_subproject_results = psr
393 _dbg(
394 "GIT", func,
395 f"command_end cmd={cmd_name} exit_code={record.exit_code} "
396 f"duration_ms={duration_ms}",
397 4,
398 )
399 logger.write(record)
400
401 return wrapper
402
403 return decorator
404
405
406def _resolve_project_root_from_ctx(ctx: Any) -> Path:
407 """Best-effort extraction of the project root from a Click context.
408
409 Tries, in order:
410
411 1. ``ctx.obj["project_root"]`` — the canonical slot set by the
412 top-level ``cli`` group (see [oct/oct/cli.py](../cli.py)).
413 2. ``ctx.obj["root"]`` — older convention used by a couple of
414 legacy commands.
415 3. :func:`oct.core.project_root.get_project_root` — the formal
416 resolver, which consults ``--root-dir`` and walks upward.
417 4. ``Path.cwd()`` as the absolute last resort.
418
419 Any exception is swallowed and falls through to the next step so
420 the decorator never blocks command execution on a resolution error.
421 """
422 obj = getattr(ctx, "obj", None)
423 if isinstance(obj, dict):
424 for key in ("project_root", "root"):
425 candidate = obj.get(key)
426 if candidate:
427 try:
428 return Path(candidate)
429 except (TypeError, ValueError):
430 pass
431 try:
432 from oct.core.project_root import get_project_root
433 return Path(get_project_root(ctx))
434 except Exception: # pragma: no cover - fall through to cwd
435 return Path.cwd()
None _rotate_if_needed(self)
Definition audit.py:240
Path|None current_path(self)
Definition audit.py:318
None __init__(self, Path project_root)
Definition audit.py:194
list[Path] _list_existing_files(self)
Definition audit.py:261
None write(self, AuditRecord record)
Definition audit.py:292
int _current_file_size(self)
Definition audit.py:231
None _prune_old_files(self)
Definition audit.py:271
Path _new_filename(self)
Definition audit.py:211
None _ensure_logs_dir(self)
Definition audit.py:206
Path|None _current_path
Definition audit.py:201
Path _resolve_project_root_from_ctx(Any ctx)
Definition audit.py:406
list[str] _redact_args(list[str]|tuple[str,...]|None argv)
Definition audit.py:141
str _utc_now_iso()
Definition audit.py:131
Callable[[Callable[..., Any]], Callable[..., Any]] audited(str cmd_name)
Definition audit.py:328