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