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.
17- Define :class:`AuditRecord` - the exact on-disk schema (blueprint
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.
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
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
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.
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)
57from __future__
import annotations
62from dataclasses
import asdict, dataclass, field
63from datetime
import datetime, timezone
64from pathlib
import Path
65from typing
import Any, Callable
67from oct.core.diagnostics
import _dbg
68from oct.core.git
import _redact_git_url
69from oct.core.option_c_dir
import resolve_logs_dir
78AUDIT_MAX_FILES: int = 20
83AUDIT_MAX_SIZE_BYTES: int = 5 * 1024 * 1024
87_AUDIT_TS_FORMAT: str =
"%Y%m%d-%H%M%S"
90_AUDIT_FILENAME_PREFIX: str =
"git-audit-"
91_AUDIT_FILENAME_SUFFIX: str =
".jsonl"
92_AUDIT_GLOB: str = f
"{_AUDIT_FILENAME_PREFIX}*{_AUDIT_FILENAME_SUFFIX}"
100_AUDIT_SUBDIR: str =
"logs"
110 """Schema for one audit row. Serialised via :func:`dataclasses.asdict`.
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`).
117 Fields whose value is unknown at record-construction time may be
118 left at their default; missing fields are emitted as ``null`` in
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
137 per_subproject_results: list[dict] = field(default_factory=list)
141 """Return the current UTC time as an ISO 8601 string.
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.
147 return datetime.now(timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ")
150def _redact_args(argv: list[str] | tuple[str, ...] |
None) -> list[str]:
151 """Run each argv element through :func:`_redact_git_url`.
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.
162 if isinstance(item, str):
163 out.append(_redact_git_url(item))
165 out.append(str(item))
175 """Append-only rotating JSONL logger for ``oct git`` commands.
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.
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.
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.
204 func =
"AuditLogger.__init__"
211 _dbg(
"GIT", func, f
"project_root={self._project_root}", 4)
216 """Create ``<project>/logs/`` on demand. Idempotent."""
218 self.
_logs_dir.mkdir(parents=
True, exist_ok=
True)
221 """Return a fresh ``git-audit-YYYYMMDD-HHMMSS.jsonl`` path.
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.
228 ts = datetime.now(timezone.utc).strftime(_AUDIT_TS_FORMAT)
229 base = f
"{_AUDIT_FILENAME_PREFIX}{ts}{_AUDIT_FILENAME_SUFFIX}"
232 while candidate.exists():
235 / f
"{_AUDIT_FILENAME_PREFIX}{ts}-{counter}{_AUDIT_FILENAME_SUFFIX}"
241 """Return the current audit file's size in bytes, 0 if missing."""
250 """Open a fresh file if the current one is missing or full.
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
257 func =
"AuditLogger._rotate_if_needed"
261 _dbg(
"GIT", func, f
"opened initial file={self._current_path.name}", 2)
265 _dbg(
"GIT", func, f
"size rotation from={self._current_path.name}", 2)
271 """Return all ``git-audit-*.jsonl`` files in sorted order.
273 Sort is lexical, which matches chronological thanks to the
274 fixed-width ``YYYYMMDD-HHMMSS`` timestamp format.
278 return sorted(self.
_logs_dir.glob(_AUDIT_GLOB))
281 """Delete the oldest files until count <= :data:`AUDIT_MAX_FILES`.
283 Deletion is best-effort: an ``OSError`` from a concurrent
284 reader holding a handle on Windows is logged but not re-raised.
286 func =
"AuditLogger._prune_old_files"
288 excess = len(files) - AUDIT_MAX_FILES
291 victims = files[:excess]
295 _dbg(
"GIT", func, f
"pruned {path.name}", 2)
296 except OSError
as exc:
297 _dbg(
"GIT", func, f
"prune failed {path.name}: {exc}", 1)
301 def write(self, record: AuditRecord) ->
None:
302 """Serialise ``record`` and append it as one JSONL line.
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
308 func =
"AuditLogger.write"
316 line = json.dumps(asdict(record), ensure_ascii=
False)
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)
328 """Current audit filename (``None`` before first write)."""
337def audited(cmd_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
338 """Decorator that wraps an ``oct git`` Click command.
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.
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.
357 Human-readable command identifier for the trace and audit row,
358 e.g. ``"oct git check"``.
362 def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
364 def wrapper(ctx: Any, *args: Any, **kwargs: Any) -> Any:
367 func = f
"audited::{cmd_name}"
368 start = time.monotonic()
369 _dbg(
"GIT", func, f
"command_start cmd={cmd_name}", 4)
375 if not isinstance(getattr(ctx,
"obj",
None), dict):
384 return fn(ctx, *args, **kwargs)
386 duration_ms = int((time.monotonic() - start) * 1000)
387 record: AuditRecord = ctx.obj.get(
"_audit_record")
or AuditRecord(
391 if not record.timestamp:
393 if not record.command:
394 record.command = cmd_name
395 record.duration_ms = duration_ms
400 psr = ctx.obj.get(
"_per_subproject_results")
401 if isinstance(psr, list):
402 record.per_subproject_results = psr
405 f
"command_end cmd={cmd_name} exit_code={record.exit_code} "
406 f
"duration_ms={duration_ms}",
417 """Best-effort extraction of the project root from a Click context.
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
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.
429 Any exception is swallowed and falls through to the next step so
430 the decorator never blocks command execution on a resolution error.
432 obj = getattr(ctx,
"obj",
None)
433 if isinstance(obj, dict):
434 for key
in (
"project_root",
"root"):
435 candidate = obj.get(key)
438 return Path(candidate)
439 except (TypeError, ValueError):
442 from oct.core.project_root
import get_project_root
443 return Path(get_project_root(ctx))
None _rotate_if_needed(self)
Path|None current_path(self)
None __init__(self, Path project_root)
list[Path] _list_existing_files(self)
None write(self, AuditRecord record)
int _current_file_size(self)
None _prune_old_files(self)
None _ensure_logs_dir(self)
Path _resolve_project_root_from_ctx(Any ctx)
list[str] _redact_args(list[str]|tuple[str,...]|None argv)
Callable[[Callable[..., Any]], Callable[..., Any]] audited(str cmd_name)