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/mcp/audit.py
4
5"""
6Purpose
7-------
8JSONL audit logger for the ``oct-mcp`` server. Writes one JSON object per
9line to ``~/.oct-mcp/audit.log`` with size-based rotation (5 MB per file)
10and count-based pruning (keep the 20 newest files).
11
12Responsibilities
13----------------
14- Define :class:`McpAuditRecord` - the on-disk schema (blueprint §5.7).
15- Implement :class:`McpAuditLogger` with the same rotation/pruning logic
16 as :class:`oct.git.audit.AuditLogger`, adapted for the global
17 ``~/.oct-mcp/`` path and the richer MCP schema.
18- Never log raw ``args`` when ``args_redacted`` is True - callers pass
19 the redacted dict; logger serialises whatever it receives.
20- Emit diagnostic events via ``_dbg("MCP", ..., level)``.
21
22Diagnostics
23-----------
24Domain: MCP
25Levels:
26 L1 - errors: IO failures, serialisation failures
27 L2 - lifecycle: file opened/rotated, files pruned
28 L3 - (none): lifecycle is reported at L2, per-write detail at L4
29 L4 - deep trace: every write() call
30
31Contracts
32---------
33- This module does not import ``click``, the MCP SDK, or any other
34 ``oct.mcp.*`` module (no cycles).
35- :meth:`McpAuditLogger.write` must never raise. IO failures are logged
36 at level 1 and swallowed - audit logging is observability, not
37 correctness.
38- Filenames follow ``mcp-audit-YYYYMMDD-HHMMSS.jsonl`` strictly so
39 lexical ordering matches chronological ordering for pruning.
40- Every :class:`McpAuditRecord` field is JSON-serialisable.
41
42Dependencies
43------------
44- oct.core.diagnostics (_dbg structured logger)
45"""
46
47from __future__ import annotations
48
49import hashlib
50import json
51from dataclasses import asdict, dataclass, field
52from datetime import datetime, timezone
53from pathlib import Path
54
55from oct.core.diagnostics import _dbg
56
57
58# ---------------------------------------------------------------------
59# Constants
60# ---------------------------------------------------------------------
61
62#: Maximum number of ``mcp-audit-*.jsonl`` files kept on disk.
63MCP_AUDIT_MAX_FILES: int = 20
64
65#: Rotation threshold per audit file.
66MCP_AUDIT_MAX_SIZE_BYTES: int = 5 * 1024 * 1024 # 5 MB
67
68#: ``strftime`` format embedded in each rotated filename.
69_AUDIT_TS_FORMAT: str = "%Y%m%d-%H%M%S"
70
71#: Filename prefix / suffix / glob for MCP audit files.
72_AUDIT_FILENAME_PREFIX: str = "mcp-audit-"
73_AUDIT_FILENAME_SUFFIX: str = ".jsonl"
74_AUDIT_GLOB: str = f"{_AUDIT_FILENAME_PREFIX}*{_AUDIT_FILENAME_SUFFIX}"
75
76#: Default directory for user-level MCP audit logs.
77_DEFAULT_AUDIT_DIR: Path = Path.home() / ".oct-mcp"
78
79#: Default audit log filename (no rotation suffix - rotated files carry
80#: their own timestamp suffix).
81_DEFAULT_AUDIT_FILENAME: str = "audit.log"
82
83
84# ---------------------------------------------------------------------
85# Data model (blueprint §5.7)
86# ---------------------------------------------------------------------
87
88
89@dataclass
91 """Schema for one MCP audit row. Serialised via :func:`dataclasses.asdict`.
92
93 All fields are JSON-primitive or containers of JSON-primitives.
94 ``args`` should already be credential-redacted by the caller before
95 being assigned; when ``args_redacted`` is ``True`` the logger does
96 not apply further sanitisation (it trusts the caller). When
97 ``args_redacted`` is ``False``, args contain the raw validated dict -
98 callers MUST set this consciously.
99
100 ``project_root_hash`` is the first 8 hex characters of
101 ``sha256(str(project_root))``, giving per-project correlation
102 without leaking the absolute path.
103 """
104
105 timestamp: str = ""
106 """ISO 8601 UTC timestamp of the request."""
107
108 session_id: str = ""
109 """Opaque identifier for the MCP session (set at server startup)."""
110
111 request_id: str = ""
112 """Unique identifier for this tool invocation."""
113
114 tool: str = ""
115 """MCP tool name, e.g. ``"oct_lint"``."""
116
117 args: dict = field(default_factory=dict)
118 """Validated (and optionally redacted) args dict."""
119
120 args_redacted: bool = True
121 """True → ``args`` has had sensitive values replaced."""
122
123 decision: str = ""
124 """Policy decision: ``"allowed"``, ``"denied"``, or ``"requires_confirmation"``."""
125
126 policy_rule: str = ""
127 """Name of the policy rule that determined the decision."""
128
129 exit_code: int | None = None
130 """Exit code from the sandboxed subprocess (``None`` if not executed)."""
131
132 duration_ms: int = 0
133 """Wall-clock time from request receipt to response dispatch, in ms."""
134
135 output_size_bytes: int = 0
136 """Byte length of the (post-redaction) tool output."""
137
138 output_truncated: bool = False
139 """True if the output was truncated to ``max_output_bytes``."""
140
141 output_hash_sha256: str = ""
142 """SHA-256 hex digest of the raw (pre-redaction) output for integrity."""
143
144 redactions_applied: int = 0
145 """Count of redaction substitutions applied by the Output Redactor."""
146
147 user: str = ""
148 """OS username of the process running the MCP server."""
149
150 project_root_hash: str = ""
151 """First 8 hex chars of sha256(str(project_root)) - correlation, not path."""
152
153
154def _utc_now_iso() -> str:
155 """Return the current UTC time as an ISO 8601 string ending in ``Z``."""
156 return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
157
158
159def _hash_project_root(project_root: Path) -> str:
160 """Return the first 8 hex chars of sha256(str(project_root))."""
161 digest = hashlib.sha256(str(project_root).encode()).hexdigest()
162 return digest[:8]
163
164
165# ---------------------------------------------------------------------
166# Rotating JSONL logger
167# ---------------------------------------------------------------------
168
169
171 """Append-only rotating JSONL logger for ``oct-mcp`` tool calls.
172
173 One instance is created per MCP server session (in ``server.py``).
174 The constructor does not open a file - the first :meth:`write` call
175 lazily creates the ``~/.oct-mcp/`` directory and the first audit
176 file. This keeps the constructor side-effect-free so the server can
177 instantiate it during startup without touching the filesystem.
178
179 Storage location
180 ----------------
181 The audit log lives at ``audit_log_path`` (from :class:`McpConfig`,
182 default ``~/.oct-mcp/audit.log``). Rotated files are placed in the
183 same parent directory with a ``mcp-audit-YYYYMMDD-HHMMSS.jsonl``
184 naming pattern. The *initial* write goes to the ``audit_log_path``
185 itself; once rotation is triggered, subsequent writes go to
186 timestamped files. Pruning removes the oldest timestamped files
187 when the count exceeds ``max_files``.
188
189 Rotation strategy
190 -----------------
191 - **Size rotation:** before each write, if the current file is at or
192 above ``max_size_bytes``, close it and open a new timestamped file.
193 - **Count pruning:** after rotation, if the count of
194 ``mcp-audit-*.jsonl`` files in the directory exceeds ``max_files``,
195 delete the oldest until the count is within the limit.
196
197 Error handling
198 --------------
199 Every IO path in :meth:`write` is wrapped in a try/except. Failures
200 are logged via ``_dbg`` at level 1 and the method returns silently.
201 The audit log must never block or crash tool execution.
202 """
203
205 self,
206 audit_log_path: str | Path | None = None,
207 max_files: int = MCP_AUDIT_MAX_FILES,
208 max_size_bytes: int = MCP_AUDIT_MAX_SIZE_BYTES,
209 ) -> None:
210 func = "McpAuditLogger.__init__"
211 raw_path = Path(audit_log_path) if audit_log_path else (
212 _DEFAULT_AUDIT_DIR / _DEFAULT_AUDIT_FILENAME
213 )
214 # Expand ~ so path works correctly across platforms.
215 self._base_path: Path = raw_path.expanduser()
216 self._audit_dir: Path = self._base_path.parent
217 self._current_path: Path | None = None
218 self._max_files: int = max(1, max_files)
219 self._max_size_bytes: int = max(1, max_size_bytes)
220 # Monotonic counter ensures new filenames are never reused within
221 # a session, even after pruning removes old files with low counters.
222 self._rotation_counter: int = 0
223 _dbg("MCP", func, f"audit_dir={self._audit_dir}", 4)
224
225 # -- internal helpers ------------------------------------------------
226
227 def _ensure_audit_dir(self) -> None:
228 """Create ``~/.oct-mcp/`` on demand. Idempotent."""
229 if not self._audit_dir.exists():
230 self._audit_dir.mkdir(parents=True, exist_ok=True)
231
232 def _new_filename(self) -> Path:
233 """Return a fresh ``mcp-audit-YYYYMMDD-HHMMSS-NNN.jsonl`` path.
234
235 Uses a monotonically increasing instance-level counter (``_rotation_counter``)
236 so that names are never reused within a session, even after old files
237 are pruned. Zero-padded to 3 digits so lexical sort order is identical
238 to chronological creation order within a session.
239 """
240 ts = datetime.now(timezone.utc).strftime(_AUDIT_TS_FORMAT)
241 self._rotation_counter += 1
242 candidate = (
243 self._audit_dir
244 / f"{_AUDIT_FILENAME_PREFIX}{ts}-{self._rotation_counter:03d}{_AUDIT_FILENAME_SUFFIX}"
245 )
246 # Collision guard: if a file with this name already exists (e.g.
247 # from a previous process run in the same second), keep incrementing.
248 while candidate.exists():
249 self._rotation_counter += 1
250 candidate = (
251 self._audit_dir
252 / f"{_AUDIT_FILENAME_PREFIX}{ts}-{self._rotation_counter:03d}{_AUDIT_FILENAME_SUFFIX}"
253 )
254 return candidate
255
256 def _current_file_size(self) -> int:
257 """Return the current audit file's on-disk size, 0 if unavailable."""
258 if self._current_path is None:
259 return 0
260 try:
261 return self._current_path.stat().st_size
262 except OSError:
263 return 0
264
265 def _rotate_if_needed(self) -> None:
266 """Open a new file if the current one is missing or full.
267
268 On first call: uses the canonical ``_base_path`` (audit.log) so
269 the primary log is always predictably named. On subsequent
270 rotations: switches to timestamped ``mcp-audit-*.jsonl`` files.
271 """
272 func = "McpAuditLogger._rotate_if_needed"
273 if self._current_path is None:
274 # First write: use the base path (audit.log).
275 self._current_path = self._base_path
276 if not self._current_path.exists():
277 self._current_path.touch()
278 _dbg("MCP", func, f"opened initial file={self._current_path.name}", 2)
279 return
280 if self._current_file_size() >= self._max_size_bytes:
281 _dbg("MCP", func, f"size rotation from={self._current_path.name}", 2)
282 self._current_path = self._new_filename()
283 self._current_path.touch() # create on disk so prune counts it
284 self._prune_old_files()
285
286 def _list_rotated_files(self) -> list[Path]:
287 """Return all ``mcp-audit-*.jsonl`` files sorted lexically (= chronologically)."""
288 if not self._audit_dir.exists():
289 return []
290 return sorted(self._audit_dir.glob(_AUDIT_GLOB))
291
292 def _prune_old_files(self) -> None:
293 """Delete the oldest rotated files until count <= ``max_files``.
294
295 The current file (just created by :meth:`_rotate_if_needed`) is
296 excluded from the candidate list so it is never accidentally pruned
297 immediately after creation. ``max_files`` counts the current file,
298 so we keep at most ``max_files - 1`` older files.
299 """
300 func = "McpAuditLogger._prune_old_files"
301 files = self._list_rotated_files()
302 # Exclude the current file - it was just created and must not be pruned.
303 candidates = [f for f in files if f != self._current_path]
304 excess = len(candidates) - (self._max_files - 1)
305 if excess <= 0:
306 return
307 for path in candidates[:excess]:
308 try:
309 path.unlink()
310 _dbg("MCP", func, f"pruned {path.name}", 2)
311 except OSError as exc: # pragma: no cover - best-effort
312 _dbg("MCP", func, f"prune failed {path.name}: {exc}", 1)
313
314 # -- public API ------------------------------------------------------
315
316 def write(self, record: McpAuditRecord) -> None:
317 """Serialise *record* and append it as one JSONL line.
318
319 Never raises. Any failure is caught, logged at level 1, and
320 swallowed so callers are never blocked by audit misbehaviour.
321 """
322 func = "McpAuditLogger.write"
323 try:
324 self._ensure_audit_dir()
325 self._rotate_if_needed()
326 assert self._current_path is not None
327 if not record.timestamp:
328 record.timestamp = _utc_now_iso()
329 line = json.dumps(asdict(record), ensure_ascii=False)
330 with self._current_path.open("a", encoding="utf-8") as fh:
331 fh.write(line)
332 fh.write("\n")
333 _dbg(
334 "MCP", func,
335 f"wrote record tool={record.tool} decision={record.decision} "
336 f"exit_code={record.exit_code}",
337 4,
338 )
339 except OSError as exc:
340 _dbg("MCP", func, f"audit write failed: {exc}", 1)
341 except (TypeError, ValueError) as exc:
342 _dbg("MCP", func, f"audit serialize failed: {exc}", 1)
343
344 @property
345 def current_path(self) -> Path | None:
346 """Current audit file path (``None`` before first write)."""
347 return self._current_path
348
349 @property
350 def audit_dir(self) -> Path:
351 """Directory where audit files are stored."""
352 return self._audit_dir
None _rotate_if_needed(self)
Definition audit.py:265
Path|None current_path(self)
Definition audit.py:345
None write(self, McpAuditRecord record)
Definition audit.py:316
None __init__(self, str|Path|None audit_log_path=None, int max_files=MCP_AUDIT_MAX_FILES, int max_size_bytes=MCP_AUDIT_MAX_SIZE_BYTES)
Definition audit.py:209
None _ensure_audit_dir(self)
Definition audit.py:227
list[Path] _list_rotated_files(self)
Definition audit.py:286
None _prune_old_files(self)
Definition audit.py:292
str _utc_now_iso()
Definition audit.py:154
str _hash_project_root(Path project_root)
Definition audit.py:159