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).
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)``.
26 L1 — errors: IO failures, serialisation failures
27 L2 — lifecycle: file opened/rotated, files pruned
28 L4 — deep trace: every write() call
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
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.
42from __future__
import annotations
46from dataclasses
import asdict, dataclass, field
47from datetime
import datetime, timezone
48from pathlib
import Path
51 from oc_diagnostics
import _dbg
as _real_dbg
53 def _dbg(*args, **kwargs) -> None:
54 _real_dbg(*args, **kwargs)
56 def _dbg(*args, **kwargs) -> None:
65MCP_AUDIT_MAX_FILES: int = 20
68MCP_AUDIT_MAX_SIZE_BYTES: int = 5 * 1024 * 1024
71_AUDIT_TS_FORMAT: str =
"%Y%m%d-%H%M%S"
74_AUDIT_FILENAME_PREFIX: str =
"mcp-audit-"
75_AUDIT_FILENAME_SUFFIX: str =
".jsonl"
76_AUDIT_GLOB: str = f
"{_AUDIT_FILENAME_PREFIX}*{_AUDIT_FILENAME_SUFFIX}"
79_DEFAULT_AUDIT_DIR: Path = Path.home() /
".oct-mcp"
83_DEFAULT_AUDIT_FILENAME: str =
"audit.log"
93 """Schema for one MCP audit row. Serialised via :func:`dataclasses.asdict`.
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.
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.
108 """ISO 8601 UTC timestamp of the request."""
111 """Opaque identifier for the MCP session (set at server startup)."""
114 """Unique identifier for this tool invocation."""
117 """MCP tool name, e.g. ``"oct_lint"``."""
119 args: dict = field(default_factory=dict)
120 """Validated (and optionally redacted) args dict."""
122 args_redacted: bool =
True
123 """True → ``args`` has had sensitive values replaced."""
126 """Policy decision: ``"allowed"``, ``"denied"``, or ``"requires_confirmation"``."""
128 policy_rule: str =
""
129 """Name of the policy rule that determined the decision."""
131 exit_code: int |
None =
None
132 """Exit code from the sandboxed subprocess (``None`` if not executed)."""
135 """Wall-clock time from request receipt to response dispatch, in ms."""
137 output_size_bytes: int = 0
138 """Byte length of the (post-redaction) tool output."""
140 output_truncated: bool =
False
141 """True if the output was truncated to ``max_output_bytes``."""
143 output_hash_sha256: str =
""
144 """SHA-256 hex digest of the raw (pre-redaction) output for integrity."""
146 redactions_applied: int = 0
147 """Count of redaction substitutions applied by the Output Redactor."""
150 """OS username of the process running the MCP server."""
152 project_root_hash: str =
""
153 """First 8 hex chars of sha256(str(project_root)) — correlation, not path."""
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")
162 """Return the first 8 hex chars of sha256(str(project_root))."""
163 digest = hashlib.sha256(str(project_root).encode()).hexdigest()
173 """Append-only rotating JSONL logger for ``oct-mcp`` tool calls.
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.
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``.
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.
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.
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,
212 func =
"McpAuditLogger.__init__"
213 raw_path = Path(audit_log_path)
if audit_log_path
else (
214 _DEFAULT_AUDIT_DIR / _DEFAULT_AUDIT_FILENAME
225 _dbg(
"MCP", func, f
"audit_dir={self._audit_dir}", 4)
230 """Create ``~/.oct-mcp/`` on demand. Idempotent."""
232 self.
_audit_dir.mkdir(parents=
True, exist_ok=
True)
235 """Return a fresh ``mcp-audit-YYYYMMDD-HHMMSS-NNN.jsonl`` path.
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.
242 ts = datetime.now(timezone.utc).strftime(_AUDIT_TS_FORMAT)
246 / f
"{_AUDIT_FILENAME_PREFIX}{ts}-{self._rotation_counter:03d}{_AUDIT_FILENAME_SUFFIX}"
250 while candidate.exists():
254 / f
"{_AUDIT_FILENAME_PREFIX}{ts}-{self._rotation_counter:03d}{_AUDIT_FILENAME_SUFFIX}"
259 """Return the current audit file's on-disk size, 0 if unavailable."""
268 """Open a new file if the current one is missing or full.
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.
274 func =
"McpAuditLogger._rotate_if_needed"
280 _dbg(
"MCP", func, f
"opened initial file={self._current_path.name}", 2)
283 _dbg(
"MCP", func, f
"size rotation from={self._current_path.name}", 2)
289 """Return all ``mcp-audit-*.jsonl`` files sorted lexically (= chronologically)."""
292 return sorted(self.
_audit_dir.glob(_AUDIT_GLOB))
295 """Delete the oldest rotated files until count <= ``max_files``.
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.
302 func =
"McpAuditLogger._prune_old_files"
306 excess = len(candidates) - (self.
_max_files - 1)
309 for path
in candidates[:excess]:
312 _dbg(
"MCP", func, f
"pruned {path.name}", 2)
313 except OSError
as exc:
314 _dbg(
"MCP", func, f
"prune failed {path.name}: {exc}", 1)
318 def write(self, record: McpAuditRecord) ->
None:
319 """Serialise *record* and append it as one JSONL line.
321 Never raises. Any failure is caught, logged at level 1, and
322 swallowed so callers are never blocked by audit misbehaviour.
324 func =
"McpAuditLogger.write"
329 if not record.timestamp:
331 line = json.dumps(asdict(record), ensure_ascii=
False)
337 f
"wrote record tool={record.tool} decision={record.decision} "
338 f
"exit_code={record.exit_code}",
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)
348 """Current audit file path (``None`` before first write)."""
353 """Directory where audit files are stored."""
None _rotate_if_needed(self)
Path|None current_path(self)
None write(self, McpAuditRecord record)
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)
None _ensure_audit_dir(self)
int _current_file_size(self)
list[Path] _list_rotated_files(self)
None _prune_old_files(self)
str _hash_project_root(Path project_root)
None _dbg(*args, **kwargs)