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