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 L3 - (none): lifecycle is reported at L2, per-write detail at L4
29 L4 - deep trace: every write() call
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
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.
44- oct.core.diagnostics (_dbg structured logger)
47from __future__
import annotations
51from dataclasses
import asdict, dataclass, field
52from datetime
import datetime, timezone
53from pathlib
import Path
55from oct.core.diagnostics
import _dbg
63MCP_AUDIT_MAX_FILES: int = 20
66MCP_AUDIT_MAX_SIZE_BYTES: int = 5 * 1024 * 1024
69_AUDIT_TS_FORMAT: str =
"%Y%m%d-%H%M%S"
72_AUDIT_FILENAME_PREFIX: str =
"mcp-audit-"
73_AUDIT_FILENAME_SUFFIX: str =
".jsonl"
74_AUDIT_GLOB: str = f
"{_AUDIT_FILENAME_PREFIX}*{_AUDIT_FILENAME_SUFFIX}"
77_DEFAULT_AUDIT_DIR: Path = Path.home() /
".oct-mcp"
81_DEFAULT_AUDIT_FILENAME: str =
"audit.log"
91 """Schema for one MCP audit row. Serialised via :func:`dataclasses.asdict`.
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.
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.
106 """ISO 8601 UTC timestamp of the request."""
109 """Opaque identifier for the MCP session (set at server startup)."""
112 """Unique identifier for this tool invocation."""
115 """MCP tool name, e.g. ``"oct_lint"``."""
117 args: dict = field(default_factory=dict)
118 """Validated (and optionally redacted) args dict."""
120 args_redacted: bool =
True
121 """True → ``args`` has had sensitive values replaced."""
124 """Policy decision: ``"allowed"``, ``"denied"``, or ``"requires_confirmation"``."""
126 policy_rule: str =
""
127 """Name of the policy rule that determined the decision."""
129 exit_code: int |
None =
None
130 """Exit code from the sandboxed subprocess (``None`` if not executed)."""
133 """Wall-clock time from request receipt to response dispatch, in ms."""
135 output_size_bytes: int = 0
136 """Byte length of the (post-redaction) tool output."""
138 output_truncated: bool =
False
139 """True if the output was truncated to ``max_output_bytes``."""
141 output_hash_sha256: str =
""
142 """SHA-256 hex digest of the raw (pre-redaction) output for integrity."""
144 redactions_applied: int = 0
145 """Count of redaction substitutions applied by the Output Redactor."""
148 """OS username of the process running the MCP server."""
150 project_root_hash: str =
""
151 """First 8 hex chars of sha256(str(project_root)) - correlation, not path."""
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")
160 """Return the first 8 hex chars of sha256(str(project_root))."""
161 digest = hashlib.sha256(str(project_root).encode()).hexdigest()
171 """Append-only rotating JSONL logger for ``oct-mcp`` tool calls.
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.
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``.
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.
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.
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,
210 func =
"McpAuditLogger.__init__"
211 raw_path = Path(audit_log_path)
if audit_log_path
else (
212 _DEFAULT_AUDIT_DIR / _DEFAULT_AUDIT_FILENAME
223 _dbg(
"MCP", func, f
"audit_dir={self._audit_dir}", 4)
228 """Create ``~/.oct-mcp/`` on demand. Idempotent."""
230 self.
_audit_dir.mkdir(parents=
True, exist_ok=
True)
233 """Return a fresh ``mcp-audit-YYYYMMDD-HHMMSS-NNN.jsonl`` path.
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.
240 ts = datetime.now(timezone.utc).strftime(_AUDIT_TS_FORMAT)
244 / f
"{_AUDIT_FILENAME_PREFIX}{ts}-{self._rotation_counter:03d}{_AUDIT_FILENAME_SUFFIX}"
248 while candidate.exists():
252 / f
"{_AUDIT_FILENAME_PREFIX}{ts}-{self._rotation_counter:03d}{_AUDIT_FILENAME_SUFFIX}"
257 """Return the current audit file's on-disk size, 0 if unavailable."""
266 """Open a new file if the current one is missing or full.
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.
272 func =
"McpAuditLogger._rotate_if_needed"
278 _dbg(
"MCP", func, f
"opened initial file={self._current_path.name}", 2)
281 _dbg(
"MCP", func, f
"size rotation from={self._current_path.name}", 2)
287 """Return all ``mcp-audit-*.jsonl`` files sorted lexically (= chronologically)."""
290 return sorted(self.
_audit_dir.glob(_AUDIT_GLOB))
293 """Delete the oldest rotated files until count <= ``max_files``.
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.
300 func =
"McpAuditLogger._prune_old_files"
304 excess = len(candidates) - (self.
_max_files - 1)
307 for path
in candidates[:excess]:
310 _dbg(
"MCP", func, f
"pruned {path.name}", 2)
311 except OSError
as exc:
312 _dbg(
"MCP", func, f
"prune failed {path.name}: {exc}", 1)
316 def write(self, record: McpAuditRecord) ->
None:
317 """Serialise *record* and append it as one JSONL line.
319 Never raises. Any failure is caught, logged at level 1, and
320 swallowed so callers are never blocked by audit misbehaviour.
322 func =
"McpAuditLogger.write"
327 if not record.timestamp:
329 line = json.dumps(asdict(record), ensure_ascii=
False)
335 f
"wrote record tool={record.tool} decision={record.decision} "
336 f
"exit_code={record.exit_code}",
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)
346 """Current audit file path (``None`` before first write)."""
351 """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)