From 2bf03d57ae7c3f7476281609a32b5e38052741ea Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Tue, 29 Sep 2026 11:29:43 +0530 Subject: [PATCH 01/27] Introduce Auditability in MCP --- src/cb_mcp/audit/__init__.py | 88 +++ src/cb_mcp/audit/catalog.py | 398 +++++++++++ src/cb_mcp/audit/classification.py | 153 +++++ src/cb_mcp/audit/config.py | 239 +++++++ src/cb_mcp/audit/descriptor.json | 177 +++++ src/cb_mcp/audit/emitter.py | 214 ++++++ src/cb_mcp/audit/exceptions.py | 87 +++ src/cb_mcp/audit/identity.py | 167 +++++ src/cb_mcp/audit/middleware.py | 404 +++++++++++ src/cb_mcp/audit/record.py | 166 +++++ src/cb_mcp/audit/sink.py | 281 ++++++++ src/cb_mcp/audit/state.py | 151 +++++ src/cb_mcp/auth.py | 49 +- src/cb_mcp/tools/query.py | 87 ++- src/cb_mcp/tools/server.py | 16 + src/cb_mcp/utils/__init__.py | 12 + src/cb_mcp/utils/constants.py | 21 + src/cb_mcp/utils/context.py | 17 + src/cb_mcp/utils/elicitation.py | 21 +- src/cb_mcp/utils/scope_enforcement.py | 14 +- src/mcp_server.py | 182 +++++ tests/unit/test_audit_catalog.py | 307 +++++++++ tests/unit/test_audit_classification.py | 134 ++++ tests/unit/test_audit_config.py | 219 ++++++ tests/unit/test_audit_integration_points.py | 457 +++++++++++++ tests/unit/test_audit_middleware.py | 711 ++++++++++++++++++++ tests/unit/test_audit_record.py | 239 +++++++ tests/unit/test_audit_sink.py | 254 +++++++ tests/unit/test_audit_state.py | 186 +++++ 29 files changed, 5438 insertions(+), 13 deletions(-) create mode 100644 src/cb_mcp/audit/__init__.py create mode 100644 src/cb_mcp/audit/catalog.py create mode 100644 src/cb_mcp/audit/classification.py create mode 100644 src/cb_mcp/audit/config.py create mode 100644 src/cb_mcp/audit/descriptor.json create mode 100644 src/cb_mcp/audit/emitter.py create mode 100644 src/cb_mcp/audit/exceptions.py create mode 100644 src/cb_mcp/audit/identity.py create mode 100644 src/cb_mcp/audit/middleware.py create mode 100644 src/cb_mcp/audit/record.py create mode 100644 src/cb_mcp/audit/sink.py create mode 100644 src/cb_mcp/audit/state.py create mode 100644 tests/unit/test_audit_catalog.py create mode 100644 tests/unit/test_audit_classification.py create mode 100644 tests/unit/test_audit_config.py create mode 100644 tests/unit/test_audit_integration_points.py create mode 100644 tests/unit/test_audit_middleware.py create mode 100644 tests/unit/test_audit_record.py create mode 100644 tests/unit/test_audit_sink.py create mode 100644 tests/unit/test_audit_state.py diff --git a/src/cb_mcp/audit/__init__.py b/src/cb_mcp/audit/__init__.py new file mode 100644 index 00000000..69933e17 --- /dev/null +++ b/src/cb_mcp/audit/__init__.py @@ -0,0 +1,88 @@ +"""Audit logging for the Couchbase MCP Server. + +The MCP server is the only place that knows *who* a caller is. Every request +reaches the cluster as the single ``CB_USERNAME`` the server connects with, so +Couchbase Server's own ``audit.log`` cannot tell two callers apart; and a whole +class of decisions — scope denials, read-only blocks, declined or skipped +confirmations — happens entirely inside this server and never reaches the +cluster at all. Those records can only be produced here. + +Records are Couchbase-format JSON Lines, matching Couchbase Server and Sync +Gateway: the same field vocabulary, a descriptor-driven event catalogue on +numbered ID blocks, and per-event filtering as configuration rather than code. + +Audit output is a separate sink from the ``CB_MCP_LOG_*`` operational logs. It +guarantees a stable schema, completeness for non-filterable events, and is +sensitive by default. + +Layout: + +* :mod:`~cb_mcp.audit.catalog` — event ids, names, descriptors +* :mod:`~cb_mcp.audit.classification` — ``tool_name -> (category, class)`` +* :mod:`~cb_mcp.audit.config` — CLI/env resolution +* :mod:`~cb_mcp.audit.emitter` — the process-wide logger +* :mod:`~cb_mcp.audit.exceptions` — typed, auditable refusals +* :mod:`~cb_mcp.audit.identity` — ``real_userid`` resolution +* :mod:`~cb_mcp.audit.middleware` — the FastMCP middleware +* :mod:`~cb_mcp.audit.record` — the record and its serialisation +* :mod:`~cb_mcp.audit.sink` — the per-process file writer +* :mod:`~cb_mcp.audit.state` — per-request refusal channel +""" + +from .catalog import SERVICE_PACKAGE, AuditEvent, build_descriptor +from .classification import ( + TOOL_CLASSIFICATION, + classify_tool, + resolve_tool_call_event, + unclassified_tool_names, +) +from .config import ResolvedAuditConfig, resolve_audit_config +from .emitter import AuditLogger, get_audit_logger, init_audit, shutdown_audit +from .exceptions import ( + AuditableRefusalError, + ConfirmationDeclinedError, + ReadOnlyWriteBlockedError, + ScopeDeniedError, + StatementScopeDeniedError, +) +from .identity import resolve_real_userid, warn_on_unauthenticated_http +from .middleware import AuditMiddleware +from .record import ( + OUTCOME_BLOCKED, + OUTCOME_DENIED, + OUTCOME_ERROR, + OUTCOME_SUCCESS, + AuditRecord, +) +from .sink import AuditSink, process_scoped_path + +__all__ = [ + "OUTCOME_BLOCKED", + "OUTCOME_DENIED", + "OUTCOME_ERROR", + "OUTCOME_SUCCESS", + "SERVICE_PACKAGE", + "TOOL_CLASSIFICATION", + "AuditEvent", + "AuditLogger", + "AuditMiddleware", + "AuditRecord", + "AuditSink", + "AuditableRefusalError", + "ConfirmationDeclinedError", + "ReadOnlyWriteBlockedError", + "ResolvedAuditConfig", + "ScopeDeniedError", + "StatementScopeDeniedError", + "build_descriptor", + "classify_tool", + "get_audit_logger", + "init_audit", + "process_scoped_path", + "resolve_audit_config", + "resolve_real_userid", + "resolve_tool_call_event", + "shutdown_audit", + "unclassified_tool_names", + "warn_on_unauthenticated_http", +] diff --git a/src/cb_mcp/audit/catalog.py b/src/cb_mcp/audit/catalog.py new file mode 100644 index 00000000..c52a485c --- /dev/null +++ b/src/cb_mcp/audit/catalog.py @@ -0,0 +1,398 @@ +"""Audit event catalogue. + +Two tiers, following the PRD and Couchbase Server / Sync Gateway precedent: + +* **Tier 1 — core block ``0xE000`` (57344).** Lifecycle, session, authorization + and guardrail events. Identical regardless of which service sub-package is + loaded: a denied scope check is the same event whether it gated a KV tool or + a query tool. +* **Tier 2 — one block per service package.** The operational package owns + ``0xF000`` (61440). Within a Tier-2 block the layout is a reusable template: + reads at ``base + 48 + n`` and writes at ``read + 32`` for category index + ``n``, so the same relative offset means the same category in every package. + +The ID space is treated as frozen. Adding a tool requires only a new entry in +:mod:`cb_mcp.audit.classification` — no new event ID, and no change to a SIEM +rule. + +Events defined in code are the source of truth for the server. ``descriptor.json`` +ships the same catalogue for operators and SIEM authors; +``tests/unit/test_audit_catalog.py`` asserts the two never drift. + +Two catalogue entries from the PRD draft are deliberately absent: + +* *tool invocation blocked (disabled)* — withheld and disabled tools are never + registered with FastMCP, so the model is never told they exist and there is + no refusal to record. Removed by product decision on 2026-08-25. +* *session terminated* — FastMCP 3.x exposes no session-end hook and a stdio + client simply closes the pipe, so the event could never be emitted reliably. + On hold by product decision. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +# --------------------------------------------------------------------------- +# Block layout +# --------------------------------------------------------------------------- + +#: Tier-1 shared core block. +CORE_BLOCK = 0xE000 # 57344 + +#: Tier-2 block for the operational service package (kv, query, index, ...). +OPERATIONAL_BLOCK = 0xF000 # 61440 + +#: Relative offset of the first read event inside a Tier-2 block. +READ_OFFSET = 48 + +#: A category's write event sits this far above its read event. +WRITE_DELTA = 32 + +#: Highest slot the intra-block template reserves for categories: reads occupy +#: ``base + 48 + n`` for ``n`` in 0..15, writes ``base + 80 + n``. +MAX_SLOT = 15 + +#: Explicit slot allocation for Tier-2 categories — ``category -> n``, where a +#: read event id is ``base + READ_OFFSET + n``. +#: +#: Deliberately a table keyed by name rather than the position of a name in a +#: list. Positional indexing reads more tidily but means retiring one category +#: silently renumbers every category after it, and **a shipped audit event id +#: can never be renumbered**: a SIEM rule written against the old number would +#: then match a different event. With an explicit table a slot keeps its number +#: for as long as the catalogue exists, whatever happens around it. +#: +#: Add a category by giving it the next free slot (:func:`next_free_slot`). +#: Never change a number already in this table. +CATEGORY_SLOTS: dict[str, int] = { + "cluster": 0, + "schema": 1, + "kv": 2, + "query": 3, + "index": 4, + "performance": 5, +} + +#: Slots retired from use, kept here so their history is visible — ``category +#: -> n``, using the number the category held. +#: +#: A retired slot may be reallocated to a new category **only** when no audit +#: record was ever emitted against it. That holds for a category retired +#: because it never had any tools classified to it: with no tools there were no +#: records, so no SIEM rule for the old meaning can exist and reuse is safe. +#: +#: A slot that has ever emitted a record must never be reused, even after the +#: category is withdrawn — reallocating it would make an old rule silently +#: match a new, unrelated event. Record such a slot here and leave it out of +#: :func:`next_free_slot`'s reusable set. +RETIRED_SLOTS: dict[str, int] = { + # Retired before release: no tool was ever classified to either category, + # so neither slot ever emitted a record and both are safe to reallocate. + # The managed Capella runtime registers this server's tool set and adds + # none of its own, so nothing outside this repo needed them either. + "vector": 6, + "analytics": 7, +} + +#: Retired slots that must never be reallocated because records exist for them. +PERMANENTLY_RESERVED_SLOTS: frozenset[int] = frozenset() + +#: Category axis for Tier-2 blocks, ordered by slot. Derived from +#: :data:`CATEGORY_SLOTS` so the two can never disagree. +CATEGORIES: tuple[str, ...] = tuple( + name for name, _ in sorted(CATEGORY_SLOTS.items(), key=lambda item: item[1]) +) + +#: Name of the service package this build ships. Recorded once at startup +#: rather than repeated on every record. +SERVICE_PACKAGE = "operational" + + +def category_index(category: str) -> int: + """Return the slot ``n`` for ``category`` within a Tier-2 block.""" + try: + return CATEGORY_SLOTS[category] + except KeyError as exc: # pragma: no cover - guarded by classification + raise ValueError( + f"Unknown audit category {category!r}; expected one of {CATEGORIES}." + ) from exc + + +def next_free_slot() -> int: + """Return the slot a newly added category should take. + + Prefers the lowest retired slot that is safe to reuse — a category retired + with no tools classified to it emitted no records, so no SIEM rule can exist + for its old meaning. Otherwise returns the lowest never-allocated slot. + + Raises: + ValueError: when the block's category template is exhausted. A new + service package should then take a fresh 0x1000 block rather than + crowding this one. + """ + taken = set(CATEGORY_SLOTS.values()) | PERMANENTLY_RESERVED_SLOTS + reusable = sorted( + slot + for slot in RETIRED_SLOTS.values() + if slot not in taken and slot not in PERMANENTLY_RESERVED_SLOTS + ) + if reusable: + return reusable[0] + for slot in range(MAX_SLOT + 1): + if slot not in taken: + return slot + raise ValueError( + f"All {MAX_SLOT + 1} category slots in this block are allocated. " + "A new service package should request its own block." + ) + + +def tool_call_event_id(category: str, operation_class: str) -> int: + """Resolve the Tier-2 event ID for a tool call. + + Args: + category: One of :data:`CATEGORIES`. + operation_class: ``"read"`` or ``"write"``. + """ + if operation_class not in ("read", "write"): + raise ValueError( + f"operation_class must be 'read' or 'write', got {operation_class!r}." + ) + base = OPERATIONAL_BLOCK + READ_OFFSET + category_index(category) + return base + WRITE_DELTA if operation_class == "write" else base + + +class AuditEvent(Enum): + """Catalogue entry. + + Value is the numeric event ``id``; the remaining members carry the + descriptor metadata that ships to operators. + """ + + # -- Core: lifecycle (57344-57359) ------------------------------------ + SERVER_STARTED = ( + CORE_BLOCK + 0, + "server started", + "MCP server started; carries the static server context", + "lifecycle", + False, + ) + SERVER_STOPPED = ( + CORE_BLOCK + 1, + "server stopped", + "MCP server shut down cleanly", + "lifecycle", + False, + ) + SERVER_CONFIGURATION = ( + CORE_BLOCK + 2, + "server configuration", + ( + "Resolved server configuration recorded at startup, including " + "read-only mode and the withheld and disabled tool sets" + ), + "lifecycle", + False, + ) + + # -- Core: session (57360-57375) -------------------------------------- + SESSION_INITIALIZED = ( + CORE_BLOCK + 16, + "session initialized", + "MCP initialize handshake completed", + "session", + True, + ) + + # -- Core: authorization (57376-57391) -------------------------------- + TOKEN_REJECTED = ( + CORE_BLOCK + 32, + "token rejected", + "Bearer JWT rejected during verification", + "authorization", + False, + ) + SCOPE_CHECK_DENIED = ( + CORE_BLOCK + 33, + "scope check denied", + "A required OAuth scope was not granted by the presented token", + "authorization", + False, + ) + + # -- Core: guardrail (57488-57503) ------------------------------------ + WRITE_BLOCKED_READ_ONLY = ( + CORE_BLOCK + 144, + "write blocked (read-only mode)", + "A SQL++ statement modifying data or structure was refused because " + "CB_MCP_READ_ONLY_MODE is enabled", + "guardrail", + False, + ) + CONFIRMATION_DECLINED = ( + CORE_BLOCK + 146, + "confirmation declined", + "The user rejected an elicitation for a confirmation-required tool", + "guardrail", + False, + ) + CONFIRMATION_SKIPPED = ( + CORE_BLOCK + 147, + "confirmation skipped", + "A confirmation-required tool executed without confirmation because " + "the client does not support elicitation", + "guardrail", + False, + ) + + def __init__( + self, + event_id: int, + event_name: str, + description: str, + category: str, + filterable: bool, + ) -> None: + self.id = event_id + self.event_name = event_name + self.description = description + self.category = category + self.filterable = filterable + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return f"" + + +@dataclass(frozen=True) +class ToolCallEvent: + """A resolved Tier-2 tool-call catalogue entry. + + Built on demand from a category and operation class rather than enumerated, + because the specific tool lives in ``tool_name`` and one ID serves a whole + category. + """ + + id: int + event_name: str + description: str + category: str + operation_class: str + + @property + def filterable(self) -> bool: + """Reads are filterable per category; writes never are. + + An operator can turn read noise down but cannot switch off the record + of who changed what. + """ + return self.operation_class == "read" + + +#: Human-readable names for the tool-call categories, used to build the +#: ``name`` field. Keys must cover :data:`CATEGORIES`. +_CATEGORY_LABELS: dict[str, str] = { + "cluster": "cluster", + "schema": "schema", + "kv": "document", + "query": "query", + "index": "index", + "performance": "performance", +} + + +def tool_call_event(category: str, operation_class: str) -> ToolCallEvent: + """Build the :class:`ToolCallEvent` for a category and operation class.""" + label = _CATEGORY_LABELS[category] + verb = "read" if operation_class == "read" else "write" + article = "An" if label[0].lower() in "aeiou" else "A" + return ToolCallEvent( + id=tool_call_event_id(category, operation_class), + event_name=f"{label} {verb}", + description=f"{article} {label} {verb} operation was requested through a tool", + category=category, + operation_class=operation_class, + ) + + +def all_tool_call_events() -> list[ToolCallEvent]: + """Every Tier-2 tool-call entry, for descriptor generation.""" + return [ + tool_call_event(category, operation_class) + for category in CATEGORIES + for operation_class in ("read", "write") + ] + + +def build_descriptor() -> dict: + """Build the descriptor document describing the whole catalogue.""" + entries = [ + { + "id": event.id, + "name": event.event_name, + "description": event.description, + "category": event.category, + "default_enabled": True, + "filterable": event.filterable, + } + for event in AuditEvent + ] + entries += [ + { + "id": event.id, + "name": event.event_name, + "description": event.description, + "category": "tool_call", + "default_enabled": True, + "filterable": event.filterable, + } + for event in all_tool_call_events() + ] + entries.sort(key=lambda entry: entry["id"]) + return { + "service_package": SERVICE_PACKAGE, + "blocks": {"core": CORE_BLOCK, "operational": OPERATIONAL_BLOCK}, + "events": entries, + } + + +#: Every event name that may appear in a record, mapped to its numeric id. +EVENT_NAMES_TO_IDS: dict[str, int] = { + **{event.event_name: event.id for event in AuditEvent}, + **{event.event_name: event.id for event in all_tool_call_events()}, +} + +#: Ids of events an operator is permitted to suppress via configuration. +FILTERABLE_IDS: frozenset[int] = frozenset( + [event.id for event in AuditEvent if event.filterable] + + [event.id for event in all_tool_call_events() if event.filterable] +) + +#: Every known event id. +ALL_IDS: frozenset[int] = frozenset(EVENT_NAMES_TO_IDS.values()) + + +__all__ = [ + "ALL_IDS", + "CATEGORIES", + "CATEGORY_SLOTS", + "MAX_SLOT", + "PERMANENTLY_RESERVED_SLOTS", + "RETIRED_SLOTS", + "CORE_BLOCK", + "EVENT_NAMES_TO_IDS", + "FILTERABLE_IDS", + "OPERATIONAL_BLOCK", + "READ_OFFSET", + "SERVICE_PACKAGE", + "WRITE_DELTA", + "AuditEvent", + "ToolCallEvent", + "all_tool_call_events", + "build_descriptor", + "category_index", + "next_free_slot", + "tool_call_event", + "tool_call_event_id", +] diff --git a/src/cb_mcp/audit/classification.py b/src/cb_mcp/audit/classification.py new file mode 100644 index 00000000..b441ae0f --- /dev/null +++ b/src/cb_mcp/audit/classification.py @@ -0,0 +1,153 @@ +"""Tool classification — the catalogue's extension point. + +The audit ID space is frozen, so adding a tool requires only a new entry in +:data:`TOOL_CLASSIFICATION`. No new event ID, no registry allocation, no +SIEM-rule change. + +``operation_class`` is authoritative at the MCP layer: a tool is classified by +what it is allowed to do. That single classification drives both the event ID +(read versus write range within the package block) and ``required_scope``, +keeping the two consistent by construction. + +A tool with no entry **fails closed** — recorded against a write ID, which is +never filterable — so a newly added tool can never be silently under-audited. +Such a record also carries ``unclassified: true`` so a reviewer is never misled +about the category, and :func:`unclassified_tool_names` lets startup report the +gap rather than leaving it to be discovered from an audit file months later. + +Entries marked ``PENDING PRD`` are classified here from the repository's own +categorisation (the ``*_WRITE_TOOLS`` lists in :mod:`cb_mcp.tools` and the +``readOnlyHint`` annotations) because they ship today while the PRD's +example-tool tables have not yet been updated to name them. The classification +is not in doubt; the PRD text is behind. Re-verify when those rows land. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from .catalog import ToolCallEvent, tool_call_event + +#: ``tool_name -> (category, operation_class)``. +#: +#: Ordered by category to match the catalogue layout rather than +#: alphabetically, so a reviewer can check a whole block in one pass. +TOOL_CLASSIFICATION: dict[str, tuple[str, str]] = { + # -- cluster / health ------------------------------------------------- + "get_server_configuration_status": ("cluster", "read"), + "test_cluster_connection": ("cluster", "read"), + "get_cluster_health_and_services": ("cluster", "read"), + "get_cluster_diagnostics_report": ("cluster", "read"), # PENDING PRD + # -- schema / discovery ---------------------------------------------- + "get_buckets_in_cluster": ("schema", "read"), + "get_scopes_in_bucket": ("schema", "read"), + "get_collections_in_scope": ("schema", "read"), + "get_scopes_and_collections_in_bucket": ("schema", "read"), + "get_schema_for_collection": ("schema", "read"), + "create_scope": ("schema", "write"), # PENDING PRD + "create_collection": ("schema", "write"), # PENDING PRD + "delete_scope": ("schema", "write"), # PENDING PRD + "delete_collection": ("schema", "write"), # PENDING PRD + # -- kv (document) ---------------------------------------------------- + "get_document_by_id": ("kv", "read"), + "lookup_subdocument": ("kv", "read"), # PENDING PRD + "upsert_document_by_id": ("kv", "write"), + "insert_document_by_id": ("kv", "write"), + "replace_document_by_id": ("kv", "write"), + "delete_document_by_id": ("kv", "write"), + "mutate_subdocument": ("kv", "write"), # PENDING PRD + # -- query (SQL++) ---------------------------------------------------- + # run_sql_plus_plus_query is re-classified per statement at invocation; the + # entry here is the class used when the statement was not inspected. + "run_sql_plus_plus_query": ("query", "read"), + "explain_sql_plus_plus_query": ("query", "read"), + # -- index ------------------------------------------------------------ + "list_indexes": ("index", "read"), + "get_index_advisor_recommendations": ("index", "read"), + "create_index": ("index", "write"), # PENDING PRD + "build_index": ("index", "write"), # PENDING PRD + "drop_index": ("index", "write"), # PENDING PRD + # -- performance ------------------------------------------------------ + "get_longest_running_queries": ("performance", "read"), + "get_most_frequent_queries": ("performance", "read"), + "get_queries_with_largest_response_sizes": ("performance", "read"), + "get_queries_with_large_result_count": ("performance", "read"), + "get_queries_using_primary_index": ("performance", "read"), + "get_queries_not_using_covering_index": ("performance", "read"), + "get_queries_not_selective": ("performance", "read"), +} + +#: The one tool whose read/write class depends on the statement it is given. +STATEMENT_CLASSIFIED_TOOL = "run_sql_plus_plus_query" + +#: Fail-closed operation class for a tool with no entry. Write IDs are never +#: filterable, so an unknown tool is always recorded. +FAILSAFE_OPERATION_CLASS = "write" + +#: Category used for the ID of an otherwise unknown tool. The record also +#: carries ``unclassified: true`` so this placeholder is never mistaken for a +#: real categorisation. +FAILSAFE_CATEGORY = "cluster" + + +def is_classified(tool_name: str) -> bool: + """True when ``tool_name`` has an explicit classification entry.""" + return tool_name in TOOL_CLASSIFICATION + + +def classify_tool( + tool_name: str, *, operation_class_override: str | None = None +) -> tuple[str, str]: + """Return ``(category, operation_class)`` for ``tool_name``. + + Args: + tool_name: The MCP tool name as registered. + operation_class_override: Supplied for statement-classified tools once + the statement has been inspected, so a SQL++ DML call is booked + against the query *write* ID rather than the read ID. + + Unknown tools fail closed to a write class. + """ + category, operation_class = TOOL_CLASSIFICATION.get( + tool_name, (FAILSAFE_CATEGORY, FAILSAFE_OPERATION_CLASS) + ) + if operation_class_override is not None: + operation_class = operation_class_override + return category, operation_class + + +def resolve_tool_call_event( + tool_name: str, *, operation_class_override: str | None = None +) -> ToolCallEvent: + """Resolve the catalogue entry for a tool invocation.""" + category, operation_class = classify_tool( + tool_name, operation_class_override=operation_class_override + ) + return tool_call_event(category, operation_class) + + +def required_scope_for(operation_class: str) -> str: + """Map an operation class onto the scope label recorded in the audit line. + + Deliberately the short form (``read`` / ``write``) rather than the full + ``couchbase-mcp:write`` scope string, matching the PRD's sample records. + """ + return "write" if operation_class == "write" else "read" + + +def unclassified_tool_names(tool_names: Iterable[str]) -> list[str]: + """Return the subset of ``tool_names`` with no classification entry.""" + return sorted(name for name in tool_names if name not in TOOL_CLASSIFICATION) + + +__all__ = [ + "FAILSAFE_CATEGORY", + "FAILSAFE_OPERATION_CLASS", + "STATEMENT_CLASSIFIED_TOOL", + "TOOL_CLASSIFICATION", + "classify_tool", + "is_classified", + "required_scope_for", + "resolve_tool_call_event", + "unclassified_tool_names", +] diff --git a/src/cb_mcp/audit/config.py b/src/cb_mcp/audit/config.py new file mode 100644 index 00000000..a07aa03b --- /dev/null +++ b/src/cb_mcp/audit/config.py @@ -0,0 +1,239 @@ +"""Resolution of the audit configuration from CLI / environment values. + +Mirrors the shape of :mod:`cb_mcp.utils.logging`'s resolution: parse, fall back +loudly on unusable input, and expose an immutable snapshot that both +``get_server_configuration_status`` and the startup records can report, so the +tool output and the audit file always agree on what is running. + +Per the PRD, enabling auditing without a file path is an error that is +*reported* rather than fatal: the server still starts, with auditing off. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..utils.constants import ( + BYTES_PER_MB, + DEFAULT_AUDIT_BACKUP_COUNT, + DEFAULT_AUDIT_ENABLED, + DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB, + DEFAULT_AUDIT_TOOL_ARGS, + MCP_SERVER_NAME, +) +from .catalog import ALL_IDS, EVENT_NAMES_TO_IDS, FILTERABLE_IDS +from .sink import process_scoped_path + +logger = logging.getLogger(f"{MCP_SERVER_NAME}.audit.config") + + +@dataclass(frozen=True) +class ResolvedAuditConfig: + """Snapshot of the audit configuration actually in force.""" + + enabled: bool + file: str | None + process_file: str | None + rotation_max_size_mb: float + max_bytes: int + retention_backup_count: int + tool_args: bool + disabled_events: tuple[int, ...] + + def as_dict(self) -> dict[str, Any]: + """JSON-friendly view for the server-configuration tool and records.""" + return { + "enabled": self.enabled, + "file": self.file, + "process_file": self.process_file, + "rotation_max_size_mb": self.rotation_max_size_mb, + "retention_backup_count": self.retention_backup_count, + "tool_args": self.tool_args, + "disabled_events": list(self.disabled_events), + } + + +def _read_event_tokens(value: str) -> list[str] | None: + """Split the raw setting into entries, from a file or a comma-separated list. + + Returns ``None`` when a file was named but could not be read, so the caller + can distinguish "nothing configured" from "configuration unreadable". + """ + candidate = Path(value) + try: + is_file = candidate.exists() and candidate.is_file() + except OSError: # pragma: no cover - unreadable path, treat as a list + is_file = False + + if not is_file: + return [part.strip() for part in value.split(",") if part.strip()] + + try: + with open(candidate, encoding="utf-8") as handle: + return [ + entry + for entry in (line.strip() for line in handle) + if entry and not entry.startswith("#") + ] + except OSError as exc: + logger.error( + "Failed to read audit disabled-events file %s: %s. " + "No events will be filtered.", + candidate, + exc, + ) + return None + + +def _resolve_event_id(token: str) -> int | None: + """Map one entry — a numeric id or a catalogue name — onto an event id.""" + if token.isdigit(): + event_id = int(token) + return event_id if event_id in ALL_IDS else None + return EVENT_NAMES_TO_IDS.get(token) + + +def parse_disabled_events(raw: str | None) -> set[int]: + """Parse ``CB_MCP_AUDIT_DISABLED_EVENTS`` into a set of event ids. + + Accepts a comma-separated list, or a path to a file with one entry per line + (``#`` comments allowed), matching how ``--disabled-tools`` already works. + Entries may be either the numeric event id or the catalogue event name. + + Two classes of entry are rejected with a warning rather than silently + honoured, because both would leave an operator believing they had filtered + something they had not: + + * an id or name that is not in the catalogue at all; + * a **non-filterable** event. Writes and security decisions are mandated — + an operator may turn read noise down but may not switch off the record of + who changed what. + """ + if not raw or not raw.strip(): + return set() + + tokens = _read_event_tokens(raw.strip()) + if tokens is None: + return set() + + resolved: set[int] = set() + unknown: list[str] = [] + not_filterable: list[str] = [] + + for token in tokens: + event_id = _resolve_event_id(token) + if event_id is None: + unknown.append(token) + elif event_id not in FILTERABLE_IDS: + not_filterable.append(token) + else: + resolved.add(event_id) + + if unknown: + logger.warning( + "Ignored unknown audit event(s) in CB_MCP_AUDIT_DISABLED_EVENTS: %s.", + sorted(unknown), + ) + if not_filterable: + logger.warning( + "Refused to disable non-filterable audit event(s): %s. Write and " + "security events are always recorded.", + sorted(not_filterable), + ) + return resolved + + +def resolve_audit_config( + *, + enabled: bool | None, + file: str | None, + rotation_max_size_mb: float | None, + retention_backup_count: int | None, + tool_args: bool | None, + disabled_events: str | None, +) -> ResolvedAuditConfig: + """Turn raw CLI/env values into the configuration actually in force.""" + is_enabled = DEFAULT_AUDIT_ENABLED if enabled is None else bool(enabled) + trimmed_file = file.strip() if isinstance(file, str) else None + trimmed_file = trimmed_file or None + + if is_enabled and trimmed_file is None: + logger.error( + "Audit logging is enabled but no audit file path is configured. " + "Set --audit-file / CB_MCP_AUDIT_FILE to enable auditing. " + "The server will start with audit logging disabled." + ) + is_enabled = False + + size_mb = ( + DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB + if rotation_max_size_mb is None + else float(rotation_max_size_mb) + ) + if size_mb <= 0: + logger.warning( + "Invalid audit rotation size %s MB; falling back to the default of %s MB.", + rotation_max_size_mb, + DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB, + ) + size_mb = DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB + + backup_count = ( + DEFAULT_AUDIT_BACKUP_COUNT + if retention_backup_count is None + else int(retention_backup_count) + ) + if backup_count < 0: + logger.warning( + "Invalid audit retention backup count %s; falling back to the " + "default of %s.", + retention_backup_count, + DEFAULT_AUDIT_BACKUP_COUNT, + ) + backup_count = DEFAULT_AUDIT_BACKUP_COUNT + + include_args = DEFAULT_AUDIT_TOOL_ARGS if tool_args is None else bool(tool_args) + + if include_args and is_enabled: + logger.warning( + "CB_MCP_AUDIT_TOOL_ARGS is enabled: tool arguments will be " + "recorded verbatim in the audit log, including full document " + "bodies passed to document-write tools. There is no redaction " + "capability in this release. Ensure the audit file's retention and " + "access controls are appropriate for the data it will contain." + ) + + filtered = parse_disabled_events(disabled_events) if is_enabled else set() + + return ResolvedAuditConfig( + enabled=is_enabled, + file=trimmed_file, + process_file=( + str(process_scoped_path(trimmed_file)) + if is_enabled and trimmed_file + else None + ), + rotation_max_size_mb=size_mb, + max_bytes=max(1, int(size_mb * BYTES_PER_MB)), + retention_backup_count=backup_count, + tool_args=include_args, + disabled_events=tuple(sorted(filtered)), + ) + + +def audit_config_from_settings(settings: Mapping[str, Any]) -> dict[str, Any] | None: + """Read the audit snapshot out of the lifespan settings mapping.""" + snapshot = settings.get("audit_config") + return dict(snapshot) if isinstance(snapshot, Mapping) else None + + +__all__ = [ + "ResolvedAuditConfig", + "audit_config_from_settings", + "parse_disabled_events", + "resolve_audit_config", +] diff --git a/src/cb_mcp/audit/descriptor.json b/src/cb_mcp/audit/descriptor.json new file mode 100644 index 00000000..f0f42733 --- /dev/null +++ b/src/cb_mcp/audit/descriptor.json @@ -0,0 +1,177 @@ +{ + "service_package": "operational", + "blocks": { + "core": 57344, + "operational": 61440 + }, + "events": [ + { + "id": 57344, + "name": "server started", + "description": "MCP server started; carries the static server context", + "category": "lifecycle", + "default_enabled": true, + "filterable": false + }, + { + "id": 57345, + "name": "server stopped", + "description": "MCP server shut down cleanly", + "category": "lifecycle", + "default_enabled": true, + "filterable": false + }, + { + "id": 57346, + "name": "server configuration", + "description": "Resolved server configuration recorded at startup, including read-only mode and the withheld and disabled tool sets", + "category": "lifecycle", + "default_enabled": true, + "filterable": false + }, + { + "id": 57360, + "name": "session initialized", + "description": "MCP initialize handshake completed", + "category": "session", + "default_enabled": true, + "filterable": true + }, + { + "id": 57376, + "name": "token rejected", + "description": "Bearer JWT rejected during verification", + "category": "authorization", + "default_enabled": true, + "filterable": false + }, + { + "id": 57377, + "name": "scope check denied", + "description": "A required OAuth scope was not granted by the presented token", + "category": "authorization", + "default_enabled": true, + "filterable": false + }, + { + "id": 57488, + "name": "write blocked (read-only mode)", + "description": "A SQL++ statement modifying data or structure was refused because CB_MCP_READ_ONLY_MODE is enabled", + "category": "guardrail", + "default_enabled": true, + "filterable": false + }, + { + "id": 57490, + "name": "confirmation declined", + "description": "The user rejected an elicitation for a confirmation-required tool", + "category": "guardrail", + "default_enabled": true, + "filterable": false + }, + { + "id": 57491, + "name": "confirmation skipped", + "description": "A confirmation-required tool executed without confirmation because the client does not support elicitation", + "category": "guardrail", + "default_enabled": true, + "filterable": false + }, + { + "id": 61488, + "name": "cluster read", + "description": "A cluster read operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": true + }, + { + "id": 61489, + "name": "schema read", + "description": "A schema read operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": true + }, + { + "id": 61490, + "name": "document read", + "description": "A document read operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": true + }, + { + "id": 61491, + "name": "query read", + "description": "A query read operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": true + }, + { + "id": 61492, + "name": "index read", + "description": "An index read operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": true + }, + { + "id": 61493, + "name": "performance read", + "description": "A performance read operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": true + }, + { + "id": 61520, + "name": "cluster write", + "description": "A cluster write operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": false + }, + { + "id": 61521, + "name": "schema write", + "description": "A schema write operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": false + }, + { + "id": 61522, + "name": "document write", + "description": "A document write operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": false + }, + { + "id": 61523, + "name": "query write", + "description": "A query write operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": false + }, + { + "id": 61524, + "name": "index write", + "description": "An index write operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": false + }, + { + "id": 61525, + "name": "performance write", + "description": "A performance write operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": false + } + ] +} diff --git a/src/cb_mcp/audit/emitter.py b/src/cb_mcp/audit/emitter.py new file mode 100644 index 00000000..04a3f509 --- /dev/null +++ b/src/cb_mcp/audit/emitter.py @@ -0,0 +1,214 @@ +"""The audit logger: catalogue plus sink, with a process-wide accessor. + +A module-level singleton, mirroring how ``get_resolved_logging_config`` already +works in this codebase. The alternative — threading a reference through every +call site — does not work for the JWT verifier, which FastMCP constructs before +the lifespan runs and which has no access to the request context. + +Every public method is a no-op when auditing is disabled or failed to start, so +callers never need to branch. Nothing here raises: an audit failure must not be +able to break a tool call. +""" + +from __future__ import annotations + +import atexit +import logging +from typing import Any + +from ..utils.constants import MCP_SERVER_NAME +from .catalog import SERVICE_PACKAGE, AuditEvent, ToolCallEvent +from .config import ResolvedAuditConfig +from .record import AuditRecord, ServerContext +from .sink import AuditSink + +logger = logging.getLogger(f"{MCP_SERVER_NAME}.audit") + + +class AuditLogger: + """Formats catalogue events into records and hands them to the sink.""" + + def __init__(self, config: ResolvedAuditConfig, sink: AuditSink | None) -> None: + self.config = config + self._sink = sink + self._server = ServerContext.detect() + self._disabled = frozenset(config.disabled_events) + + @property + def active(self) -> bool: + """True when records are actually being written.""" + return self._sink is not None + + @property + def server(self) -> ServerContext: + return self._server + + @property + def stats(self) -> dict[str, int]: + return self._sink.stats if self._sink is not None else {} + + def is_enabled_for(self, event_id: int) -> bool: + """False when the operator has filtered this event out.""" + return event_id not in self._disabled + + # -- emission --------------------------------------------------------- + + def emit(self, record: AuditRecord) -> None: + """Write one record, unless the event is filtered or auditing is off.""" + if self._sink is None or not self.is_enabled_for(record.id): + return + try: + self._sink.emit(record.to_json_line(self._server)) + except Exception: # auditing must never break a call + logger.exception("Unexpected failure while emitting an audit record") + + def emit_event( + self, + event: AuditEvent, + *, + outcome: str, + real_userid: dict[str, str] | None = None, + cid: str | None = None, + reason: str | None = None, + **payload: Any, + ) -> None: + """Emit a core-catalogue event.""" + self.emit( + AuditRecord( + id=event.id, + name=event.event_name, + description=event.description, + outcome=outcome, + real_userid=real_userid, + cid=cid, + reason=reason, + payload=payload, + ) + ) + + def emit_tool_call( + self, + event: ToolCallEvent, + *, + outcome: str, + real_userid: dict[str, str] | None = None, + cid: str | None = None, + reason: str | None = None, + **payload: Any, + ) -> None: + """Emit a Tier-2 tool-call event.""" + self.emit( + AuditRecord( + id=event.id, + name=event.event_name, + description=event.description, + outcome=outcome, + real_userid=real_userid, + cid=cid, + reason=reason, + payload=payload, + ) + ) + + def close(self) -> None: + if self._sink is not None: + self._sink.close() + self._sink = None + + +#: Inactive logger used before initialisation and after shutdown, so callers +#: never have to guard against ``None``. +_DISABLED = AuditLogger( + ResolvedAuditConfig( + enabled=False, + file=None, + process_file=None, + rotation_max_size_mb=0.0, + max_bytes=0, + retention_backup_count=0, + tool_args=False, + disabled_events=(), + ), + sink=None, +) + +_active: AuditLogger = _DISABLED + + +def get_audit_logger() -> AuditLogger: + """Return the process-wide audit logger, active or not.""" + return _active + + +def init_audit(config: ResolvedAuditConfig) -> AuditLogger: + """Build and install the audit logger for this process. + + A sink that cannot be opened is reported as an error and auditing is left + off — per the PRD, a bad audit path must not prevent the server starting. + """ + global _active # noqa: PLW0603 + + if not config.enabled or config.file is None: + _active = AuditLogger(config, sink=None) + return _active + + try: + sink = AuditSink( + config.file, + max_bytes=config.max_bytes, + backup_count=config.retention_backup_count, + ) + sink.start() + except (OSError, ValueError) as exc: + logger.error( + "Failed to open the audit file %r: %s. The server will start with " + "audit logging disabled.", + config.file, + exc, + ) + _active = AuditLogger(config, sink=None) + return _active + + _active = AuditLogger(config, sink=sink) + logger.info( + "Audit logging enabled. Writing to %s (service_package=%s, " + "max_bytes=%d, backups=%d, tool_args=%s, disabled_events=%s). " + "Each server process writes its own file; the configured path has the " + "process id inserted so concurrent stdio servers cannot corrupt one " + "another's records.", + sink.path, + SERVICE_PACKAGE, + config.max_bytes, + config.retention_backup_count, + config.tool_args, + list(config.disabled_events) or "none", + ) + return _active + + +def shutdown_audit() -> None: + """Flush and close the audit sink, and revert to the inactive logger.""" + global _active # noqa: PLW0603 + current = _active + _active = _DISABLED + current.close() + + +@atexit.register +def _shutdown_at_exit() -> None: + """Best-effort flush if the process exits without the lifespan closing. + + A stdio client that kills the server, or an unhandled exit, would otherwise + lose whatever is still queued. This cannot help on ``SIGKILL``; a missing + ``server stopped`` record therefore means "not a clean shutdown", not + "tampered with", and ``AUDIT.md`` says so. + """ + shutdown_audit() + + +__all__ = [ + "AuditLogger", + "get_audit_logger", + "init_audit", + "shutdown_audit", +] diff --git a/src/cb_mcp/audit/exceptions.py b/src/cb_mcp/audit/exceptions.py new file mode 100644 index 00000000..7c240b92 --- /dev/null +++ b/src/cb_mcp/audit/exceptions.py @@ -0,0 +1,87 @@ +"""Typed exceptions for auditable MCP-layer refusals. + +Three semantically different refusals used to surface as a bare +``PermissionError`` carrying only a human-readable message, and a read-only +DML block surfaced as ``ValueError`` — the same type a malformed query raises. +An audit middleware that assigned ``outcome``/``reason`` by matching on those +message strings would silently mis-attribute records after the next copy edit, +and a mis-attributed audit record is worse than a missing one. + +Every class here **subclasses the exception type the call site raised before**, +so existing ``pytest.raises`` assertions and any client-visible behaviour stay +exactly as they were. The public message text is unchanged too; only the type +is narrowed. + +Note that FastMCP wraps a tool's exception in ``ToolError`` before it reaches +middleware (the original is preserved on ``__cause__``). Middleware therefore +does **not** rely on catching these types — the raising site records the +structured refusal into the per-request audit state (see +:mod:`cb_mcp.audit.state`), and these classes exist so the raise site is +self-documenting and so callers can still discriminate programmatically. +""" + +from .catalog import AuditEvent + + +class AuditableRefusalError(Exception): + """Mixin marking an exception the audit layer knows how to classify. + + Carries the catalogue event plus the ``outcome``/``reason`` pair the record + should report. Subclasses inherit from both this and the original builtin + exception type, keeping backwards compatibility at the call site. + """ + + audit_event: AuditEvent + audit_outcome: str + audit_reason: str + + +class ScopeDeniedError(AuditableRefusalError, PermissionError): + """A token was present but lacked a scope the tool requires.""" + + audit_event = AuditEvent.SCOPE_CHECK_DENIED + audit_outcome = "denied" + audit_reason = "missing_scope" + + +class ConfirmationDeclinedError(AuditableRefusalError, PermissionError): + """The user rejected an elicitation for a confirmation-required tool.""" + + audit_event = AuditEvent.CONFIRMATION_DECLINED + audit_outcome = "blocked" + audit_reason = "confirmation_declined" + + +class ReadOnlyWriteBlockedError(AuditableRefusalError, ValueError): + """A SQL++ statement that modifies data or structure was refused. + + Raised when ``CB_MCP_READ_ONLY_MODE`` is enabled. Subclasses ``ValueError`` + because that is what ``run_sql_plus_plus_query`` raised for this case + before, and callers (including tests) match on it. + """ + + audit_event = AuditEvent.WRITE_BLOCKED_READ_ONLY + audit_outcome = "blocked" + audit_reason = "read_only_mode" + + +class StatementScopeDeniedError(AuditableRefusalError, PermissionError): + """A SQL++ modification was refused because the token lacks write scope. + + Distinct from :class:`ScopeDeniedError`: the per-tool scope wrapper classifies + SQL++ as a read tool, so this denial is decided by inspecting the statement + at invocation rather than by the tool's static classification. + """ + + audit_event = AuditEvent.SCOPE_CHECK_DENIED + audit_outcome = "denied" + audit_reason = "missing_scope" + + +__all__ = [ + "AuditableRefusalError", + "ConfirmationDeclinedError", + "ReadOnlyWriteBlockedError", + "ScopeDeniedError", + "StatementScopeDeniedError", +] diff --git a/src/cb_mcp/audit/identity.py b/src/cb_mcp/audit/identity.py new file mode 100644 index 00000000..f4937d19 --- /dev/null +++ b/src/cb_mcp/audit/identity.py @@ -0,0 +1,167 @@ +"""Resolution of ``real_userid`` — the access-decision identity. + +This is the only trustworthy answer to "who did this", and the reason an MCP +audit log exists at all: the cluster only ever sees the single +``CB_USERNAME`` the server connects with, so Couchbase Server's own audit log +cannot tell two callers apart. + +Three domains, per the PRD: + +* ``oauth`` — Streamable HTTP with OAuth active. The user is the bearer token's + subject. +* ``local`` — stdio. The user is the OS process owner. +* ``anonymous`` — HTTP without OAuth. There is no caller authentication, so + there is no identity to record. + +The ``domain`` is recorded faithfully rather than papered over, because two of +these cases are degraded and a reviewer needs to be able to filter them out +rather than trust them: + +* ``anonymous`` collapses every caller together — exactly the problem auditing + is meant to solve. :func:`warn_on_unauthenticated_http` reports this at + startup. +* ``local`` inside a container resolves to the image's user (often ``root``), + which looks like an identity but identifies nothing. Documented in + ``AUDIT.md``. +""" + +from __future__ import annotations + +import getpass +import logging +import os + +from fastmcp.server.dependencies import get_access_token + +from ..utils.constants import MCP_SERVER_NAME, STREAMABLE_HTTP_TRANSPORT + +logger = logging.getLogger(f"{MCP_SERVER_NAME}.audit.identity") + +DOMAIN_OAUTH = "oauth" +DOMAIN_LOCAL = "local" +DOMAIN_ANONYMOUS = "anonymous" + +#: Recorded when the OS username cannot be determined at all. +UNKNOWN_USER = "unknown" + + +def _process_owner() -> str: + """Best-effort OS process owner. + + ``getpass.getuser`` consults the environment before the password database + and raises on a container with no matching passwd entry and no ``USER`` set, + so both are guarded. + """ + try: + return getpass.getuser() + except Exception: + logger.debug("getpass.getuser() failed; falling back to uid", exc_info=True) + try: + return str(os.getuid()) # type: ignore[attr-defined] + except AttributeError: # pragma: no cover - Windows has no getuid + return UNKNOWN_USER + except Exception: # pragma: no cover - defensive + return UNKNOWN_USER + + +def _token_subject(token: object) -> str | None: + """Extract the caller's subject from a FastMCP access token. + + Prefers the parsed ``subject``, then the raw ``sub`` claim, then the + ``client_id``. A token that yields none of these is treated as having no + usable identity rather than being reported under a misleading value. + """ + subject = getattr(token, "subject", None) + if isinstance(subject, str) and subject.strip(): + return subject.strip() + + claims = getattr(token, "claims", None) + if isinstance(claims, dict): + sub = claims.get("sub") + if isinstance(sub, str) and sub.strip(): + return sub.strip() + + client_id = getattr(token, "client_id", None) + if isinstance(client_id, str) and client_id.strip(): + return client_id.strip() + + return None + + +def resolve_real_userid(transport: str) -> dict[str, str]: + """Return the ``{"domain": ..., "user": ...}`` pair for this request. + + Reads the request-scoped access token, which is ``None`` whenever no + authenticated caller is present — stdio, or HTTP with OAuth disabled. + """ + token = _current_token() + + if token is not None: + subject = _token_subject(token) + if subject is not None: + return {"domain": DOMAIN_OAUTH, "user": subject} + + if transport == STREAMABLE_HTTP_TRANSPORT: + return {"domain": DOMAIN_ANONYMOUS, "user": DOMAIN_ANONYMOUS} + + return {"domain": DOMAIN_LOCAL, "user": _process_owner()} + + +def _current_token() -> object | None: + """Return this request's access token, or ``None`` outside a request. + + ``get_access_token`` raises when there is no request context at all — for + example on a stdio server, or when auditing is exercised outside FastMCP — + so every caller goes through this guard rather than repeating it. + """ + try: + return get_access_token() + except Exception: + logger.debug("No access token available for this request", exc_info=True) + return None + + +def client_id_of_current_token() -> str | None: + """Return the OAuth ``client_id`` for this request, when there is one.""" + token = _current_token() + client_id = getattr(token, "client_id", None) if token is not None else None + return client_id if isinstance(client_id, str) and client_id else None + + +def scopes_of_current_token() -> list[str] | None: + """Return the scopes held by this request's token, when there is one.""" + token = _current_token() + if token is None: + return None + scopes = getattr(token, "scopes", None) + return sorted(scopes) if scopes else [] + + +def warn_on_unauthenticated_http(transport: str, oauth_enabled: bool) -> None: + """Warn when auditing is enabled on an HTTP transport without OAuth. + + In that configuration every record resolves to ``anonymous`` and the audit + log cannot answer "who did this" — the operator should know at startup, not + when an auditor asks. + """ + if transport == STREAMABLE_HTTP_TRANSPORT and not oauth_enabled: + logger.warning( + "Audit logging is enabled on the %s transport without OAuth. " + "Every audit record will report real_userid " + '{"domain": "anonymous", "user": "anonymous"} because callers are ' + "not authenticated. Configure the CB_MCP_OAUTH_JWT_* settings to " + "record the actual caller identity.", + transport, + ) + + +__all__ = [ + "DOMAIN_ANONYMOUS", + "DOMAIN_LOCAL", + "DOMAIN_OAUTH", + "UNKNOWN_USER", + "client_id_of_current_token", + "resolve_real_userid", + "scopes_of_current_token", + "warn_on_unauthenticated_http", +] diff --git a/src/cb_mcp/audit/middleware.py b/src/cb_mcp/audit/middleware.py new file mode 100644 index 00000000..5eeb6841 --- /dev/null +++ b/src/cb_mcp/audit/middleware.py @@ -0,0 +1,404 @@ +"""The audit middleware. + +Sits in FastMCP's message pipeline and produces one record per tool call, plus +the session and guardrail records. It is deliberately *not* the only audit hook: +a rejected bearer token never becomes a JSON-RPC message, so it is caught in +the JWT verifier, and server lifecycle records come from the lifespan. What +middleware alone cannot see is *why* a call was refused — FastMCP wraps a +tool's exception in ``ToolError`` — so refusals are recorded by the gate that +made the decision, into :mod:`cb_mcp.audit.state`, and read back here. + +Correlation is minted here and nowhere else. ``on_message`` is the outermost +middleware hook and runs for every MCP request — ``initialize``, ``tools/call``, +the listings — so it is the one place that mints the per-request ``cid``. Every +other hook, and every guardrail, only *reads* it out of +:mod:`cb_mcp.audit.state`. + +Doing it here rather than in the authentication layer is deliberate. There is +no "token accepted" event, so on the success path the auth layer emits no +record for a shared ``cid`` to join to, and a *rejected* token is refused +before any MCP message exists, which makes its record a singleton whatever id +it carries. Minting in middleware therefore loses nothing, and it means a host +with a completely different authentication stack — the managed Capella runtime +— inherits the whole correlation model without writing any code. + +A request is the only correlation scope. No session identifier is recorded: +MCP is moving to a stateless model in which a server must not treat connection +or process identity as a proxy for session continuity, so an id derived from it +would group unrelated interleaved conversations together. + +Emission rules, following the PRD: + +* A call refused at a gate emits **only** the core authorization or guardrail + event. The tool never ran, so there is no tool-call event; the ``reason`` + vocabulary for tool calls covers execution failures, not policy decisions. +* A call that ran emits a tool-call event with ``success`` or ``error``. +* A confirmation that was silently skipped emits its own guardrail record + *and* is noted on the tool-call record, because the tool did run. Both + records share the correlation id, so they join. +""" + +from __future__ import annotations + +import logging +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + +from fastmcp.exceptions import NotFoundError +from fastmcp.server.middleware import Middleware + +from ..utils.constants import MCP_SERVER_NAME +from . import state +from .catalog import AuditEvent +from .classification import ( + STATEMENT_CLASSIFIED_TOOL, + is_classified, + required_scope_for, + resolve_tool_call_event, +) +from .emitter import AuditLogger, get_audit_logger +from .identity import ( + client_id_of_current_token, + resolve_real_userid, + scopes_of_current_token, +) +from .record import ( + OUTCOME_BLOCKED, + OUTCOME_ERROR, + OUTCOME_SUCCESS, + REASON_EXECUTION_ERROR, +) + +logger = logging.getLogger(f"{MCP_SERVER_NAME}.audit.middleware") + +#: Argument names that together identify the keyspace a tool acted on. Every +#: keyspace-scoped tool in this server uses exactly these names. +_KEYSPACE_ARGS = ("bucket_name", "scope_name", "collection_name") + +#: Event ids that describe a policy refusal rather than an invocation. +_REFUSAL_EVENTS: dict[int, AuditEvent] = { + event.id: event + for event in ( + AuditEvent.SCOPE_CHECK_DENIED, + AuditEvent.WRITE_BLOCKED_READ_ONLY, + AuditEvent.CONFIRMATION_DECLINED, + ) +} + + +def extract_keyspace(arguments: dict[str, Any] | None) -> str | None: + """Build the dotted ``bucket.scope.collection`` keyspace from tool arguments. + + Returns the longest unbroken prefix that is present, so a bucket-only tool + yields ``"travel-sample"`` and a document tool yields + ``"travel-sample.inventory.airline"``. Returns ``None`` for tools that do + not address a keyspace at all, such as the cluster-level ones. + """ + if not arguments: + return None + parts: list[str] = [] + for name in _KEYSPACE_ARGS: + value = arguments.get(name) + if not isinstance(value, str) or not value: + break + parts.append(value) + return ".".join(parts) if parts else None + + +@contextmanager +def _request_state(context: Any) -> Iterator[dict[str, Any]]: + """Yield this request's audit state, installing it if it is missing. + + ``on_message`` normally installs the state, so the inner hooks find it + already there and reuse it — that is what keeps one ``cid`` across a + request. The fallback covers a hook invoked without ``on_message`` having + run: correlation degrades to per-hook rather than disappearing. + """ + existing = state.current() + if existing is not None: + yield existing + return + token = state.install(cid=str(uuid.uuid4())) + try: + installed = state.current() + yield installed if installed is not None else {} + finally: + state.reset(token) + + +class AuditMiddleware(Middleware): + """Emits session and tool-call audit records.""" + + def __init__( + self, + *, + transport: str, + cb_userid: str | None = None, + audit_logger: AuditLogger | None = None, + ) -> None: + """ + Args: + transport: The resolved transport name, used to decide whether an + unauthenticated caller is ``anonymous`` (HTTP) or the local + process owner (stdio). + cb_userid: The Couchbase user the server connects to the cluster + with. Recorded so a reviewer can see which cluster identity the + MCP-layer caller was mapped onto — the collapse this audit log + exists to undo. + audit_logger: Injected in tests; defaults to the process logger. + """ + self._transport = transport + self._cb_userid = cb_userid + self._injected_logger = audit_logger + + @property + def _audit(self) -> AuditLogger: + return self._injected_logger or get_audit_logger() + + # -- correlation ------------------------------------------------------ + + async def on_message(self, context, call_next): # type: ignore[no-untyped-def] + """Mint this request's correlation id, for every hook below to read. + + Runs for every MCP *request*; notifications reach neither this hook nor + ``on_notification`` in FastMCP 3.4.6, so nothing is minted for them. + """ + audit = self._audit + if not audit.active: + return await call_next(context) + + token = state.install(cid=str(uuid.uuid4())) + try: + return await call_next(context) + finally: + state.reset(token) + + # -- session ---------------------------------------------------------- + + async def on_initialize(self, context, call_next): # type: ignore[no-untyped-def] + """Record the MCP initialize handshake: what connected. + + A standalone record: it carries this request's ``cid`` like any other, + but nothing later joins to it, because no session identifier is + recorded. Its value is the statement that a client of this name and + version connected at this time, under this identity. + """ + result = await call_next(context) + audit = self._audit + if not audit.active: + return result + try: + params = getattr(context.message, "params", None) + client_info = getattr(params, "clientInfo", None) + with _request_state(context) as request_state: + audit.emit_event( + AuditEvent.SESSION_INITIALIZED, + outcome=OUTCOME_SUCCESS, + real_userid=resolve_real_userid(self._transport), + cid=request_state.get("cid"), + protocol_version=getattr(params, "protocolVersion", None), + client_name=getattr(client_info, "name", None), + client_version=getattr(client_info, "version", None), + ) + except Exception: # auditing must never break a session + logger.exception("Failed to emit the session-initialized audit record") + return result + + # -- tool calls ------------------------------------------------------- + + async def on_call_tool(self, context, call_next): # type: ignore[no-untyped-def] + """Record one tool call, or the gate that refused it.""" + audit = self._audit + if not audit.active: + return await call_next(context) + + tool_name = getattr(context.message, "name", None) or "unknown" + arguments = getattr(context.message, "arguments", None) + real_userid = resolve_real_userid(self._transport) + + with _request_state(context) as request_state: + cid = request_state.get("cid") + try: + result = await call_next(context) + except Exception as exc: + self._emit_outcome( + audit, + tool_name=tool_name, + arguments=arguments, + cid=cid, + real_userid=real_userid, + failure=exc, + ) + raise + self._emit_outcome( + audit, + tool_name=tool_name, + arguments=arguments, + cid=cid, + real_userid=real_userid, + failure=None, + result=result, + ) + return result + + # -- emission --------------------------------------------------------- + + def _emit_outcome( + self, + audit: AuditLogger, + *, + tool_name: str, + arguments: dict[str, Any] | None, + cid: str | None, + real_userid: dict[str, str], + failure: BaseException | None, + result: Any = None, + ) -> None: + """Emit whichever records this call earned. Never raises.""" + try: + if isinstance(failure, NotFoundError): + # The tool does not exist as far as this server is concerned — + # either the name is wrong, or the tool was withheld at + # registration by read-only mode or the disabled set. Nothing + # executed, so emitting a tool-call record would state an + # ``execution_error`` that never happened, and withheld tools + # deliberately produce no audit event: a client is never told + # they exist, so there is no refusal to record. The enforced + # tool surface is captured once by the server-configuration + # event at startup instead. + logger.debug( + "No audit record for unknown or withheld tool %r", tool_name + ) + return + + confirmation = state.get_confirmation() + if confirmation == state.CONFIRMATION_SKIPPED: + audit.emit_event( + AuditEvent.CONFIRMATION_SKIPPED, + outcome=OUTCOME_BLOCKED, + real_userid=real_userid, + cid=cid, + reason="confirmation_unsupported", + tool_name=tool_name, + confirmation="skipped", + ) + + refusal = state.get_refusal() + if refusal is not None: + self._emit_refusal( + audit, + refusal=refusal, + tool_name=tool_name, + cid=cid, + real_userid=real_userid, + ) + return + + self._emit_tool_call( + audit, + tool_name=tool_name, + arguments=arguments, + cid=cid, + real_userid=real_userid, + confirmation=confirmation, + failure=failure, + result=result, + ) + except Exception: # auditing must never break a call + logger.exception("Failed to emit an audit record for tool %r", tool_name) + + def _emit_refusal( + self, + audit: AuditLogger, + *, + refusal: dict[str, Any], + tool_name: str, + cid: str | None, + real_userid: dict[str, str], + ) -> None: + """Emit the core authorization or guardrail record for a refused call.""" + event = _REFUSAL_EVENTS.get(int(refusal["event_id"])) + if event is None: # pragma: no cover - guarded by the state contract + logger.warning( + "Ignoring audit refusal for unknown event id %r", + refusal.get("event_id"), + ) + return + + payload: dict[str, Any] = {"tool_name": tool_name} + for key, value in refusal.items(): + if key not in ("event_id", "event_name", "outcome", "reason"): + payload[key] = value + + if event is AuditEvent.SCOPE_CHECK_DENIED: + payload.setdefault("client_id", client_id_of_current_token()) + payload.setdefault("scopes", scopes_of_current_token()) + + audit.emit_event( + event, + outcome=str(refusal["outcome"]), + real_userid=real_userid, + cid=cid, + reason=str(refusal["reason"]), + **payload, + ) + + def _emit_tool_call( + self, + audit: AuditLogger, + *, + tool_name: str, + arguments: dict[str, Any] | None, + cid: str | None, + real_userid: dict[str, str], + confirmation: str | None, + failure: BaseException | None, + result: Any, + ) -> None: + """Emit the Tier-2 record for a call that actually reached the tool.""" + override = ( + state.get_statement_class() + if tool_name == STATEMENT_CLASSIFIED_TOOL + else None + ) + event = resolve_tool_call_event(tool_name, operation_class_override=override) + + outcome = OUTCOME_SUCCESS + reason: str | None = None + if failure is not None: + outcome = OUTCOME_ERROR + reason = REASON_EXECUTION_ERROR + elif getattr(result, "is_error", False): + # A tool that reports failure through the result rather than by + # raising is still a failed operation as far as an auditor cares. + outcome = OUTCOME_ERROR + reason = REASON_EXECUTION_ERROR + + payload: dict[str, Any] = { + "tool_name": tool_name, + "required_scope": required_scope_for(event.operation_class), + "ks": extract_keyspace(arguments), + "cb_userid": self._cb_userid, + "confirmation": confirmation, + } + if not is_classified(tool_name): + # Fail-closed classification: recorded against a write id so it is + # never filtered, and flagged so the placeholder category is never + # mistaken for a real one. + payload["unclassified"] = True + if audit.config.tool_args and arguments: + payload["args"] = arguments + + audit.emit_tool_call( + event, + outcome=outcome, + real_userid=real_userid, + cid=cid, + reason=reason, + **payload, + ) + + +__all__ = ["AuditMiddleware", "extract_keyspace"] diff --git a/src/cb_mcp/audit/record.py b/src/cb_mcp/audit/record.py new file mode 100644 index 00000000..bbf3370e --- /dev/null +++ b/src/cb_mcp/audit/record.py @@ -0,0 +1,166 @@ +"""The audit record and its JSON-Lines serialisation. + +One immutable record per line, matching Couchbase Server and Sync Gateway: +the same field vocabulary (``id``, ``name``, ``description``, ``timestamp``, +``real_userid``, ``cid``, ``outcome``) with identical semantics, so a customer +already running Couchbase auditing gets one format across the estate. + +Key order is fixed rather than incidental. Audit files are read by people as +well as machines, and a stable prefix (id, name, description, timestamp, who, +where, correlation) means a human can scan a file without a parser. ``None`` +values are omitted entirely so absence is unambiguous — notably ``reason``, +which the PRD specifies is present only when ``outcome`` is not ``success``. + +Correlation has one scope: ``cid`` groups the records of one request. There is +deliberately no session identifier — MCP is moving to a stateless model in +which connection identity must not be read as session continuity. +""" + +from __future__ import annotations + +import json +import socket +from dataclasses import dataclass, field +from datetime import datetime, timezone +from importlib.metadata import PackageNotFoundError, version +from typing import Any + +_PACKAGE_NAME = "couchbase-mcp-server" + +#: MCP-layer dispositions, per the PRD. +OUTCOME_SUCCESS = "success" +OUTCOME_DENIED = "denied" +OUTCOME_BLOCKED = "blocked" +OUTCOME_ERROR = "error" + +#: Reasons recorded on a non-success tool call. +REASON_EXECUTION_ERROR = "execution_error" +REASON_INVALID_ARGUMENTS = "invalid_arguments" + +#: Reason recorded when a bearer token is rejected. Deliberately a single +#: value: FastMCP's JWT verifier collapses expiry, signature, audience and +#: issuer failures into one ``None`` return, so a finer taxonomy would have to +#: be re-derived by decoding the token a second time. Out of phase-1 scope. +REASON_TOKEN_INVALID = "token_invalid" # noqa: S105 - an audit reason, not a credential + + +def _server_version() -> str: + try: + return version(_PACKAGE_NAME) + except PackageNotFoundError: # pragma: no cover - editable/source checkouts + return "unknown" + + +def _hostname() -> str: + try: + return socket.gethostname() + except Exception: # pragma: no cover - defensive + return "unknown" + + +def utc_timestamp() -> str: + """Current UTC time as ``YYYY-MM-DDTHH:MM:SS.mmmZ``. + + Millisecond precision with a literal ``Z``, matching the PRD's samples and + Sync Gateway's audit format. ``datetime.isoformat`` would emit microseconds + and a ``+00:00`` offset, so the value is assembled explicitly. + """ + now = datetime.now(timezone.utc) + return f"{now.strftime('%Y-%m-%dT%H:%M:%S')}.{now.microsecond // 1000:03d}Z" + + +@dataclass(frozen=True) +class ServerContext: + """Static per-process context, resolved once at startup.""" + + version: str + host: str + + @classmethod + def detect(cls) -> ServerContext: + return cls(version=_server_version(), host=_hostname()) + + def as_dict(self) -> dict[str, str]: + return {"version": self.version, "host": self.host} + + +@dataclass +class AuditRecord: + """A single audit event. + + Args: + id: Catalogue event id. + name: Catalogue event name. + description: Catalogue event description. + outcome: MCP-layer disposition. + real_userid: ``{"domain": ..., "user": ...}`` access-decision identity. + Omitted for records that are not attributable to a caller, such as + server start. + cid: Server-minted correlation id joining the records of one request. + reason: Present only when ``outcome`` is not ``success``. + payload: Event-specific fields, merged in after the fixed prefix. + """ + + id: int + name: str + description: str + outcome: str + real_userid: dict[str, str] | None = None + cid: str | None = None + reason: str | None = None + timestamp: str = field(default_factory=utc_timestamp) + payload: dict[str, Any] = field(default_factory=dict) + + def as_dict(self, server: ServerContext) -> dict[str, Any]: + """Materialise the record in wire order, dropping absent fields.""" + document: dict[str, Any] = { + "id": self.id, + "name": self.name, + "description": self.description, + "timestamp": self.timestamp, + } + if self.real_userid is not None: + document["real_userid"] = self.real_userid + document["server"] = server.as_dict() + if self.cid is not None: + document["cid"] = self.cid + document["outcome"] = self.outcome + if self.reason is not None: + document["reason"] = self.reason + for key, value in self.payload.items(): + if value is not None: + document[key] = value + return document + + def to_json_line(self, server: ServerContext) -> str: + """Serialise to a single JSON line, newline included. + + ``default=str`` guarantees a line is always produced: tool arguments + can contain arbitrary caller-supplied values, and a record that failed + to serialise would be a silently missing audit entry — strictly worse + than a stringified value. ``ensure_ascii=False`` keeps non-Latin + document ids and keyspace names readable. + """ + return ( + json.dumps( + self.as_dict(server), + ensure_ascii=False, + separators=(",", ":"), + default=str, + ) + + "\n" + ) + + +__all__ = [ + "OUTCOME_BLOCKED", + "OUTCOME_DENIED", + "OUTCOME_ERROR", + "OUTCOME_SUCCESS", + "REASON_EXECUTION_ERROR", + "REASON_INVALID_ARGUMENTS", + "REASON_TOKEN_INVALID", + "AuditRecord", + "ServerContext", + "utc_timestamp", +] diff --git a/src/cb_mcp/audit/sink.py b/src/cb_mcp/audit/sink.py new file mode 100644 index 00000000..4bced888 --- /dev/null +++ b/src/cb_mcp/audit/sink.py @@ -0,0 +1,281 @@ +"""The audit sink: a per-process JSON-Lines file with size-based rotation. + +Two properties drive this design. + +**No shared-file rotation.** ``logging.handlers.RotatingFileHandler`` is not +multi-process safe, and the default stdio deployment is inherently +multi-process: every MCP client spawns its own server process, and they all +read the same ``CB_MCP_AUDIT_FILE`` from the environment. Sharing one rotating +file across them produces interleaved partial lines and rotation races that +silently destroy records. So each process writes its **own** file, with the +PID inserted before the extension (``audit.log`` → ``audit.12345.log``). No +locking is needed because no two processes ever touch the same file, and it +behaves identically on every platform. This matches the PRD's own position that +auditing is per-node and consolidation is the operator's responsibility. + +**Never block the event loop.** ``emit`` only puts a formatted line on a +bounded queue; a dedicated daemon thread does the writing and flushing. A +synchronous write from async middleware would stall the whole server on every +tool call. When the queue is full, records are dropped and counted rather than +applying back-pressure to tool execution. + +Runtime failures — disk full, permissions revoked, the file unmounted — are +reported loudly to the operational log as errors and counted, and the server +keeps serving. That is a deliberate product decision: audit unavailability +should not become an outage. +""" + +from __future__ import annotations + +import contextlib +import logging +import os +import queue +import threading +import time +from pathlib import Path + +from ..utils.constants import MCP_SERVER_NAME + +logger = logging.getLogger(f"{MCP_SERVER_NAME}.audit.sink") + +#: Bounded queue depth. Deep enough to absorb a burst of tool calls while the +#: writer thread is mid-flush, shallow enough that a wedged disk cannot grow +#: memory without limit. +DEFAULT_QUEUE_SIZE = 10_000 + +#: Lines coalesced into a single write+flush by the writer thread. +_WRITE_BATCH = 64 + +#: Minimum seconds between repeated write-failure error records, so a full disk +#: cannot flood the operational log. +_ERROR_LOG_INTERVAL_SECONDS = 60.0 + +#: Seconds the writer thread is given to drain on close. +DEFAULT_CLOSE_TIMEOUT = 5.0 + +_SENTINEL = object() + + +def process_scoped_path(path: str | os.PathLike[str], pid: int | None = None) -> Path: + """Insert the process id before the file extension. + + ``audit.log`` becomes ``audit..log``; a path with no suffix becomes + ``audit.``. Exposed separately so startup can log, and tests can + assert, the exact file that will be written. + """ + resolved = Path(path) + actual_pid = os.getpid() if pid is None else pid + if resolved.suffix: + return resolved.with_name(f"{resolved.stem}.{actual_pid}{resolved.suffix}") + return resolved.with_name(f"{resolved.name}.{actual_pid}") + + +class AuditSink: + """Writes audit lines to a per-process file on a background thread.""" + + def __init__( + self, + path: str | os.PathLike[str], + *, + max_bytes: int, + backup_count: int, + queue_size: int = DEFAULT_QUEUE_SIZE, + ) -> None: + """Open the sink's file, creating parent directories as needed. + + Raises: + OSError: if the file cannot be created or opened. The caller is + expected to report this and continue without auditing rather + than abort startup. + """ + if max_bytes <= 0: + raise ValueError(f"max_bytes must be positive, got {max_bytes}.") + if backup_count < 0: + raise ValueError(f"backup_count must not be negative, got {backup_count}.") + + self.path = process_scoped_path(path) + self._max_bytes = max_bytes + self._backup_count = backup_count + self._queue: queue.Queue = queue.Queue(maxsize=queue_size) + self._thread: threading.Thread | None = None + self._closed = threading.Event() + + # Counters are only mutated by the writer thread, except ``dropped`` + # which is incremented by the caller. Both are plain ints guarded by + # the GIL for a single increment; exactness under contention is not + # required for a diagnostic counter. + self._written = 0 + self._dropped = 0 + self._errors = 0 + self._last_error_log = 0.0 + + self.path.parent.mkdir(parents=True, exist_ok=True) + # Line-buffered text append. Opened eagerly so a misconfigured path + # fails at startup, where it can be reported, rather than on the first + # audited operation. + # Deliberately not a context manager: the stream is owned for the + # lifetime of the sink and written by the background thread. Closing it + # per record would defeat both the batching and the append semantics. + self._stream = open(self.path, "a", encoding="utf-8") # noqa: SIM115 + self._size = self.path.stat().st_size + + # -- lifecycle -------------------------------------------------------- + + def start(self) -> None: + """Start the writer thread. Idempotent.""" + if self._thread is not None: + return + self._thread = threading.Thread( + target=self._run, name="cb-mcp-audit-writer", daemon=True + ) + self._thread.start() + + def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: + """Drain the queue, stop the writer thread and close the file. + + Safe to call more than once. Best-effort: a wedged filesystem cannot be + allowed to hang shutdown, so the drain is bounded by ``timeout``. + """ + if self._closed.is_set(): + return + self._closed.set() + if self._thread is not None: + # A full queue means the writer is already behind; it will reach + # the sentinel-free shutdown path via the join timeout below. + with contextlib.suppress(queue.Full): + self._queue.put_nowait(_SENTINEL) + self._thread.join(timeout=timeout) + self._thread = None + try: + if not self._stream.closed: + self._stream.flush() + self._stream.close() + except OSError: # pragma: no cover - nothing useful left to do + pass + + # -- producer side ---------------------------------------------------- + + def emit(self, line: str) -> None: + """Queue one already-formatted line. Never raises, never blocks.""" + if self._closed.is_set(): + self._dropped += 1 + return + try: + self._queue.put_nowait(line) + except queue.Full: + self._dropped += 1 + self._log_failure("audit queue is full") + + @property + def stats(self) -> dict[str, int]: + """Counters for ``get_server_configuration_status``. + + ``dropped`` is the number that matters: it is the only way an operator + learns that audit records were lost. + """ + return { + "written": self._written, + "dropped": self._dropped, + "write_errors": self._errors, + } + + # -- writer thread ---------------------------------------------------- + + def _run(self) -> None: + while True: + item = self._queue.get() + if item is _SENTINEL: + return + batch = [item] + # Coalesce whatever else is already waiting into one write+flush. + while len(batch) < _WRITE_BATCH: + try: + nxt = self._queue.get_nowait() + except queue.Empty: + break + if nxt is _SENTINEL: + self._write_batch(batch) + return + batch.append(nxt) + self._write_batch(batch) + + def _write_batch(self, lines: list[str]) -> None: + for line in lines: + try: + self._rotate_if_needed(len(line.encode("utf-8"))) + self._stream.write(line) + self._size += len(line.encode("utf-8")) + self._written += 1 + except OSError as exc: + self._errors += 1 + self._dropped += 1 + self._log_failure(f"failed to write audit record: {exc}") + try: + self._stream.flush() + except OSError as exc: + self._errors += 1 + self._log_failure(f"failed to flush audit file: {exc}") + + def _rotate_if_needed(self, incoming_bytes: int) -> None: + """Roll the file over when the next line would exceed ``max_bytes``. + + A single line larger than ``max_bytes`` is still written rather than + discarded — losing the record would be worse than briefly exceeding the + cap — but it lands in a file of its own on the next rotation. + """ + if self._size == 0 or self._size + incoming_bytes <= self._max_bytes: + return + self._stream.flush() + self._stream.close() + + if self._backup_count == 0: + # Retention of zero still means the live file stays bounded, so it + # is truncated rather than allowed to grow without limit. + self._stream = open(self.path, "w", encoding="utf-8") # noqa: SIM115 + self._size = 0 + return + + # Shift existing backups down, oldest first, then move the live file + # into slot 1. ``Path.replace`` overwrites atomically on POSIX and + # Windows alike. + oldest = self.path.with_name(f"{self.path.name}.{self._backup_count}") + if oldest.exists(): + oldest.unlink() + for index in range(self._backup_count - 1, 0, -1): + source = self.path.with_name(f"{self.path.name}.{index}") + if source.exists(): + source.replace(self.path.with_name(f"{self.path.name}.{index + 1}")) + self.path.replace(self.path.with_name(f"{self.path.name}.1")) + self._stream = open(self.path, "a", encoding="utf-8") # noqa: SIM115 + self._size = 0 + + def _log_failure(self, message: str) -> None: + """Report a sink failure as an error, rate-limited. + + The first failure is always reported; subsequent ones at most once per + interval, carrying the running totals so the operational log shows the + scale of the gap without being flooded by it. + """ + now = time.monotonic() + if self._last_error_log and now - self._last_error_log < ( + _ERROR_LOG_INTERVAL_SECONDS + ): + return + self._last_error_log = now + logger.error( + "Audit sink failure: %s (file=%s, dropped=%d, write_errors=%d). " + "Audit records are being lost; the server continues to serve.", + message, + self.path, + self._dropped, + self._errors, + ) + + +__all__ = [ + "DEFAULT_CLOSE_TIMEOUT", + "DEFAULT_QUEUE_SIZE", + "AuditSink", + "process_scoped_path", +] diff --git a/src/cb_mcp/audit/state.py b/src/cb_mcp/audit/state.py new file mode 100644 index 00000000..dbab86e0 --- /dev/null +++ b/src/cb_mcp/audit/state.py @@ -0,0 +1,151 @@ +"""Per-request audit state. + +Holds two kinds of thing for the life of one MCP request: the correlation id +that every record for that request shares, and the refusal channel. + +**Correlation.** ``cid`` identifies one request. It is written once, by the +middleware's outermost hook, and only read afterwards. Keeping it here rather +than passing it down as an argument means tool code can reach it too — which is +what a later phase needs in order to propagate it to Couchbase Server as a +SQL++ ``client_context_id``. + +There is deliberately no session id. MCP is moving to a stateless model in +which a server must not treat connection identity as session continuity, so a +request is the only correlation scope this audit log claims. + +**Refusals.** FastMCP wraps a tool's exception in ``ToolError`` before +middleware sees it, so middleware cannot reliably classify a refusal from the +exception it catches. Instead the raising site records a structured refusal +here, and the middleware reads it after ``call_next`` returns or raises. + +The contextvar holds a **mutable dict** that the middleware installs once per +tool call. Everything downstream mutates that dict *in place* rather than +rebinding the contextvar. This matters: FastMCP runs synchronous tools on a +worker thread via ``anyio.to_thread``, which copies the context. A rebind +inside the worker would be invisible to the middleware, but an in-place +mutation of the shared object is visible because both contexts reference the +same dict. This behaviour is covered by +``tests/unit/test_audit_state.py::test_mutation_from_worker_thread_is_visible``. + +Every accessor is a no-op when no state is installed, so tool code can record +unconditionally without caring whether auditing is enabled. +""" + +from __future__ import annotations + +from contextvars import ContextVar, Token +from typing import Any + +_AUDIT_STATE: ContextVar[dict[str, Any] | None] = ContextVar( + "cb_mcp_audit_state", default=None +) + +#: Recorded when a confirmation-gated tool ran without confirmation because the +#: client does not advertise elicitation support. +CONFIRMATION_SKIPPED = "skipped" + +#: Recorded when the user accepted an elicitation. +CONFIRMATION_ACCEPTED = "accepted" + +#: Recorded when the user rejected an elicitation. +CONFIRMATION_DECLINED = "declined" + + +def install(*, cid: str | None = None) -> Token: + """Install a fresh state dict for this request and return its reset token. + + Args: + cid: Correlation id shared by every record this request produces. + """ + return _AUDIT_STATE.set({"cid": cid}) + + +def reset(token: Token) -> None: + """Restore the previous state, undoing :func:`install`.""" + _AUDIT_STATE.reset(token) + + +def current() -> dict[str, Any] | None: + """Return the live state dict, or ``None`` when auditing is not tracking.""" + return _AUDIT_STATE.get() + + +def record_refusal( + *, event_id: int, event_name: str, outcome: str, reason: str, **extra: Any +) -> None: + """Record a structured MCP-layer refusal for the middleware to emit. + + Safe to call when no state is installed. Only the first refusal in a call + is kept — the earliest gate to fire is the one that actually stopped the + request, and a later one cannot have run. + """ + state = _AUDIT_STATE.get() + if state is None or "refusal" in state: + return + state["refusal"] = { + "event_id": event_id, + "event_name": event_name, + "outcome": outcome, + "reason": reason, + **extra, + } + + +def record_confirmation(status: str) -> None: + """Record the confirmation disposition for this call.""" + state = _AUDIT_STATE.get() + if state is not None: + state["confirmation"] = status + + +def record_statement_class(operation_class: str) -> None: + """Record the read/write class of an inspected SQL++ statement. + + Lets the middleware book a SQL++ call against the query *write* ID when the + statement actually modifies data or structure, and lets the read-only guard + and the audit record share a single parse of the statement. + """ + state = _AUDIT_STATE.get() + if state is not None: + state["statement_class"] = operation_class + + +def get_cid() -> str | None: + """Return this request's correlation id, if state is installed.""" + state = _AUDIT_STATE.get() + return state.get("cid") if state else None + + +def get_refusal() -> dict[str, Any] | None: + """Return the recorded refusal, if any.""" + state = _AUDIT_STATE.get() + return state.get("refusal") if state else None + + +def get_confirmation() -> str | None: + """Return the recorded confirmation disposition, if any.""" + state = _AUDIT_STATE.get() + return state.get("confirmation") if state else None + + +def get_statement_class() -> str | None: + """Return the recorded SQL++ statement class, if any.""" + state = _AUDIT_STATE.get() + return state.get("statement_class") if state else None + + +__all__ = [ + "CONFIRMATION_ACCEPTED", + "CONFIRMATION_DECLINED", + "CONFIRMATION_SKIPPED", + "current", + "get_cid", + "get_confirmation", + "get_refusal", + "get_statement_class", + "install", + "record_confirmation", + "record_refusal", + "record_statement_class", + "reset", +] diff --git a/src/cb_mcp/auth.py b/src/cb_mcp/auth.py index bbb9ccf6..499b550f 100644 --- a/src/cb_mcp/auth.py +++ b/src/cb_mcp/auth.py @@ -20,12 +20,17 @@ """ import logging +import uuid from typing import ClassVar -from fastmcp.server.auth import AuthProvider, RemoteAuthProvider +from fastmcp.server.auth import AccessToken, AuthProvider, RemoteAuthProvider from fastmcp.server.auth.providers.jwt import JWTVerifier from pydantic import AnyHttpUrl, ValidationError +from .audit.catalog import AuditEvent +from .audit.emitter import get_audit_logger +from .audit.identity import DOMAIN_ANONYMOUS +from .audit.record import OUTCOME_DENIED, REASON_TOKEN_INVALID from .utils.constants import ( DEFAULT_OAUTH_ALGORITHM, MCP_SERVER_NAME, @@ -103,6 +108,48 @@ def _extract_scopes(self, claims: dict) -> list[str]: raw = super()._extract_scopes(claims) return [self._scope_aliases.get(s, s) for s in raw] + async def verify_token(self, token: str) -> AccessToken | None: + """Validate a bearer token, auditing a rejection. + + This is the one audit hook that cannot live in middleware: a rejected + token is refused in the ASGI auth layer, so no JSON-RPC message is ever + constructed and the MCP message pipeline never runs. + + The rejection is recorded without a fine-grained reason. FastMCP's + verifier collapses expiry, bad signature, wrong audience and wrong + issuer into a single ``None`` return, keeping the discriminating detail + in a debug log line only; re-deriving it would mean decoding the token a + second time and duplicating upstream validation logic. A single + ``token_invalid`` reason is the honest record. ``real_userid`` is + ``anonymous`` because a rejected token establishes no identity. + + A token that is *missing* entirely is refused before this method is + reached and is therefore not audited in this release — see ``AUDIT.md``. + + Correlation: this record carries a freshly minted ``cid``, so that + grouping an audit file by ``cid`` works unconditionally, and carries no + ``sid``, because no MCP session exists. It is a deliberate singleton — + the rejection produced a 401 and no MCP message was ever dispatched, so + no sibling record can exist. Nothing is read from the request scope + here on purpose: correlation is minted in middleware precisely so that + a host with a different authentication layer inherits it unchanged. + """ + result = await super().verify_token(token) + if result is None: + audit = get_audit_logger() + if audit.active: + audit.emit_event( + AuditEvent.TOKEN_REJECTED, + outcome=OUTCOME_DENIED, + real_userid={ + "domain": DOMAIN_ANONYMOUS, + "user": DOMAIN_ANONYMOUS, + }, + cid=str(uuid.uuid4()), + reason=REASON_TOKEN_INVALID, + ) + return result + def build_oauth( *, diff --git a/src/cb_mcp/tools/query.py b/src/cb_mcp/tools/query.py index 5ac507a5..e7660561 100644 --- a/src/cb_mcp/tools/query.py +++ b/src/cb_mcp/tools/query.py @@ -12,6 +12,9 @@ from fastmcp.server.dependencies import get_access_token from lark_sqlpp import modifies_data, modifies_structure, parse_sqlpp +from ..audit import state as audit_state +from ..audit.emitter import get_audit_logger +from ..audit.exceptions import ReadOnlyWriteBlockedError, StatementScopeDeniedError from ..utils.connection import connect_to_bucket, format_keyspace from ..utils.constants import MCP_SERVER_NAME, SCOPE_WRITE from ..utils.context import get_cluster_connection @@ -28,6 +31,27 @@ def safe_ident(name: str) -> str: return "`" + name.replace("`", "``") + "`" +def _query_correlation_options() -> dict[str, str]: + """SDK query options carrying this request's audit correlation id. + + Couchbase Server's SQL++ audit records carry ``clientContextId``, captured + from the query's ``client_context_id``. Propagating our ``cid`` into it + makes an MCP audit record joinable to the Server audit record for the same + statement — an exact join, not a timestamp heuristic. + + Nothing consumes that join yet; it is set now so the correlation exists in + historical data by the time a later phase wants to query it. Empty when + auditing is inactive, so an unaudited call is byte-for-byte the request it + was before. + + Only the query service can do this. The KV protocol exposes no + client-supplied correlation field, so a document-level join stays + approximate — see ``AUDIT.md``. + """ + cid = audit_state.get_cid() + return {"client_context_id": cid} if cid else {} + + def get_schema_for_collection( ctx: Context, bucket_name: str, scope_name: str, collection_name: str ) -> dict[str, Any]: @@ -160,11 +184,38 @@ def run_sql_plus_plus_query( results = [] # EXPLAIN statements are always safe to execute and should bypass write checks. - if block_query_writes and not _is_explain_statement(query): - parsed_query = parse_sqlpp(query) - kind = _blocked_write_kind(parsed_query) + is_explain = _is_explain_statement(query) + + # This tool is the one whose audit category depends on the statement it + # is given, so when auditing is active the statement must be inspected + # even where the write guard would not have needed to. Otherwise an + # *allowed* DML statement would be recorded against the query-read id, + # and read ids are filterable — a successful mutation could be filtered + # out of the audit log. The parse is done once here and shared with the + # guard below, so an audited call never pays for it twice, and a call + # made with auditing off pays nothing at all. + audit_active = get_audit_logger().active + needs_audit_classification = audit_active and not is_explain + + if is_explain and audit_active: + audit_state.record_statement_class("read") + + if (block_query_writes or needs_audit_classification) and not is_explain: + try: + kind = _blocked_write_kind(parse_sqlpp(query)) + except Exception: + if block_query_writes: + raise + if audit_active: + audit_state.record_statement_class("write") + kind = None + else: + if audit_active: + audit_state.record_statement_class( + "write" if kind is not None else "read" + ) - if kind is not None: + if kind is not None and block_query_writes: if lacks_write_scope and not read_only_mode: # lacks_write_scope implies token is not None here. held_scopes = sorted(set(token.scopes or [])) @@ -173,18 +224,34 @@ def run_sql_plus_plus_query( f"'{SCOPE_WRITE}' scope; token scopes are {held_scopes}." ) logger.warning(msg) - raise PermissionError(msg) + audit_state.record_refusal( + event_id=StatementScopeDeniedError.audit_event.id, + event_name=StatementScopeDeniedError.audit_event.event_name, + outcome=StatementScopeDeniedError.audit_outcome, + reason=StatementScopeDeniedError.audit_reason, + required_scope="write", + statement_kind=kind, + ) + raise StatementScopeDeniedError(msg) msg = f"{kind.capitalize()} modification query is not allowed in read-only mode" logger.error(msg) - raise ValueError(msg) + audit_state.record_refusal( + event_id=ReadOnlyWriteBlockedError.audit_event.id, + event_name=ReadOnlyWriteBlockedError.audit_event.event_name, + outcome=ReadOnlyWriteBlockedError.audit_outcome, + reason=ReadOnlyWriteBlockedError.audit_reason, + statement_kind=kind, + ) + raise ReadOnlyWriteBlockedError(msg) # Reached only for read-only queries (or when writes are allowed). # Forward named parameters only when provided so existing callers that # pass none keep the exact previous behaviour. + options = _query_correlation_options() result = ( - scope.query(query, named_parameters=named_parameters) + scope.query(query, named_parameters=named_parameters, **options) if named_parameters is not None - else scope.query(query) + else scope.query(query, **options) ) for row in result: results.append(row) @@ -245,7 +312,9 @@ def run_cluster_query(ctx: Context, query: str, **kwargs: Any) -> list[dict[str, try: logger.debug("Executing cluster query") - result = cluster.query(query, **kwargs) + # Caller-supplied kwargs win, so an explicit client_context_id is never + # overwritten by the audit correlation id. + result = cluster.query(query, **{**_query_correlation_options(), **kwargs}) for row in result: results.append(row) logger.info(f"Cluster query returned {len(results)} row(s)") diff --git a/src/cb_mcp/tools/server.py b/src/cb_mcp/tools/server.py index b2b37157..ce8f33b4 100644 --- a/src/cb_mcp/tools/server.py +++ b/src/cb_mcp/tools/server.py @@ -10,10 +10,12 @@ from fastmcp import Context +from ..audit.emitter import get_audit_logger from ..utils.config import get_settings from ..utils.connection import connect_to_bucket from ..utils.constants import MCP_SERVER_NAME from ..utils.context import ( + get_audit_config, get_cluster_connection, get_cluster_provider, get_logging_config, @@ -66,11 +68,25 @@ def get_server_configuration_status(ctx: Context) -> dict[str, Any]: # implementations that don't populate it. logging_status = get_logging_config(ctx) + # Audit state: the resolved configuration plus the sink counters. The + # counters matter as much as the config — ``dropped`` is the only way an + # operator discovers that audit records were lost to a full disk or revoked + # permissions, since the sink deliberately fails open rather than turning an + # audit outage into a server outage. + audit_status = get_audit_config(ctx) + if audit_status is not None: + audit_status = dict(audit_status) + audit_logger = get_audit_logger() + audit_status["active"] = audit_logger.active + if audit_logger.active: + audit_status["stats"] = audit_logger.stats + return { "server_name": MCP_SERVER_NAME, "status": "running", "configuration": configuration, "logging": logging_status, + "audit": audit_status, "connections": connection_status, } diff --git a/src/cb_mcp/utils/__init__.py b/src/cb_mcp/utils/__init__.py index d525271e..ebbb88f1 100644 --- a/src/cb_mcp/utils/__init__.py +++ b/src/cb_mcp/utils/__init__.py @@ -30,6 +30,11 @@ ALLOWED_LOG_SINKS, ALLOWED_OAUTH_ALGORITHMS, ALLOWED_TRANSPORTS, + DEFAULT_AUDIT_BACKUP_COUNT, + DEFAULT_AUDIT_ENABLED, + DEFAULT_AUDIT_FILE, + DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB, + DEFAULT_AUDIT_TOOL_ARGS, DEFAULT_HOST, DEFAULT_LOG_BACKUP_COUNT, DEFAULT_LOG_FILE, @@ -52,6 +57,7 @@ # Context utilities from .context import ( AppContext, + get_audit_config, get_cluster_connection, get_cluster_provider, get_logging_config, @@ -98,11 +104,17 @@ "get_cluster_connection", "get_cluster_provider", "get_logging_config", + "get_audit_config", # Index utilities "fetch_indexes_from_rest_api", # Constants "MCP_SERVER_NAME", "DEFAULT_READ_ONLY_MODE", + "DEFAULT_AUDIT_ENABLED", + "DEFAULT_AUDIT_FILE", + "DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB", + "DEFAULT_AUDIT_BACKUP_COUNT", + "DEFAULT_AUDIT_TOOL_ARGS", "DEFAULT_TRANSPORT", "DEFAULT_LOG_LEVEL", "DEFAULT_LOG_MAX_BYTES", diff --git a/src/cb_mcp/utils/constants.py b/src/cb_mcp/utils/constants.py index 9e552c9b..4e25b582 100644 --- a/src/cb_mcp/utils/constants.py +++ b/src/cb_mcp/utils/constants.py @@ -52,6 +52,27 @@ # layer (mcp_server.py) wires this as the --log-file Click default. DEFAULT_LOG_FILE = "mcp_server.log" +# Audit Logging Configuration +# Audit is a separate sink from the CB_MCP_LOG_* operational logs: it +# guarantees a stable schema, completeness for non-filterable events, and is +# sensitive by default. Opt-in — an operator must both enable it and name a +# file. Retention is expressed in bytes (rotation size x backup count), matching +# the operational log configuration. +DEFAULT_AUDIT_ENABLED = False +# No default path: enabling audit without naming a file is a configuration +# error that is reported at startup, leaving audit off rather than aborting. +DEFAULT_AUDIT_FILE = None +# Rotation size per audit file, in MB. +DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB = 1.0 +# Rotated backups retained per audit file, excluding the live file. Much larger +# than the operational-log default because audits are reviewed on a quarterly +# or annual cadence, not for day-to-day triage. +DEFAULT_AUDIT_BACKUP_COUNT = 1000 +# Tool argument values are sensitive: document-write tools carry entire +# document bodies. There is no redaction capability in this release, so the +# default is off and enabling it emits a startup warning. +DEFAULT_AUDIT_TOOL_ARGS = False + # OAuth Scopes # Tokens carrying SCOPE_READ may call read-only tools (including SQL++ query, # which is classified read-only at startup and runtime-gated by diff --git a/src/cb_mcp/utils/context.py b/src/cb_mcp/utils/context.py index 737a544e..38587a0f 100644 --- a/src/cb_mcp/utils/context.py +++ b/src/cb_mcp/utils/context.py @@ -24,12 +24,17 @@ class AppContext: management) are disabled and KV and index write tools are not loaded. logging_config: Optional snapshot of the active logging configuration, populated by the server entrypoint after configuring its loggers. + audit_config: Optional snapshot of the active audit configuration, + populated by the server entrypoint after initialising the audit + sink. Reported by ``get_server_configuration_status`` so the tool + output and the audit file always agree on what is running. """ cluster_provider: ClusterProvider | None = None settings: Mapping[str, Any] = field(default_factory=dict) read_only_mode: bool = True logging_config: Mapping[str, Any] | None = None + audit_config: Mapping[str, Any] | None = None def get_cluster_provider(ctx: Context): @@ -48,6 +53,18 @@ def get_logging_config(ctx: Context) -> Mapping[str, Any] | None: return getattr(ctx.request_context.lifespan_context, "logging_config", None) # type: ignore +def get_audit_config(ctx: Context) -> Mapping[str, Any] | None: + """Return the audit-config snapshot attached to the lifespan context. + + Returns ``None`` when the server entrypoint doesn't populate the field — + including host servers whose lifespan context type doesn't carry an + ``audit_config`` attribute at all — so a host that has not adopted auditing + is indistinguishable from one running with it switched off, which is the + correct report in both cases. + """ + return getattr(ctx.request_context.lifespan_context, "audit_config", None) # type: ignore + + def get_cluster_connection(ctx: Context) -> Cluster: """Return the Couchbase cluster for this request via the provider.""" provider = get_cluster_provider(ctx) diff --git a/src/cb_mcp/utils/elicitation.py b/src/cb_mcp/utils/elicitation.py index 81696127..8c7d90bd 100644 --- a/src/cb_mcp/utils/elicitation.py +++ b/src/cb_mcp/utils/elicitation.py @@ -13,6 +13,8 @@ from mcp import types from pydantic import BaseModel, Field +from ..audit import state as audit_state +from ..audit.exceptions import ConfirmationDeclinedError from .constants import MCP_SERVER_NAME logger = logging.getLogger(f"{MCP_SERVER_NAME}.utils.elicitation") @@ -92,6 +94,12 @@ async def wrapper(*args, **kwargs): f"Client does not advertise elicitation support for '{tool_name}'; " "proceeding without confirmation" ) + # A confirmation-required tool is about to run *without* + # confirmation. Before auditing existed this bypass left no + # trace above DEBUG, which made it invisible in exactly the + # situation an auditor would care about. Record it so the + # middleware can emit the guardrail event. + audit_state.record_confirmation(audit_state.CONFIRMATION_SKIPPED) else: message = _build_confirmation_message(tool_name, call_arguments) result = await ctx.elicit( @@ -104,8 +112,17 @@ async def wrapper(*args, **kwargs): ): msg = f"Execution of '{tool_name}' was not confirmed by the user." logger.warning(msg) - raise PermissionError(msg) - + audit_state.record_confirmation(audit_state.CONFIRMATION_DECLINED) + audit_state.record_refusal( + event_id=ConfirmationDeclinedError.audit_event.id, + event_name=ConfirmationDeclinedError.audit_event.event_name, + outcome=ConfirmationDeclinedError.audit_outcome, + reason=ConfirmationDeclinedError.audit_reason, + confirmation="declined", + ) + raise ConfirmationDeclinedError(msg) + + audit_state.record_confirmation(audit_state.CONFIRMATION_ACCEPTED) logger.info(f"User confirmed execution of '{tool_name}'") # Keep wrapper behavior transparent: await async tools, call sync tools directly. diff --git a/src/cb_mcp/utils/scope_enforcement.py b/src/cb_mcp/utils/scope_enforcement.py index d1c1f9ab..b1cb6da9 100644 --- a/src/cb_mcp/utils/scope_enforcement.py +++ b/src/cb_mcp/utils/scope_enforcement.py @@ -22,6 +22,8 @@ from fastmcp.server.dependencies import get_access_token +from ..audit import state as audit_state +from ..audit.exceptions import ScopeDeniedError from .constants import MCP_SERVER_NAME, SCOPE_READ, SCOPE_WRITE logger = logging.getLogger(f"{MCP_SERVER_NAME}.utils.scope_enforcement") @@ -97,7 +99,17 @@ async def wrapper(*args, **kwargs): if hint: msg = f"{msg} {hint}" logger.warning(msg) - raise PermissionError(msg) + # Record the refusal for the audit middleware before raising. + # FastMCP wraps a tool's exception in ToolError, so the + # middleware cannot classify this from the exception it sees. + audit_state.record_refusal( + event_id=ScopeDeniedError.audit_event.id, + event_name=ScopeDeniedError.audit_event.event_name, + outcome=ScopeDeniedError.audit_outcome, + reason=ScopeDeniedError.audit_reason, + required_scope=sorted(required_scopes), + ) + raise ScopeDeniedError(msg) if inspect.iscoroutinefunction(fn): return await fn(*args, **kwargs) diff --git a/src/mcp_server.py b/src/mcp_server.py index 2b00a75c..b172d5d0 100644 --- a/src/mcp_server.py +++ b/src/mcp_server.py @@ -11,12 +11,26 @@ from fastmcp.tools import FunctionTool # Reusable tools and utilities from the cb_mcp package +from cb_mcp.audit import ( + AuditEvent, + AuditMiddleware, + get_audit_logger, + init_audit, + resolve_audit_config, + shutdown_audit, + unclassified_tool_names, + warn_on_unauthenticated_http, +) +from cb_mcp.audit.record import OUTCOME_SUCCESS from cb_mcp.auth import OAuthConfigError, resolve_oauth from cb_mcp.tool_registration import prepare_tools_for_registration from cb_mcp.tools import TOOL_ANNOTATIONS from cb_mcp.utils import ( ALLOWED_OAUTH_ALGORITHMS, ALLOWED_TRANSPORTS, + DEFAULT_AUDIT_ENABLED, + DEFAULT_AUDIT_FILE, + DEFAULT_AUDIT_TOOL_ARGS, DEFAULT_HOST, DEFAULT_LOG_BACKUP_COUNT, DEFAULT_LOG_FILE, @@ -48,6 +62,56 @@ logger = logging.getLogger(MCP_SERVER_NAME) +def _start_audit( + audit_config, + *, + transport: str, + oauth_enabled: bool, + read_only_mode: bool, + registered_tool_names: list[str], + disabled_tool_names: set[str], + confirmation_required_tool_names: set[str], +) -> None: + """Initialise auditing and write the two startup records. + + Kept out of the lifespan body so the entrypoint stays readable. Emits + nothing at all when auditing is disabled. + """ + audit = init_audit(audit_config) + if not audit.active: + return + + warn_on_unauthenticated_http(transport, oauth_enabled) + + unclassified = unclassified_tool_names(registered_tool_names) + if unclassified: + # Fail-closed classification keeps these audited, but an operator + # should not have to discover the gap by reading the audit file. + logger.warning( + "Audit: %d registered tool(s) have no classification entry and " + "will be recorded against a write event id: %s", + len(unclassified), + unclassified, + ) + + audit.emit_event(AuditEvent.SERVER_STARTED, outcome=OUTCOME_SUCCESS) + audit.emit_event( + AuditEvent.SERVER_CONFIGURATION, + outcome=OUTCOME_SUCCESS, + transport=transport, + read_only_mode=read_only_mode, + oauth_enabled=oauth_enabled, + # The withheld and disabled tool sets are recorded here rather than as + # per-call refusals: neither kind of tool is registered with FastMCP, so + # a client is never told they exist and there is no invocation to + # refuse. This record is what makes the enforced surface auditable. + registered_tools=sorted(registered_tool_names), + disabled_tools=sorted(disabled_tool_names), + confirmation_required_tools=sorted(confirmation_required_tool_names), + audit_config=audit_config.as_dict(), + ) + + @click.command(context_settings={"show_default": True}) @click.option( "--connection-string", @@ -251,6 +315,72 @@ help="Rotated backups kept for the DEBUG log file. Overrides " "--log-retention-backup-count for DEBUG; inherits it when unset.", ) +@click.option( + "--audit-log-enabled", + "audit_log_enabled", + envvar="CB_MCP_AUDIT_LOG_ENABLED", + type=bool, + default=DEFAULT_AUDIT_ENABLED, + help="Enable audit logging. Applies to both stdio and http transports; the " + "set of applicable events differs by transport (authorization events are " + "http-only). Requires --audit-file. Audit output is a separate sink from " + "the --log-* operational logs, with a stable schema and its own retention.", +) +@click.option( + "--audit-file", + "audit_file", + envvar="CB_MCP_AUDIT_FILE", + default=DEFAULT_AUDIT_FILE, + help="Path to the audit log file. Required when --audit-log-enabled is " + "true; if it is missing the server still starts, with auditing disabled and " + "an error recorded. Each server process writes its own file with the " + "process id inserted before the extension (audit.log -> audit.1234.log), " + "because a single stdio deployment runs one server process per client and " + "sharing one file between them would corrupt records.", +) +@click.option( + "--audit-rotation-max-size-mb", + "audit_rotation_max_size_mb", + envvar="CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB", + type=click.FloatRange(min=0), + default=None, + help="Maximum size in MB an audit file may reach before it rotates. " + "Default is 1 MB. 0 is invalid and falls back to the default with a " + "startup warning.", +) +@click.option( + "--audit-retention-backup-count", + "audit_retention_backup_count", + envvar="CB_MCP_AUDIT_RETENTION_BACKUP_COUNT", + type=click.IntRange(min=0), + default=None, + help="Number of rotated audit files retained, excluding the live file. " + "Default is 1000. Set to 0 to keep only the live file, which stays bounded " + "by the rotation size.", +) +@click.option( + "--audit-tool-args", + "audit_tool_args", + envvar="CB_MCP_AUDIT_TOOL_ARGS", + type=bool, + default=DEFAULT_AUDIT_TOOL_ARGS, + help="Record tool argument values in the audit log. Off by default: there " + "is no redaction capability in this release, so enabling this writes " + "arguments verbatim, including the full document bodies passed to " + "document-write tools. Review the audit file's retention and access " + "controls before enabling it.", +) +@click.option( + "--audit-disabled-events", + "audit_disabled_events", + envvar="CB_MCP_AUDIT_DISABLED_EVENTS", + default=None, + help="Audit events to suppress. Accepts comma-separated event ids or " + "names (e.g. '61490,document read'), or a file path with one entry per " + "line. Only filterable events can be suppressed: write and security events " + "are always recorded, and an attempt to disable one is refused with a " + "warning.", +) @click.option( "--oauth-jwks-uri", envvar="CB_MCP_OAUTH_JWT_JWKS_URI", @@ -329,6 +459,12 @@ def main( port, disabled_tools, confirmation_required_tools, + audit_log_enabled, + audit_file, + audit_rotation_max_size_mb, + audit_retention_backup_count, + audit_tool_args, + audit_disabled_events, oauth_jwks_uri, oauth_issuer, oauth_audience, @@ -392,6 +528,18 @@ def main( invalid_sinks=log_sinks.invalid_tokens, ) + # Resolved after configure_logging so the error for "audit enabled without + # a file" and the tool-args warning land in the operator's configured log + # sinks rather than on the last-resort handler. + audit_config = resolve_audit_config( + enabled=audit_log_enabled, + file=audit_file, + rotation_max_size_mb=audit_rotation_max_size_mb, + retention_backup_count=audit_retention_backup_count, + tool_args=audit_tool_args, + disabled_events=audit_disabled_events, + ) + try: auth = resolve_oauth( transport=transport, @@ -444,12 +592,27 @@ def main( "oauth_scope_write_label": oauth_scope_write, "disabled_tools": disabled_tool_names, "confirmation_required_tools": configured_confirmation_tool_names, + # Audit configuration as resolved, so get_server_configuration_status + # and the startup audit record report exactly the same thing. + "audit_config": audit_config.as_dict(), } ctx.obj = settings @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: """Build the lifespan AppContext with settings captured from the CLI.""" + # Audit is initialised first so the server-started record is the first + # line in the file and every later record shares its static context. + _start_audit( + audit_config, + transport=transport, + oauth_enabled=auth is not None, + read_only_mode=read_only_mode, + registered_tool_names=[tool.__name__ for tool in final_tools], + disabled_tool_names=disabled_tool_names, + confirmation_required_tool_names=configured_confirmation_tool_names, + ) + logger.info( f"MCP server initialized in lazy mode for tool discovery. " f"Modes: (read_only_mode={read_only_mode})" @@ -467,6 +630,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: settings=settings, read_only_mode=read_only_mode, logging_config=resolved_logging.as_dict() if resolved_logging else None, + audit_config=audit_config.as_dict(), ) try: yield app_context @@ -476,6 +640,15 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: finally: if app_context.cluster_provider: app_context.cluster_provider.close() + # Recorded before the sink closes so the stop record is the last + # line written. A process killed outright leaves no stop record; + # its absence means "not a clean shutdown", not "tampered with". + active_audit = get_audit_logger() + if active_audit.active: + active_audit.emit_event( + AuditEvent.SERVER_STOPPED, outcome=OUTCOME_SUCCESS + ) + shutdown_audit() logger.info("Closing MCP server") # Map user-friendly transport names to SDK transport names @@ -483,6 +656,15 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: mcp = FastMCP(MCP_SERVER_NAME, lifespan=app_lifespan, auth=auth) + # Registered unconditionally: the middleware short-circuits when auditing + # is inactive, so one registration path serves both modes. ``transport`` + # decides whether an unauthenticated caller is recorded as anonymous (http) + # or as the local process owner (stdio); ``cb_userid`` records the single + # cluster identity every caller collapses onto, which is the gap this audit + # log exists to close. + if audit_config.enabled: + mcp.add_middleware(AuditMiddleware(transport=transport, cb_userid=username)) + logger.info( f"Registering {len(final_tools)} tool(s) with modes (read_only_mode={read_only_mode})" ) diff --git a/tests/unit/test_audit_catalog.py b/tests/unit/test_audit_catalog.py new file mode 100644 index 00000000..7ce9f7f2 --- /dev/null +++ b/tests/unit/test_audit_catalog.py @@ -0,0 +1,307 @@ +"""Tests for the audit event catalogue and its shipped descriptor. + +The event ids are a wire contract: once a customer writes a SIEM rule against +``61522`` it cannot be changed. These tests pin every id from the PRD literally +rather than recomputing them from the same helpers the implementation uses, so +an accidental renumbering fails loudly instead of silently agreeing with itself. + +Coverage map: +- literal PRD ids for every core and tool-call event +- block layout arithmetic (read offset, write delta, category order) +- filterability policy: writes never filterable, reads filterable +- descriptor.json matches the in-code catalogue exactly +- events deliberately absent (disabled-tool, session-terminated) +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from cb_mcp.audit import catalog +from cb_mcp.audit.catalog import ( + CATEGORIES, + CORE_BLOCK, + OPERATIONAL_BLOCK, + READ_OFFSET, + WRITE_DELTA, + AuditEvent, + all_tool_call_events, + build_descriptor, + tool_call_event, + tool_call_event_id, +) + +DESCRIPTOR_PATH = Path(catalog.__file__).with_name("descriptor.json") + + +def test_block_bases_match_the_prd(): + assert CORE_BLOCK == 57344 # 0xE000 + assert OPERATIONAL_BLOCK == 61440 # 0xF000 + assert READ_OFFSET == 48 + assert WRITE_DELTA == 32 + + +def test_category_order_is_the_wire_contract(): + # The list index is the ``n`` in base+48+n, so reordering silently + # renumbers every tool-call event. + assert CATEGORIES == ( + "cluster", + "schema", + "kv", + "query", + "index", + "performance", + ) + + +@pytest.mark.parametrize( + ("event", "expected_id", "expected_name"), + [ + (AuditEvent.SERVER_STARTED, 57344, "server started"), + (AuditEvent.SERVER_STOPPED, 57345, "server stopped"), + (AuditEvent.SERVER_CONFIGURATION, 57346, "server configuration"), + (AuditEvent.SESSION_INITIALIZED, 57360, "session initialized"), + (AuditEvent.TOKEN_REJECTED, 57376, "token rejected"), + (AuditEvent.SCOPE_CHECK_DENIED, 57377, "scope check denied"), + ( + AuditEvent.WRITE_BLOCKED_READ_ONLY, + 57488, + "write blocked (read-only mode)", + ), + (AuditEvent.CONFIRMATION_DECLINED, 57490, "confirmation declined"), + (AuditEvent.CONFIRMATION_SKIPPED, 57491, "confirmation skipped"), + ], +) +def test_core_event_ids_are_the_prd_values(event, expected_id, expected_name): + assert event.id == expected_id + assert event.event_name == expected_name + assert event.description + + +@pytest.mark.parametrize( + ("category", "read_id", "write_id"), + [ + ("cluster", 61488, 61520), + ("schema", 61489, 61521), + ("kv", 61490, 61522), + ("query", 61491, 61523), + ("index", 61492, 61524), + ("performance", 61493, 61525), + ], +) +def test_tool_call_ids_are_the_prd_values(category, read_id, write_id): + assert tool_call_event_id(category, "read") == read_id + assert tool_call_event_id(category, "write") == write_id + assert write_id - read_id == WRITE_DELTA + + +def test_kv_events_use_the_document_label(): + # The PRD names the KV events "document read"/"document write" even though + # the category axis calls the category "kv". + assert tool_call_event("kv", "read").event_name == "document read" + assert tool_call_event("kv", "write").event_name == "document write" + + +def test_unknown_category_and_class_are_rejected(): + with pytest.raises(ValueError, match="Unknown audit category"): + tool_call_event_id("nonsense", "read") + with pytest.raises(ValueError, match="must be 'read' or 'write'"): + tool_call_event_id("kv", "sideways") + + +def test_writes_are_never_filterable_and_reads_always_are(): + for event in all_tool_call_events(): + assert event.filterable is (event.operation_class == "read") + + +def test_security_and_lifecycle_events_are_not_filterable(): + for event in AuditEvent: + if event is AuditEvent.SESSION_INITIALIZED: + assert event.filterable is True + else: + assert event.filterable is False, f"{event.event_name} must be mandated" + + +def test_ids_are_unique(): + ids = [event.id for event in AuditEvent] + [ + event.id for event in all_tool_call_events() + ] + assert len(ids) == len(set(ids)) + + +def test_removed_and_deferred_events_are_absent(): + """Two PRD draft entries are deliberately not implemented. + + 57489 (tool invocation blocked - disabled) was removed by product decision: + withheld tools are never registered, so the model is never told they exist + and there is no refusal to record. 57361 (session terminated) is on hold + because FastMCP exposes no session-end hook. + """ + ids = {event.id for event in AuditEvent} + assert 57489 not in ids + assert 57361 not in ids + + +def test_shipped_descriptor_matches_the_code(): + """The descriptor is the operator-facing contract; it must not drift. + + Regenerate with:: + + python -c "import json,sys; sys.path.insert(0,'src'); \ +from cb_mcp.audit.catalog import build_descriptor; \ +print(json.dumps(build_descriptor(), indent=2))" > src/cb_mcp/audit/descriptor.json + """ + assert DESCRIPTOR_PATH.exists(), "descriptor.json must ship with the package" + shipped = json.loads(DESCRIPTOR_PATH.read_text(encoding="utf-8")) + assert shipped == build_descriptor() + + +def test_descriptor_entries_are_well_formed(): + shipped = json.loads(DESCRIPTOR_PATH.read_text(encoding="utf-8")) + assert shipped["service_package"] == "operational" + assert shipped["blocks"] == {"core": 57344, "operational": 61440} + ids = [entry["id"] for entry in shipped["events"]] + assert ids == sorted(ids), "descriptor must be ordered by id" + for entry in shipped["events"]: + assert set(entry) == { + "id", + "name", + "description", + "category", + "default_enabled", + "filterable", + } + assert entry["default_enabled"] is True + assert entry["description"].strip() + + +# --------------------------------------------------------------------------- +# slot allocation +# --------------------------------------------------------------------------- + + +def test_category_slots_and_categories_cannot_disagree(): + """``CATEGORIES`` is derived from the slot table, ordered by slot.""" + assert ( + tuple( + name + for name, _ in sorted(catalog.CATEGORY_SLOTS.items(), key=lambda kv: kv[1]) + ) + == catalog.CATEGORIES + ) + + +def test_slot_numbers_are_unique_and_within_the_template(): + slots = list(catalog.CATEGORY_SLOTS.values()) + assert len(slots) == len(set(slots)), "two categories share a slot" + assert all(0 <= slot <= catalog.MAX_SLOT for slot in slots) + + +def test_slot_table_pins_the_shipped_numbers(): + """These numbers are a wire contract. Changing one renumbers a live event. + + Pinned literally so an edit to the table has to be a deliberate, visible + test change rather than something that quietly renumbers a customer's SIEM + rule. + """ + assert catalog.CATEGORY_SLOTS == { + "cluster": 0, + "schema": 1, + "kv": 2, + "query": 3, + "index": 4, + "performance": 5, + } + + +def test_retiring_a_category_does_not_move_its_neighbours(monkeypatch): + """The reason the slot table is explicit rather than positional. + + Under positional indexing, removing ``index`` would have shifted + ``performance`` from 61493 down to 61492 — silently renumbering a shipped + event that customers already have SIEM rules for. + """ + trimmed = { + name: slot for name, slot in catalog.CATEGORY_SLOTS.items() if name != "index" + } + monkeypatch.setattr(catalog, "CATEGORY_SLOTS", trimmed) + assert catalog.tool_call_event_id("performance", "read") == 61493 + assert catalog.tool_call_event_id("query", "read") == 61491 + + +def test_next_free_slot_takes_the_lowest_never_allocated(monkeypatch): + monkeypatch.setattr(catalog, "RETIRED_SLOTS", {}) + monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) + assert catalog.next_free_slot() == 6 + + +def test_next_free_slot_reuses_a_retired_slot_first(monkeypatch): + """Reuse is safe only for a slot that never emitted a record. + + A category retired with no tools classified to it produced no records, so + no SIEM rule can exist for its old meaning. + """ + monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"vector": 6}) + monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) + assert catalog.next_free_slot() == 6 + + +def test_permanently_reserved_slots_are_never_reallocated(monkeypatch): + """A slot that has emitted records must not be handed to a new category.""" + monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"vector": 6}) + monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset({6})) + assert catalog.next_free_slot() == 7 + + +def test_exhausted_block_is_an_explicit_error(monkeypatch): + monkeypatch.setattr( + catalog, "CATEGORY_SLOTS", {f"c{n}": n for n in range(catalog.MAX_SLOT + 1)} + ) + monkeypatch.setattr(catalog, "RETIRED_SLOTS", {}) + monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) + with pytest.raises(ValueError, match="request its own block"): + catalog.next_free_slot() + + +def test_retired_categories_are_gone_but_their_slots_are_recorded(): + """vector and analytics were retired before release. + + Neither ever had a tool classified to it, so neither slot ever emitted a + record — which is precisely the condition that makes a slot safe to hand to + a future category. The numbers stay recorded so the history is visible and + nobody wonders why the live table starts skipping at 6. + """ + assert "vector" not in catalog.CATEGORY_SLOTS + assert "analytics" not in catalog.CATEGORY_SLOTS + assert catalog.RETIRED_SLOTS == {"vector": 6, "analytics": 7} + # Never emitted a record, so never permanently reserved. + assert not catalog.PERMANENTLY_RESERVED_SLOTS + + for name in ("vector", "analytics"): + with pytest.raises(ValueError, match="Unknown audit category"): + catalog.tool_call_event_id(name, "read") + + +def test_retired_ids_are_absent_from_the_catalogue_and_descriptor(): + """61494/61495 and 61526/61527 must not resolve to anything.""" + retired = {61494, 61495, 61526, 61527} + live = {event.id for event in all_tool_call_events()} + assert live & retired == set() + + shipped = json.loads(DESCRIPTOR_PATH.read_text(encoding="utf-8")) + assert {entry["id"] for entry in shipped["events"]} & retired == set() + + +def test_retirement_did_not_move_the_surviving_ids(): + """The point of the explicit slot table, asserted on the real retirement.""" + assert tool_call_event_id("cluster", "read") == 61488 + assert tool_call_event_id("performance", "read") == 61493 + assert tool_call_event_id("performance", "write") == 61525 + + +def test_a_new_category_would_reuse_the_lowest_retired_slot(): + """Reuse is safe here because neither retired slot ever emitted a record.""" + assert catalog.next_free_slot() == 6 diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py new file mode 100644 index 00000000..db5d99a3 --- /dev/null +++ b/tests/unit/test_audit_classification.py @@ -0,0 +1,134 @@ +"""Tests for tool classification. + +The classification map is what keeps every shipped tool auditable, so the +central test asserts it covers the registered tool set exactly — no gaps and no +stale entries for tools that no longer exist. + +Coverage map: +- every registered tool is classified, and nothing extra is +- the repository's own write-tool lists agree with the classification +- read-only tools are classified read +- SQL++ statement override moves the event to the query write id +- unknown tools fail closed to a write id +- required_scope is derived from the operation class +- unclassified_tool_names reports gaps +""" + +from __future__ import annotations + +import pytest + +from cb_mcp.audit.catalog import CATEGORIES +from cb_mcp.audit.classification import ( + TOOL_CLASSIFICATION, + classify_tool, + is_classified, + required_scope_for, + resolve_tool_call_event, + unclassified_tool_names, +) +from cb_mcp.tools import ( + ALL_TOOLS, + COLLECTION_WRITE_TOOLS, + INDEX_WRITE_TOOLS, + KV_WRITE_TOOLS, + READ_ONLY_TOOLS, +) + +REGISTERED = {tool.__name__ for tool in ALL_TOOLS} +WRITE_TOOL_NAMES = { + tool.__name__ + for tool in KV_WRITE_TOOLS + COLLECTION_WRITE_TOOLS + INDEX_WRITE_TOOLS +} +READ_ONLY_NAMES = {tool.__name__ for tool in READ_ONLY_TOOLS} + + +def test_every_registered_tool_is_classified(): + assert unclassified_tool_names(REGISTERED) == [] + + +def test_no_stale_entries_for_tools_that_do_not_exist(): + assert sorted(set(TOOL_CLASSIFICATION) - REGISTERED) == [] + + +def test_classification_covers_the_registered_set_exactly(): + assert set(TOOL_CLASSIFICATION) == REGISTERED + + +@pytest.mark.parametrize("tool_name", sorted(WRITE_TOOL_NAMES)) +def test_repository_write_tools_are_classified_write(tool_name): + """The audit class must agree with the server's own categorisation.""" + _, operation_class = classify_tool(tool_name) + assert operation_class == "write", tool_name + assert resolve_tool_call_event(tool_name).filterable is False + + +@pytest.mark.parametrize("tool_name", sorted(READ_ONLY_NAMES)) +def test_read_only_tools_are_classified_read(tool_name): + _, operation_class = classify_tool(tool_name) + assert operation_class == "read", tool_name + + +@pytest.mark.parametrize( + ("tool_name", "expected_id"), + [ + ("get_document_by_id", 61490), + ("lookup_subdocument", 61490), + ("upsert_document_by_id", 61522), + ("mutate_subdocument", 61522), + ("delete_document_by_id", 61522), + ("get_cluster_diagnostics_report", 61488), + ("test_cluster_connection", 61488), + ("get_buckets_in_cluster", 61489), + ("get_schema_for_collection", 61489), + ("create_scope", 61521), + ("delete_collection", 61521), + ("explain_sql_plus_plus_query", 61491), + ("run_sql_plus_plus_query", 61491), + ("list_indexes", 61492), + ("create_index", 61524), + ("drop_index", 61524), + ("get_longest_running_queries", 61493), + ("get_queries_not_selective", 61493), + ], +) +def test_tool_maps_to_the_expected_event_id(tool_name, expected_id): + assert resolve_tool_call_event(tool_name).id == expected_id + + +def test_sqlpp_write_statement_moves_to_the_query_write_id(): + read_event = resolve_tool_call_event("run_sql_plus_plus_query") + write_event = resolve_tool_call_event( + "run_sql_plus_plus_query", operation_class_override="write" + ) + assert read_event.id == 61491 + assert write_event.id == 61523 + assert read_event.filterable is True + # A successful mutation must not be filed under a filterable read id. + assert write_event.filterable is False + + +def test_unknown_tool_fails_closed_to_a_non_filterable_write(): + assert is_classified("some_future_tool") is False + event = resolve_tool_call_event("some_future_tool") + assert event.operation_class == "write" + assert event.filterable is False + + +def test_unclassified_tool_names_reports_only_the_gaps(): + names = ["get_document_by_id", "brand_new_a", "brand_new_b"] + assert unclassified_tool_names(names) == ["brand_new_a", "brand_new_b"] + + +@pytest.mark.parametrize( + ("operation_class", "expected"), + [("read", "read"), ("write", "write")], +) +def test_required_scope_is_derived_from_the_operation_class(operation_class, expected): + assert required_scope_for(operation_class) == expected + + +def test_every_classification_uses_a_known_category_and_class(): + for tool_name, (category, operation_class) in TOOL_CLASSIFICATION.items(): + assert category in CATEGORIES, tool_name + assert operation_class in ("read", "write"), tool_name diff --git a/tests/unit/test_audit_config.py b/tests/unit/test_audit_config.py new file mode 100644 index 00000000..0e95da52 --- /dev/null +++ b/tests/unit/test_audit_config.py @@ -0,0 +1,219 @@ +"""Tests for audit configuration resolution. + +Coverage map: +- defaults when nothing is configured +- enabling without a file is reported and leaves auditing off, not fatal +- rotation size and backup count fallbacks on invalid input +- MB to bytes conversion +- disabled-events parsing: ids, names, files, comments, whitespace +- non-filterable events cannot be suppressed +- unknown events are ignored with a warning +- the tool-args warning fires only when it can matter +- the snapshot round-trips for the status tool +""" + +from __future__ import annotations + +import logging + +import pytest + +from cb_mcp.audit.config import ( + ResolvedAuditConfig, + parse_disabled_events, + resolve_audit_config, +) +from cb_mcp.utils.constants import BYTES_PER_MB + + +def _resolve(**overrides) -> ResolvedAuditConfig: + options = { + "enabled": None, + "file": None, + "rotation_max_size_mb": None, + "retention_backup_count": None, + "tool_args": None, + "disabled_events": None, + } + options.update(overrides) + return resolve_audit_config(**options) + + +# --------------------------------------------------------------------------- +# defaults +# --------------------------------------------------------------------------- + + +def test_defaults_are_off_and_match_the_prd(): + config = _resolve() + assert config.enabled is False + assert config.file is None + assert config.process_file is None + assert config.rotation_max_size_mb == 1.0 + assert config.retention_backup_count == 1000 + assert config.tool_args is False + assert config.disabled_events == () + + +def test_enabled_with_a_file_resolves_the_process_scoped_path(tmp_path): + config = _resolve(enabled=True, file=str(tmp_path / "audit.log")) + assert config.enabled is True + assert config.process_file is not None + assert config.process_file.endswith(".log") + assert "audit." in config.process_file + + +def test_mb_is_converted_to_bytes(tmp_path): + config = _resolve( + enabled=True, file=str(tmp_path / "a.log"), rotation_max_size_mb=2.5 + ) + assert config.max_bytes == int(2.5 * BYTES_PER_MB) + + +# --------------------------------------------------------------------------- +# error and fallback handling +# --------------------------------------------------------------------------- + + +def test_enabled_without_a_file_is_reported_and_disables_auditing(caplog): + with caplog.at_level(logging.ERROR): + config = _resolve(enabled=True, file=None) + # The PRD is explicit: the server must still start. + assert config.enabled is False + assert config.process_file is None + assert "no audit file path is configured" in caplog.text + + +def test_blank_file_is_treated_as_absent(caplog): + with caplog.at_level(logging.ERROR): + config = _resolve(enabled=True, file=" ") + assert config.enabled is False + assert config.file is None + + +@pytest.mark.parametrize("bad_size", [0, 0.0, -5]) +def test_invalid_rotation_size_falls_back_with_a_warning(tmp_path, caplog, bad_size): + with caplog.at_level(logging.WARNING): + config = _resolve( + enabled=True, + file=str(tmp_path / "a.log"), + rotation_max_size_mb=bad_size, + ) + assert config.rotation_max_size_mb == 1.0 + assert "falling back to the default" in caplog.text + + +def test_negative_backup_count_falls_back_with_a_warning(tmp_path, caplog): + with caplog.at_level(logging.WARNING): + config = _resolve( + enabled=True, file=str(tmp_path / "a.log"), retention_backup_count=-3 + ) + assert config.retention_backup_count == 1000 + assert "retention backup count" in caplog.text + + +def test_zero_backup_count_is_honoured(tmp_path): + config = _resolve( + enabled=True, file=str(tmp_path / "a.log"), retention_backup_count=0 + ) + assert config.retention_backup_count == 0 + + +def test_tool_args_warns_only_when_auditing_is_active(tmp_path, caplog): + with caplog.at_level(logging.WARNING): + _resolve(enabled=True, file=str(tmp_path / "a.log"), tool_args=True) + assert "recorded verbatim" in caplog.text + + caplog.clear() + with caplog.at_level(logging.WARNING): + _resolve(enabled=False, tool_args=True) + # Nothing is being written, so there is nothing to warn about. + assert "recorded verbatim" not in caplog.text + + +# --------------------------------------------------------------------------- +# disabled events +# --------------------------------------------------------------------------- + + +def test_disabled_events_accepts_ids_and_names(): + assert parse_disabled_events("61490") == {61490} + assert parse_disabled_events("document read") == {61490} + assert parse_disabled_events("61490, query read") == {61490, 61491} + + +@pytest.mark.parametrize("raw", [None, "", " ", ","]) +def test_empty_disabled_events_is_empty(raw): + assert parse_disabled_events(raw) == set() + + +def test_disabled_events_from_a_file_ignores_comments_and_blanks(tmp_path): + path = tmp_path / "filters.txt" + path.write_text( + "\n".join( + [ + "# suppress the noisy reads", + "document read", + "", + " query read ", + "# trailing comment", + ] + ), + encoding="utf-8", + ) + assert parse_disabled_events(str(path)) == {61490, 61491} + + +def test_non_filterable_events_cannot_be_suppressed(caplog): + with caplog.at_level(logging.WARNING): + # A write id, a security event and a lifecycle event. + result = parse_disabled_events("61522, scope check denied, server started") + assert result == set() + assert "Refused to disable non-filterable" in caplog.text + + +def test_unknown_events_are_ignored_with_a_warning(caplog): + with caplog.at_level(logging.WARNING): + result = parse_disabled_events("99999, not an event, document read") + assert result == {61490} + assert "unknown audit event" in caplog.text.lower() + + +def test_filters_are_not_parsed_when_auditing_is_disabled(): + config = _resolve(enabled=False, disabled_events="document read") + assert config.disabled_events == () + + +def test_session_initialized_is_filterable(tmp_path): + config = _resolve( + enabled=True, + file=str(tmp_path / "a.log"), + disabled_events="session initialized", + ) + assert config.disabled_events == (57360,) + + +# --------------------------------------------------------------------------- +# snapshot +# --------------------------------------------------------------------------- + + +def test_as_dict_is_json_friendly_and_complete(tmp_path): + config = _resolve( + enabled=True, + file=str(tmp_path / "audit.log"), + rotation_max_size_mb=4, + retention_backup_count=7, + tool_args=True, + disabled_events="document read", + ) + snapshot = config.as_dict() + assert snapshot == { + "enabled": True, + "file": str(tmp_path / "audit.log"), + "process_file": config.process_file, + "rotation_max_size_mb": 4.0, + "retention_backup_count": 7, + "tool_args": True, + "disabled_events": [61490], + } diff --git a/tests/unit/test_audit_integration_points.py b/tests/unit/test_audit_integration_points.py new file mode 100644 index 00000000..2e612260 --- /dev/null +++ b/tests/unit/test_audit_integration_points.py @@ -0,0 +1,457 @@ +"""Tests for the audit hooks added to existing gates. + +Auditing required narrowing three exception types that already existed. Every +new class subclasses the type the call site raised before, so existing callers +and tests are unaffected — these tests assert that backwards compatibility +explicitly, because it is the whole reason the refactor was safe. + +They also assert the other half of the contract: each gate records a structured +refusal into the per-request audit state *before* raising, since FastMCP wraps +the exception and the middleware could not otherwise tell a scope denial from a +read-only block from a genuine error. + +Coverage map: +- exception subclassing preserves PermissionError / ValueError contracts +- scope check records a refusal and raises ScopeDeniedError +- confirmation decline records a refusal and raises ConfirmationDeclinedError +- confirmation skip records the skipped disposition +- confirmation accept records the accepted disposition +- read-only SQL++ block records a refusal and raises ReadOnlyWriteBlockedError +- SQL++ statement class is recorded for audit when auditing is active +- SQL++ classification is not computed when auditing is off (no added cost) +- identity resolution across the three domains +""" + +from __future__ import annotations + +import logging +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from fastmcp import Context + +from cb_mcp.audit import state as audit_state +from cb_mcp.audit.exceptions import ( + ConfirmationDeclinedError, + ReadOnlyWriteBlockedError, + ScopeDeniedError, + StatementScopeDeniedError, +) +from cb_mcp.audit.identity import ( + DOMAIN_ANONYMOUS, + DOMAIN_LOCAL, + DOMAIN_OAUTH, + resolve_real_userid, + warn_on_unauthenticated_http, +) +from cb_mcp.tools.query import _query_correlation_options, run_sql_plus_plus_query +from cb_mcp.utils.constants import SCOPE_READ, SCOPE_WRITE +from cb_mcp.utils.elicitation import ConfirmationResult, wrap_with_confirmation +from cb_mcp.utils.scope_enforcement import wrap_with_scope_check + +# --------------------------------------------------------------------------- +# backwards compatibility of the narrowed exception types +# --------------------------------------------------------------------------- + + +def test_exceptions_preserve_their_original_base_types(): + """Existing ``pytest.raises`` assertions must keep passing unchanged.""" + assert issubclass(ScopeDeniedError, PermissionError) + assert issubclass(StatementScopeDeniedError, PermissionError) + assert issubclass(ConfirmationDeclinedError, PermissionError) + assert issubclass(ReadOnlyWriteBlockedError, ValueError) + + +@pytest.mark.parametrize( + "exception_class", + [ + ScopeDeniedError, + StatementScopeDeniedError, + ConfirmationDeclinedError, + ReadOnlyWriteBlockedError, + ], +) +def test_exceptions_carry_their_audit_classification(exception_class): + assert exception_class.audit_event.id + assert exception_class.audit_outcome in ("denied", "blocked") + assert exception_class.audit_reason + + +# --------------------------------------------------------------------------- +# scope enforcement +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_scope_check_records_a_refusal_and_raises_scope_denied(): + async def tool(ctx=None): + return "should not run" + + tool.__name__ = "upsert_document_by_id" + wrapped = wrap_with_scope_check(tool, {SCOPE_WRITE}) + token = audit_state.install() + try: + with ( + patch( + "cb_mcp.utils.scope_enforcement.get_access_token", + return_value=SimpleNamespace(scopes=[SCOPE_READ]), + ), + pytest.raises(ScopeDeniedError), + ): + await wrapped() + refusal = audit_state.get_refusal() + assert refusal is not None + assert refusal["event_id"] == 57377 + assert refusal["outcome"] == "denied" + assert refusal["reason"] == "missing_scope" + assert refusal["required_scope"] == [SCOPE_WRITE] + finally: + audit_state.reset(token) + + +@pytest.mark.asyncio +async def test_scope_check_records_nothing_when_the_token_is_sufficient(): + async def tool(ctx=None): + return "ran" + + tool.__name__ = "upsert_document_by_id" + wrapped = wrap_with_scope_check(tool, {SCOPE_WRITE}) + token = audit_state.install() + try: + with patch( + "cb_mcp.utils.scope_enforcement.get_access_token", + return_value=SimpleNamespace(scopes=[SCOPE_WRITE]), + ): + assert await wrapped() == "ran" + assert audit_state.get_refusal() is None + finally: + audit_state.reset(token) + + +# --------------------------------------------------------------------------- +# confirmation +# --------------------------------------------------------------------------- + + +def _ctx_with_elicitation( + *, supported: bool, action: str = "accept", confirm: bool = True +) -> Context: + async def elicit(message, schema): + return SimpleNamespace(action=action, data=ConfirmationResult(confirm=confirm)) + + session = SimpleNamespace(check_client_capability=lambda _caps: supported) + return SimpleNamespace( # type: ignore[return-value] + request_context=SimpleNamespace(session=session), elicit=elicit + ) + + +@pytest.mark.asyncio +async def test_declined_confirmation_records_a_refusal(): + async def tool(ctx=None): + return "should not run" + + tool.__name__ = "delete_document_by_id" + wrapped = wrap_with_confirmation(tool) + token = audit_state.install() + try: + with pytest.raises(ConfirmationDeclinedError): + await wrapped(ctx=_ctx_with_elicitation(supported=True, action="decline")) + refusal = audit_state.get_refusal() + assert refusal["event_id"] == 57490 + assert refusal["outcome"] == "blocked" + assert refusal["reason"] == "confirmation_declined" + assert audit_state.get_confirmation() == "declined" + finally: + audit_state.reset(token) + + +@pytest.mark.asyncio +async def test_skipped_confirmation_is_recorded_and_the_tool_still_runs(): + """The pre-audit behaviour left this bypass invisible above DEBUG.""" + + async def tool(ctx=None): + return "ran without confirmation" + + tool.__name__ = "delete_document_by_id" + wrapped = wrap_with_confirmation(tool) + token = audit_state.install() + try: + result = await wrapped(ctx=_ctx_with_elicitation(supported=False)) + assert result == "ran without confirmation" + assert audit_state.get_confirmation() == "skipped" + # Not a refusal: the call proceeded. + assert audit_state.get_refusal() is None + finally: + audit_state.reset(token) + + +@pytest.mark.asyncio +async def test_accepted_confirmation_is_recorded(): + async def tool(ctx=None): + return "ran" + + tool.__name__ = "delete_document_by_id" + wrapped = wrap_with_confirmation(tool) + token = audit_state.install() + try: + assert await wrapped(ctx=_ctx_with_elicitation(supported=True)) == "ran" + assert audit_state.get_confirmation() == "accepted" + finally: + audit_state.reset(token) + + +# --------------------------------------------------------------------------- +# SQL++ read-only guard and statement classification +# --------------------------------------------------------------------------- + + +def _query_ctx(*, read_only_mode: bool): + scope = SimpleNamespace(query=lambda *a, **k: iter(())) + bucket = SimpleNamespace(scope=lambda _name: scope) + cluster = SimpleNamespace(bucket=lambda _name: bucket) + ctx = SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=SimpleNamespace( + cluster_provider=SimpleNamespace(get_cluster=lambda _ctx: cluster), + read_only_mode=read_only_mode, + ) + ) + ) + return ctx + + +def test_read_only_block_records_a_refusal_and_raises_a_value_error(): + ctx = _query_ctx(read_only_mode=True) + token = audit_state.install() + try: + # Still a ValueError, so pre-existing callers are unaffected. + with pytest.raises(ValueError, match="not allowed in read-only mode"): + run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") + refusal = audit_state.get_refusal() + assert refusal["event_id"] == 57488 + assert refusal["outcome"] == "blocked" + assert refusal["reason"] == "read_only_mode" + assert refusal["statement_kind"] == "data" + finally: + audit_state.reset(token) + + +def test_statement_class_is_recorded_when_auditing_is_active(): + """An *allowed* DML must still be classified, or it would be filed as a read.""" + ctx = _query_ctx(read_only_mode=False) + token = audit_state.install() + try: + with patch( + "cb_mcp.tools.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ): + run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") + assert audit_state.get_statement_class() == "write" + finally: + audit_state.reset(token) + + +def test_select_is_classified_read_when_auditing_is_active(): + ctx = _query_ctx(read_only_mode=False) + token = audit_state.install() + try: + with patch( + "cb_mcp.tools.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ): + run_sql_plus_plus_query(ctx, "b", "s", "SELECT * FROM users") + assert audit_state.get_statement_class() == "read" + finally: + audit_state.reset(token) + + +def test_explain_is_classified_read_without_parsing(): + ctx = _query_ctx(read_only_mode=True) + token = audit_state.install() + try: + with patch( + "cb_mcp.tools.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ): + run_sql_plus_plus_query(ctx, "b", "s", "EXPLAIN SELECT * FROM users") + assert audit_state.get_statement_class() == "read" + finally: + audit_state.reset(token) + + +def test_no_statement_parse_when_auditing_is_off_and_writes_are_allowed(): + """Auditing off must not add cost to the busiest tool in the server.""" + ctx = _query_ctx(read_only_mode=False) + token = audit_state.install() + try: + with patch("cb_mcp.tools.query.parse_sqlpp") as parse: + with patch( + "cb_mcp.tools.query.get_audit_logger", + return_value=SimpleNamespace(active=False), + ): + run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") + parse.assert_not_called() + assert audit_state.get_statement_class() is None + finally: + audit_state.reset(token) + + +def test_unparseable_statement_still_runs_when_writes_are_allowed_and_auditing_on(): + """Enabling auditing must not fail a call the server would otherwise run. + + lark_sqlpp is a partial grammar: valid statements it cannot parse (MERGE, a + leading block comment, newer syntax) raise from ``parse_sqlpp``. With writes + allowed the parse is only for audit classification, so a parse failure must + degrade to the fail-closed 'write' class and let the query execute — not + surface as a tool-call failure that appears only when auditing is on. + """ + ctx = _query_ctx(read_only_mode=False) + token = audit_state.install() + try: + with ( + patch( + "cb_mcp.tools.query.parse_sqlpp", + side_effect=Exception("unparseable by the partial grammar"), + ), + patch( + "cb_mcp.tools.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ), + ): + result = run_sql_plus_plus_query( + ctx, "b", "s", "MERGE INTO a USING b ON a.id = b.id" + ) + # Executed rather than raised, and booked against the never-filtered + # query write id so a possible mutation is never dropped from the log. + assert result == [] + assert audit_state.get_statement_class() == "write" + finally: + audit_state.reset(token) + + +def test_unparseable_statement_still_fails_safe_when_writes_are_blocked(): + """The security write guard must keep failing closed on an unparseable + statement: without a definitive read/write verdict it cannot prove the + statement is a read, so it must refuse rather than execute. This is the + pre-audit behaviour and must be preserved exactly.""" + ctx = _query_ctx(read_only_mode=True) + token = audit_state.install() + try: + with ( + patch( + "cb_mcp.tools.query.parse_sqlpp", + side_effect=Exception("unparseable by the partial grammar"), + ), + patch( + "cb_mcp.tools.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ), + pytest.raises(Exception, match="unparseable"), + ): + run_sql_plus_plus_query( + ctx, "b", "s", "MERGE INTO a USING b ON a.id = b.id" + ) + finally: + audit_state.reset(token) + + +# --------------------------------------------------------------------------- +# identity +# --------------------------------------------------------------------------- + + +def test_stdio_resolves_to_the_local_process_owner(): + with patch("cb_mcp.audit.identity.get_access_token", return_value=None): + identity = resolve_real_userid("stdio") + assert identity["domain"] == DOMAIN_LOCAL + assert identity["user"] + + +def test_http_without_a_token_is_anonymous(): + with patch("cb_mcp.audit.identity.get_access_token", return_value=None): + identity = resolve_real_userid("http") + assert identity == {"domain": DOMAIN_ANONYMOUS, "user": DOMAIN_ANONYMOUS} + + +def test_oauth_prefers_the_token_subject(): + token = SimpleNamespace( + subject="agent-svc-7", + claims={"sub": "ignored"}, + client_id="client-1", + scopes=[], + ) + with patch("cb_mcp.audit.identity.get_access_token", return_value=token): + identity = resolve_real_userid("http") + assert identity == {"domain": DOMAIN_OAUTH, "user": "agent-svc-7"} + + +def test_oauth_falls_back_to_the_sub_claim_then_client_id(): + sub_only = SimpleNamespace( + subject=None, claims={"sub": "from-claim"}, client_id="client-1", scopes=[] + ) + with patch("cb_mcp.audit.identity.get_access_token", return_value=sub_only): + assert resolve_real_userid("http")["user"] == "from-claim" + + client_only = SimpleNamespace( + subject=None, claims={}, client_id="client-1", scopes=[] + ) + with patch("cb_mcp.audit.identity.get_access_token", return_value=client_only): + assert resolve_real_userid("http")["user"] == "client-1" + + +def test_token_with_no_usable_identity_is_not_reported_as_oauth(): + """Recording an empty subject under the oauth domain would be misleading.""" + empty = SimpleNamespace(subject=None, claims={}, client_id=None, scopes=[]) + with patch("cb_mcp.audit.identity.get_access_token", return_value=empty): + assert resolve_real_userid("http")["domain"] == DOMAIN_ANONYMOUS + + +def test_unauthenticated_http_warns_at_startup(caplog): + with caplog.at_level(logging.WARNING): + warn_on_unauthenticated_http("http", oauth_enabled=False) + assert "anonymous" in caplog.text + + caplog.clear() + with caplog.at_level(logging.WARNING): + warn_on_unauthenticated_http("http", oauth_enabled=True) + warn_on_unauthenticated_http("stdio", oauth_enabled=False) + assert caplog.text == "" + + +# --------------------------------------------------------------------------- +# SQL++ correlation id propagation +# --------------------------------------------------------------------------- + + +def test_query_carries_no_correlation_id_when_auditing_is_off(): + """An unaudited query must be byte-for-byte the request it was before. + + The options dict is spread into the SDK call, so an empty dict means the + call site is unchanged from the pre-audit behaviour. + """ + assert audit_state.current() is None + assert _query_correlation_options() == {} + + +def test_query_carries_the_requests_correlation_id_when_auditing_is_on(): + """The id we stamp on the audit record is the one Server will see. + + Couchbase Server's SQL++ audit record carries ``clientContextId``, taken + from this parameter. Sending the same ``cid`` is what makes an MCP record + and a Server record joinable by equality rather than by timestamp. + """ + token = audit_state.install(cid="cid-abc-123") + try: + assert _query_correlation_options() == {"client_context_id": "cid-abc-123"} + finally: + audit_state.reset(token) + + +def test_query_correlation_is_omitted_when_state_carries_no_cid(): + """State can exist without a cid — a hook invoked outside on_message.""" + token = audit_state.install() + try: + assert _query_correlation_options() == {} + finally: + audit_state.reset(token) diff --git a/tests/unit/test_audit_middleware.py b/tests/unit/test_audit_middleware.py new file mode 100644 index 00000000..9a49407c --- /dev/null +++ b/tests/unit/test_audit_middleware.py @@ -0,0 +1,711 @@ +"""End-to-end tests for the audit middleware. + +These run a real ``FastMCP`` server with the middleware installed and a real +:class:`AuditSink` writing to a temporary file, then read the file back. Nothing +is mocked between the tool call and the record on disk, because the behaviours +that matter here are exactly the ones a mock would paper over: + +* FastMCP wraps a tool's exception in ``ToolError``, so a refusal has to travel + to the middleware through the per-request audit state rather than through the + exception. +* Synchronous tools run on a worker thread, so that state has to be a mutable + object shared across a copied context rather than a rebound contextvar. + +Coverage map: +- session-initialized record from the initialize handshake +- tool-call read and write records, ids and required_scope +- keyspace extraction from tool arguments +- success, execution error and result-reported error outcomes +- scope denial recorded as a core authorization event, with no tool-call event +- read-only SQL++ block recorded as a guardrail event +- confirmation declined and confirmation skipped +- SQL++ statement-driven read/write classification +- argument capture on and off +- per-event filtering +- correlation: one ``cid`` per request, readable from tool code, over the + in-memory and real HTTP transports, and no session identifier anywhere +""" + +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass, field +from pathlib import Path + +import pytest +import uvicorn +from fastmcp import Client, Context, FastMCP +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.exceptions import ToolError + +from cb_mcp.audit import state as audit_state +from cb_mcp.audit.catalog import AuditEvent +from cb_mcp.audit.config import resolve_audit_config +from cb_mcp.audit.emitter import AuditLogger +from cb_mcp.audit.exceptions import ReadOnlyWriteBlockedError, ScopeDeniedError +from cb_mcp.audit.middleware import AuditMiddleware, extract_keyspace +from cb_mcp.audit.sink import AuditSink + + +def _build_logger(tmp_path: Path, **overrides) -> AuditLogger: + """An active AuditLogger writing to a real file under ``tmp_path``.""" + options = { + "enabled": True, + "file": str(tmp_path / "audit.log"), + "rotation_max_size_mb": 8.0, + "retention_backup_count": 2, + "tool_args": False, + "disabled_events": None, + } + options.update(overrides) + config = resolve_audit_config(**options) + sink = AuditSink( + config.file, + max_bytes=config.max_bytes, + backup_count=config.retention_backup_count, + ) + sink.start() + return AuditLogger(config, sink) + + +def _records(audit: AuditLogger) -> list[dict]: + """Close the sink so the writer thread drains, then parse every line.""" + path = audit._sink.path + audit.close() + if not path.exists(): + return [] + return [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + +def _server(audit: AuditLogger, transport: str = "stdio") -> FastMCP: + mcp = FastMCP("audit-test") + mcp.add_middleware( + AuditMiddleware( + transport=transport, cb_userid="mcp_service", audit_logger=audit + ) + ) + return mcp + + +# --------------------------------------------------------------------------- +# keyspace extraction +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("arguments", "expected"), + [ + (None, None), + ({}, None), + ({"limit": 5}, None), + ({"bucket_name": "travel-sample"}, "travel-sample"), + ({"bucket_name": "b", "scope_name": "s"}, "b.s"), + ({"bucket_name": "b", "scope_name": "s", "collection_name": "c"}, "b.s.c"), + # A gap truncates rather than producing a misleading dotted name. + ({"bucket_name": "b", "collection_name": "c"}, "b"), + ({"scope_name": "s", "collection_name": "c"}, None), + ({"bucket_name": "", "scope_name": "s"}, None), + ], +) +def test_extract_keyspace(arguments, expected): + assert extract_keyspace(arguments) == expected + + +# --------------------------------------------------------------------------- +# session + tool calls +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_session_initialized_is_recorded(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + async with Client(mcp): + pass + + records = _records(audit) + sessions = [r for r in records if r["id"] == AuditEvent.SESSION_INITIALIZED.id] + assert len(sessions) == 1 + record = sessions[0] + assert record["name"] == "session initialized" + assert record["outcome"] == "success" + assert record["client_name"] + assert record["protocol_version"] + assert record["real_userid"]["domain"] == "local" + assert record["cid"] + + +@pytest.mark.asyncio +async def test_read_tool_call_record(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id( + ctx: Context, bucket_name: str, scope_name: str, collection_name: str + ) -> str: + """read a document""" + return "doc" + + async with Client(mcp) as client: + await client.call_tool( + "get_document_by_id", + { + "bucket_name": "travel-sample", + "scope_name": "inventory", + "collection_name": "airline", + }, + ) + + calls = [r for r in _records(audit) if r["id"] == 61490] + assert len(calls) == 1 + record = calls[0] + assert record["name"] == "document read" + assert record["outcome"] == "success" + assert record["tool_name"] == "get_document_by_id" + assert record["required_scope"] == "read" + assert record["ks"] == "travel-sample.inventory.airline" + assert record["cb_userid"] == "mcp_service" + assert "reason" not in record + # Arguments are not captured unless explicitly enabled. + assert "args" not in record + + +@pytest.mark.asyncio +async def test_write_tool_call_record_is_a_write_id(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def upsert_document_by_id( + ctx: Context, bucket_name: str, scope_name: str, collection_name: str + ) -> str: + """write a document""" + return "ok" + + async with Client(mcp) as client: + await client.call_tool( + "upsert_document_by_id", + {"bucket_name": "b", "scope_name": "s", "collection_name": "c"}, + ) + + calls = [ + r for r in _records(audit) if r.get("tool_name") == "upsert_document_by_id" + ] + assert [r["id"] for r in calls] == [61522] + assert calls[0]["required_scope"] == "write" + + +@pytest.mark.asyncio +async def test_execution_error_outcome(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """raises""" + raise RuntimeError("cluster unreachable") + + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + + calls = [r for r in _records(audit) if r["id"] == 61490] + assert len(calls) == 1 + assert calls[0]["outcome"] == "error" + assert calls[0]["reason"] == "execution_error" + + +@pytest.mark.asyncio +async def test_argument_capture_when_enabled(tmp_path): + audit = _build_logger(tmp_path, tool_args=True) + mcp = _server(audit) + + @mcp.tool + def upsert_document_by_id( + ctx: Context, bucket_name: str, document_content: dict + ) -> str: + """write""" + return "ok" + + async with Client(mcp) as client: + await client.call_tool( + "upsert_document_by_id", + {"bucket_name": "b", "document_content": {"secret": "value"}}, + ) + + calls = [ + r for r in _records(audit) if r.get("tool_name") == "upsert_document_by_id" + ] + # No redaction exists in this release: the body is recorded verbatim. This + # asserts the documented behaviour so a future redaction change is a + # deliberate, visible test update rather than a silent one. + assert calls[0]["args"]["document_content"] == {"secret": "value"} + + +@pytest.mark.asyncio +async def test_unclassified_tool_fails_closed_to_a_write_id(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def brand_new_tool(ctx: Context) -> str: + """not in the classification map""" + return "ok" + + async with Client(mcp) as client: + await client.call_tool("brand_new_tool", {}) + + calls = [r for r in _records(audit) if r.get("tool_name") == "brand_new_tool"] + assert calls[0]["required_scope"] == "write" + assert calls[0]["unclassified"] is True + + +# --------------------------------------------------------------------------- +# refusals +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_scope_denial_emits_authz_event_and_no_tool_call(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def upsert_document_by_id(ctx: Context, bucket_name: str) -> str: + """denied before it runs""" + audit_state.record_refusal( + event_id=ScopeDeniedError.audit_event.id, + event_name=ScopeDeniedError.audit_event.event_name, + outcome=ScopeDeniedError.audit_outcome, + reason=ScopeDeniedError.audit_reason, + required_scope=["couchbase-mcp:write"], + ) + raise ScopeDeniedError("missing scope") + + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("upsert_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + authz = [r for r in records if r["id"] == AuditEvent.SCOPE_CHECK_DENIED.id] + assert len(authz) == 1 + assert authz[0]["outcome"] == "denied" + assert authz[0]["reason"] == "missing_scope" + assert authz[0]["tool_name"] == "upsert_document_by_id" + # The tool never ran, so there is no tool-call event to double-count it. + assert not [r for r in records if r["id"] == 61522] + + +@pytest.mark.asyncio +async def test_read_only_block_emits_guardrail_event(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def run_sql_plus_plus_query(ctx: Context, bucket_name: str, query: str) -> str: + """blocked by read-only mode""" + audit_state.record_statement_class("write") + audit_state.record_refusal( + event_id=ReadOnlyWriteBlockedError.audit_event.id, + event_name=ReadOnlyWriteBlockedError.audit_event.event_name, + outcome=ReadOnlyWriteBlockedError.audit_outcome, + reason=ReadOnlyWriteBlockedError.audit_reason, + statement_kind="data", + ) + raise ReadOnlyWriteBlockedError("not allowed in read-only mode") + + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool( + "run_sql_plus_plus_query", {"bucket_name": "b", "query": "DELETE ..."} + ) + + records = _records(audit) + guardrail = [r for r in records if r["id"] == AuditEvent.WRITE_BLOCKED_READ_ONLY.id] + assert len(guardrail) == 1 + assert guardrail[0]["outcome"] == "blocked" + assert guardrail[0]["reason"] == "read_only_mode" + assert guardrail[0]["statement_kind"] == "data" + + +@pytest.mark.asyncio +async def test_confirmation_skipped_emits_guardrail_and_tool_call(tmp_path): + """The tool *did* run, so both records are produced and they share a cid.""" + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def delete_document_by_id(ctx: Context, bucket_name: str) -> str: + """runs without confirmation because the client cannot elicit""" + audit_state.record_confirmation(audit_state.CONFIRMATION_SKIPPED) + return "deleted" + + async with Client(mcp) as client: + await client.call_tool("delete_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + skipped = [r for r in records if r["id"] == AuditEvent.CONFIRMATION_SKIPPED.id] + calls = [r for r in records if r["id"] == 61522] + assert len(skipped) == 1 + assert len(calls) == 1 + assert skipped[0]["reason"] == "confirmation_unsupported" + assert calls[0]["confirmation"] == "skipped" + assert calls[0]["outcome"] == "success" + # Same request, so an investigator can join the two records. + assert skipped[0]["cid"] == calls[0]["cid"] + + +@pytest.mark.asyncio +async def test_sqlpp_write_statement_uses_the_query_write_id(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def run_sql_plus_plus_query(ctx: Context, bucket_name: str, query: str) -> str: + """an allowed DML statement""" + audit_state.record_statement_class("write") + return "ok" + + async with Client(mcp) as client: + await client.call_tool( + "run_sql_plus_plus_query", {"bucket_name": "b", "query": "UPDATE ..."} + ) + + calls = [ + r for r in _records(audit) if r.get("tool_name") == "run_sql_plus_plus_query" + ] + # 61523, not the 61491 read id: a successful mutation must not be filed as + # a read, because read ids are filterable and could be suppressed. + assert [r["id"] for r in calls] == [61523] + assert calls[0]["required_scope"] == "write" + + +@pytest.mark.asyncio +async def test_disabled_event_is_not_written(tmp_path): + audit = _build_logger(tmp_path, disabled_events="document read") + mcp = _server(audit) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """filtered out""" + return "doc" + + async with Client(mcp) as client: + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + + assert not [r for r in _records(audit) if r["id"] == 61490] + + +@pytest.mark.asyncio +async def test_inactive_logger_writes_nothing_and_does_not_break_calls(tmp_path): + """With auditing off the middleware must be transparent.""" + config = resolve_audit_config( + enabled=False, + file=None, + rotation_max_size_mb=None, + retention_backup_count=None, + tool_args=None, + disabled_events=None, + ) + audit = AuditLogger(config, sink=None) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """still works""" + return "doc" + + async with Client(mcp) as client: + result = await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + + assert result is not None + assert not audit.active + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.asyncio +async def test_withheld_or_unknown_tool_produces_no_record(tmp_path): + """A tool that was never registered must not be audited. + + Read-only mode and the disabled set both work by withholding the tool from + registration, so a client is never told it exists and there is no refusal to + record — that was the product decision behind dropping event 57489. Emitting + a tool-call record here would also assert an ``execution_error`` that never + happened, because nothing ran. The enforced tool surface is captured once by + the server-configuration record at startup instead. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """the only registered tool""" + return "doc" + + async with Client(mcp) as client: + with pytest.raises(Exception, match="Unknown tool"): + # Withheld in read-only mode, so absent from the registry. + await client.call_tool("upsert_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + assert not [r for r in records if r.get("tool_name") == "upsert_document_by_id"] + assert not [r for r in records if r["id"] == 61522] + # The session record still lands, so the sink itself was working. + assert [r for r in records if r["id"] == AuditEvent.SESSION_INITIALIZED.id] + + +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# correlation: one cid per request, and no session identifier at all +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_each_request_gets_its_own_cid(tmp_path): + """A ``cid`` identifies one request, so two calls must never share one.""" + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id( + ctx: Context, bucket_name: str, scope_name: str, collection_name: str + ) -> str: + """read a document""" + return "doc" + + async with Client(mcp) as client: + for _ in range(2): + await client.call_tool( + "get_document_by_id", + {"bucket_name": "b", "scope_name": "s", "collection_name": "c"}, + ) + + records = _records(audit) + session = next(r for r in records if r["id"] == AuditEvent.SESSION_INITIALIZED.id) + calls = [r for r in records if r["id"] == 61490] + assert len(calls) == 2 + + cids = [session["cid"], calls[0]["cid"], calls[1]["cid"]] + assert all(cids), "every record must carry a cid" + assert len(set(cids)) == 3, "three requests, three distinct cids" + + +@pytest.mark.asyncio +async def test_no_record_carries_a_session_identifier(tmp_path): + """The audit log deliberately claims no session scope. + + MCP is moving to a stateless model in which a server must not treat + connection or process identity as session continuity, so an id derived from + it would group unrelated interleaved conversations. Asserted across a whole + run rather than one record, so no emission path can reintroduce one. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def upsert_document_by_id(ctx: Context, bucket_name: str) -> str: + """write a document""" + return "ok" + + async with Client(mcp) as client: + await client.call_tool("upsert_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + assert records + for record in records: + assert "sid" not in record + assert not [key for key in record if "session" in key.lower()] + + +@pytest.mark.asyncio +async def test_session_initialized_is_a_standalone_record(tmp_path): + """It still says what connected; it just does not join to anything. + + The value of 57360 is the statement that a client of this name and version + connected at this time under this identity — not a correlation key. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """read a document""" + return "doc" + + async with Client(mcp) as client: + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + session = next(r for r in records if r["id"] == AuditEvent.SESSION_INITIALIZED.id) + assert session["cid"] + assert session["client_name"] + assert session["protocol_version"] + # Nothing else shares its cid. + assert [r for r in records if r["cid"] == session["cid"]] == [session] + + +@pytest.mark.asyncio +async def test_guardrail_and_tool_call_records_share_one_cid(tmp_path): + """Correlation must survive the path that travels through the state dict. + + A refusal is recorded by the gate and emitted by the middleware, so this is + the case where a broken hand-off would show up as a mismatched id rather + than as a missing record. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def delete_document_by_id(ctx: Context, bucket_name: str) -> str: + """runs without confirmation because the client cannot elicit""" + audit_state.record_confirmation(audit_state.CONFIRMATION_SKIPPED) + return "deleted" + + async with Client(mcp) as client: + await client.call_tool("delete_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + skipped = [r for r in records if r["id"] == AuditEvent.CONFIRMATION_SKIPPED.id] + calls = [r for r in records if r["id"] == 61522] + assert len(skipped) == 1 + assert len(calls) == 1 + assert skipped[0]["cid"] == calls[0]["cid"] + + +@pytest.mark.asyncio +async def test_scope_denial_record_carries_a_cid(tmp_path): + """A refused call produces one record, and it is still correlatable.""" + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def upsert_document_by_id(ctx: Context, bucket_name: str) -> str: + """refused before doing anything""" + audit_state.record_refusal( + event_id=ScopeDeniedError.audit_event.id, + event_name=ScopeDeniedError.audit_event.event_name, + outcome=ScopeDeniedError.audit_outcome, + reason=ScopeDeniedError.audit_reason, + required_scope="couchbase-mcp:write", + ) + raise ScopeDeniedError("missing scope") + + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("upsert_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + denied = [r for r in records if r["id"] == AuditEvent.SCOPE_CHECK_DENIED.id] + assert len(denied) == 1 + assert denied[0]["cid"] + + +@pytest.mark.asyncio +async def test_tool_code_can_read_the_requests_cid(tmp_path): + """The hook a later phase needs to reach Couchbase Server audit. + + Propagating the ``cid`` to a SQL++ query as ``client_context_id`` requires + tool code — running on a worker thread, for a sync tool — to see the same + ``cid`` the middleware will stamp on the record. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + seen: dict[str, str | None] = {} + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """read a document""" + seen["cid"] = audit_state.get_cid() + return "doc" + + async with Client(mcp) as client: + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + call = next(r for r in records if r["id"] == 61490) + assert seen["cid"] == call["cid"] + + +@pytest.mark.asyncio +async def test_correlation_survives_without_on_message(tmp_path): + """The fallback: a hook invoked with no request state still correlates. + + ``on_message`` normally installs the state. A host that dispatches straight + to ``on_call_tool`` must still produce joinable records rather than records + with no ``cid`` at all. + """ + audit = _build_logger(tmp_path) + middleware = AuditMiddleware( + transport="stdio", cb_userid="mcp_service", audit_logger=audit + ) + + @dataclass + class _Message: + name: str = "get_document_by_id" + arguments: dict[str, str] = field(default_factory=lambda: {"bucket_name": "b"}) + + @dataclass + class _Context: + message: _Message = field(default_factory=_Message) + fastmcp_context: None = None + + async def _call_next(_context): + return "doc" + + assert audit_state.current() is None + await middleware.on_call_tool(_Context(), _call_next) + # State is torn down again, so nothing leaks into the next request. + assert audit_state.current() is None + + records = _records(audit) + call = next(r for r in records if r["id"] == 61490) + assert call["cid"] + + +@pytest.mark.asyncio +async def test_cid_is_per_request_over_a_real_http_transport(tmp_path): + """stdio and in-memory share a code path; HTTP does not. + + Streamable HTTP assigns its own transport-level ``Mcp-Session-Id``, which + must never leak into a record — the audit format has no session field. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit, transport="streamable-http") + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """read a document""" + return "doc" + + # Port 0 so a busy port cannot make this test flaky; the kernel picks one + # and we read it back off the bound socket. + config = uvicorn.Config( + mcp.http_app(transport="http"), host="127.0.0.1", port=0, log_level="error" + ) + server = uvicorn.Server(config) + serving = asyncio.create_task(server.serve()) + try: + while not server.started: + await asyncio.sleep(0.05) + port = server.servers[0].sockets[0].getsockname()[1] + transport = StreamableHttpTransport(url=f"http://127.0.0.1:{port}/mcp/") + async with Client(transport) as client: + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + await client.call_tool("get_document_by_id", {"bucket_name": "b2"}) + finally: + server.should_exit = True + await serving + + records = _records(audit) + calls = [r for r in records if r["id"] == 61490] + assert len(calls) == 2 + assert calls[0]["cid"] != calls[1]["cid"] + for record in records: + assert "sid" not in record diff --git a/tests/unit/test_audit_record.py b/tests/unit/test_audit_record.py new file mode 100644 index 00000000..f64ec1ab --- /dev/null +++ b/tests/unit/test_audit_record.py @@ -0,0 +1,239 @@ +"""Tests for the audit record and its serialisation. + +Coverage map: +- timestamp format is the PRD's millisecond UTC form +- fixed key order in the wire document +- absent fields are omitted rather than serialised as null +- reason appears only on non-success outcomes +- payload keys with a None value are dropped +- non-serialisable argument values never lose the whole record +- one JSON object per line, newline terminated +- the PRD's own sample records round-trip +""" + +from __future__ import annotations + +import json +import re +from datetime import datetime + +import pytest + +from cb_mcp.audit.record import ( + OUTCOME_DENIED, + OUTCOME_SUCCESS, + AuditRecord, + ServerContext, + utc_timestamp, +) + +SERVER = ServerContext(version="1.1.0", host="mcp-node-3") + + +def test_timestamp_is_millisecond_utc_with_a_literal_z(): + stamp = utc_timestamp() + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", stamp), stamp + # Parseable back to a datetime, so downstream tooling can rely on it. + datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%S.%fZ") + + +def test_key_order_is_stable_and_readable(): + record = AuditRecord( + id=61490, + name="document read", + description="A document read operation was requested through a tool", + outcome=OUTCOME_SUCCESS, + real_userid={"domain": "local", "user": "cbmcp"}, + cid="req-1", + payload={"tool_name": "get_document_by_id", "ks": "b.s.c"}, + ) + keys = list(record.as_dict(SERVER)) + assert keys == [ + "id", + "name", + "description", + "timestamp", + "real_userid", + "server", + "cid", + "outcome", + "tool_name", + "ks", + ] + + +def test_absent_fields_are_omitted_not_null(): + record = AuditRecord( + id=57344, name="server started", description="d", outcome=OUTCOME_SUCCESS + ) + document = record.as_dict(SERVER) + assert "real_userid" not in document + assert "cid" not in document + assert "reason" not in document + assert document["server"] == {"version": "1.1.0", "host": "mcp-node-3"} + + +def test_no_session_identifier_is_ever_written(): + """The audit format carries no session id, by design. + + MCP is moving to a stateless model in which connection identity must not be + read as session continuity, so a request is the only correlation scope this + format claims. Pinned as a test so a future field cannot be added without + a deliberate, visible change here. + """ + record = AuditRecord( + id=57360, + name="session initialized", + description="An MCP session was initialized", + outcome="success", + real_userid={"domain": "local", "user": "cbmcp"}, + cid="req-9", + ) + document = record.as_dict(SERVER) + assert document["cid"] == "req-9" + assert "sid" not in document + assert not [key for key in document if "session" in key.lower()] + + +def test_reason_is_present_only_on_failure(): + denied = AuditRecord( + id=57377, + name="scope check denied", + description="d", + outcome=OUTCOME_DENIED, + reason="missing_scope", + ) + assert denied.as_dict(SERVER)["reason"] == "missing_scope" + + ok = AuditRecord( + id=61490, name="document read", description="d", outcome=OUTCOME_SUCCESS + ) + assert "reason" not in ok.as_dict(SERVER) + + +def test_none_payload_values_are_dropped(): + record = AuditRecord( + id=61488, + name="cluster read", + description="d", + outcome=OUTCOME_SUCCESS, + payload={ + "tool_name": "test_cluster_connection", + "ks": None, + "cb_userid": None, + "confirmation": None, + }, + ) + document = record.as_dict(SERVER) + assert document["tool_name"] == "test_cluster_connection" + # A cluster-level tool addresses no keyspace; the key simply does not appear. + assert "ks" not in document + assert "cb_userid" not in document + assert "confirmation" not in document + + +def test_to_json_line_is_one_object_terminated_by_a_newline(): + record = AuditRecord(id=1, name="n", description="d", outcome=OUTCOME_SUCCESS) + line = record.to_json_line(SERVER) + assert line.endswith("\n") + assert "\n" not in line[:-1], "a record must never span lines" + assert json.loads(line)["id"] == 1 + + +def test_unserialisable_values_do_not_lose_the_record(): + """Caller-supplied arguments can contain anything. + + A record that failed to serialise would be a silently missing audit entry, + which is strictly worse than a stringified value. + """ + + class Opaque: + def __repr__(self) -> str: + return "" + + record = AuditRecord( + id=61522, + name="document write", + description="d", + outcome=OUTCOME_SUCCESS, + payload={"args": {"document_content": Opaque()}}, + ) + document = json.loads(record.to_json_line(SERVER)) + assert document["args"]["document_content"] == "" + + +def test_non_ascii_values_are_preserved_not_escaped(): + record = AuditRecord( + id=61490, + name="document read", + description="d", + outcome=OUTCOME_SUCCESS, + payload={"ks": "bücher.inventar.artikel"}, + ) + line = record.to_json_line(SERVER) + assert "bücher" in line + assert json.loads(line)["ks"] == "bücher.inventar.artikel" + + +def test_server_context_detect_populates_both_fields(): + detected = ServerContext.detect() + assert detected.version + assert detected.host + + +@pytest.mark.parametrize( + ("record", "expected_subset"), + [ + ( + # The PRD's "successful document read" sample. + AuditRecord( + id=61490, + name="document read", + description="A document was read by ID", + outcome="success", + real_userid={"domain": "local", "user": "cbmcp"}, + cid="req-5f2a", + payload={ + "tool_name": "get_document_by_id", + "required_scope": "read", + "ks": "travel-sample.inventory.airline", + }, + ), + { + "id": 61490, + "outcome": "success", + "tool_name": "get_document_by_id", + "required_scope": "read", + "ks": "travel-sample.inventory.airline", + }, + ), + ( + # The PRD's "denied write" sample. + AuditRecord( + id=57377, + name="scope check denied", + description="A write tool was requested without write scope", + outcome="denied", + real_userid={"domain": "oauth", "user": "agent-svc-7"}, + cid="req-7c19", + reason="missing_scope", + payload={ + "client_id": "agent-svc-7", + "scopes": ["couchbase-mcp:read"], + "required_scope": "write", + }, + ), + { + "id": 57377, + "outcome": "denied", + "reason": "missing_scope", + "client_id": "agent-svc-7", + "scopes": ["couchbase-mcp:read"], + }, + ), + ], +) +def test_prd_sample_records_serialise(record, expected_subset): + document = json.loads(record.to_json_line(SERVER)) + for key, value in expected_subset.items(): + assert document[key] == value diff --git a/tests/unit/test_audit_sink.py b/tests/unit/test_audit_sink.py new file mode 100644 index 00000000..4d942ede --- /dev/null +++ b/tests/unit/test_audit_sink.py @@ -0,0 +1,254 @@ +"""Tests for the audit sink. + +The sink deliberately does not use ``logging.handlers.RotatingFileHandler``. +A single stdio deployment runs one server process per MCP client, and they all +read the same ``CB_MCP_AUDIT_FILE``; sharing one rotating file between them +produces interleaved partial lines and rotation races. Each process therefore +writes its own file, with the pid inserted before the extension. + +Coverage map: +- per-process filename derivation, with and without a suffix +- lines are written and flushed by the background thread +- rotation at the size threshold, and backup shifting +- backup_count=0 truncates rather than growing without limit +- retention: files beyond backup_count are removed +- an oversized single line is written rather than lost +- constructor validation +- drop counter when the queue is full +- close() is idempotent and drains +- two concurrent sinks never share a file +""" + +from __future__ import annotations + +import json +import os +import threading +from pathlib import Path + +import pytest + +from cb_mcp.audit.sink import AuditSink, process_scoped_path + + +def _drain(sink: AuditSink) -> None: + """Close the sink so the writer thread finishes and flushes.""" + sink.close(timeout=10.0) + + +# --------------------------------------------------------------------------- +# naming +# --------------------------------------------------------------------------- + + +def test_process_scoped_path_inserts_pid_before_the_suffix(): + result = process_scoped_path("/var/log/audit.log", pid=4321) + assert result == Path("/var/log/audit.4321.log") + + +def test_process_scoped_path_appends_when_there_is_no_suffix(): + result = process_scoped_path("/var/log/audit", pid=4321) + assert result == Path("/var/log/audit.4321") + + +def test_process_scoped_path_uses_the_live_pid_by_default(): + assert str(os.getpid()) in process_scoped_path("audit.log").name + + +def test_sink_writes_to_the_process_scoped_path(tmp_path): + sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, backup_count=1) + try: + assert sink.path == tmp_path / f"audit.{os.getpid()}.log" + assert sink.path.exists() + finally: + _drain(sink) + + +# --------------------------------------------------------------------------- +# writing +# --------------------------------------------------------------------------- + + +def test_lines_are_written_and_flushed(tmp_path): + sink = AuditSink(tmp_path / "audit.log", max_bytes=1_000_000, backup_count=1) + sink.start() + for index in range(50): + sink.emit(json.dumps({"n": index}) + "\n") + _drain(sink) + + lines = sink.path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 50 + assert [json.loads(line)["n"] for line in lines] == list(range(50)) + assert sink.stats["written"] == 50 + assert sink.stats["dropped"] == 0 + assert sink.stats["write_errors"] == 0 + + +def test_existing_file_is_appended_not_truncated(tmp_path): + path = tmp_path / f"audit.{os.getpid()}.log" + path.write_text('{"pre":true}\n', encoding="utf-8") + + sink = AuditSink(tmp_path / "audit.log", max_bytes=1_000_000, backup_count=1) + sink.start() + sink.emit('{"post":true}\n') + _drain(sink) + + lines = path.read_text(encoding="utf-8").splitlines() + assert json.loads(lines[0]) == {"pre": True} + assert json.loads(lines[1]) == {"post": True} + + +# --------------------------------------------------------------------------- +# rotation and retention +# --------------------------------------------------------------------------- + + +def test_rotation_shifts_backups_and_honours_retention(tmp_path): + line = json.dumps({"payload": "x" * 80}) + "\n" + # Room for roughly two lines before rotating. + sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, backup_count=2) + sink.start() + for _ in range(12): + sink.emit(line) + _drain(sink) + + live = sink.path + first = live.with_name(live.name + ".1") + second = live.with_name(live.name + ".2") + third = live.with_name(live.name + ".3") + + assert live.exists() + assert first.exists() + assert second.exists() + # Retention is two backups, so a third must never appear. + assert not third.exists() + for candidate in (live, first, second): + assert candidate.stat().st_size <= len(line) * 2 + + +def test_backup_count_zero_truncates_the_live_file(tmp_path): + line = json.dumps({"payload": "y" * 80}) + "\n" + sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, backup_count=0) + sink.start() + for _ in range(20): + sink.emit(line) + _drain(sink) + + # Retention of zero still means the rotation size caps the live file. + assert sink.path.stat().st_size <= len(line) * 2 + assert not sink.path.with_name(sink.path.name + ".1").exists() + + +def test_a_single_oversized_line_is_written_not_dropped(tmp_path): + sink = AuditSink(tmp_path / "audit.log", max_bytes=64, backup_count=1) + sink.start() + huge = json.dumps({"payload": "z" * 500}) + "\n" + sink.emit(huge) + _drain(sink) + + written = sink.path.read_text(encoding="utf-8") + "".join( + p.read_text(encoding="utf-8") + for p in tmp_path.iterdir() + if p.name.startswith(sink.path.name + ".") + ) + # Losing the record would be worse than briefly exceeding the size cap. + assert "z" * 500 in written + assert sink.stats["dropped"] == 0 + + +# --------------------------------------------------------------------------- +# validation and failure handling +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("max_bytes", "backup_count", "match"), + [ + (0, 1, "max_bytes must be positive"), + (-1, 1, "max_bytes must be positive"), + (10, -1, "backup_count must not be negative"), + ], +) +def test_constructor_validates_its_arguments(tmp_path, max_bytes, backup_count, match): + with pytest.raises(ValueError, match=match): + AuditSink( + tmp_path / "audit.log", max_bytes=max_bytes, backup_count=backup_count + ) + + +def test_parent_directories_are_created(tmp_path): + nested = tmp_path / "deep" / "deeper" / "audit.log" + sink = AuditSink(nested, max_bytes=4096, backup_count=1) + try: + assert sink.path.parent.is_dir() + finally: + _drain(sink) + + +def test_unwritable_path_raises_so_startup_can_report_it(tmp_path): + # A directory where the file should be is the portable way to make open() + # fail without depending on running as an unprivileged user. + collision = tmp_path / f"audit.{os.getpid()}.log" + collision.mkdir() + with pytest.raises(OSError): + AuditSink(tmp_path / "audit.log", max_bytes=4096, backup_count=1) + + +def test_full_queue_drops_and_counts_rather_than_blocking(tmp_path): + # No writer thread started, so nothing drains the queue. + sink = AuditSink( + tmp_path / "audit.log", max_bytes=1_000_000, backup_count=1, queue_size=4 + ) + for _ in range(10): + sink.emit('{"x":1}\n') + assert sink.stats["dropped"] == 6 + _drain(sink) + + +def test_emit_after_close_is_counted_not_raised(tmp_path): + sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, backup_count=1) + sink.start() + _drain(sink) + sink.emit('{"late":true}\n') + assert sink.stats["dropped"] == 1 + + +def test_close_is_idempotent(tmp_path): + sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, backup_count=1) + sink.start() + sink.close() + sink.close() # must not raise + + +def test_start_is_idempotent(tmp_path): + sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, backup_count=1) + sink.start() + sink.start() + try: + threads = [t for t in threading.enumerate() if t.name == "cb-mcp-audit-writer"] + assert len(threads) == 1 + finally: + _drain(sink) + + +def test_emit_from_many_threads_loses_nothing(tmp_path): + """The producer side must be safe to call from any thread.""" + sink = AuditSink(tmp_path / "audit.log", max_bytes=10_000_000, backup_count=1) + sink.start() + + def worker(worker_id: int) -> None: + for index in range(100): + sink.emit(json.dumps({"w": worker_id, "i": index}) + "\n") + + threads = [threading.Thread(target=worker, args=(w,)) for w in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + _drain(sink) + + lines = sink.path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 800 + # Every line must be complete JSON: no interleaved partial writes. + parsed = [json.loads(line) for line in lines] + assert len({(r["w"], r["i"]) for r in parsed}) == 800 diff --git a/tests/unit/test_audit_state.py b/tests/unit/test_audit_state.py new file mode 100644 index 00000000..6fd35cad --- /dev/null +++ b/tests/unit/test_audit_state.py @@ -0,0 +1,186 @@ +"""Tests for the per-request audit state channel. + +The load-bearing test here is +:func:`test_mutation_from_a_worker_thread_is_visible`. FastMCP runs synchronous +tools on a worker thread via ``anyio.to_thread``, which *copies* the context. +Rebinding a contextvar inside the worker would therefore be invisible to the +middleware that installed it. Mutating a shared dict in place is visible, +because both contexts hold a reference to the same object. Every refusal record +depends on that, so it is asserted directly rather than assumed. + +Coverage map: +- accessors are no-ops when no state is installed +- install/reset nesting +- first refusal wins +- confirmation and statement-class recording +- extra payload keys survive +- in-place mutation crosses a thread boundary via a copied context +- the correlation id round-trips, and is readable from a worker thread +""" + +from __future__ import annotations + +import contextvars +from concurrent.futures import ThreadPoolExecutor +from contextvars import copy_context + +from cb_mcp.audit import state + + +def test_accessors_are_safe_with_no_state_installed(): + assert state.current() is None + assert state.get_refusal() is None + assert state.get_confirmation() is None + assert state.get_statement_class() is None + # Recording must not raise when nothing is tracking. + state.record_refusal(event_id=1, event_name="x", outcome="denied", reason="r") + state.record_confirmation(state.CONFIRMATION_SKIPPED) + state.record_statement_class("write") + + +def test_install_and_reset(): + assert state.current() is None + token = state.install() + try: + # Installed state always carries the correlation slot, empty when the + # caller supplied none, so a reader never has to distinguish "no state" + # from "state without a cid". + assert state.current() == {"cid": None} + finally: + state.reset(token) + assert state.current() is None + + +def test_correlation_id_round_trips(): + token = state.install(cid="cid-1") + try: + assert state.get_cid() == "cid-1" + finally: + state.reset(token) + # The accessor is safe with nothing installed. + assert state.get_cid() is None + + +def test_correlation_id_is_readable_from_a_worker_thread(): + """A sync tool runs on a worker thread, and needs the request's cid there. + + This is what a later phase depends on to propagate the ``cid`` to Couchbase + Server as a SQL++ ``client_context_id`` from inside tool code. + """ + token = state.install(cid="cid-worker") + try: + with ThreadPoolExecutor(max_workers=1) as pool: + # copy_context() mirrors what anyio.to_thread does for sync tools. + ctx = copy_context() + seen = pool.submit(ctx.run, state.get_cid) + assert seen.result() == "cid-worker" + finally: + state.reset(token) + + +def test_refusal_round_trip_with_extra_payload(): + token = state.install() + try: + state.record_refusal( + event_id=57377, + event_name="scope check denied", + outcome="denied", + reason="missing_scope", + required_scope=["couchbase-mcp:write"], + ) + refusal = state.get_refusal() + assert refusal == { + "event_id": 57377, + "event_name": "scope check denied", + "outcome": "denied", + "reason": "missing_scope", + "required_scope": ["couchbase-mcp:write"], + } + finally: + state.reset(token) + + +def test_first_refusal_wins(): + """The earliest gate to fire is the one that stopped the request.""" + token = state.install() + try: + state.record_refusal( + event_id=57377, + event_name="scope check denied", + outcome="denied", + reason="missing_scope", + ) + state.record_refusal( + event_id=57490, + event_name="confirmation declined", + outcome="blocked", + reason="confirmation_declined", + ) + assert state.get_refusal()["event_id"] == 57377 + finally: + state.reset(token) + + +def test_confirmation_and_statement_class(): + token = state.install() + try: + state.record_confirmation(state.CONFIRMATION_ACCEPTED) + state.record_statement_class("write") + assert state.get_confirmation() == "accepted" + assert state.get_statement_class() == "write" + # Last write wins for these, unlike refusals. + state.record_confirmation(state.CONFIRMATION_DECLINED) + assert state.get_confirmation() == "declined" + finally: + state.reset(token) + + +def test_mutation_from_a_worker_thread_is_visible(): + """In-place mutation crosses a copied context; a rebind would not. + + This mirrors exactly what ``anyio.to_thread.run_sync`` does for a + synchronous MCP tool: copy the current context and run the callable inside + it on another thread. + """ + token = state.install() + try: + context = contextvars.copy_context() + + def in_worker() -> None: + state.record_refusal( + event_id=57488, + event_name="write blocked (read-only mode)", + outcome="blocked", + reason="read_only_mode", + ) + state.record_statement_class("write") + + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(context.run, in_worker).result() + + # Read back from the *installing* context, not the worker's copy. + refusal = state.get_refusal() + assert refusal is not None + assert refusal["event_id"] == 57488 + assert state.get_statement_class() == "write" + finally: + state.reset(token) + + +def test_rebinding_in_a_worker_would_not_be_visible(): + """Documents why the shared-dict design is necessary, not incidental.""" + token = state.install() + try: + context = contextvars.copy_context() + + def rebind_in_worker() -> None: + state.install() # rebinds the contextvar inside the copy + state.record_confirmation(state.CONFIRMATION_SKIPPED) + + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(context.run, rebind_in_worker).result() + + # The installing context never sees it — hence record_* must mutate. + assert state.get_confirmation() is None + finally: + state.reset(token) From 6c7ffed6b78a702db41d9540b19333692fe84837 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Tue, 29 Sep 2026 06:26:48 +0000 Subject: [PATCH 02/27] DA-2038: Port auditing onto the multi-package layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main split the repo into service packages and added a second server. Three consequences for auditing, all handled here. **Auditing is now opt-in per server.** ServerSpec gains `audit_package`, naming the Tier-2 block a server's tool calls are booked against. Operational sets "operational"; Operational Insights leaves it None and is not audited. This is not a shortcut: the two servers collide on five tool names (create_index, list_indexes, get_collections_in_scope, get_schema_for_collection, get_server_configuration_status), so auditing OI against operational's block would give two different operations the same event id — the one failure the two-tier catalogue exists to prevent. OI's block is left unallocated rather than spent on a tool surface that is still moving; an id, once records exist against it, can never be renumbered. **The catalogue and classification take a package dimension.** CATEGORY_SLOTS, RETIRED_SLOTS and TOOL_CLASSIFICATION are keyed by service package, and SERVICE_PACKAGE_BLOCKS maps package -> block base. Adding OI later is a table, a block and one spec field, and renumbers nothing. **A search category for the FTS tools, at slot 8.** Ids 61496 / 61528. Slots 6 and 7 (retired vector/analytics) stay retired: reuse was permitted by the documented rule — neither ever emitted a record — but declined, because two dead numbers cost nothing in a 16-slot block while a number that has meant two things is a trap for anyone reading an old rule. next_free_slot no longer offers retired slots, so that stays a deliberate decision rather than a default. Six new operational tools classified, taking the table to 40/40 against the registered set: list_fts_indexes, get_fts_index_definition, run_fts_query (search read), get_cluster_metrics and discover_tool_input_values (cluster read), get_index_stats (index read). All ship read-only; the search *write* id exists for when FTS index management lands. Wiring follows main's seams rather than working around them: * CLI flags -> core/cli/options.py as an `audit_options` stack, resolved by CliParams.audit, so both subcommands accept them and neither retypes them. * Lifespan, middleware registration and the startup/shutdown records -> core/app.py, which takes `audit_config` for the same reason it takes `logging_config`: a host that resolves configuration without a command line can populate it without adopting this repo's CLI. * The status-tool audit block -> the shared tools/status.py. Also satisfies two of main's guards rather than bypassing them: the five audit loggers are added to the logger-name snapshot, and `audit_config` is classified in the settings test as deliberately unreported, since get_server_configuration_status and the `server configuration` record both already carry it in full. 1059 unit tests pass; ruff check and format clean. --- src/cb_mcp/audit/catalog.py | 151 ++++++++++++------ src/cb_mcp/audit/classification.py | 167 +++++++++++++------- src/cb_mcp/audit/config.py | 4 +- src/cb_mcp/audit/descriptor.json | 16 ++ src/cb_mcp/audit/emitter.py | 4 +- src/cb_mcp/audit/identity.py | 4 +- src/cb_mcp/audit/middleware.py | 20 ++- src/cb_mcp/audit/sink.py | 4 +- src/cb_mcp/core/app.py | 115 ++++++++++++++ src/cb_mcp/core/cli/options.py | 76 +++++++++ src/cb_mcp/core/spec.py | 17 ++ src/cb_mcp/servers/operational/spec.py | 3 + src/cb_mcp/utils/cli_params.py | 20 +++ src/cb_mcp/utils/context.py | 1 - src/mcp_server.py | 1 + tests/unit/test_audit_catalog.py | 63 +++++--- tests/unit/test_audit_classification.py | 42 +++-- tests/unit/test_audit_integration_points.py | 23 +-- tests/unit/test_logger_names.py | 5 + tests/unit/test_settings_classification.py | 12 +- 20 files changed, 582 insertions(+), 166 deletions(-) diff --git a/src/cb_mcp/audit/catalog.py b/src/cb_mcp/audit/catalog.py index c52a485c..0137f87e 100644 --- a/src/cb_mcp/audit/catalog.py +++ b/src/cb_mcp/audit/catalog.py @@ -44,6 +44,23 @@ #: Tier-2 block for the operational service package (kv, query, index, ...). OPERATIONAL_BLOCK = 0xF000 # 61440 +#: Service package this build audits by default, and the only one today. +#: +#: The repository ships two servers. Only ``operational`` is audited: the +#: Operational Insights server's tool surface is new and still moving, and a +#: Tier-2 block, once records exist against it, can never be renumbered. Its +#: block is therefore left unallocated rather than spent early. +DEFAULT_SERVICE_PACKAGE = "operational" + +#: ``service package -> Tier-2 block base``. Blocks are 0x1000 apart, so the +#: next package takes 0x10000. Adding one means an entry here, an entry in +#: :data:`CATEGORY_SLOTS`, a classification table in +#: :mod:`cb_mcp.audit.classification`, and ``audit_package`` on its +#: ``ServerSpec`` — and renumbers nothing that already exists. +SERVICE_PACKAGE_BLOCKS: dict[str, int] = { + "operational": OPERATIONAL_BLOCK, +} + #: Relative offset of the first read event inside a Tier-2 block. READ_OFFSET = 48 @@ -66,13 +83,22 @@ #: #: Add a category by giving it the next free slot (:func:`next_free_slot`). #: Never change a number already in this table. -CATEGORY_SLOTS: dict[str, int] = { - "cluster": 0, - "schema": 1, - "kv": 2, - "query": 3, - "index": 4, - "performance": 5, +CATEGORY_SLOTS: dict[str, dict[str, int]] = { + "operational": { + "cluster": 0, + "schema": 1, + "kv": 2, + "query": 3, + "index": 4, + "performance": 5, + # Slots 6 and 7 are retired (see RETIRED_SLOTS) and deliberately left + # unused, so search took the next never-allocated slot instead of + # reusing one. Reuse was permitted by the rule below — neither retired + # slot ever emitted a record — but declined: two dead numbers cost + # nothing in a 16-slot block, and a slot that has meant two different + # things is a trap for anyone reading old rules. + "search": 8, + }, } #: Slots retired from use, kept here so their history is visible — ``category @@ -87,69 +113,88 @@ #: category is withdrawn — reallocating it would make an old rule silently #: match a new, unrelated event. Record such a slot here and leave it out of #: :func:`next_free_slot`'s reusable set. -RETIRED_SLOTS: dict[str, int] = { - # Retired before release: no tool was ever classified to either category, - # so neither slot ever emitted a record and both are safe to reallocate. - # The managed Capella runtime registers this server's tool set and adds - # none of its own, so nothing outside this repo needed them either. - "vector": 6, - "analytics": 7, +RETIRED_SLOTS: dict[str, dict[str, int]] = { + "operational": { + # Retired before release: no tool was ever classified to either + # category, so neither slot ever emitted a record and both would be + # safe to reallocate. Left unallocated by choice when the search + # category was added on 2026-09-29 — see the note in CATEGORY_SLOTS. + "vector": 6, + "analytics": 7, + }, } #: Retired slots that must never be reallocated because records exist for them. PERMANENTLY_RESERVED_SLOTS: frozenset[int] = frozenset() -#: Category axis for Tier-2 blocks, ordered by slot. Derived from -#: :data:`CATEGORY_SLOTS` so the two can never disagree. -CATEGORIES: tuple[str, ...] = tuple( - name for name, _ in sorted(CATEGORY_SLOTS.items(), key=lambda item: item[1]) -) -#: Name of the service package this build ships. Recorded once at startup +def categories_for(package: str = DEFAULT_SERVICE_PACKAGE) -> tuple[str, ...]: + """Category axis for ``package``'s Tier-2 block, ordered by slot. + + Derived from :data:`CATEGORY_SLOTS` so the two can never disagree. + """ + return tuple( + name + for name, _ in sorted(CATEGORY_SLOTS[package].items(), key=lambda item: item[1]) + ) + + +#: Category axis of the default package, kept as a module constant because +#: it is the axis every descriptor and every test refers to today. +CATEGORIES: tuple[str, ...] = categories_for() + +#: Name of the service package this build audits. Recorded once at startup #: rather than repeated on every record. -SERVICE_PACKAGE = "operational" +SERVICE_PACKAGE = DEFAULT_SERVICE_PACKAGE -def category_index(category: str) -> int: - """Return the slot ``n`` for ``category`` within a Tier-2 block.""" +def category_index(category: str, package: str = DEFAULT_SERVICE_PACKAGE) -> int: + """Return the slot ``n`` for ``category`` within ``package``'s block.""" try: - return CATEGORY_SLOTS[category] + return CATEGORY_SLOTS[package][category] except KeyError as exc: # pragma: no cover - guarded by classification raise ValueError( - f"Unknown audit category {category!r}; expected one of {CATEGORIES}." + f"Unknown audit category {category!r} for service package " + f"{package!r}; expected one of {categories_for(package)}." ) from exc -def next_free_slot() -> int: +def next_free_slot(package: str = DEFAULT_SERVICE_PACKAGE) -> int: """Return the slot a newly added category should take. - Prefers the lowest retired slot that is safe to reuse — a category retired - with no tools classified to it emitted no records, so no SIEM rule can exist - for its old meaning. Otherwise returns the lowest never-allocated slot. + Returns the lowest **never-allocated** slot. Retired slots are deliberately + not offered, even the ones :data:`RETIRED_SLOTS` records as safe to reuse: + when the search category was added on 2026-09-29 the reusable slots 6 and 7 + were declined in favour of slot 8, on the grounds that two dead numbers cost + nothing in a 16-slot block while a slot that has meant two different things + is a trap for anyone reading an old rule. Reuse remains permissible, but it + is a decision someone must take deliberately rather than inherit from a + helper. Raises: ValueError: when the block's category template is exhausted. A new service package should then take a fresh 0x1000 block rather than crowding this one. """ - taken = set(CATEGORY_SLOTS.values()) | PERMANENTLY_RESERVED_SLOTS - reusable = sorted( - slot - for slot in RETIRED_SLOTS.values() - if slot not in taken and slot not in PERMANENTLY_RESERVED_SLOTS + taken = ( + set(CATEGORY_SLOTS[package].values()) + | set(RETIRED_SLOTS.get(package, {}).values()) + | PERMANENTLY_RESERVED_SLOTS ) - if reusable: - return reusable[0] for slot in range(MAX_SLOT + 1): if slot not in taken: return slot raise ValueError( - f"All {MAX_SLOT + 1} category slots in this block are allocated. " - "A new service package should request its own block." + f"All {MAX_SLOT + 1} category slots in the {package!r} block are " + "allocated or retired. A new service package should request its own " + "block; reusing a retired slot is possible but must be decided " + "explicitly — see RETIRED_SLOTS." ) -def tool_call_event_id(category: str, operation_class: str) -> int: +def tool_call_event_id( + category: str, operation_class: str, package: str = DEFAULT_SERVICE_PACKAGE +) -> int: """Resolve the Tier-2 event ID for a tool call. Args: @@ -160,7 +205,11 @@ def tool_call_event_id(category: str, operation_class: str) -> int: raise ValueError( f"operation_class must be 'read' or 'write', got {operation_class!r}." ) - base = OPERATIONAL_BLOCK + READ_OFFSET + category_index(category) + base = ( + SERVICE_PACKAGE_BLOCKS[package] + + READ_OFFSET + + category_index(category, package) + ) return base + WRITE_DELTA if operation_class == "write" else base @@ -299,16 +348,19 @@ def filterable(self) -> bool: "query": "query", "index": "index", "performance": "performance", + "search": "search", } -def tool_call_event(category: str, operation_class: str) -> ToolCallEvent: +def tool_call_event( + category: str, operation_class: str, package: str = DEFAULT_SERVICE_PACKAGE +) -> ToolCallEvent: """Build the :class:`ToolCallEvent` for a category and operation class.""" label = _CATEGORY_LABELS[category] verb = "read" if operation_class == "read" else "write" article = "An" if label[0].lower() in "aeiou" else "A" return ToolCallEvent( - id=tool_call_event_id(category, operation_class), + id=tool_call_event_id(category, operation_class, package), event_name=f"{label} {verb}", description=f"{article} {label} {verb} operation was requested through a tool", category=category, @@ -316,11 +368,13 @@ def tool_call_event(category: str, operation_class: str) -> ToolCallEvent: ) -def all_tool_call_events() -> list[ToolCallEvent]: - """Every Tier-2 tool-call entry, for descriptor generation.""" +def all_tool_call_events( + package: str = DEFAULT_SERVICE_PACKAGE, +) -> list[ToolCallEvent]: + """Every Tier-2 tool-call entry for ``package``, for descriptor generation.""" return [ - tool_call_event(category, operation_class) - for category in CATEGORIES + tool_call_event(category, operation_class, package) + for category in categories_for(package) for operation_class in ("read", "write") ] @@ -352,7 +406,7 @@ def build_descriptor() -> dict: entries.sort(key=lambda entry: entry["id"]) return { "service_package": SERVICE_PACKAGE, - "blocks": {"core": CORE_BLOCK, "operational": OPERATIONAL_BLOCK}, + "blocks": {"core": CORE_BLOCK, **SERVICE_PACKAGE_BLOCKS}, "events": entries, } @@ -377,6 +431,9 @@ def build_descriptor() -> dict: "ALL_IDS", "CATEGORIES", "CATEGORY_SLOTS", + "DEFAULT_SERVICE_PACKAGE", + "SERVICE_PACKAGE_BLOCKS", + "categories_for", "MAX_SLOT", "PERMANENTLY_RESERVED_SLOTS", "RETIRED_SLOTS", diff --git a/src/cb_mcp/audit/classification.py b/src/cb_mcp/audit/classification.py index b441ae0f..f138c1bd 100644 --- a/src/cb_mcp/audit/classification.py +++ b/src/cb_mcp/audit/classification.py @@ -26,55 +26,85 @@ from collections.abc import Iterable -from .catalog import ToolCallEvent, tool_call_event +from .catalog import DEFAULT_SERVICE_PACKAGE, ToolCallEvent, tool_call_event -#: ``tool_name -> (category, operation_class)``. +#: ``service package -> {tool_name -> (category, operation_class)}``. #: -#: Ordered by category to match the catalogue layout rather than -#: alphabetically, so a reviewer can check a whole block in one pass. -TOOL_CLASSIFICATION: dict[str, tuple[str, str]] = { - # -- cluster / health ------------------------------------------------- - "get_server_configuration_status": ("cluster", "read"), - "test_cluster_connection": ("cluster", "read"), - "get_cluster_health_and_services": ("cluster", "read"), - "get_cluster_diagnostics_report": ("cluster", "read"), # PENDING PRD - # -- schema / discovery ---------------------------------------------- - "get_buckets_in_cluster": ("schema", "read"), - "get_scopes_in_bucket": ("schema", "read"), - "get_collections_in_scope": ("schema", "read"), - "get_scopes_and_collections_in_bucket": ("schema", "read"), - "get_schema_for_collection": ("schema", "read"), - "create_scope": ("schema", "write"), # PENDING PRD - "create_collection": ("schema", "write"), # PENDING PRD - "delete_scope": ("schema", "write"), # PENDING PRD - "delete_collection": ("schema", "write"), # PENDING PRD - # -- kv (document) ---------------------------------------------------- - "get_document_by_id": ("kv", "read"), - "lookup_subdocument": ("kv", "read"), # PENDING PRD - "upsert_document_by_id": ("kv", "write"), - "insert_document_by_id": ("kv", "write"), - "replace_document_by_id": ("kv", "write"), - "delete_document_by_id": ("kv", "write"), - "mutate_subdocument": ("kv", "write"), # PENDING PRD - # -- query (SQL++) ---------------------------------------------------- - # run_sql_plus_plus_query is re-classified per statement at invocation; the - # entry here is the class used when the statement was not inspected. - "run_sql_plus_plus_query": ("query", "read"), - "explain_sql_plus_plus_query": ("query", "read"), - # -- index ------------------------------------------------------------ - "list_indexes": ("index", "read"), - "get_index_advisor_recommendations": ("index", "read"), - "create_index": ("index", "write"), # PENDING PRD - "build_index": ("index", "write"), # PENDING PRD - "drop_index": ("index", "write"), # PENDING PRD - # -- performance ------------------------------------------------------ - "get_longest_running_queries": ("performance", "read"), - "get_most_frequent_queries": ("performance", "read"), - "get_queries_with_largest_response_sizes": ("performance", "read"), - "get_queries_with_large_result_count": ("performance", "read"), - "get_queries_using_primary_index": ("performance", "read"), - "get_queries_not_using_covering_index": ("performance", "read"), - "get_queries_not_selective": ("performance", "read"), +#: Keyed by service package, not by bare tool name. The repository ships two +#: servers and they genuinely collide on five names — ``create_index``, +#: ``list_indexes``, ``get_collections_in_scope``, ``get_schema_for_collection`` +#: and ``get_server_configuration_status``. A flat table cannot represent both, +#: and booking one server's tool against another's block would put records in a +#: block whose ids mean something else. +#: +#: Only ``operational`` is populated today; the Operational Insights server is +#: not audited (see :data:`cb_mcp.audit.catalog.DEFAULT_SERVICE_PACKAGE`). +#: Adding it later means a table here and a block in +#: :data:`~cb_mcp.audit.catalog.SERVICE_PACKAGE_BLOCKS`, and renumbers nothing. +#: +#: Within a package, ordered by category to match the catalogue layout rather +#: than alphabetically, so a reviewer can check a whole block in one pass. +TOOL_CLASSIFICATION: dict[str, dict[str, tuple[str, str]]] = { + "operational": { + # -- cluster / health --------------------------------------------- + "get_server_configuration_status": ("cluster", "read"), + "test_cluster_connection": ("cluster", "read"), + "get_cluster_health_and_services": ("cluster", "read"), + "get_cluster_diagnostics_report": ("cluster", "read"), # PENDING PRD + "get_cluster_metrics": ("cluster", "read"), # PENDING PRD + # Reads bundled reference data rather than the cluster, so it touches + # no keyspace and has no service of its own. Booked as a cluster read: + # it is a server-level informational call, which is what that category + # already covers. + "discover_tool_input_values": ("cluster", "read"), # PENDING PRD + # -- schema / discovery -------------------------------------------- + "get_buckets_in_cluster": ("schema", "read"), + "get_scopes_in_bucket": ("schema", "read"), + "get_collections_in_scope": ("schema", "read"), + "get_scopes_and_collections_in_bucket": ("schema", "read"), + "get_schema_for_collection": ("schema", "read"), + "create_scope": ("schema", "write"), # PENDING PRD + "create_collection": ("schema", "write"), # PENDING PRD + "delete_scope": ("schema", "write"), # PENDING PRD + "delete_collection": ("schema", "write"), # PENDING PRD + # -- kv (document) -------------------------------------------------- + "get_document_by_id": ("kv", "read"), + "lookup_subdocument": ("kv", "read"), # PENDING PRD + "upsert_document_by_id": ("kv", "write"), + "insert_document_by_id": ("kv", "write"), + "replace_document_by_id": ("kv", "write"), + "delete_document_by_id": ("kv", "write"), + "mutate_subdocument": ("kv", "write"), # PENDING PRD + # -- query (SQL++) --------------------------------------------------- + # run_sql_plus_plus_query is re-classified per statement at invocation; + # the entry here is the class used when the statement was not inspected. + "run_sql_plus_plus_query": ("query", "read"), + "explain_sql_plus_plus_query": ("query", "read"), + # -- index ----------------------------------------------------------- + "list_indexes": ("index", "read"), + "get_index_advisor_recommendations": ("index", "read"), + "get_index_stats": ("index", "read"), # PENDING PRD + "create_index": ("index", "write"), # PENDING PRD + "build_index": ("index", "write"), # PENDING PRD + "drop_index": ("index", "write"), # PENDING PRD + # -- performance ----------------------------------------------------- + "get_longest_running_queries": ("performance", "read"), + "get_most_frequent_queries": ("performance", "read"), + "get_queries_with_largest_response_sizes": ("performance", "read"), + "get_queries_with_large_result_count": ("performance", "read"), + "get_queries_using_primary_index": ("performance", "read"), + "get_queries_not_using_covering_index": ("performance", "read"), + "get_queries_not_selective": ("performance", "read"), + # -- search (FTS) ------------------------------------------------------ + # Its own category rather than folded into index: run_fts_query is a + # query, so a reviewer filtering on `index read` should not get search + # traffic mixed in. All three tools ship read-only; the search *write* + # id (61528) exists in the catalogue for when FTS index management + # lands, so adding it will need no new id. + "list_fts_indexes": ("search", "read"), # PENDING PRD + "get_fts_index_definition": ("search", "read"), # PENDING PRD + "run_fts_query": ("search", "read"), # PENDING PRD + }, } #: The one tool whose read/write class depends on the statement it is given. @@ -90,13 +120,23 @@ FAILSAFE_CATEGORY = "cluster" -def is_classified(tool_name: str) -> bool: - """True when ``tool_name`` has an explicit classification entry.""" - return tool_name in TOOL_CLASSIFICATION +def classification_for( + package: str = DEFAULT_SERVICE_PACKAGE, +) -> dict[str, tuple[str, str]]: + """Return ``package``'s classification table, or an empty one if unaudited.""" + return TOOL_CLASSIFICATION.get(package, {}) + + +def is_classified(tool_name: str, package: str = DEFAULT_SERVICE_PACKAGE) -> bool: + """True when ``tool_name`` has an explicit entry in ``package``'s table.""" + return tool_name in classification_for(package) def classify_tool( - tool_name: str, *, operation_class_override: str | None = None + tool_name: str, + *, + package: str = DEFAULT_SERVICE_PACKAGE, + operation_class_override: str | None = None, ) -> tuple[str, str]: """Return ``(category, operation_class)`` for ``tool_name``. @@ -108,7 +148,7 @@ def classify_tool( Unknown tools fail closed to a write class. """ - category, operation_class = TOOL_CLASSIFICATION.get( + category, operation_class = classification_for(package).get( tool_name, (FAILSAFE_CATEGORY, FAILSAFE_OPERATION_CLASS) ) if operation_class_override is not None: @@ -117,13 +157,18 @@ def classify_tool( def resolve_tool_call_event( - tool_name: str, *, operation_class_override: str | None = None + tool_name: str, + *, + package: str = DEFAULT_SERVICE_PACKAGE, + operation_class_override: str | None = None, ) -> ToolCallEvent: - """Resolve the catalogue entry for a tool invocation.""" + """Resolve the catalogue entry for a tool invocation in ``package``.""" category, operation_class = classify_tool( - tool_name, operation_class_override=operation_class_override + tool_name, + package=package, + operation_class_override=operation_class_override, ) - return tool_call_event(category, operation_class) + return tool_call_event(category, operation_class, package) def required_scope_for(operation_class: str) -> str: @@ -135,13 +180,17 @@ def required_scope_for(operation_class: str) -> str: return "write" if operation_class == "write" else "read" -def unclassified_tool_names(tool_names: Iterable[str]) -> list[str]: - """Return the subset of ``tool_names`` with no classification entry.""" - return sorted(name for name in tool_names if name not in TOOL_CLASSIFICATION) +def unclassified_tool_names( + tool_names: Iterable[str], package: str = DEFAULT_SERVICE_PACKAGE +) -> list[str]: + """Return the subset of ``tool_names`` with no entry in ``package``'s table.""" + table = classification_for(package) + return sorted(name for name in tool_names if name not in table) __all__ = [ "FAILSAFE_CATEGORY", + "classification_for", "FAILSAFE_OPERATION_CLASS", "STATEMENT_CLASSIFIED_TOOL", "TOOL_CLASSIFICATION", diff --git a/src/cb_mcp/audit/config.py b/src/cb_mcp/audit/config.py index a07aa03b..056a4c8b 100644 --- a/src/cb_mcp/audit/config.py +++ b/src/cb_mcp/audit/config.py @@ -23,12 +23,12 @@ DEFAULT_AUDIT_ENABLED, DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB, DEFAULT_AUDIT_TOOL_ARGS, - MCP_SERVER_NAME, + LOGGER_NAMESPACE, ) from .catalog import ALL_IDS, EVENT_NAMES_TO_IDS, FILTERABLE_IDS from .sink import process_scoped_path -logger = logging.getLogger(f"{MCP_SERVER_NAME}.audit.config") +logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.config") @dataclass(frozen=True) diff --git a/src/cb_mcp/audit/descriptor.json b/src/cb_mcp/audit/descriptor.json index f0f42733..eb9bcc67 100644 --- a/src/cb_mcp/audit/descriptor.json +++ b/src/cb_mcp/audit/descriptor.json @@ -125,6 +125,14 @@ "default_enabled": true, "filterable": true }, + { + "id": 61496, + "name": "search read", + "description": "A search read operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": true + }, { "id": 61520, "name": "cluster write", @@ -172,6 +180,14 @@ "category": "tool_call", "default_enabled": true, "filterable": false + }, + { + "id": 61528, + "name": "search write", + "description": "A search write operation was requested through a tool", + "category": "tool_call", + "default_enabled": true, + "filterable": false } ] } diff --git a/src/cb_mcp/audit/emitter.py b/src/cb_mcp/audit/emitter.py index 04a3f509..75eb24d9 100644 --- a/src/cb_mcp/audit/emitter.py +++ b/src/cb_mcp/audit/emitter.py @@ -16,13 +16,13 @@ import logging from typing import Any -from ..utils.constants import MCP_SERVER_NAME +from ..utils.constants import LOGGER_NAMESPACE from .catalog import SERVICE_PACKAGE, AuditEvent, ToolCallEvent from .config import ResolvedAuditConfig from .record import AuditRecord, ServerContext from .sink import AuditSink -logger = logging.getLogger(f"{MCP_SERVER_NAME}.audit") +logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit") class AuditLogger: diff --git a/src/cb_mcp/audit/identity.py b/src/cb_mcp/audit/identity.py index f4937d19..2ebced87 100644 --- a/src/cb_mcp/audit/identity.py +++ b/src/cb_mcp/audit/identity.py @@ -33,9 +33,9 @@ from fastmcp.server.dependencies import get_access_token -from ..utils.constants import MCP_SERVER_NAME, STREAMABLE_HTTP_TRANSPORT +from ..utils.constants import LOGGER_NAMESPACE, STREAMABLE_HTTP_TRANSPORT -logger = logging.getLogger(f"{MCP_SERVER_NAME}.audit.identity") +logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.identity") DOMAIN_OAUTH = "oauth" DOMAIN_LOCAL = "local" diff --git a/src/cb_mcp/audit/middleware.py b/src/cb_mcp/audit/middleware.py index 5eeb6841..defaddbb 100644 --- a/src/cb_mcp/audit/middleware.py +++ b/src/cb_mcp/audit/middleware.py @@ -49,9 +49,9 @@ from fastmcp.exceptions import NotFoundError from fastmcp.server.middleware import Middleware -from ..utils.constants import MCP_SERVER_NAME +from ..utils.constants import LOGGER_NAMESPACE from . import state -from .catalog import AuditEvent +from .catalog import DEFAULT_SERVICE_PACKAGE, AuditEvent from .classification import ( STATEMENT_CLASSIFIED_TOOL, is_classified, @@ -71,7 +71,7 @@ REASON_EXECUTION_ERROR, ) -logger = logging.getLogger(f"{MCP_SERVER_NAME}.audit.middleware") +logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.middleware") #: Argument names that together identify the keyspace a tool acted on. Every #: keyspace-scoped tool in this server uses exactly these names. @@ -136,6 +136,7 @@ def __init__( *, transport: str, cb_userid: str | None = None, + service_package: str = DEFAULT_SERVICE_PACKAGE, audit_logger: AuditLogger | None = None, ) -> None: """ @@ -143,6 +144,10 @@ def __init__( transport: The resolved transport name, used to decide whether an unauthenticated caller is ``anonymous`` (HTTP) or the local process owner (stdio). + service_package: Which Tier-2 block this server's tool calls are + booked against, and which classification table names them. + Supplied from ``ServerSpec.audit_package`` so two servers that + share a tool name cannot share an event id. cb_userid: The Couchbase user the server connects to the cluster with. Recorded so a reviewer can see which cluster identity the MCP-layer caller was mapped onto — the collapse this audit log @@ -151,6 +156,7 @@ def __init__( """ self._transport = transport self._cb_userid = cb_userid + self._service_package = service_package self._injected_logger = audit_logger @property @@ -363,7 +369,11 @@ def _emit_tool_call( if tool_name == STATEMENT_CLASSIFIED_TOOL else None ) - event = resolve_tool_call_event(tool_name, operation_class_override=override) + event = resolve_tool_call_event( + tool_name, + package=self._service_package, + operation_class_override=override, + ) outcome = OUTCOME_SUCCESS reason: str | None = None @@ -383,7 +393,7 @@ def _emit_tool_call( "cb_userid": self._cb_userid, "confirmation": confirmation, } - if not is_classified(tool_name): + if not is_classified(tool_name, self._service_package): # Fail-closed classification: recorded against a write id so it is # never filtered, and flagged so the placeholder category is never # mistaken for a real one. diff --git a/src/cb_mcp/audit/sink.py b/src/cb_mcp/audit/sink.py index 4bced888..93ef6a40 100644 --- a/src/cb_mcp/audit/sink.py +++ b/src/cb_mcp/audit/sink.py @@ -35,9 +35,9 @@ import time from pathlib import Path -from ..utils.constants import MCP_SERVER_NAME +from ..utils.constants import LOGGER_NAMESPACE -logger = logging.getLogger(f"{MCP_SERVER_NAME}.audit.sink") +logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.sink") #: Bounded queue depth. Deep enough to absorb a burst of tool calls while the #: writer thread is mid-flush, shallow enough that a wedged disk cannot grow diff --git a/src/cb_mcp/core/app.py b/src/cb_mcp/core/app.py index 1e6ec5df..f8fdef7f 100644 --- a/src/cb_mcp/core/app.py +++ b/src/cb_mcp/core/app.py @@ -21,6 +21,17 @@ from fastmcp.server.auth import AuthProvider from fastmcp.tools import FunctionTool +from ..audit import ( + AuditEvent, + AuditMiddleware, + ResolvedAuditConfig, + get_audit_logger, + init_audit, + shutdown_audit, + unclassified_tool_names, + warn_on_unauthenticated_http, +) +from ..audit.record import OUTCOME_SUCCESS from ..utils.constants import ( LOGGER_NAMESPACE, NETWORK_TRANSPORTS, @@ -35,6 +46,61 @@ logger = logging.getLogger(f"{LOGGER_NAMESPACE}.core.app") +def _start_audit( + spec: ServerSpec, + audit_config: ResolvedAuditConfig, + *, + transport: str, + oauth_enabled: bool, + read_only_mode: bool, + registered_tool_names: list[str], + settings: Mapping[str, Any], +) -> None: + """Initialise auditing and write the two startup records. + + Emits nothing at all when the server declares no audit package, or when + auditing is configured off or its sink could not be opened. + """ + audit = init_audit(audit_config) + if not audit.active: + return + + warn_on_unauthenticated_http(transport, oauth_enabled) + + unclassified = unclassified_tool_names(registered_tool_names, spec.audit_package) + if unclassified: + # Fail-closed classification keeps these audited, but an operator + # should not have to discover the gap by reading the audit file. + logger.warning( + "Audit: %d registered tool(s) in server %r have no classification " + "entry and will be recorded against a write event id: %s", + len(unclassified), + spec.id, + unclassified, + ) + + audit.emit_event(AuditEvent.SERVER_STARTED, outcome=OUTCOME_SUCCESS) + audit.emit_event( + AuditEvent.SERVER_CONFIGURATION, + outcome=OUTCOME_SUCCESS, + server_id=spec.id, + service_package=spec.audit_package, + transport=transport, + read_only_mode=read_only_mode, + oauth_enabled=oauth_enabled, + # The withheld and disabled tool sets are recorded here rather than as + # per-call refusals: neither kind of tool is registered with FastMCP, so + # a client is never told they exist and there is no invocation to + # refuse. This record is what makes the enforced surface auditable. + registered_tools=sorted(registered_tool_names), + disabled_tools=sorted(settings.get("disabled_tools", ())), + confirmation_required_tools=sorted( + settings.get("confirmation_required_tools", ()) + ), + audit_config=audit_config.as_dict(), + ) + + def build_app( spec: ServerSpec, *, @@ -44,6 +110,7 @@ def build_app( auth: AuthProvider | None = None, read_only_mode: bool = True, logging_config: Mapping[str, Any] | None = None, + audit_config: ResolvedAuditConfig | None = None, ) -> FastMCP: """Build the ``FastMCP`` application for ``spec``, ready to ``run()``. @@ -63,14 +130,35 @@ def build_app( populate it without adopting ours. It surfaces via ``get_server_configuration_status``. + ``audit_config`` is threaded through from the host for the same reason as + ``logging_config``: a host that resolves its configuration from somewhere + other than a command line — the managed Capella runtime, for instance — can + populate it without adopting this repo's CLI. Auditing activates only when + the host supplies a config *and* ``spec.audit_package`` names a Tier-2 + block, so a server that has not been given an audit block cannot have its + records booked against another server's ids. + The returned server is *not* started; the caller chooses the transport and calls ``run()``. See :func:`run_app` for the standard invocation. """ + audits = audit_config is not None and spec.audit_package is not None @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: """Build the lifespan AppContext from host-resolved configuration.""" transport = settings.get("transport") + # Audit is initialised first so the server-started record is the first + # line in the file and every later record shares its static context. + if audits: + _start_audit( + spec, + audit_config, # type: ignore[arg-type] + transport=str(transport), + oauth_enabled=bool(settings.get("oauth_enabled", False)), + read_only_mode=read_only_mode, + registered_tool_names=[tool.__name__ for tool in tools], + settings=settings, + ) # Name the server: both servers log into the same hierarchy, so in an # aggregated stream these lines are otherwise indistinguishable. The # wire-visible name is a static fact and lives in the env-info record, @@ -88,6 +176,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: settings=settings, read_only_mode=read_only_mode, logging_config=logging_config, + audit_config=audit_config.as_dict() if audit_config is not None else None, server_id=spec.id, server_name=spec.fastmcp_name, ) @@ -99,10 +188,36 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: finally: if app_context.cluster_provider: app_context.cluster_provider.close() + # Recorded before the sink closes so the stop record is the last + # line written. A process killed outright leaves no stop record; + # its absence means "not a clean shutdown", not "tampered with". + if audits: + active_audit = get_audit_logger() + if active_audit.active: + active_audit.emit_event( + AuditEvent.SERVER_STOPPED, outcome=OUTCOME_SUCCESS + ) + shutdown_audit() logger.info("Closing MCP server") mcp = FastMCP(spec.fastmcp_name, lifespan=app_lifespan, auth=auth) + # Registered before tools so it wraps every one of them. The middleware + # short-circuits whenever the sink is inactive, so a failed sink degrades to + # no records rather than to a half-instrumented server. ``transport`` + # decides whether an unauthenticated caller is recorded as anonymous (http) + # or as the local process owner (stdio); ``cb_userid`` records the single + # backing identity every caller collapses onto, which is the gap this audit + # log exists to close. + if audits: + mcp.add_middleware( + AuditMiddleware( + transport=str(settings.get("transport")), + cb_userid=settings.get("username"), + service_package=spec.audit_package, # type: ignore[arg-type] + ) + ) + logger.info( f"Registering {len(tools)} tool(s) for server '{spec.id}' " f"with modes (read_only_mode={read_only_mode})" diff --git a/src/cb_mcp/core/cli/options.py b/src/cb_mcp/core/cli/options.py index 12a67152..fbd955fc 100644 --- a/src/cb_mcp/core/cli/options.py +++ b/src/cb_mcp/core/cli/options.py @@ -25,6 +25,9 @@ from ...utils.constants import ( ALLOWED_OAUTH_ALGORITHMS, ALLOWED_TRANSPORTS, + DEFAULT_AUDIT_ENABLED, + DEFAULT_AUDIT_FILE, + DEFAULT_AUDIT_TOOL_ARGS, DEFAULT_HOST, DEFAULT_LOG_BACKUP_COUNT, DEFAULT_LOG_LEVEL, @@ -391,3 +394,76 @@ def logging_options(*, default_log_file: str) -> Callable: ), ) """OAuth resource-server configuration. Shared by every server; scope label defaults are per-server.""" + + +audit_options = compose( + click.option( + "--audit-log-enabled", + "audit_log_enabled", + envvar="CB_MCP_AUDIT_LOG_ENABLED", + type=bool, + default=DEFAULT_AUDIT_ENABLED, + help="Enable audit logging. Applies to both stdio and http transports; the " + "set of applicable events differs by transport (authorization events are " + "http-only). Requires --audit-file. Audit output is a separate sink from " + "the --log-* operational logs, with a stable schema and its own retention. " + "Honored only by servers that declare an audit package; on a server that " + "does not, the flag resolves and is reported but nothing is recorded.", + ), + click.option( + "--audit-file", + "audit_file", + envvar="CB_MCP_AUDIT_FILE", + default=DEFAULT_AUDIT_FILE, + help="Path to the audit log file. Required when --audit-log-enabled is " + "true; if it is missing the server still starts, with auditing disabled and " + "an error recorded. Each server process writes its own file with the " + "process id inserted before the extension (audit.log -> audit.1234.log), " + "because a single stdio deployment runs one server process per client and " + "sharing one file between them would corrupt records.", + ), + click.option( + "--audit-rotation-max-size-mb", + "audit_rotation_max_size_mb", + envvar="CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB", + type=click.FloatRange(min=0), + default=None, + help="Maximum size in MB an audit file may reach before it rotates. " + "Default is 1 MB. 0 is invalid and falls back to the default with a " + "startup warning.", + ), + click.option( + "--audit-retention-backup-count", + "audit_retention_backup_count", + envvar="CB_MCP_AUDIT_RETENTION_BACKUP_COUNT", + type=click.IntRange(min=0), + default=None, + help="Number of rotated audit files retained, excluding the live file. " + "Default is 1000. Set to 0 to keep only the live file, which stays bounded " + "by the rotation size.", + ), + click.option( + "--audit-tool-args", + "audit_tool_args", + envvar="CB_MCP_AUDIT_TOOL_ARGS", + type=bool, + default=DEFAULT_AUDIT_TOOL_ARGS, + help="Record tool argument values in the audit log. Off by default: there " + "is no redaction capability in this release, so enabling this writes " + "arguments verbatim, including the full document bodies passed to " + "document-write tools. Review the audit file's retention and access " + "controls before enabling it.", + ), + click.option( + "--audit-disabled-events", + "audit_disabled_events", + envvar="CB_MCP_AUDIT_DISABLED_EVENTS", + default=None, + help="Audit events to suppress. Accepts comma-separated event ids or " + "names (e.g. '61490,document read'), or a file path with one entry per " + "line. Only filterable events can be suppressed: write and security events " + "are always recorded, and an attempt to disable one is refused with a " + "warning.", + ), +) +"""Audit sink configuration. Shared by every server; honored where a spec declares an audit package.""" diff --git a/src/cb_mcp/core/spec.py b/src/cb_mcp/core/spec.py index 5da06ea6..abbb679f 100644 --- a/src/cb_mcp/core/spec.py +++ b/src/cb_mcp/core/spec.py @@ -140,3 +140,20 @@ class ServerSpec: #: Settings keys whose *presence* may be logged but never their value. secret_settings_keys: tuple[str, ...] = () + + #: Tier-2 audit service package this server's tools are classified under, + #: or ``None`` when this server is not audited. + #: + #: Auditing is opt-in per server rather than automatic, because a Tier-2 + #: block and its category slots are a wire contract: once records exist + #: against an id, that id can never mean anything else. A server with no + #: package would otherwise have every tool fall through classification's + #: fail-closed path and be recorded against *another* server's block — + #: same numbers, different meaning, which is the one failure the two-tier + #: catalogue exists to prevent. + #: + #: ``None`` costs nothing: the audit middleware is simply not registered, + #: and the resolved audit configuration is still reported by + #: ``get_server_configuration_status`` so an operator can see that the + #: flags were honored and nothing is being recorded. + audit_package: str | None = None diff --git a/src/cb_mcp/servers/operational/spec.py b/src/cb_mcp/servers/operational/spec.py index 86f965fe..d5777533 100644 --- a/src/cb_mcp/servers/operational/spec.py +++ b/src/cb_mcp/servers/operational/spec.py @@ -58,4 +58,7 @@ def _configure_couchbase_sdk_logging(logger_root: str, level: int) -> None: # server has. mTLS client credentials have no analytics equivalent. safe_settings_keys=(), secret_settings_keys=("client_cert_path", "client_key_path"), + # The only audited server today. Its Tier-2 block (0xF000) and category + # slots are shipped and frozen; see cb_mcp.audit.catalog. + audit_package="operational", ) diff --git a/src/cb_mcp/utils/cli_params.py b/src/cb_mcp/utils/cli_params.py index e4760e7d..21faead7 100644 --- a/src/cb_mcp/utils/cli_params.py +++ b/src/cb_mcp/utils/cli_params.py @@ -43,8 +43,10 @@ import click from fastmcp.server.auth import AuthProvider +from ..audit.config import ResolvedAuditConfig, resolve_audit_config from ..auth import OAuthConfigError, resolve_oauth from ..core.cli.options import ( + audit_options, compose, credential_options, logging_options, @@ -308,6 +310,7 @@ class CliParams: transport: TransportParams gating: GatingParams oauth: OAuthParams + audit: ResolvedAuditConfig @classmethod def from_click( @@ -319,6 +322,19 @@ def from_click( transport=TransportParams.from_click(params), gating=GatingParams.from_click(params), oauth=OAuthParams.from_click(params), + # Resolved eagerly like the rest, but note the ordering contract in + # ``_start_server``: ``cli.logging.apply()`` runs before anything + # reads ``cli.audit``, so the "audit enabled without a file" error + # and the tool-args warning land in the operator's configured log + # sinks rather than on the last-resort handler. + audit=resolve_audit_config( + enabled=params.get("audit_log_enabled"), + file=params.get("audit_file"), + rotation_max_size_mb=params.get("audit_rotation_max_size_mb"), + retention_backup_count=params.get("audit_retention_backup_count"), + tool_args=params.get("audit_tool_args"), + disabled_events=params.get("audit_disabled_events"), + ), ) def resolve_auth(self, spec: ServerSpec) -> AuthProvider | None: @@ -399,6 +415,9 @@ def build_settings( **cli.oauth.as_settings(enabled=oauth_enabled), "disabled_tools": gated.disabled, "confirmation_required_tools": gated.confirmation_required, + # Audit configuration as resolved, so get_server_configuration_status + # and the startup audit record report exactly the same thing. + "audit_config": cli.audit.as_dict(), } ) return settings @@ -441,4 +460,5 @@ def server_options( tool_gating_options, logging_options(default_log_file=default_log_file), oauth_options, + audit_options, ) diff --git a/src/cb_mcp/utils/context.py b/src/cb_mcp/utils/context.py index 50a2d221..b6f4448c 100644 --- a/src/cb_mcp/utils/context.py +++ b/src/cb_mcp/utils/context.py @@ -114,4 +114,3 @@ def get_audit_config(ctx: Context) -> Mapping[str, Any] | None: correct report in both cases. """ return getattr(ctx.request_context.lifespan_context, "audit_config", None) # type: ignore - diff --git a/src/mcp_server.py b/src/mcp_server.py index 790895b5..9695f0bf 100644 --- a/src/mcp_server.py +++ b/src/mcp_server.py @@ -104,6 +104,7 @@ def _start_server( auth=auth, read_only_mode=cli.gating.read_only_mode, logging_config=resolved_logging_snapshot(), + audit_config=cli.audit, ) run_app( diff --git a/tests/unit/test_audit_catalog.py b/tests/unit/test_audit_catalog.py index 7ce9f7f2..6478aebc 100644 --- a/tests/unit/test_audit_catalog.py +++ b/tests/unit/test_audit_catalog.py @@ -54,6 +54,7 @@ def test_category_order_is_the_wire_contract(): "query", "index", "performance", + "search", ) @@ -188,14 +189,16 @@ def test_category_slots_and_categories_cannot_disagree(): assert ( tuple( name - for name, _ in sorted(catalog.CATEGORY_SLOTS.items(), key=lambda kv: kv[1]) + for name, _ in sorted( + catalog.CATEGORY_SLOTS["operational"].items(), key=lambda kv: kv[1] + ) ) == catalog.CATEGORIES ) def test_slot_numbers_are_unique_and_within_the_template(): - slots = list(catalog.CATEGORY_SLOTS.values()) + slots = list(catalog.CATEGORY_SLOTS["operational"].values()) assert len(slots) == len(set(slots)), "two categories share a slot" assert all(0 <= slot <= catalog.MAX_SLOT for slot in slots) @@ -207,13 +210,16 @@ def test_slot_table_pins_the_shipped_numbers(): test change rather than something that quietly renumbers a customer's SIEM rule. """ - assert catalog.CATEGORY_SLOTS == { + assert catalog.CATEGORY_SLOTS["operational"] == { "cluster": 0, "schema": 1, "kv": 2, "query": 3, "index": 4, "performance": 5, + # Added 2026-09-29 with the FTS tools. Slot 8, not the reusable + # retired 6, so no number has ever meant two things. + "search": 8, } @@ -225,9 +231,11 @@ def test_retiring_a_category_does_not_move_its_neighbours(monkeypatch): event that customers already have SIEM rules for. """ trimmed = { - name: slot for name, slot in catalog.CATEGORY_SLOTS.items() if name != "index" + name: slot + for name, slot in catalog.CATEGORY_SLOTS["operational"].items() + if name != "index" } - monkeypatch.setattr(catalog, "CATEGORY_SLOTS", trimmed) + monkeypatch.setattr(catalog, "CATEGORY_SLOTS", {"operational": trimmed}) assert catalog.tool_call_event_id("performance", "read") == 61493 assert catalog.tool_call_event_id("query", "read") == 61491 @@ -235,30 +243,37 @@ def test_retiring_a_category_does_not_move_its_neighbours(monkeypatch): def test_next_free_slot_takes_the_lowest_never_allocated(monkeypatch): monkeypatch.setattr(catalog, "RETIRED_SLOTS", {}) monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) + # 0-5 and 8 are allocated, so 6 is the lowest never-allocated slot. assert catalog.next_free_slot() == 6 -def test_next_free_slot_reuses_a_retired_slot_first(monkeypatch): - """Reuse is safe only for a slot that never emitted a record. +def test_next_free_slot_skips_retired_slots(monkeypatch): + """Retired slots are not offered, even when reuse would be safe. - A category retired with no tools classified to it produced no records, so - no SIEM rule can exist for its old meaning. + Reuse remains *permissible* for a slot that never emitted a record — the + rule in ``RETIRED_SLOTS`` still says so. It is simply not automatic. When + the search category was added the reusable slot 6 was declined in favour of + 8, on the grounds that a number which has meant two different things is a + trap for anyone reading an old rule. Handing retired slots back from a + helper would make the trap the default path. """ - monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"vector": 6}) + monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"vector": 6}}) monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) - assert catalog.next_free_slot() == 6 + assert catalog.next_free_slot() == 7 def test_permanently_reserved_slots_are_never_reallocated(monkeypatch): """A slot that has emitted records must not be handed to a new category.""" - monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"vector": 6}) + monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"vector": 6}}) monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset({6})) assert catalog.next_free_slot() == 7 def test_exhausted_block_is_an_explicit_error(monkeypatch): monkeypatch.setattr( - catalog, "CATEGORY_SLOTS", {f"c{n}": n for n in range(catalog.MAX_SLOT + 1)} + catalog, + "CATEGORY_SLOTS", + {"operational": {f"c{n}": n for n in range(catalog.MAX_SLOT + 1)}}, ) monkeypatch.setattr(catalog, "RETIRED_SLOTS", {}) monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) @@ -274,9 +289,9 @@ def test_retired_categories_are_gone_but_their_slots_are_recorded(): a future category. The numbers stay recorded so the history is visible and nobody wonders why the live table starts skipping at 6. """ - assert "vector" not in catalog.CATEGORY_SLOTS - assert "analytics" not in catalog.CATEGORY_SLOTS - assert catalog.RETIRED_SLOTS == {"vector": 6, "analytics": 7} + assert "vector" not in catalog.CATEGORY_SLOTS["operational"] + assert "analytics" not in catalog.CATEGORY_SLOTS["operational"] + assert catalog.RETIRED_SLOTS == {"operational": {"vector": 6, "analytics": 7}} # Never emitted a record, so never permanently reserved. assert not catalog.PERMANENTLY_RESERVED_SLOTS @@ -302,6 +317,16 @@ def test_retirement_did_not_move_the_surviving_ids(): assert tool_call_event_id("performance", "write") == 61525 -def test_a_new_category_would_reuse_the_lowest_retired_slot(): - """Reuse is safe here because neither retired slot ever emitted a record.""" - assert catalog.next_free_slot() == 6 +def test_a_new_category_takes_the_next_never_allocated_slot(): + """Slots 6 and 7 stay retired; the next category gets 9, after search's 8.""" + assert catalog.next_free_slot() == 9 + + +def test_search_ids_are_the_shipped_numbers(): + """Pinned literally, like every other shipped id. + + Search took slot 8 rather than the reusable retired slot 6, so these are + 61496/61528 and not 61494/61526. + """ + assert tool_call_event_id("search", "read") == 61496 + assert tool_call_event_id("search", "write") == 61528 diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py index db5d99a3..e7bc8233 100644 --- a/tests/unit/test_audit_classification.py +++ b/tests/unit/test_audit_classification.py @@ -27,32 +27,44 @@ resolve_tool_call_event, unclassified_tool_names, ) -from cb_mcp.tools import ( - ALL_TOOLS, - COLLECTION_WRITE_TOOLS, - INDEX_WRITE_TOOLS, - KV_WRITE_TOOLS, - READ_ONLY_TOOLS, -) +from cb_mcp.tools.operational import ALL_TOOLS, READ_ONLY_TOOLS, WRITE_TOOLS + +#: The audited package. Only ``operational`` has a Tier-2 block and a +#: classification table; the Operational Insights server declares no +#: ``audit_package`` and is deliberately not audited. +PACKAGE = "operational" +#: Main collapsed the four per-area tool groups into read-only and write when +#: the second server landed (#250), so the parity assertions below read the two +#: that remain. The contract they test is unchanged: the audit class must agree +#: with the server's own categorisation of every registered tool. REGISTERED = {tool.__name__ for tool in ALL_TOOLS} -WRITE_TOOL_NAMES = { - tool.__name__ - for tool in KV_WRITE_TOOLS + COLLECTION_WRITE_TOOLS + INDEX_WRITE_TOOLS -} +WRITE_TOOL_NAMES = {tool.__name__ for tool in WRITE_TOOLS} READ_ONLY_NAMES = {tool.__name__ for tool in READ_ONLY_TOOLS} def test_every_registered_tool_is_classified(): - assert unclassified_tool_names(REGISTERED) == [] + assert unclassified_tool_names(REGISTERED, PACKAGE) == [] def test_no_stale_entries_for_tools_that_do_not_exist(): - assert sorted(set(TOOL_CLASSIFICATION) - REGISTERED) == [] + assert sorted(set(TOOL_CLASSIFICATION[PACKAGE]) - REGISTERED) == [] def test_classification_covers_the_registered_set_exactly(): - assert set(TOOL_CLASSIFICATION) == REGISTERED + assert set(TOOL_CLASSIFICATION[PACKAGE]) == REGISTERED + + +def test_only_the_operational_package_is_classified(): + """Operational Insights is deliberately unaudited. + + Its tools collide with operational's on five names, so classifying it + without its own Tier-2 block would book its records against operational + ids. Adding it is a table here plus a block in the catalogue — this test + is what will fail, informatively, on the day someone adds one without + the other. + """ + assert set(TOOL_CLASSIFICATION) == {"operational"} @pytest.mark.parametrize("tool_name", sorted(WRITE_TOOL_NAMES)) @@ -129,6 +141,6 @@ def test_required_scope_is_derived_from_the_operation_class(operation_class, exp def test_every_classification_uses_a_known_category_and_class(): - for tool_name, (category, operation_class) in TOOL_CLASSIFICATION.items(): + for tool_name, (category, operation_class) in TOOL_CLASSIFICATION[PACKAGE].items(): assert category in CATEGORIES, tool_name assert operation_class in ("read", "write"), tool_name diff --git a/tests/unit/test_audit_integration_points.py b/tests/unit/test_audit_integration_points.py index 2e612260..44330026 100644 --- a/tests/unit/test_audit_integration_points.py +++ b/tests/unit/test_audit_integration_points.py @@ -45,7 +45,10 @@ resolve_real_userid, warn_on_unauthenticated_http, ) -from cb_mcp.tools.query import _query_correlation_options, run_sql_plus_plus_query +from cb_mcp.tools.operational.query import ( + _query_correlation_options, + run_sql_plus_plus_query, +) from cb_mcp.utils.constants import SCOPE_READ, SCOPE_WRITE from cb_mcp.utils.elicitation import ConfirmationResult, wrap_with_confirmation from cb_mcp.utils.scope_enforcement import wrap_with_scope_check @@ -243,7 +246,7 @@ def test_statement_class_is_recorded_when_auditing_is_active(): token = audit_state.install() try: with patch( - "cb_mcp.tools.query.get_audit_logger", + "cb_mcp.tools.operational.query.get_audit_logger", return_value=SimpleNamespace(active=True), ): run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") @@ -257,7 +260,7 @@ def test_select_is_classified_read_when_auditing_is_active(): token = audit_state.install() try: with patch( - "cb_mcp.tools.query.get_audit_logger", + "cb_mcp.tools.operational.query.get_audit_logger", return_value=SimpleNamespace(active=True), ): run_sql_plus_plus_query(ctx, "b", "s", "SELECT * FROM users") @@ -271,7 +274,7 @@ def test_explain_is_classified_read_without_parsing(): token = audit_state.install() try: with patch( - "cb_mcp.tools.query.get_audit_logger", + "cb_mcp.tools.operational.query.get_audit_logger", return_value=SimpleNamespace(active=True), ): run_sql_plus_plus_query(ctx, "b", "s", "EXPLAIN SELECT * FROM users") @@ -285,9 +288,9 @@ def test_no_statement_parse_when_auditing_is_off_and_writes_are_allowed(): ctx = _query_ctx(read_only_mode=False) token = audit_state.install() try: - with patch("cb_mcp.tools.query.parse_sqlpp") as parse: + with patch("cb_mcp.tools.operational.query.parse_sqlpp") as parse: with patch( - "cb_mcp.tools.query.get_audit_logger", + "cb_mcp.tools.operational.query.get_audit_logger", return_value=SimpleNamespace(active=False), ): run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") @@ -311,11 +314,11 @@ def test_unparseable_statement_still_runs_when_writes_are_allowed_and_auditing_o try: with ( patch( - "cb_mcp.tools.query.parse_sqlpp", + "cb_mcp.tools.operational.query.parse_sqlpp", side_effect=Exception("unparseable by the partial grammar"), ), patch( - "cb_mcp.tools.query.get_audit_logger", + "cb_mcp.tools.operational.query.get_audit_logger", return_value=SimpleNamespace(active=True), ), ): @@ -340,11 +343,11 @@ def test_unparseable_statement_still_fails_safe_when_writes_are_blocked(): try: with ( patch( - "cb_mcp.tools.query.parse_sqlpp", + "cb_mcp.tools.operational.query.parse_sqlpp", side_effect=Exception("unparseable by the partial grammar"), ), patch( - "cb_mcp.tools.query.get_audit_logger", + "cb_mcp.tools.operational.query.get_audit_logger", return_value=SimpleNamespace(active=True), ), pytest.raises(Exception, match="unparseable"), diff --git a/tests/unit/test_logger_names.py b/tests/unit/test_logger_names.py index d41c6cc8..a14257ce 100644 --- a/tests/unit/test_logger_names.py +++ b/tests/unit/test_logger_names.py @@ -47,6 +47,11 @@ # "couchbase" root, which belongs to the SDK. EXPECTED_LOGGER_NAMES = { # shared + "cb_mcp.audit.config": "couchbase.mcp.audit.config", + "cb_mcp.audit.emitter": "couchbase.mcp.audit", + "cb_mcp.audit.identity": "couchbase.mcp.audit.identity", + "cb_mcp.audit.middleware": "couchbase.mcp.audit.middleware", + "cb_mcp.audit.sink": "couchbase.mcp.audit.sink", "cb_mcp.auth": "couchbase.mcp.auth", "cb_mcp.core.app": "couchbase.mcp.core.app", "cb_mcp.tool_registration": "couchbase.mcp.tool_registration", diff --git a/tests/unit/test_settings_classification.py b/tests/unit/test_settings_classification.py index a2014ad7..d86a2a0b 100644 --- a/tests/unit/test_settings_classification.py +++ b/tests/unit/test_settings_classification.py @@ -43,8 +43,16 @@ # Listing them here is the opt-out: it forces the omission to be a decision # somebody wrote down, rather than an oversight. Keyed by server id. DELIBERATELY_UNREPORTED: dict[str, set[str]] = { - "operational": set(), - "operational-insights": set(), + # audit_config is a nested mapping, not a scalar setting, and it is already + # reported in full by two places that are better suited to it: + # ``get_server_configuration_status`` returns it alongside the live sink + # counters, and the ``server configuration`` audit record carries it into + # the audit file itself. Flattening it into the env-info snapshot would + # duplicate that without adding anything, so it is classified here rather + # than on either ServerSpec. It holds no secrets — a file path, sizes, and + # a list of disabled event ids. + "operational": {"audit_config"}, + "operational-insights": {"audit_config"}, } From 5cef475619c46566e70a9d2261881584e146bf03 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Tue, 29 Sep 2026 12:21:49 +0530 Subject: [PATCH 03/27] Update documentation --- DOCKER.md | 31 ++++++++++++++++++++ README.md | 38 ++++++++++++++++++++++++ server.json | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) diff --git a/DOCKER.md b/DOCKER.md index ca41e0f8..71542632 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -212,6 +212,12 @@ The detailed explanation for the environment variables can be found on the [GitH | `CB_MCP_OAUTH_MCP_BASE_URL` | Public base URL of this server. When set, publishes RFC 9728 Protected Resource Metadata for PRM-aware clients | None | | `CB_MCP_OAUTH_SCOPE_READ_LABEL` | Override the OAuth scope label treated as 'read' access (advertised in PRM and matched against the token `scope`/`scp` claim). Use when your IdP can't emit the canonical form | `couchbase-mcp:read` | | `CB_MCP_OAUTH_SCOPE_WRITE_LABEL` | Override the OAuth scope label treated as 'write' access; same semantics as the read label | `couchbase-mcp:write` | +| `CB_MCP_AUDIT_LOG_ENABLED` | Enable audit logging — a separate sink from the `CB_MCP_LOG_*` operational logs, with a stable schema and its own retention (see [Auditing](#auditing)) | `false` | +| `CB_MCP_AUDIT_FILE` | Path to the audit log file. Required when auditing is enabled; the process id is inserted before the extension (`audit.log` → `audit.1234.log`). Mount a volume at this path to keep records after the container stops | None | +| `CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB` | Maximum size **in MB** an audit file may reach before it rotates. `0` is invalid and falls back to the default with a startup warning | `1` (1 MB) | +| `CB_MCP_AUDIT_RETENTION_BACKUP_COUNT`| Rotated audit files retained, excluding the live file. `0` keeps only the live file | `1000` | +| `CB_MCP_AUDIT_TOOL_ARGS` | Record tool argument values. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | +| `CB_MCP_AUDIT_DISABLED_EVENTS` | Audit events to suppress: comma-separated ids or names (e.g. `61490,document read`), or a file with one entry per line. Only filterable events can be suppressed | None | ### Disabling Tools @@ -391,6 +397,31 @@ The server logs to `stderr` by default. Logging is configured with the `CB_MCP_L For more details, see the [documentation](https://docs.couchbase.com/mcp-server/configuration/logging.html). +### Auditing + +Audit logging records **who did what, and whether it was allowed** — a different question from the operational logs, which record what went wrong. It is off by default and enabled with the `CB_MCP_AUDIT_*` variables in the [Environment Variables](#environment-variables) table. + +Records are JSON Lines, one immutable record per line, in the same field vocabulary as Couchbase Server and Sync Gateway. + +- **`CB_MCP_AUDIT_LOG_ENABLED` / `CB_MCP_AUDIT_FILE`** — both are needed. Enabling auditing without a path leaves the server running with auditing disabled and an error in the log. +- **Mount a volume at the audit path.** The audit file is written inside the container like any other file, so without a volume the records are destroyed when the container is removed — which defeats the purpose of keeping them. This matters more than it does for logs: audit retention defaults to 1000 rotated backups precisely because the records are meant to outlive the process. +- **One file per container.** The process id is inserted before the extension (`audit.log` → `audit.1234.log`), so several containers can share a mounted directory without corrupting each other's records. Consolidating them is the operator's job. +- **Filtering** — `CB_MCP_AUDIT_DISABLED_EVENTS` turns read noise down. Write and security events cannot be disabled; an attempt to do so is refused with a warning. +- **`CB_MCP_AUDIT_TOOL_ARGS`** — off by default, and warns at startup when enabled. There is no redaction in this release, so arguments are written verbatim, including full document bodies. +- **Fail-open** — if the audit file cannot be written, the server keeps serving and records are dropped and counted. `get_server_configuration_status` reports the live `written` / `dropped` / `write_errors` counters. + +Auditing applies to the **operational** server. The `operational-insights` server accepts the same variables and reports them, but records nothing. + +```bash +docker run --rm \ + -e CB_CONNECTION_STRING=couchbases://your-cluster \ + -e CB_USERNAME=user -e CB_PASSWORD=password \ + -e CB_MCP_AUDIT_LOG_ENABLED=true \ + -e CB_MCP_AUDIT_FILE=/audit/audit.log \ + -v "$(pwd)/audit:/audit" \ + couchbase/mcp-server-couchbase:latest +``` + ### OAuth 2.1 Authorization When running with `CB_MCP_TRANSPORT=http`, the server can act as an **OAuth 2.1 resource server**: it validates incoming bearer JWTs against your identity provider's JWKS. It is provider-agnostic (any OAuth 2.1 / OIDC provider that publishes a JWKS — Auth0, Okta, Keycloak, AWS Cognito, Microsoft Entra, etc.) and does **not** issue tokens or manage users. OAuth settings are ignored on `stdio`. diff --git a/README.md b/README.md index e1487952..f4f35304 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,12 @@ The server can be configured using environment variables or command line argumen | `CB_MCP_OAUTH_MCP_BASE_URL` | `--oauth-mcp-base-url` | Public base URL of this server. When set, publishes RFC 9728 Protected Resource Metadata so PRM-aware clients can discover the IdP | None | | `CB_MCP_OAUTH_SCOPE_READ_LABEL` | `--oauth-scope-read-label` | Override the OAuth scope label treated as 'read' access (advertised in PRM and matched against the token's `scope`/`scp` claim). Use when your IdP can't emit the canonical form | `couchbase-mcp:read` | | `CB_MCP_OAUTH_SCOPE_WRITE_LABEL` | `--oauth-scope-write-label` | Override the OAuth scope label treated as 'write' access; same semantics as the read label | `couchbase-mcp:write` | +| `CB_MCP_AUDIT_LOG_ENABLED` | `--audit-log-enabled` | Enable audit logging — a separate sink from the `CB_MCP_LOG_*` operational logs, with a stable schema and its own retention (see [Auditing](#auditing)) | `false` | +| `CB_MCP_AUDIT_FILE` | `--audit-file` | Path to the audit log file. Required when auditing is enabled; the process id is inserted before the extension (`audit.log` → `audit.1234.log`) | None | +| `CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB` | `--audit-rotation-max-size-mb` | Maximum size **in MB** an audit file may reach before it rotates. `0` is invalid and falls back to the default with a startup warning | `1` (1 MB) | +| `CB_MCP_AUDIT_RETENTION_BACKUP_COUNT` | `--audit-retention-backup-count` | Rotated audit files retained, excluding the live file. `0` keeps only the live file | `1000` | +| `CB_MCP_AUDIT_TOOL_ARGS` | `--audit-tool-args` | Record tool argument values in the audit log. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | +| `CB_MCP_AUDIT_DISABLED_EVENTS` | `--audit-disabled-events` | Audit events to suppress: comma-separated ids or names (e.g. `61490,document read`), or a file with one entry per line. Only filterable events can be suppressed | None | #### Read-Only Mode Configuration @@ -461,6 +467,38 @@ uvx couchbase-mcp-server --log-level=debug --log-sinks=file \ For more details, see the [documentation](https://docs.couchbase.com/mcp-server/configuration/logging.html). +### Auditing + +Audit logging answers a different question from the operational logs. Logs tell you **what went wrong**; the audit log tells you **who did what, and whether it was allowed**. It exists because the MCP server is the only layer that can answer either question about an agent: every request reaches the cluster as the single `CB_USERNAME` the server connects with, so Couchbase Server's own audit log cannot tell two callers apart — and decisions such as a scope denial, a read-only block or a declined confirmation never reach the cluster at all. + +Auditing is **off by default**. It is enabled with `CB_MCP_AUDIT_LOG_ENABLED` and a path in `CB_MCP_AUDIT_FILE`; enabling it without a path leaves the server running with auditing disabled and an error in the log. + +Records are **JSON Lines** — one immutable record per line — using the same field vocabulary as Couchbase Server and Sync Gateway (`id`, `name`, `description`, `timestamp`, `real_userid`, `server`, `cid`, `outcome`, `reason`), so an existing audit pipeline gets one format across the estate. + +```json +{"id":61522,"name":"document write","description":"A document write operation was requested through a tool","timestamp":"2026-09-29T09:14:22.418Z","real_userid":{"domain":"oauth","user":"agent-svc-7"},"server":{"version":"1.0.2","host":"mcp-01"},"cid":"9f2c...","outcome":"success","tool_name":"upsert_document_by_id","required_scope":"write","ks":"travel-sample.inventory.airline","cb_userid":"mcp_service"} +``` + +- **Identity** — `real_userid` records the *caller*, in one of three domains: `oauth` (the bearer token's subject), `local` (the OS process owner, on stdio), or `anonymous` (HTTP with OAuth disabled). Enabling auditing on HTTP without OAuth warns at startup, because every record then resolves to `anonymous`. +- **Correlation** — `cid` groups every record produced by one request, so an authorization denial, a guardrail refusal and the eventual tool result join without matching on timestamps. There is deliberately **no session identifier**: MCP is moving to a stateless model in which connection identity must not be read as session continuity. For SQL++, the same `cid` is sent as the query's `client_context_id`, which Couchbase Server records as `clientContextId` — an exact join between the two logs. +- **Events** — a fixed catalogue on numbered ID blocks: server lifecycle and configuration, session initialize, token rejected, scope check denied, read-only write blocked, confirmation declined or skipped, and one read/write pair per tool category (cluster, schema, document, query, index, performance, search). Adding a tool needs no new ID and no change to a SIEM rule. The full catalogue ships as `descriptor.json` inside the package. +- **Filtering** — `CB_MCP_AUDIT_DISABLED_EVENTS` turns *read* noise down. Write and security events are **not filterable**: an attempt to disable one is refused with a warning, so the record of who changed what cannot be switched off by configuration. +- **Retention** — rotation size and backup count work like the operational logs, but the default retention is far larger (1000 backups) because audit records are reviewed on a quarterly or annual cadence, not for day-to-day triage. +- **Per-process files** — the process id is inserted before the extension, because a stdio deployment runs one server process per client and a shared rotating file would interleave and corrupt records. +- **Fail-open** — if the audit file cannot be written, the server keeps serving and the records are dropped and counted rather than turning an audit outage into a server outage. `get_server_configuration_status` reports the live `written` / `dropped` / `write_errors` counters, and `dropped` is the only way to discover that records were lost. +- **Tool arguments** — recorded only when `CB_MCP_AUDIT_TOOL_ARGS` is enabled, which warns at startup. There is no redaction in this release, so arguments are written verbatim, including the full document bodies passed to document-write tools. + +Auditing currently applies to the **operational** server. The Operational Insights server accepts the same flags and reports them, but records nothing. + +```bash +# Audit to a file, keeping the default 1000 rotated backups +uvx couchbase-mcp-server --audit-log-enabled=true --audit-file=/var/log/cb-mcp/audit.log + +# Turn down read noise, keeping every write and security event +uvx couchbase-mcp-server --audit-log-enabled=true --audit-file=/var/log/cb-mcp/audit.log \ + --audit-disabled-events="document read,query read" +``` + ### Client Specific Configuration
diff --git a/server.json b/server.json index e7450362..5ad275b7 100644 --- a/server.json +++ b/server.json @@ -247,6 +247,48 @@ "isRequired": false, "format": "string", "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ENABLED", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Requires CB_MCP_AUDIT_FILE. Default is false.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_FILE", + "description": "Path to the audit log file, required when CB_MCP_AUDIT_LOG_ENABLED is true. Each server process writes its own file with the process id inserted before the extension (audit.log becomes audit.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with auditing disabled and an error recorded.", + "isRequired": false, + "format": "filepath", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB", + "description": "Maximum size in MB an audit file may reach before it rotates. 0 is invalid and falls back to the default with a startup warning. Default is 1 MB.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_RETENTION_BACKUP_COUNT", + "description": "Number of rotated audit files retained, excluding the live file. Much larger than the operational-log default because audit records are reviewed on a quarterly or annual cadence. Set to 0 to keep only the live file. Default is 1000.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_TOOL_ARGS", + "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_DISABLED_EVENTS", + "description": "Audit events to suppress, as comma-separated event ids or names (for example '61490,document read'), or a path to a file with one entry per line. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "isRequired": false, + "format": "string", + "isSecret": false } ], "packageArguments": [ @@ -753,6 +795,48 @@ "isRequired": false, "format": "string", "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ENABLED", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Requires CB_MCP_AUDIT_FILE. Default is false.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_FILE", + "description": "Path to the audit log file, required when CB_MCP_AUDIT_LOG_ENABLED is true. Each server process writes its own file with the process id inserted before the extension (audit.log becomes audit.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with auditing disabled and an error recorded.", + "isRequired": false, + "format": "filepath", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB", + "description": "Maximum size in MB an audit file may reach before it rotates. 0 is invalid and falls back to the default with a startup warning. Default is 1 MB.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_RETENTION_BACKUP_COUNT", + "description": "Number of rotated audit files retained, excluding the live file. Much larger than the operational-log default because audit records are reviewed on a quarterly or annual cadence. Set to 0 to keep only the live file. Default is 1000.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_TOOL_ARGS", + "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_DISABLED_EVENTS", + "description": "Audit events to suppress, as comma-separated event ids or names (for example '61490,document read'), or a path to a file with one entry per line. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "isRequired": false, + "format": "string", + "isSecret": false } ] } From 81875132b3fd08d470b88088603afcd56ae8299c Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Tue, 29 Sep 2026 12:34:22 +0530 Subject: [PATCH 04/27] Change audit disable events to Event IDs only --- DOCKER.md | 4 +-- README.md | 6 ++--- server.json | 4 +-- src/cb_mcp/audit/config.py | 27 ++++++++++++++------ src/cb_mcp/core/cli/options.py | 10 ++++---- tests/unit/test_audit_config.py | 39 ++++++++++++++++++++--------- tests/unit/test_audit_middleware.py | 2 +- 7 files changed, 59 insertions(+), 33 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index 71542632..48b7f7f8 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -217,7 +217,7 @@ The detailed explanation for the environment variables can be found on the [GitH | `CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB` | Maximum size **in MB** an audit file may reach before it rotates. `0` is invalid and falls back to the default with a startup warning | `1` (1 MB) | | `CB_MCP_AUDIT_RETENTION_BACKUP_COUNT`| Rotated audit files retained, excluding the live file. `0` keeps only the live file | `1000` | | `CB_MCP_AUDIT_TOOL_ARGS` | Record tool argument values. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | -| `CB_MCP_AUDIT_DISABLED_EVENTS` | Audit events to suppress: comma-separated ids or names (e.g. `61490,document read`), or a file with one entry per line. Only filterable events can be suppressed | None | +| `CB_MCP_AUDIT_DISABLED_EVENTS` | Audit events to suppress: comma-separated numeric event ids (e.g. `61490,61491`), or a file with one id per line. Only filterable events can be suppressed | None | ### Disabling Tools @@ -406,7 +406,7 @@ Records are JSON Lines, one immutable record per line, in the same field vocabul - **`CB_MCP_AUDIT_LOG_ENABLED` / `CB_MCP_AUDIT_FILE`** — both are needed. Enabling auditing without a path leaves the server running with auditing disabled and an error in the log. - **Mount a volume at the audit path.** The audit file is written inside the container like any other file, so without a volume the records are destroyed when the container is removed — which defeats the purpose of keeping them. This matters more than it does for logs: audit retention defaults to 1000 rotated backups precisely because the records are meant to outlive the process. - **One file per container.** The process id is inserted before the extension (`audit.log` → `audit.1234.log`), so several containers can share a mounted directory without corrupting each other's records. Consolidating them is the operator's job. -- **Filtering** — `CB_MCP_AUDIT_DISABLED_EVENTS` turns read noise down. Write and security events cannot be disabled; an attempt to do so is refused with a warning. +- **Filtering** — `CB_MCP_AUDIT_DISABLED_EVENTS` turns read noise down, taking numeric event ids (e.g. `61490,61491`). Write and security events cannot be disabled; an attempt to do so is refused with a warning. - **`CB_MCP_AUDIT_TOOL_ARGS`** — off by default, and warns at startup when enabled. There is no redaction in this release, so arguments are written verbatim, including full document bodies. - **Fail-open** — if the audit file cannot be written, the server keeps serving and records are dropped and counted. `get_server_configuration_status` reports the live `written` / `dropped` / `write_errors` counters. diff --git a/README.md b/README.md index f4f35304..a1c6cd16 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ The server can be configured using environment variables or command line argumen | `CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB` | `--audit-rotation-max-size-mb` | Maximum size **in MB** an audit file may reach before it rotates. `0` is invalid and falls back to the default with a startup warning | `1` (1 MB) | | `CB_MCP_AUDIT_RETENTION_BACKUP_COUNT` | `--audit-retention-backup-count` | Rotated audit files retained, excluding the live file. `0` keeps only the live file | `1000` | | `CB_MCP_AUDIT_TOOL_ARGS` | `--audit-tool-args` | Record tool argument values in the audit log. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | -| `CB_MCP_AUDIT_DISABLED_EVENTS` | `--audit-disabled-events` | Audit events to suppress: comma-separated ids or names (e.g. `61490,document read`), or a file with one entry per line. Only filterable events can be suppressed | None | +| `CB_MCP_AUDIT_DISABLED_EVENTS` | `--audit-disabled-events` | Audit events to suppress: comma-separated numeric event ids (e.g. `61490,61491`), or a file with one id per line. Only filterable events can be suppressed | None | #### Read-Only Mode Configuration @@ -482,7 +482,7 @@ Records are **JSON Lines** — one immutable record per line — using the same - **Identity** — `real_userid` records the *caller*, in one of three domains: `oauth` (the bearer token's subject), `local` (the OS process owner, on stdio), or `anonymous` (HTTP with OAuth disabled). Enabling auditing on HTTP without OAuth warns at startup, because every record then resolves to `anonymous`. - **Correlation** — `cid` groups every record produced by one request, so an authorization denial, a guardrail refusal and the eventual tool result join without matching on timestamps. There is deliberately **no session identifier**: MCP is moving to a stateless model in which connection identity must not be read as session continuity. For SQL++, the same `cid` is sent as the query's `client_context_id`, which Couchbase Server records as `clientContextId` — an exact join between the two logs. - **Events** — a fixed catalogue on numbered ID blocks: server lifecycle and configuration, session initialize, token rejected, scope check denied, read-only write blocked, confirmation declined or skipped, and one read/write pair per tool category (cluster, schema, document, query, index, performance, search). Adding a tool needs no new ID and no change to a SIEM rule. The full catalogue ships as `descriptor.json` inside the package. -- **Filtering** — `CB_MCP_AUDIT_DISABLED_EVENTS` turns *read* noise down. Write and security events are **not filterable**: an attempt to disable one is refused with a warning, so the record of who changed what cannot be switched off by configuration. +- **Filtering** — `CB_MCP_AUDIT_DISABLED_EVENTS` turns *read* noise down, taking numeric event ids (the id is the wire contract; event names are prose and may be reworded, so they are not accepted). Write and security events are **not filterable**: an attempt to disable one is refused with a warning, so the record of who changed what cannot be switched off by configuration. - **Retention** — rotation size and backup count work like the operational logs, but the default retention is far larger (1000 backups) because audit records are reviewed on a quarterly or annual cadence, not for day-to-day triage. - **Per-process files** — the process id is inserted before the extension, because a stdio deployment runs one server process per client and a shared rotating file would interleave and corrupt records. - **Fail-open** — if the audit file cannot be written, the server keeps serving and the records are dropped and counted rather than turning an audit outage into a server outage. `get_server_configuration_status` reports the live `written` / `dropped` / `write_errors` counters, and `dropped` is the only way to discover that records were lost. @@ -496,7 +496,7 @@ uvx couchbase-mcp-server --audit-log-enabled=true --audit-file=/var/log/cb-mcp/a # Turn down read noise, keeping every write and security event uvx couchbase-mcp-server --audit-log-enabled=true --audit-file=/var/log/cb-mcp/audit.log \ - --audit-disabled-events="document read,query read" + --audit-disabled-events="61490,61491" # document read, query read ``` ### Client Specific Configuration diff --git a/server.json b/server.json index 5ad275b7..e10a69e6 100644 --- a/server.json +++ b/server.json @@ -285,7 +285,7 @@ }, { "name": "CB_MCP_AUDIT_DISABLED_EVENTS", - "description": "Audit events to suppress, as comma-separated event ids or names (for example '61490,document read'), or a path to a file with one entry per line. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", "isRequired": false, "format": "string", "isSecret": false @@ -833,7 +833,7 @@ }, { "name": "CB_MCP_AUDIT_DISABLED_EVENTS", - "description": "Audit events to suppress, as comma-separated event ids or names (for example '61490,document read'), or a path to a file with one entry per line. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", "isRequired": false, "format": "string", "isSecret": false diff --git a/src/cb_mcp/audit/config.py b/src/cb_mcp/audit/config.py index 056a4c8b..ba5545c1 100644 --- a/src/cb_mcp/audit/config.py +++ b/src/cb_mcp/audit/config.py @@ -25,7 +25,7 @@ DEFAULT_AUDIT_TOOL_ARGS, LOGGER_NAMESPACE, ) -from .catalog import ALL_IDS, EVENT_NAMES_TO_IDS, FILTERABLE_IDS +from .catalog import ALL_IDS, FILTERABLE_IDS from .sink import process_scoped_path logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.config") @@ -90,11 +90,21 @@ def _read_event_tokens(value: str) -> list[str] | None: def _resolve_event_id(token: str) -> int | None: - """Map one entry — a numeric id or a catalogue name — onto an event id.""" - if token.isdigit(): - event_id = int(token) - return event_id if event_id in ALL_IDS else None - return EVENT_NAMES_TO_IDS.get(token) + """Map one entry — a numeric event id — onto an event id. + + Numeric ids only. Catalogue names were accepted here at one point and were + withdrawn deliberately: the id is the wire contract and the name is not. + Names carry spaces and are English prose ("write blocked (read-only mode)"), + which makes them awkward to quote in a shell and in a Docker ``-e`` value, + and a name is free to be reworded for clarity in a way an id never is — so + a filter written against a name could silently stop matching after an + editorial change. ``descriptor.json`` maps every id to its name for anyone + composing a filter. + """ + if not token.isdigit(): + return None + event_id = int(token) + return event_id if event_id in ALL_IDS else None def parse_disabled_events(raw: str | None) -> set[int]: @@ -102,13 +112,14 @@ def parse_disabled_events(raw: str | None) -> set[int]: Accepts a comma-separated list, or a path to a file with one entry per line (``#`` comments allowed), matching how ``--disabled-tools`` already works. - Entries may be either the numeric event id or the catalogue event name. + Entries are **numeric event ids**; see :func:`_resolve_event_id` for why + catalogue names are not accepted. Two classes of entry are rejected with a warning rather than silently honoured, because both would leave an operator believing they had filtered something they had not: - * an id or name that is not in the catalogue at all; + * an entry that is not a numeric id in the catalogue at all; * a **non-filterable** event. Writes and security decisions are mandated — an operator may turn read noise down but may not switch off the record of who changed what. diff --git a/src/cb_mcp/core/cli/options.py b/src/cb_mcp/core/cli/options.py index fbd955fc..1c791b41 100644 --- a/src/cb_mcp/core/cli/options.py +++ b/src/cb_mcp/core/cli/options.py @@ -459,11 +459,11 @@ def logging_options(*, default_log_file: str) -> Callable: "audit_disabled_events", envvar="CB_MCP_AUDIT_DISABLED_EVENTS", default=None, - help="Audit events to suppress. Accepts comma-separated event ids or " - "names (e.g. '61490,document read'), or a file path with one entry per " - "line. Only filterable events can be suppressed: write and security events " - "are always recorded, and an attempt to disable one is refused with a " - "warning.", + help="Audit events to suppress. Accepts comma-separated numeric event " + "ids (e.g. '61490,61491'), or a file path with one id per line. Ids are " + "listed in the audit descriptor. Only filterable events can be " + "suppressed: write and security events are always recorded, and an " + "attempt to disable one is refused with a warning.", ), ) """Audit sink configuration. Shared by every server; honored where a spec declares an audit package.""" diff --git a/tests/unit/test_audit_config.py b/tests/unit/test_audit_config.py index 0e95da52..f0b86f49 100644 --- a/tests/unit/test_audit_config.py +++ b/tests/unit/test_audit_config.py @@ -5,7 +5,7 @@ - enabling without a file is reported and leaves auditing off, not fatal - rotation size and backup count fallbacks on invalid input - MB to bytes conversion -- disabled-events parsing: ids, names, files, comments, whitespace +- disabled-events parsing: ids only, files, comments, whitespace - non-filterable events cannot be suppressed - unknown events are ignored with a warning - the tool-args warning fires only when it can matter @@ -136,10 +136,25 @@ def test_tool_args_warns_only_when_auditing_is_active(tmp_path, caplog): # --------------------------------------------------------------------------- -def test_disabled_events_accepts_ids_and_names(): +def test_disabled_events_accepts_numeric_ids(): assert parse_disabled_events("61490") == {61490} - assert parse_disabled_events("document read") == {61490} - assert parse_disabled_events("61490, query read") == {61490, 61491} + assert parse_disabled_events("61490, 61491") == {61490, 61491} + assert parse_disabled_events(" 61490 ,61491 ") == {61490, 61491} + + +def test_catalogue_names_are_not_accepted(caplog): + """Names were accepted once and were withdrawn deliberately. + + The id is the wire contract; the name is prose. Names carry spaces and + parentheses, which makes them awkward to quote in a shell or a Docker -e + value, and a name may be reworded for clarity in a way an id never is — so + a filter written against one could silently stop matching. A name is now + treated as an unknown entry and warned about, rather than half-working. + """ + with caplog.at_level(logging.WARNING): + assert parse_disabled_events("document read") == set() + assert parse_disabled_events("61490, query read") == {61490} + assert "unknown audit event" in caplog.text.lower() @pytest.mark.parametrize("raw", [None, "", " ", ","]) @@ -153,9 +168,9 @@ def test_disabled_events_from_a_file_ignores_comments_and_blanks(tmp_path): "\n".join( [ "# suppress the noisy reads", - "document read", + "61490", "", - " query read ", + " 61491 ", "# trailing comment", ] ), @@ -166,21 +181,21 @@ def test_disabled_events_from_a_file_ignores_comments_and_blanks(tmp_path): def test_non_filterable_events_cannot_be_suppressed(caplog): with caplog.at_level(logging.WARNING): - # A write id, a security event and a lifecycle event. - result = parse_disabled_events("61522, scope check denied, server started") + # A write id, a security event and a lifecycle event, all by id. + result = parse_disabled_events("61522, 57377, 57344") assert result == set() assert "Refused to disable non-filterable" in caplog.text def test_unknown_events_are_ignored_with_a_warning(caplog): with caplog.at_level(logging.WARNING): - result = parse_disabled_events("99999, not an event, document read") + result = parse_disabled_events("99999, not an event, 61490") assert result == {61490} assert "unknown audit event" in caplog.text.lower() def test_filters_are_not_parsed_when_auditing_is_disabled(): - config = _resolve(enabled=False, disabled_events="document read") + config = _resolve(enabled=False, disabled_events="61490") assert config.disabled_events == () @@ -188,7 +203,7 @@ def test_session_initialized_is_filterable(tmp_path): config = _resolve( enabled=True, file=str(tmp_path / "a.log"), - disabled_events="session initialized", + disabled_events="57360", ) assert config.disabled_events == (57360,) @@ -205,7 +220,7 @@ def test_as_dict_is_json_friendly_and_complete(tmp_path): rotation_max_size_mb=4, retention_backup_count=7, tool_args=True, - disabled_events="document read", + disabled_events="61490", ) snapshot = config.as_dict() assert snapshot == { diff --git a/tests/unit/test_audit_middleware.py b/tests/unit/test_audit_middleware.py index 9a49407c..7da7d8b8 100644 --- a/tests/unit/test_audit_middleware.py +++ b/tests/unit/test_audit_middleware.py @@ -389,7 +389,7 @@ def run_sql_plus_plus_query(ctx: Context, bucket_name: str, query: str) -> str: @pytest.mark.asyncio async def test_disabled_event_is_not_written(tmp_path): - audit = _build_logger(tmp_path, disabled_events="document read") + audit = _build_logger(tmp_path, disabled_events="61490") # document read mcp = _server(audit) @mcp.tool From e4717b335e2f88e3ac005216a671f29f8d33e3d3 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Tue, 29 Sep 2026 13:54:55 +0530 Subject: [PATCH 05/27] Review comment addressed --- src/cb_mcp/audit/sink.py | 49 ++++++- src/cb_mcp/core/app.py | 30 ++-- src/cb_mcp/tools/operational/query.py | 146 +++++++++++--------- tests/unit/test_audit_integration_points.py | 137 +++++++++++++++++- tests/unit/test_audit_sink.py | 96 +++++++++++++ 5 files changed, 374 insertions(+), 84 deletions(-) diff --git a/src/cb_mcp/audit/sink.py b/src/cb_mcp/audit/sink.py index 93ef6a40..b24aabbd 100644 --- a/src/cb_mcp/audit/sink.py +++ b/src/cb_mcp/audit/sink.py @@ -54,6 +54,12 @@ #: Seconds the writer thread is given to drain on close. DEFAULT_CLOSE_TIMEOUT = 5.0 +#: How long the writer blocks on an empty queue before re-checking whether the +#: sink has been closed. The sentinel normally wakes it immediately; this is the +#: fallback for the one case the sentinel cannot cover — a queue already full +#: when ``close`` runs, so the sentinel could not be posted. +_POLL_SECONDS = 0.2 + _SENTINEL = object() @@ -141,8 +147,10 @@ def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: return self._closed.set() if self._thread is not None: - # A full queue means the writer is already behind; it will reach - # the sentinel-free shutdown path via the join timeout below. + # Best-effort: on a full queue the sentinel cannot be posted, and + # dropping a queued record to make room for it would lose an audit + # line purely to shut down faster. The writer polls the closed flag + # instead, so it still exits once it has drained what it has. with contextlib.suppress(queue.Full): self._queue.put_nowait(_SENTINEL) self._thread.join(timeout=timeout) @@ -184,7 +192,15 @@ def stats(self) -> dict[str, int]: def _run(self) -> None: while True: - item = self._queue.get() + try: + item = self._queue.get(timeout=_POLL_SECONDS) + except queue.Empty: + # Nothing left to write. Exit only once the sink is closed, + # which is what lets the thread finish when ``close`` could not + # post the sentinel into a full queue. + if self._closed.is_set(): + return + continue if item is _SENTINEL: return batch = [item] @@ -200,20 +216,41 @@ def _run(self) -> None: batch.append(nxt) self._write_batch(batch) + def _reopen_if_closed(self) -> None: + """Re-establish the stream if it is not open. + + Rotation closes the live file before reopening it, so a failure in + between — a full disk, a revoked permission, an unmounted volume — + leaves the sink holding a closed handle. Without this the sink would + never write again even after the condition cleared, which is the worst + possible failure mode for an audit log: silent and permanent. Recovery + is attempted per record, so the first write after the disk frees up + succeeds. + """ + if self._stream is not None and not self._stream.closed: + return + self._stream = open(self.path, "a", encoding="utf-8") # noqa: SIM115 + self._size = self.path.stat().st_size + def _write_batch(self, lines: list[str]) -> None: for line in lines: try: + self._reopen_if_closed() self._rotate_if_needed(len(line.encode("utf-8"))) self._stream.write(line) self._size += len(line.encode("utf-8")) self._written += 1 - except OSError as exc: + except (OSError, ValueError) as exc: + # ValueError is "I/O operation on closed file": reachable when + # ``close`` closes the stream while this thread is still + # draining, and not an OSError subclass. self._errors += 1 self._dropped += 1 self._log_failure(f"failed to write audit record: {exc}") try: - self._stream.flush() - except OSError as exc: + if not self._stream.closed: + self._stream.flush() + except (OSError, ValueError) as exc: self._errors += 1 self._log_failure(f"failed to flush audit file: {exc}") diff --git a/src/cb_mcp/core/app.py b/src/cb_mcp/core/app.py index f8fdef7f..b79b6452 100644 --- a/src/cb_mcp/core/app.py +++ b/src/cb_mcp/core/app.py @@ -171,22 +171,32 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # whenever the user runs with --log-level DEBUG. log_environment_info(transport, settings, spec) send_install_ping(transport, server_id=spec.id) - app_context = AppContext( - cluster_provider=provider_factory(), - settings=settings, - read_only_mode=read_only_mode, - logging_config=logging_config, - audit_config=audit_config.as_dict() if audit_config is not None else None, - server_id=spec.id, - server_name=spec.fastmcp_name, - ) + # Built inside the try, not before it: ``provider_factory`` opens a + # connection and can fail, and auditing has already started by this + # point — with a sink open, a writer thread running and the + # server-started record queued. Constructing this outside the try meant + # a provider failure skipped the cleanup below entirely, losing + # whatever was queued and leaving the sink open for the life of the + # process. A failed startup is exactly when the audit trail matters. + app_context: AppContext | None = None try: + app_context = AppContext( + cluster_provider=provider_factory(), + settings=settings, + read_only_mode=read_only_mode, + logging_config=logging_config, + audit_config=( + audit_config.as_dict() if audit_config is not None else None + ), + server_id=spec.id, + server_name=spec.fastmcp_name, + ) yield app_context except Exception as e: logger.error(f"Error in app lifespan: {e}", exc_info=True) raise finally: - if app_context.cluster_provider: + if app_context is not None and app_context.cluster_provider: app_context.cluster_provider.close() # Recorded before the sink closes so the stop record is the last # line written. A process killed outright leaves no stop record; diff --git a/src/cb_mcp/tools/operational/query.py b/src/cb_mcp/tools/operational/query.py index 7acf02ff..01451830 100644 --- a/src/cb_mcp/tools/operational/query.py +++ b/src/cb_mcp/tools/operational/query.py @@ -157,10 +157,6 @@ def run_sql_plus_plus_query( function — it keeps FTS-specific result shape (score, fragments, facets) out of general query results and gives clearer tracing of what was searched. """ - cluster = get_cluster_connection(ctx) - - bucket = connect_to_bucket(cluster, bucket_name) - app_context = ctx.request_context.lifespan_context read_only_mode = app_context.read_only_mode @@ -177,75 +173,91 @@ def run_sql_plus_plus_query( lacks_write_scope = token is not None and SCOPE_WRITE not in (token.scopes or []) block_query_writes = read_only_mode or lacks_write_scope + # Classification and the write guard both run *before* the cluster is + # touched. Connecting first meant an unreachable cluster or a missing + # bucket raised before the statement was ever inspected, so a failed + # attempt to run DML was recorded against the query *read* id — and read + # ids are filterable, so "someone tried to UPDATE and it failed" could be + # filtered out of the audit log by an operator turning read noise down. + # Nothing here does I/O: read-only mode and the token both come from the + # request context. + # + # It also means a statement the guard refuses is now refused without a + # connection attempt, so the caller gets the accurate reason (blocked) + # rather than whatever connection error happened to come first. + results: list[dict[str, Any]] = [] + # EXPLAIN statements are always safe to execute and should bypass write checks. + is_explain = _is_explain_statement(query) + + # This tool is the one whose audit category depends on the statement it + # is given, so when auditing is active the statement must be inspected + # even where the write guard would not have needed to. Otherwise an + # *allowed* DML statement would be recorded against the query-read id, + # and read ids are filterable — a successful mutation could be filtered + # out of the audit log. The parse is done once here and shared with the + # guard below, so an audited call never pays for it twice, and a call + # made with auditing off pays nothing at all. + audit_active = get_audit_logger().active + needs_audit_classification = audit_active and not is_explain + + if is_explain and audit_active: + audit_state.record_statement_class("read") + + if (block_query_writes or needs_audit_classification) and not is_explain: + try: + kind = _blocked_write_kind(parse_sqlpp(query)) + except Exception: + if block_query_writes: + raise + if audit_active: + audit_state.record_statement_class("write") + kind = None + else: + if audit_active: + audit_state.record_statement_class( + "write" if kind is not None else "read" + ) + + if kind is not None and block_query_writes: + if lacks_write_scope and not read_only_mode: + # lacks_write_scope implies token is not None here. + held_scopes = sorted(set(token.scopes or [])) + msg = ( + f"SQL++ {kind} modification requires the " + f"'{SCOPE_WRITE}' scope; token scopes are {held_scopes}." + ) + logger.warning(msg) + audit_state.record_refusal( + event_id=StatementScopeDeniedError.audit_event.id, + event_name=StatementScopeDeniedError.audit_event.event_name, + outcome=StatementScopeDeniedError.audit_outcome, + reason=StatementScopeDeniedError.audit_reason, + required_scope="write", + statement_kind=kind, + ) + raise StatementScopeDeniedError(msg) + msg = f"{kind.capitalize()} modification query is not allowed in read-only mode" + logger.error(msg) + audit_state.record_refusal( + event_id=ReadOnlyWriteBlockedError.audit_event.id, + event_name=ReadOnlyWriteBlockedError.audit_event.event_name, + outcome=ReadOnlyWriteBlockedError.audit_outcome, + reason=ReadOnlyWriteBlockedError.audit_reason, + statement_kind=kind, + ) + raise ReadOnlyWriteBlockedError(msg) + + # Only now is the cluster touched. Everything above is a policy + # decision made from the request context alone. + cluster = get_cluster_connection(ctx) + bucket = connect_to_bucket(cluster, bucket_name) + try: scope = bucket.scope(scope_name) logger.debug( f"Executing SQL++ query in {bucket_name}.{scope_name} " f"(write_blocked={block_query_writes})" ) - - results = [] - # EXPLAIN statements are always safe to execute and should bypass write checks. - is_explain = _is_explain_statement(query) - - # This tool is the one whose audit category depends on the statement it - # is given, so when auditing is active the statement must be inspected - # even where the write guard would not have needed to. Otherwise an - # *allowed* DML statement would be recorded against the query-read id, - # and read ids are filterable — a successful mutation could be filtered - # out of the audit log. The parse is done once here and shared with the - # guard below, so an audited call never pays for it twice, and a call - # made with auditing off pays nothing at all. - audit_active = get_audit_logger().active - needs_audit_classification = audit_active and not is_explain - - if is_explain and audit_active: - audit_state.record_statement_class("read") - - if (block_query_writes or needs_audit_classification) and not is_explain: - try: - kind = _blocked_write_kind(parse_sqlpp(query)) - except Exception: - if block_query_writes: - raise - if audit_active: - audit_state.record_statement_class("write") - kind = None - else: - if audit_active: - audit_state.record_statement_class( - "write" if kind is not None else "read" - ) - - if kind is not None and block_query_writes: - if lacks_write_scope and not read_only_mode: - # lacks_write_scope implies token is not None here. - held_scopes = sorted(set(token.scopes or [])) - msg = ( - f"SQL++ {kind} modification requires the " - f"'{SCOPE_WRITE}' scope; token scopes are {held_scopes}." - ) - logger.warning(msg) - audit_state.record_refusal( - event_id=StatementScopeDeniedError.audit_event.id, - event_name=StatementScopeDeniedError.audit_event.event_name, - outcome=StatementScopeDeniedError.audit_outcome, - reason=StatementScopeDeniedError.audit_reason, - required_scope="write", - statement_kind=kind, - ) - raise StatementScopeDeniedError(msg) - msg = f"{kind.capitalize()} modification query is not allowed in read-only mode" - logger.error(msg) - audit_state.record_refusal( - event_id=ReadOnlyWriteBlockedError.audit_event.id, - event_name=ReadOnlyWriteBlockedError.audit_event.event_name, - outcome=ReadOnlyWriteBlockedError.audit_outcome, - reason=ReadOnlyWriteBlockedError.audit_reason, - statement_kind=kind, - ) - raise ReadOnlyWriteBlockedError(msg) - # Reached only for read-only queries (or when writes are allowed). # Forward named parameters only when provided so existing callers that # pass none keep the exact previous behaviour. diff --git a/tests/unit/test_audit_integration_points.py b/tests/unit/test_audit_integration_points.py index 44330026..e4a4a1e2 100644 --- a/tests/unit/test_audit_integration_points.py +++ b/tests/unit/test_audit_integration_points.py @@ -24,14 +24,18 @@ from __future__ import annotations +import asyncio +import json import logging from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from fastmcp import Context from cb_mcp.audit import state as audit_state +from cb_mcp.audit.config import resolve_audit_config +from cb_mcp.audit.emitter import get_audit_logger from cb_mcp.audit.exceptions import ( ConfirmationDeclinedError, ReadOnlyWriteBlockedError, @@ -45,6 +49,8 @@ resolve_real_userid, warn_on_unauthenticated_http, ) +from cb_mcp.core.app import build_app +from cb_mcp.servers.operational.spec import SPEC from cb_mcp.tools.operational.query import ( _query_correlation_options, run_sql_plus_plus_query, @@ -359,6 +365,67 @@ def test_unparseable_statement_still_fails_safe_when_writes_are_blocked(): audit_state.reset(token) +def test_statement_is_classified_before_the_cluster_is_touched(): + """A failed DML attempt must not be booked against the filterable read id. + + Connecting first meant an unreachable cluster raised before the statement + was ever inspected, so "someone tried to UPDATE and it failed" was recorded + as a query *read* — and read ids are filterable, so an operator turning + read noise down would lose it. + """ + + def unreachable(_ctx): + raise RuntimeError("cluster unreachable") + + ctx = SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=SimpleNamespace( + cluster_provider=SimpleNamespace(get_cluster=unreachable), + read_only_mode=False, + ) + ) + ) + token = audit_state.install() + try: + with ( + patch( + "cb_mcp.tools.operational.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ), + pytest.raises(RuntimeError, match="unreachable"), + ): + run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") + assert audit_state.get_statement_class() == "write" + finally: + audit_state.reset(token) + + +def test_read_only_block_does_not_need_a_reachable_cluster(): + """The guard is a policy decision, so it answers before any connection. + + The caller now gets the accurate reason — blocked — rather than whichever + connection error happened to come first. + """ + + def unreachable(_ctx): + raise RuntimeError("cluster unreachable") + + ctx = SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=SimpleNamespace( + cluster_provider=SimpleNamespace(get_cluster=unreachable), + read_only_mode=True, + ) + ) + ) + token = audit_state.install() + try: + with pytest.raises(ReadOnlyWriteBlockedError): + run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") + finally: + audit_state.reset(token) + + # --------------------------------------------------------------------------- # identity # --------------------------------------------------------------------------- @@ -458,3 +525,71 @@ def test_query_correlation_is_omitted_when_state_carries_no_cid(): assert _query_correlation_options() == {} finally: audit_state.reset(token) + + +# --------------------------------------------------------------------------- +# lifecycle +# --------------------------------------------------------------------------- + + +def test_audit_is_shut_down_when_provider_startup_fails(tmp_path): + """A failed startup is exactly when the audit trail matters. + + ``_start_audit`` runs first, so by the time the provider is constructed the + sink is open, the writer thread is running and the server-started record is + queued. Building the AppContext outside the try meant a provider failure + skipped the cleanup entirely: the queued record was lost and the sink stayed + open for the life of the process. + """ + audit_config = resolve_audit_config( + enabled=True, + file=str(tmp_path / "audit.log"), + rotation_max_size_mb=None, + retention_backup_count=None, + tool_args=None, + disabled_events=None, + ) + captured: dict = {} + + def capture(*_args, **kwargs): + captured["lifespan"] = kwargs.get("lifespan") + return MagicMock() + + def exploding_provider(): + raise RuntimeError("cannot reach the cluster") + + with patch("cb_mcp.core.app.FastMCP", side_effect=capture): + build_app( + SPEC, + tools=list(SPEC.tools.all_tools)[:2], + settings={ + "transport": "stdio", + "oauth_enabled": False, + "disabled_tools": set(), + "confirmation_required_tools": set(), + }, + provider_factory=exploding_provider, + read_only_mode=True, + audit_config=audit_config, + ) + + async def drive(): + async with captured["lifespan"](SimpleNamespace()): + pass # pragma: no cover - startup raises before the body runs + + with pytest.raises(RuntimeError, match="cannot reach the cluster"): + asyncio.run(drive()) + + # The sink was closed rather than left open for the process lifetime. + assert not get_audit_logger().active + + written = [ + json.loads(line) + for path in tmp_path.iterdir() + if path.suffix == ".log" + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + names = [record["name"] for record in written] + assert "server started" in names, "queued records were lost on the failed startup" + assert "server stopped" in names, "no clean-shutdown record for the failed startup" diff --git a/tests/unit/test_audit_sink.py b/tests/unit/test_audit_sink.py index 4d942ede..e3a1d353 100644 --- a/tests/unit/test_audit_sink.py +++ b/tests/unit/test_audit_sink.py @@ -24,7 +24,9 @@ import json import os import threading +import time from pathlib import Path +from unittest.mock import patch import pytest @@ -36,6 +38,14 @@ def _drain(sink: AuditSink) -> None: sink.close(timeout=10.0) +def _settle(sink: AuditSink, timeout: float = 2.0) -> None: + """Wait for the writer to drain the queue, without closing the sink.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline and not sink._queue.empty(): + time.sleep(0.02) + time.sleep(0.1) + + # --------------------------------------------------------------------------- # naming # --------------------------------------------------------------------------- @@ -252,3 +262,89 @@ def worker(worker_id: int) -> None: # Every line must be complete JSON: no interleaved partial writes. parsed = [json.loads(line) for line in lines] assert len({(r["w"], r["i"]) for r in parsed}) == 800 + + +def test_sink_recovers_after_a_failed_rotation(tmp_path): + """A failed reopen must not stop the sink for good. + + Rotation closes the live file before reopening it. If that reopen fails — + full disk, revoked permission, unmounted volume — the sink was left holding + a closed handle and never wrote again, even after the condition cleared. + Silent and permanent is the worst failure mode an audit log can have. + """ + sink = AuditSink(tmp_path / "a.log", max_bytes=200, backup_count=0) + sink.start() + line = '{"pad":"' + "x" * 80 + '"}\n' + + for _ in range(3): + sink.emit(line) + _settle(sink) + before = sink.stats["written"] + + real_open = open + failed = {"done": False} + # The sink writes a process-scoped path (a..log), so match on the + # resolved filename rather than the configured one. + target = str(sink.path) + + def flaky(*args, **kwargs): + # Fail the first reopen attempted during rotation, then behave. + if not failed["done"] and args and str(args[0]) == target: + mode = args[1] if len(args) > 1 else kwargs.get("mode", "r") + if "w" in mode or "a" in mode: + failed["done"] = True + raise OSError("disk full") + return real_open(*args, **kwargs) + + with patch("builtins.open", side_effect=flaky): + for _ in range(3): + sink.emit(line) + _settle(sink) + + assert failed["done"], "the probe never exercised the failing reopen" + assert sink.stats["write_errors"] >= 1, "the failed reopen was not recorded" + + # The measurement that matters: writes attempted *after* the failure. The + # count is taken here, once the stream is known to be closed, so a record + # that happened to land before the failing rotation cannot mask a sink that + # never recovered. + stalled_at = sink.stats["written"] + for _ in range(3): + sink.emit(line) + _settle(sink) + sink.close() + + assert sink.stats["written"] > stalled_at, ( + "sink never wrote again after the failed rotation: " + f"written stayed at {stalled_at}" + ) + assert before <= stalled_at + + +def test_writer_exits_when_the_queue_is_full_at_close(tmp_path): + """close() cannot post the sentinel into a full queue. + + Dropping a queued record to make room would lose an audit line purely to + shut down faster, so the writer polls the closed flag instead. Before that, + it blocked on an empty get() forever and the thread leaked. + """ + sink = AuditSink(tmp_path / "b.log", max_bytes=10_000, backup_count=1, queue_size=4) + sink.start() + time.sleep(0.05) + + with patch.object(sink, "_write_batch", side_effect=lambda lines: time.sleep(0.5)): + for index in range(20): + sink.emit(f'{{"i":{index}}}\n') + sink.close(timeout=3.0) + + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline: + if not any( + t.name == "cb-mcp-audit-writer" and t.is_alive() + for t in threading.enumerate() + ): + break + time.sleep(0.05) + assert not any( + t.name == "cb-mcp-audit-writer" and t.is_alive() for t in threading.enumerate() + ), "writer thread did not exit" From 440c52bbdf7b2dba813ab5705dbe4f710f460529 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Tue, 29 Sep 2026 14:44:01 +0530 Subject: [PATCH 06/27] Fixed the reviews --- DOCKER.md | 7 +- README.md | 7 +- src/cb_mcp/audit/config.py | 8 - src/cb_mcp/audit/middleware.py | 15 +- src/cb_mcp/audit/sink.py | 68 ++++-- src/cb_mcp/core/app.py | 13 +- src/cb_mcp/utils/cli_params.py | 3 - src/cb_mcp/utils/scope_enforcement.py | 11 +- tests/unit/test_audit_integration_points.py | 19 +- tests/unit/test_audit_sink.py | 73 ++++++- tests/unit/test_audit_wiring.py | 220 ++++++++++++++++++++ tests/unit/test_cli_options.py | 99 +++++++++ tests/unit/test_settings_classification.py | 12 +- 13 files changed, 498 insertions(+), 57 deletions(-) create mode 100644 tests/unit/test_audit_wiring.py diff --git a/DOCKER.md b/DOCKER.md index 48b7f7f8..aa7d5a5b 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -213,9 +213,9 @@ The detailed explanation for the environment variables can be found on the [GitH | `CB_MCP_OAUTH_SCOPE_READ_LABEL` | Override the OAuth scope label treated as 'read' access (advertised in PRM and matched against the token `scope`/`scp` claim). Use when your IdP can't emit the canonical form | `couchbase-mcp:read` | | `CB_MCP_OAUTH_SCOPE_WRITE_LABEL` | Override the OAuth scope label treated as 'write' access; same semantics as the read label | `couchbase-mcp:write` | | `CB_MCP_AUDIT_LOG_ENABLED` | Enable audit logging — a separate sink from the `CB_MCP_LOG_*` operational logs, with a stable schema and its own retention (see [Auditing](#auditing)) | `false` | -| `CB_MCP_AUDIT_FILE` | Path to the audit log file. Required when auditing is enabled; the process id is inserted before the extension (`audit.log` → `audit.1234.log`). Mount a volume at this path to keep records after the container stops | None | +| `CB_MCP_AUDIT_FILE` | Path to the audit log file. Required when auditing is enabled; the hostname and process id are inserted before the extension (`audit.log` → `audit...log`), so containers sharing a volume never share a file. Mount a volume at this path to keep records after the container stops | None | | `CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB` | Maximum size **in MB** an audit file may reach before it rotates. `0` is invalid and falls back to the default with a startup warning | `1` (1 MB) | -| `CB_MCP_AUDIT_RETENTION_BACKUP_COUNT`| Rotated audit files retained, excluding the live file. `0` keeps only the live file | `1000` | +| `CB_MCP_AUDIT_RETENTION_BACKUP_COUNT`| Rotated backups retained per audit file, excluding the live file. Bounds one file, not the directory: each container start creates a new file with its own backups | `1000` | | `CB_MCP_AUDIT_TOOL_ARGS` | Record tool argument values. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | | `CB_MCP_AUDIT_DISABLED_EVENTS` | Audit events to suppress: comma-separated numeric event ids (e.g. `61490,61491`), or a file with one id per line. Only filterable events can be suppressed | None | @@ -405,7 +405,8 @@ Records are JSON Lines, one immutable record per line, in the same field vocabul - **`CB_MCP_AUDIT_LOG_ENABLED` / `CB_MCP_AUDIT_FILE`** — both are needed. Enabling auditing without a path leaves the server running with auditing disabled and an error in the log. - **Mount a volume at the audit path.** The audit file is written inside the container like any other file, so without a volume the records are destroyed when the container is removed — which defeats the purpose of keeping them. This matters more than it does for logs: audit retention defaults to 1000 rotated backups precisely because the records are meant to outlive the process. -- **One file per container.** The process id is inserted before the extension (`audit.log` → `audit.1234.log`), so several containers can share a mounted directory without corrupting each other's records. Consolidating them is the operator's job. +- **One file per container.** The container's hostname and process id are inserted before the extension (`audit.log` → `audit.3f2a91c4b7de.1.log`). The hostname is what makes this safe: this image's `ENTRYPOINT` is exec form, so the server is **PID 1 in every container**, and a name built from the pid alone would give every container sharing a volume the same `audit.1.log` — interleaved lines and rotation races that silently destroy records. Docker assigns each container a distinct hostname by default. If you override `--hostname` to the same value on two containers **and** share one volume between them, give each its own subdirectory instead. Consolidating the files is the operator's job. +- **Retention is per file, not per deployment.** `CB_MCP_AUDIT_RETENTION_BACKUP_COUNT` bounds the backups of *one* audit file. Each new container is a new hostname and so a new file with its own backups, and nothing prunes the files of containers that have exited. On a long-lived mounted volume, total usage grows with the number of container starts — size it accordingly, or sweep old `audit.*.log*` files yourself. - **Filtering** — `CB_MCP_AUDIT_DISABLED_EVENTS` turns read noise down, taking numeric event ids (e.g. `61490,61491`). Write and security events cannot be disabled; an attempt to do so is refused with a warning. - **`CB_MCP_AUDIT_TOOL_ARGS`** — off by default, and warns at startup when enabled. There is no redaction in this release, so arguments are written verbatim, including full document bodies. - **Fail-open** — if the audit file cannot be written, the server keeps serving and records are dropped and counted. `get_server_configuration_status` reports the live `written` / `dropped` / `write_errors` counters. diff --git a/README.md b/README.md index a1c6cd16..0b5e15c7 100644 --- a/README.md +++ b/README.md @@ -308,9 +308,9 @@ The server can be configured using environment variables or command line argumen | `CB_MCP_OAUTH_SCOPE_READ_LABEL` | `--oauth-scope-read-label` | Override the OAuth scope label treated as 'read' access (advertised in PRM and matched against the token's `scope`/`scp` claim). Use when your IdP can't emit the canonical form | `couchbase-mcp:read` | | `CB_MCP_OAUTH_SCOPE_WRITE_LABEL` | `--oauth-scope-write-label` | Override the OAuth scope label treated as 'write' access; same semantics as the read label | `couchbase-mcp:write` | | `CB_MCP_AUDIT_LOG_ENABLED` | `--audit-log-enabled` | Enable audit logging — a separate sink from the `CB_MCP_LOG_*` operational logs, with a stable schema and its own retention (see [Auditing](#auditing)) | `false` | -| `CB_MCP_AUDIT_FILE` | `--audit-file` | Path to the audit log file. Required when auditing is enabled; the process id is inserted before the extension (`audit.log` → `audit.1234.log`) | None | +| `CB_MCP_AUDIT_FILE` | `--audit-file` | Path to the audit log file. Required when auditing is enabled; the hostname and process id are inserted before the extension (`audit.log` → `audit...log`) | None | | `CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB` | `--audit-rotation-max-size-mb` | Maximum size **in MB** an audit file may reach before it rotates. `0` is invalid and falls back to the default with a startup warning | `1` (1 MB) | -| `CB_MCP_AUDIT_RETENTION_BACKUP_COUNT` | `--audit-retention-backup-count` | Rotated audit files retained, excluding the live file. `0` keeps only the live file | `1000` | +| `CB_MCP_AUDIT_RETENTION_BACKUP_COUNT` | `--audit-retention-backup-count` | Rotated backups retained **per audit file**, excluding the live file. `0` keeps only the live file. This bounds one file, not the directory — see [Auditing](#auditing) | `1000` | | `CB_MCP_AUDIT_TOOL_ARGS` | `--audit-tool-args` | Record tool argument values in the audit log. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | | `CB_MCP_AUDIT_DISABLED_EVENTS` | `--audit-disabled-events` | Audit events to suppress: comma-separated numeric event ids (e.g. `61490,61491`), or a file with one id per line. Only filterable events can be suppressed | None | @@ -484,7 +484,8 @@ Records are **JSON Lines** — one immutable record per line — using the same - **Events** — a fixed catalogue on numbered ID blocks: server lifecycle and configuration, session initialize, token rejected, scope check denied, read-only write blocked, confirmation declined or skipped, and one read/write pair per tool category (cluster, schema, document, query, index, performance, search). Adding a tool needs no new ID and no change to a SIEM rule. The full catalogue ships as `descriptor.json` inside the package. - **Filtering** — `CB_MCP_AUDIT_DISABLED_EVENTS` turns *read* noise down, taking numeric event ids (the id is the wire contract; event names are prose and may be reworded, so they are not accepted). Write and security events are **not filterable**: an attempt to disable one is refused with a warning, so the record of who changed what cannot be switched off by configuration. - **Retention** — rotation size and backup count work like the operational logs, but the default retention is far larger (1000 backups) because audit records are reviewed on a quarterly or annual cadence, not for day-to-day triage. -- **Per-process files** — the process id is inserted before the extension, because a stdio deployment runs one server process per client and a shared rotating file would interleave and corrupt records. +- **Per-writer files** — the hostname and process id are inserted before the extension (`audit.log` → `audit...log`), because a stdio deployment runs one server process per client and a shared rotating file would interleave and corrupt records. The hostname matters in containers: the published image's `ENTRYPOINT` is exec form, so the server is PID 1 in every container and a pid-only name would collide across containers sharing a volume. +- **Retention is per file** — the backup count bounds the rotated copies of *one* audit file. Every process, and so every stdio session and every restart, writes a new file with its own backups, and nothing prunes the files of processes that have exited. Total disk use therefore grows with the number of server starts; size the volume accordingly or sweep old `audit.*.log*` files. - **Fail-open** — if the audit file cannot be written, the server keeps serving and the records are dropped and counted rather than turning an audit outage into a server outage. `get_server_configuration_status` reports the live `written` / `dropped` / `write_errors` counters, and `dropped` is the only way to discover that records were lost. - **Tool arguments** — recorded only when `CB_MCP_AUDIT_TOOL_ARGS` is enabled, which warns at startup. There is no redaction in this release, so arguments are written verbatim, including the full document bodies passed to document-write tools. diff --git a/src/cb_mcp/audit/config.py b/src/cb_mcp/audit/config.py index ba5545c1..61cb20f4 100644 --- a/src/cb_mcp/audit/config.py +++ b/src/cb_mcp/audit/config.py @@ -12,7 +12,6 @@ from __future__ import annotations import logging -from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from typing import Any @@ -236,15 +235,8 @@ def resolve_audit_config( ) -def audit_config_from_settings(settings: Mapping[str, Any]) -> dict[str, Any] | None: - """Read the audit snapshot out of the lifespan settings mapping.""" - snapshot = settings.get("audit_config") - return dict(snapshot) if isinstance(snapshot, Mapping) else None - - __all__ = [ "ResolvedAuditConfig", - "audit_config_from_settings", "parse_disabled_events", "resolve_audit_config", ] diff --git a/src/cb_mcp/audit/middleware.py b/src/cb_mcp/audit/middleware.py index defaddbb..4575f11c 100644 --- a/src/cb_mcp/audit/middleware.py +++ b/src/cb_mcp/audit/middleware.py @@ -54,6 +54,7 @@ from .catalog import DEFAULT_SERVICE_PACKAGE, AuditEvent from .classification import ( STATEMENT_CLASSIFIED_TOOL, + classify_tool, is_classified, required_scope_for, resolve_tool_call_event, @@ -65,7 +66,6 @@ scopes_of_current_token, ) from .record import ( - OUTCOME_BLOCKED, OUTCOME_ERROR, OUTCOME_SUCCESS, REASON_EXECUTION_ERROR, @@ -281,9 +281,14 @@ def _emit_outcome( confirmation = state.get_confirmation() if confirmation == state.CONFIRMATION_SKIPPED: + # Not ``blocked``: the tool *ran*, unconfirmed, which is what + # the catalogue entry describes. Recording it as blocked would + # make a reviewer counting ``outcome=blocked`` read unconfirmed + # destructive executions as prevented ones — the exact opposite + # of what happened. The ``reason`` carries the disposition. audit.emit_event( AuditEvent.CONFIRMATION_SKIPPED, - outcome=OUTCOME_BLOCKED, + outcome=OUTCOME_SUCCESS, real_userid=real_userid, cid=cid, reason="confirmation_unsupported", @@ -341,6 +346,12 @@ def _emit_refusal( if event is AuditEvent.SCOPE_CHECK_DENIED: payload.setdefault("client_id", client_id_of_current_token()) payload.setdefault("scopes", scopes_of_current_token()) + # One shape for this field across every event that carries it: the + # short class, as on tool-call records. ``setdefault`` leaves the + # SQL++ statement gate's explicit value alone, since that gate + # classifies per statement rather than per tool. + _, operation_class = classify_tool(tool_name, package=self._service_package) + payload.setdefault("required_scope", required_scope_for(operation_class)) audit.emit_event( event, diff --git a/src/cb_mcp/audit/sink.py b/src/cb_mcp/audit/sink.py index b24aabbd..28c5b132 100644 --- a/src/cb_mcp/audit/sink.py +++ b/src/cb_mcp/audit/sink.py @@ -7,11 +7,21 @@ multi-process: every MCP client spawns its own server process, and they all read the same ``CB_MCP_AUDIT_FILE`` from the environment. Sharing one rotating file across them produces interleaved partial lines and rotation races that -silently destroy records. So each process writes its **own** file, with the -PID inserted before the extension (``audit.log`` → ``audit.12345.log``). No -locking is needed because no two processes ever touch the same file, and it -behaves identically on every platform. This matches the PRD's own position that -auditing is per-node and consolidation is the operator's responsibility. +silently destroy records. So each writer gets its **own** file, named +``audit...log``. No locking is needed because no two processes ever +touch the same file, and it behaves identically on every platform. This matches +the PRD's own position that auditing is per-node and consolidation is the +operator's responsibility. + +The host is part of the name, not just the pid, because the pid alone is not +unique across containers. This image's ``ENTRYPOINT`` is exec form, so the +server is **PID 1 in every container**: several containers sharing one mounted +volume would every one of them write ``audit.1.log`` — precisely the +interleaving this design exists to prevent, in the configuration the Docker +documentation recommends. Docker assigns each container a distinct hostname by +default, which restores uniqueness. Two containers explicitly given the same +hostname and the same volume will still collide; that is documented rather than +defended against. **Never block the event loop.** ``emit`` only puts a formatted line on a bounded queue; a dedicated daemon thread does the writing and flushing. A @@ -31,6 +41,8 @@ import logging import os import queue +import re +import socket import threading import time from pathlib import Path @@ -63,18 +75,43 @@ _SENTINEL = object() -def process_scoped_path(path: str | os.PathLike[str], pid: int | None = None) -> Path: - """Insert the process id before the file extension. +#: Characters kept from a hostname. Anything else — dots that would confuse the +#: suffix split, path separators, whitespace — collapses to a single dash. +_HOST_UNSAFE = re.compile(r"[^A-Za-z0-9_-]+") - ``audit.log`` becomes ``audit..log``; a path with no suffix becomes - ``audit.``. Exposed separately so startup can log, and tests can - assert, the exact file that will be written. + +def _host_token(host: str | None = None) -> str: + """A filename-safe short hostname, or ``unknown`` if it cannot be read.""" + try: + raw = socket.gethostname() if host is None else host + except Exception: # pragma: no cover - defensive + return "unknown" + # Short form only: an FQDN would put dots in the middle of the filename. + token = _HOST_UNSAFE.sub("-", (raw or "").split(".")[0]).strip("-") + return token[:64] or "unknown" + + +def process_scoped_path( + path: str | os.PathLike[str], + pid: int | None = None, + host: str | None = None, +) -> Path: + """Insert the host and process id before the file extension. + + ``audit.log`` becomes ``audit...log``; a path with no suffix + becomes ``audit..``. Exposed separately so startup can log, and + tests can assert, the exact file that will be written. + + The host is included because the pid is not unique across containers: this + image's ENTRYPOINT is exec form, so the server is PID 1 in every container + and a shared volume would otherwise give every container ``audit.1.log``. """ resolved = Path(path) actual_pid = os.getpid() if pid is None else pid + scope = f"{_host_token(host)}.{actual_pid}" if resolved.suffix: - return resolved.with_name(f"{resolved.stem}.{actual_pid}{resolved.suffix}") - return resolved.with_name(f"{resolved.name}.{actual_pid}") + return resolved.with_name(f"{resolved.stem}.{scope}{resolved.suffix}") + return resolved.with_name(f"{resolved.name}.{scope}") class AuditSink: @@ -229,6 +266,13 @@ def _reopen_if_closed(self) -> None: """ if self._stream is not None and not self._stream.closed: return + if self._closed.is_set(): + # ``close`` joins the writer with a timeout and then closes the + # stream, so a writer still draining past that timeout — the wedged + # filesystem this timeout exists for — would otherwise reopen a file + # nobody will close again, and append records dated after "server + # stopped". Refuse, and let the caller count the record as dropped. + raise ValueError("audit sink is closed") self._stream = open(self.path, "a", encoding="utf-8") # noqa: SIM115 self._size = self.path.stat().st_size diff --git a/src/cb_mcp/core/app.py b/src/cb_mcp/core/app.py index b79b6452..d076c59f 100644 --- a/src/cb_mcp/core/app.py +++ b/src/cb_mcp/core/app.py @@ -31,7 +31,7 @@ unclassified_tool_names, warn_on_unauthenticated_http, ) -from ..audit.record import OUTCOME_SUCCESS +from ..audit.record import OUTCOME_ERROR, OUTCOME_SUCCESS from ..utils.constants import ( LOGGER_NAMESPACE, NETWORK_TRANSPORTS, @@ -179,6 +179,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # whatever was queued and leaving the sink open for the life of the # process. A failed startup is exactly when the audit trail matters. app_context: AppContext | None = None + failed = False try: app_context = AppContext( cluster_provider=provider_factory(), @@ -193,6 +194,7 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: ) yield app_context except Exception as e: + failed = True logger.error(f"Error in app lifespan: {e}", exc_info=True) raise finally: @@ -204,8 +206,15 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: if audits: active_audit = get_audit_logger() if active_audit.active: + # The catalogue describes this event as "shut down + # cleanly", so it must not claim success for a lifespan + # that exited by raising. A failed startup that recorded + # "server started" followed by "success" would read as a + # healthy run in an audit review. active_audit.emit_event( - AuditEvent.SERVER_STOPPED, outcome=OUTCOME_SUCCESS + AuditEvent.SERVER_STOPPED, + outcome=OUTCOME_ERROR if failed else OUTCOME_SUCCESS, + reason="lifespan_error" if failed else None, ) shutdown_audit() logger.info("Closing MCP server") diff --git a/src/cb_mcp/utils/cli_params.py b/src/cb_mcp/utils/cli_params.py index 21faead7..c2933dd5 100644 --- a/src/cb_mcp/utils/cli_params.py +++ b/src/cb_mcp/utils/cli_params.py @@ -415,9 +415,6 @@ def build_settings( **cli.oauth.as_settings(enabled=oauth_enabled), "disabled_tools": gated.disabled, "confirmation_required_tools": gated.confirmation_required, - # Audit configuration as resolved, so get_server_configuration_status - # and the startup audit record report exactly the same thing. - "audit_config": cli.audit.as_dict(), } ) return settings diff --git a/src/cb_mcp/utils/scope_enforcement.py b/src/cb_mcp/utils/scope_enforcement.py index 744655c7..85f4daea 100644 --- a/src/cb_mcp/utils/scope_enforcement.py +++ b/src/cb_mcp/utils/scope_enforcement.py @@ -109,12 +109,21 @@ async def wrapper(*args, **kwargs): # Record the refusal for the audit middleware before raising. # FastMCP wraps a tool's exception in ToolError, so the # middleware cannot classify this from the exception it sees. + # Deliberately does not record ``required_scope``. This gate + # holds the server's full scope labels + # (``couchbase-mcp:write``), while the tool-call record and the + # SQL++ statement gate both record the short class + # (``write``) that the PRD's samples use. One event id must not + # carry two JSON shapes — a SIEM field mapping would have to + # handle a list and a string for event 57377 — so the + # middleware derives the short form from the tool's audit + # classification instead, in one place. audit_state.record_refusal( event_id=ScopeDeniedError.audit_event.id, event_name=ScopeDeniedError.audit_event.event_name, outcome=ScopeDeniedError.audit_outcome, reason=ScopeDeniedError.audit_reason, - required_scope=sorted(required_scopes), + missing_scopes=sorted(missing), ) raise ScopeDeniedError(msg) diff --git a/tests/unit/test_audit_integration_points.py b/tests/unit/test_audit_integration_points.py index e4a4a1e2..a90c071d 100644 --- a/tests/unit/test_audit_integration_points.py +++ b/tests/unit/test_audit_integration_points.py @@ -114,7 +114,11 @@ async def tool(ctx=None): assert refusal["event_id"] == 57377 assert refusal["outcome"] == "denied" assert refusal["reason"] == "missing_scope" - assert refusal["required_scope"] == [SCOPE_WRITE] + # The gate records what it knows: which labels were missing. The + # short ``required_scope`` is added by the middleware, so event + # 57377 carries one JSON shape whichever gate refused the call. + assert refusal["missing_scopes"] == [SCOPE_WRITE] + assert "required_scope" not in refusal finally: audit_state.reset(token) @@ -590,6 +594,13 @@ async def drive(): for line in path.read_text(encoding="utf-8").splitlines() if line.strip() ] - names = [record["name"] for record in written] - assert "server started" in names, "queued records were lost on the failed startup" - assert "server stopped" in names, "no clean-shutdown record for the failed startup" + by_name = {record["name"]: record for record in written} + assert "server started" in by_name, "queued records were lost on the failed startup" + assert "server stopped" in by_name, "no shutdown record for the failed startup" + + # The catalogue describes this event as "shut down cleanly", so a lifespan + # that exited by raising must not claim success. "server started" followed + # by "success" would read as a healthy run in an audit review. + stopped = by_name["server stopped"] + assert stopped["outcome"] == "error", stopped + assert stopped["reason"] == "lifespan_error", stopped diff --git a/tests/unit/test_audit_sink.py b/tests/unit/test_audit_sink.py index e3a1d353..cabfca7a 100644 --- a/tests/unit/test_audit_sink.py +++ b/tests/unit/test_audit_sink.py @@ -51,24 +51,52 @@ def _settle(sink: AuditSink, timeout: float = 2.0) -> None: # --------------------------------------------------------------------------- -def test_process_scoped_path_inserts_pid_before_the_suffix(): - result = process_scoped_path("/var/log/audit.log", pid=4321) - assert result == Path("/var/log/audit.4321.log") +def test_process_scoped_path_inserts_host_and_pid_before_the_suffix(): + result = process_scoped_path("/var/log/audit.log", pid=4321, host="node-a") + assert result == Path("/var/log/audit.node-a.4321.log") def test_process_scoped_path_appends_when_there_is_no_suffix(): - result = process_scoped_path("/var/log/audit", pid=4321) - assert result == Path("/var/log/audit.4321") + result = process_scoped_path("/var/log/audit", pid=4321, host="node-a") + assert result == Path("/var/log/audit.node-a.4321") -def test_process_scoped_path_uses_the_live_pid_by_default(): +def test_process_scoped_path_uses_the_live_host_and_pid_by_default(): assert str(os.getpid()) in process_scoped_path("audit.log").name +def test_containers_sharing_a_volume_do_not_share_a_file(): + """The reason the host is in the name at all. + + This image's ENTRYPOINT is exec form, so the server is PID 1 in *every* + container. Two containers mounting the same volume would both have written + ``audit.1.log`` — the interleaving and rotation races that per-writer files + exist to prevent, in the configuration DOCKER.md recommends. Docker gives + each container a distinct hostname by default, which restores uniqueness. + """ + first = process_scoped_path("/audit/audit.log", pid=1, host="3f2a91c4b7de") + second = process_scoped_path("/audit/audit.log", pid=1, host="a81ce4470f19") + assert first != second + assert first.name == "audit.3f2a91c4b7de.1.log" + + +def test_host_token_is_filename_safe(): + """An FQDN or an odd hostname must not put dots or separators in the name.""" + assert ( + process_scoped_path("a.log", pid=7, host="node1.example.com").name + == "a.node1.7.log" + ) + assert ( + process_scoped_path("a.log", pid=7, host="we ird/name").name + == "a.we-ird-name.7.log" + ) + assert process_scoped_path("a.log", pid=7, host="").name == "a.unknown.7.log" + + def test_sink_writes_to_the_process_scoped_path(tmp_path): sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, backup_count=1) try: - assert sink.path == tmp_path / f"audit.{os.getpid()}.log" + assert sink.path == process_scoped_path(tmp_path / "audit.log") assert sink.path.exists() finally: _drain(sink) @@ -95,7 +123,7 @@ def test_lines_are_written_and_flushed(tmp_path): def test_existing_file_is_appended_not_truncated(tmp_path): - path = tmp_path / f"audit.{os.getpid()}.log" + path = process_scoped_path(tmp_path / "audit.log") path.write_text('{"pre":true}\n', encoding="utf-8") sink = AuditSink(tmp_path / "audit.log", max_bytes=1_000_000, backup_count=1) @@ -198,7 +226,7 @@ def test_parent_directories_are_created(tmp_path): def test_unwritable_path_raises_so_startup_can_report_it(tmp_path): # A directory where the file should be is the portable way to make open() # fail without depending on running as an unprivileged user. - collision = tmp_path / f"audit.{os.getpid()}.log" + collision = process_scoped_path(tmp_path / "audit.log") collision.mkdir() with pytest.raises(OSError): AuditSink(tmp_path / "audit.log", max_bytes=4096, backup_count=1) @@ -348,3 +376,30 @@ def test_writer_exits_when_the_queue_is_full_at_close(tmp_path): assert not any( t.name == "cb-mcp-audit-writer" and t.is_alive() for t in threading.enumerate() ), "writer thread did not exit" + + +def test_a_draining_writer_does_not_reopen_the_file_after_close(tmp_path): + """``close`` joins with a timeout, then closes the stream. + + A writer still draining past that timeout — the wedged filesystem the + timeout exists for — would otherwise reopen a handle nobody closes again, + and append records dated after "server stopped". It must drop them instead. + """ + sink = AuditSink(tmp_path / "c.log", max_bytes=100_000, backup_count=1) + sink.start() + released = threading.Event() + real_write = sink._write_batch + + def slow(lines): + released.wait(timeout=5.0) + return real_write(lines) + + with patch.object(sink, "_write_batch", side_effect=slow): + sink.emit('{"first":1}\n') + time.sleep(0.2) + sink.emit('{"second":2}\n') + sink.close(timeout=0.2) # returns while the batch is still in flight + released.set() + time.sleep(0.4) + + assert sink._stream.closed, "the writer reopened the stream after close" diff --git a/tests/unit/test_audit_wiring.py b/tests/unit/test_audit_wiring.py new file mode 100644 index 00000000..cc01009e --- /dev/null +++ b/tests/unit/test_audit_wiring.py @@ -0,0 +1,220 @@ +"""Production wiring for auditing: the paths no unit test injected its own logger into. + +Every other audit test builds its own ``AuditLogger`` and its own ``FastMCP``, +which is right for isolating behaviour but leaves the actual assembly +unexercised. These tests drive the real seams instead: + +- ``build_app`` -> ``_start_audit`` -> the startup records, and the shutdown + record the lifespan's ``finally`` emits. +- That a server declaring no ``audit_package`` registers no middleware and + writes nothing, which is what keeps Operational Insights out of the + operational Tier-2 block. +- ``CouchbaseJWTVerifier.verify_token`` emitting ``token rejected`` — the one + audit hook that cannot live in middleware, because a rejected token never + becomes a JSON-RPC message. +- The six audit flags reaching a resolved configuration through Click. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from _all_specs import ALL_SPECS + +from cb_mcp.audit.config import resolve_audit_config +from cb_mcp.audit.emitter import get_audit_logger, init_audit, shutdown_audit +from cb_mcp.auth import CouchbaseJWTVerifier +from cb_mcp.core.app import build_app + + +def _records(directory: Path) -> list[dict]: + return [ + json.loads(line) + for path in sorted(directory.iterdir()) + if ".log" in path.name + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + +def _audit_config(tmp_path: Path, **overrides): + options = { + "enabled": True, + "file": str(tmp_path / "audit.log"), + "rotation_max_size_mb": None, + "retention_backup_count": None, + "tool_args": None, + "disabled_events": None, + } + options.update(overrides) + return resolve_audit_config(**options) + + +def _run_lifespan(spec, audit_config, tmp_path): + """Build the app for ``spec`` and take its lifespan through a clean cycle.""" + captured: dict = {} + added: list = [] + + def capture_app(*_args, **kwargs): + captured["lifespan"] = kwargs.get("lifespan") + app = MagicMock() + app.add_middleware.side_effect = added.append + return app + + with patch("cb_mcp.core.app.FastMCP", side_effect=capture_app): + build_app( + spec, + tools=list(spec.tools.all_tools)[:2], + settings={ + "transport": "stdio", + "oauth_enabled": False, + "disabled_tools": {"drop_index"}, + "confirmation_required_tools": set(), + }, + provider_factory=MagicMock, + read_only_mode=True, + audit_config=audit_config, + ) + + async def drive(): + async with captured["lifespan"](MagicMock()): + pass + + asyncio.run(drive()) + shutdown_audit() + return added + + +@pytest.mark.parametrize("spec", ALL_SPECS, ids=lambda s: s.id) +def test_audit_wiring_follows_the_spec_s_audit_package(spec, tmp_path): + """Auditing is opt-in per server, and the opt-out is total. + + A server with no ``audit_package`` must register no middleware and write + no records: its tools would otherwise fall through classification's + fail-closed path and be booked against another server's Tier-2 block, + where the same ids mean different operations. + """ + added = _run_lifespan(spec, _audit_config(tmp_path), tmp_path) + records = _records(tmp_path) + + if spec.audit_package is None: + assert added == [], f"{spec.id} registered audit middleware" + assert records == [], f"{spec.id} wrote audit records" + return + + assert [type(mw).__name__ for mw in added] == ["AuditMiddleware"] + assert [record["name"] for record in records] == [ + "server started", + "server configuration", + "server stopped", + ] + + +def test_startup_records_report_the_enforced_surface(tmp_path): + """The configuration record is what makes withheld tools auditable. + + Neither a disabled nor a read-only-withheld tool is ever registered with + FastMCP, so a client is never told it exists and there is no invocation to + refuse. This record is the only place that surface is stated. + """ + spec = next(s for s in ALL_SPECS if s.audit_package is not None) + _run_lifespan(spec, _audit_config(tmp_path), tmp_path) + + config_record = next( + r for r in _records(tmp_path) if r["name"] == "server configuration" + ) + assert config_record["server_id"] == spec.id + assert config_record["service_package"] == spec.audit_package + assert config_record["read_only_mode"] is True + assert config_record["oauth_enabled"] is False + assert config_record["disabled_tools"] == ["drop_index"] + assert config_record["registered_tools"] + assert config_record["audit_config"]["enabled"] is True + + +def test_a_clean_shutdown_is_recorded_as_success(tmp_path): + spec = next(s for s in ALL_SPECS if s.audit_package is not None) + _run_lifespan(spec, _audit_config(tmp_path), tmp_path) + + stopped = next(r for r in _records(tmp_path) if r["name"] == "server stopped") + assert stopped["outcome"] == "success" + assert "reason" not in stopped + + +def test_nothing_is_written_when_auditing_is_configured_off(tmp_path): + """The middleware is still registered; it just never has a sink to write to. + + Registration is deliberately not conditional on the flag: the middleware + short-circuits on an inactive logger, so one registration path serves both + modes and a sink that fails to *open* degrades to no records rather than to + a half-instrumented server. What must hold is that nothing is written. + """ + spec = next(s for s in ALL_SPECS if s.audit_package is not None) + _run_lifespan(spec, _audit_config(tmp_path, enabled=False), tmp_path) + assert not get_audit_logger().active + assert _records(tmp_path) == [] + + +# --------------------------------------------------------------------------- +# the verifier hook +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_rejected_token_is_audited_by_the_verifier(tmp_path): + """The one audit hook that cannot live in middleware. + + A rejected bearer token is refused in the ASGI auth layer, so no JSON-RPC + message is ever constructed and the MCP pipeline never runs. Without this + override the single most security-relevant event in the catalogue would + never be emitted. + """ + init_audit(_audit_config(tmp_path)) + try: + verifier = CouchbaseJWTVerifier( + jwks_uri="https://idp.example.com/.well-known/jwks.json", + issuer="https://idp.example.com/", + audience="couchbase-mcp", + ) + with patch.object( + CouchbaseJWTVerifier.__bases__[0], "verify_token", return_value=None + ): + assert await verifier.verify_token("not-a-real-token") is None + finally: + shutdown_audit() + + rejected = [r for r in _records(tmp_path) if r["name"] == "token rejected"] + assert len(rejected) == 1, _records(tmp_path) + record = rejected[0] + assert record["id"] == 57376 + assert record["outcome"] == "denied" + assert record["reason"] == "token_invalid" + # A rejected token establishes no identity, and the record is a deliberate + # singleton: it still carries a cid so grouping a file by cid works + # unconditionally, but nothing else can ever share it. + assert record["real_userid"] == {"domain": "anonymous", "user": "anonymous"} + assert record["cid"] + + +@pytest.mark.asyncio +async def test_an_accepted_token_emits_no_record(tmp_path): + """There is no "token accepted" event; success is silent at this layer.""" + init_audit(_audit_config(tmp_path)) + try: + verifier = CouchbaseJWTVerifier( + jwks_uri="https://idp.example.com/.well-known/jwks.json", + issuer="https://idp.example.com/", + audience="couchbase-mcp", + ) + with patch.object( + CouchbaseJWTVerifier.__bases__[0], "verify_token", return_value=MagicMock() + ): + assert await verifier.verify_token("a-valid-token") is not None + finally: + shutdown_audit() + + assert [r for r in _records(tmp_path) if r["name"] == "token rejected"] == [] diff --git a/tests/unit/test_cli_options.py b/tests/unit/test_cli_options.py index 0bd93199..a5546d09 100644 --- a/tests/unit/test_cli_options.py +++ b/tests/unit/test_cli_options.py @@ -217,3 +217,102 @@ def test_cli_translates_oauth_config_error_to_usage_error(self): ) assert result.exit_code == 2 assert "must be distinct" in result.output + + +class TestAuditOptions: + """The six audit flags, through Click, to a resolved configuration. + + Audit settings do not appear in ``app_context.settings`` — deliberately, so + they are not duplicated into the env-info snapshot — so these assert on the + ``ResolvedAuditConfig`` the entrypoint hands to ``build_app`` instead. + """ + + @staticmethod + def _resolved(args: list[str], env: dict | None = None): + captured: dict = {} + real = mcp_server.build_app + + def capture(*a, **kw): + captured["audit"] = kw.get("audit_config") + return real(*a, **kw) + + with ( + patch("cb_mcp.core.app.FastMCP", return_value=MagicMock()), + patch("mcp_server.build_app", side_effect=capture), + patch("mcp_server.run_app"), + ): + result = CliRunner().invoke( + mcp_server.main, + ["--connection-string", "couchbase://localhost", *args], + env={**os.environ, **(env or {})}, + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + return captured["audit"] + + def test_auditing_is_off_by_default(self): + config = self._resolved([]) + assert config.enabled is False + assert config.file is None + assert config.tool_args is False + + def test_flags_reach_the_resolved_configuration(self, tmp_path): + config = self._resolved( + [ + "--audit-log-enabled", + "true", + "--audit-file", + str(tmp_path / "audit.log"), + "--audit-rotation-max-size-mb", + "4", + "--audit-retention-backup-count", + "7", + "--audit-tool-args", + "true", + "--audit-disabled-events", + "61490,61491", + ] + ) + assert config.enabled is True + assert config.file == str(tmp_path / "audit.log") + assert config.rotation_max_size_mb == 4.0 + assert config.retention_backup_count == 7 + assert config.tool_args is True + assert config.disabled_events == (61490, 61491) + # The process-scoped path is resolved once, here, so startup can log + # the exact file and an operator is never left guessing. + assert config.process_file and config.process_file.endswith(".log") + + def test_env_vars_are_honoured(self, tmp_path): + config = self._resolved( + [], + env={ + "CB_MCP_AUDIT_LOG_ENABLED": "true", + "CB_MCP_AUDIT_FILE": str(tmp_path / "from-env.log"), + "CB_MCP_AUDIT_TOOL_ARGS": "true", + }, + ) + assert config.enabled is True + assert config.file == str(tmp_path / "from-env.log") + assert config.tool_args is True + + def test_enabling_without_a_file_starts_the_server_with_auditing_off(self): + """Per the PRD this is reported, not fatal: the server still starts.""" + config = self._resolved(["--audit-log-enabled", "true"]) + assert config.enabled is False + + def test_both_subcommands_accept_the_audit_flags(self): + for command in ("operational", "operational-insights"): + result = CliRunner().invoke( + mcp_server.main, [command, "--help"], catch_exceptions=False + ) + assert result.exit_code == 0 + for flag in ( + "--audit-log-enabled", + "--audit-file", + "--audit-rotation-max-size-mb", + "--audit-retention-backup-count", + "--audit-tool-args", + "--audit-disabled-events", + ): + assert flag in result.output, f"{flag} missing from {command} --help" diff --git a/tests/unit/test_settings_classification.py b/tests/unit/test_settings_classification.py index d86a2a0b..a2014ad7 100644 --- a/tests/unit/test_settings_classification.py +++ b/tests/unit/test_settings_classification.py @@ -43,16 +43,8 @@ # Listing them here is the opt-out: it forces the omission to be a decision # somebody wrote down, rather than an oversight. Keyed by server id. DELIBERATELY_UNREPORTED: dict[str, set[str]] = { - # audit_config is a nested mapping, not a scalar setting, and it is already - # reported in full by two places that are better suited to it: - # ``get_server_configuration_status`` returns it alongside the live sink - # counters, and the ``server configuration`` audit record carries it into - # the audit file itself. Flattening it into the env-info snapshot would - # duplicate that without adding anything, so it is classified here rather - # than on either ServerSpec. It holds no secrets — a file path, sizes, and - # a list of disabled event ids. - "operational": {"audit_config"}, - "operational-insights": {"audit_config"}, + "operational": set(), + "operational-insights": set(), } From 3c80cd8274e62726bbc72cacd13de273a3c72951 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Wed, 30 Sep 2026 16:26:34 +0530 Subject: [PATCH 07/27] Elicitation Fixed --- src/cb_mcp/utils/elicitation.py | 2 +- tests/unit/test_audit_integration_points.py | 2 +- tests/unit/test_confirmation_tools.py | 89 ++++++++++++++++++--- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/cb_mcp/utils/elicitation.py b/src/cb_mcp/utils/elicitation.py index 28eec601..c4508f12 100644 --- a/src/cb_mcp/utils/elicitation.py +++ b/src/cb_mcp/utils/elicitation.py @@ -104,7 +104,7 @@ async def wrapper(*args, **kwargs): message = _build_confirmation_message(tool_name, call_arguments) result = await ctx.elicit( message=message, - schema=ConfirmationResult, + response_type=ConfirmationResult, ) if result.action != "accept" or ( diff --git a/tests/unit/test_audit_integration_points.py b/tests/unit/test_audit_integration_points.py index a90c071d..1768b433 100644 --- a/tests/unit/test_audit_integration_points.py +++ b/tests/unit/test_audit_integration_points.py @@ -150,7 +150,7 @@ async def tool(ctx=None): def _ctx_with_elicitation( *, supported: bool, action: str = "accept", confirm: bool = True ) -> Context: - async def elicit(message, schema): + async def elicit(message, response_type): return SimpleNamespace(action=action, data=ConfirmationResult(confirm=confirm)) session = SimpleNamespace(check_client_capability=lambda _caps: supported) diff --git a/tests/unit/test_confirmation_tools.py b/tests/unit/test_confirmation_tools.py index 6f293d2c..54690e29 100644 --- a/tests/unit/test_confirmation_tools.py +++ b/tests/unit/test_confirmation_tools.py @@ -10,13 +10,21 @@ - capability detection and fallback when elicitation is not advertised - fail-closed behavior for unexpected runtime errors - positional argument forwarding compatibility + - that the fakes below have not drifted from the real FastMCP signature """ +import inspect from types import SimpleNamespace import pytest from fastmcp import Context +from fastmcp.server.elicitation import ( + AcceptedElicitation, + CancelledElicitation, + DeclinedElicitation, +) +import cb_mcp.utils.elicitation as production_elicitation from cb_mcp.tools.operational import TOOL_ANNOTATIONS, get_tools from cb_mcp.utils.config import parse_tool_names from cb_mcp.utils.elicitation import ( @@ -176,8 +184,8 @@ def __init__(self): session=FakeSession(supports_elicitation), ) - async def elicit(self, message, schema): - return await elicit_callback(message, schema) + async def elicit(self, message, response_type): + return await elicit_callback(message, response_type) return FakeContext() @@ -192,7 +200,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def decline_elicit(message, schema): + async def decline_elicit(message, response_type): return SimpleNamespace(action="decline") fake_ctx = self._make_context( @@ -217,7 +225,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def cancel_elicit(message, schema): + async def cancel_elicit(message, response_type): return SimpleNamespace(action="cancel") fake_ctx = self._make_context( @@ -242,7 +250,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def reject_confirm_elicit(message, schema): + async def reject_confirm_elicit(message, response_type): return SimpleNamespace( action="accept", data=SimpleNamespace(confirm=False), @@ -270,7 +278,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def accept_elicit(message, schema): + async def accept_elicit(message, response_type): return SimpleNamespace( action="accept", data=SimpleNamespace(confirm=True), @@ -296,7 +304,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def should_not_be_called_elicit(message, schema): + async def should_not_be_called_elicit(message, response_type): raise AssertionError("elicit should not be called without client support") fake_ctx = self._make_context( @@ -318,7 +326,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def transient_error_elicit(message, schema): + async def transient_error_elicit(message, response_type): raise RuntimeError("Transient elicitation failure") fake_ctx = self._make_context( @@ -344,7 +352,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def accept_elicit(message, schema): + async def accept_elicit(message, response_type): return SimpleNamespace( action="accept", data=SimpleNamespace(confirm=True), @@ -378,7 +386,7 @@ def __init__(self): session=None, ) - async def elicit(self, message, schema): + async def elicit(self, message, response_type): raise AssertionError("elicit must not be called when session is None") result = await wrapped(ctx=FakeContext()) @@ -402,7 +410,7 @@ def __init__(self): session=SimpleNamespace(), # no check_client_capability ) - async def elicit(self, message, schema): + async def elicit(self, message, response_type): raise AssertionError( "elicit must not be called when capability check is unavailable" ) @@ -422,7 +430,7 @@ async def async_sample_tool(ctx: Context) -> bool: wrapped = wrap_with_confirmation(async_sample_tool) - async def accept_elicit(message, schema): + async def accept_elicit(message, response_type): return SimpleNamespace( action="accept", data=SimpleNamespace(confirm=True), @@ -436,3 +444,60 @@ async def accept_elicit(message, schema): result = await wrapped(ctx=fake_ctx) assert result is True assert called is True + + +class TestElicitSignatureContract: + """The fakes in this file must not drift from the real FastMCP API. + + This class exists because of a bug it would have caught. The production + call passed ``schema=ConfirmationResult`` while every ``Context.elicit`` + fake in this file declared ``elicit(self, message, schema)``. The tests + therefore agreed with the bug and disagreed with FastMCP, and stayed green + while the confirmation path raised + + Context.elicit() got an unexpected keyword argument 'schema' + + in every deployment whose client advertises elicitation. The parameter has + been ``response_type`` since at least fastmcp 3.2.0, the project's own + minimum pin, so the call never worked with any supported version. + + A hand-written fake can only ever prove the code agrees with the fake. + These tests bind against the installed library instead. + """ + + def test_the_keywords_we_send_bind_to_the_real_signature(self): + """The guard: rename the parameter upstream and this fails, not prod.""" + signature = inspect.signature(Context.elicit) + signature.bind( + SimpleNamespace(), # self + message="Confirm execution of 'replace_document_by_id'?", + response_type=ConfirmationResult, + ) + + def test_the_old_keyword_is_genuinely_gone(self): + """Pins the reason for the change, so nobody reverts it as cosmetic.""" + with pytest.raises(TypeError): + inspect.signature(Context.elicit).bind( + SimpleNamespace(), message="x", schema=ConfirmationResult + ) + + def test_the_production_call_uses_the_real_keyword(self): + """Checks the module that actually calls FastMCP, not a fake of it. + + The binding tests above prove which keyword is correct; this proves the + production call site uses it. Together they close the gap that let the + bug ship: agreement between the code and a fake of the library is not + agreement with the library. + """ + real = list(inspect.signature(Context.elicit).parameters) + assert real[:3] == ["self", "message", "response_type"], real + + source = inspect.getsource(production_elicitation) + assert "response_type=ConfirmationResult" in source + assert "schema=Confirmation" not in source + + def test_the_result_types_expose_what_the_wrapper_reads(self): + """The wrapper branches on ``result.action`` and guards ``result.data``.""" + for cls in (AcceptedElicitation, DeclinedElicitation, CancelledElicitation): + assert "action" in cls.model_fields, cls.__name__ + assert "data" in AcceptedElicitation.model_fields From 638c93225b0049f92acb35da1de90003fb072bd1 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Wed, 30 Sep 2026 19:39:46 +0530 Subject: [PATCH 08/27] Review fixed --- tests/unit/test_confirmation_tools.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_confirmation_tools.py b/tests/unit/test_confirmation_tools.py index 54690e29..2e8df6da 100644 --- a/tests/unit/test_confirmation_tools.py +++ b/tests/unit/test_confirmation_tools.py @@ -24,7 +24,6 @@ DeclinedElicitation, ) -import cb_mcp.utils.elicitation as production_elicitation from cb_mcp.tools.operational import TOOL_ANNOTATIONS, get_tools from cb_mcp.utils.config import parse_tool_names from cb_mcp.utils.elicitation import ( @@ -492,7 +491,11 @@ def test_the_production_call_uses_the_real_keyword(self): real = list(inspect.signature(Context.elicit).parameters) assert real[:3] == ["self", "message", "response_type"], real - source = inspect.getsource(production_elicitation) + # The module is derived from a symbol this file already imports, so + # there is one import style for cb_mcp.utils.elicitation (CodeQL + # py/import-and-import-from) and the assertion is anchored to the + # function under test rather than to a separately imported name. + source = inspect.getsource(inspect.getmodule(wrap_with_confirmation)) assert "response_type=ConfirmationResult" in source assert "schema=Confirmation" not in source From 07af34c1e1b4c7d4c0d2c0a8282f61d2bef0c733 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Tue, 6 Oct 2026 22:59:09 +0530 Subject: [PATCH 09/27] Extended time based rotation and other requirements --- DOCKER.md | 28 +- README.md | 54 +- server.json | 64 ++- src/cb_mcp/audit/__init__.py | 22 +- src/cb_mcp/audit/config.py | 264 +++++++-- src/cb_mcp/audit/emitter.py | 109 +++- src/cb_mcp/audit/sink.py | 487 +++++++++++++++-- src/cb_mcp/core/cli/options.py | 84 +-- src/cb_mcp/utils/__init__.py | 10 +- src/cb_mcp/utils/cli_params.py | 12 +- src/cb_mcp/utils/constants.py | 41 +- tests/unit/test_audit_config.py | 287 ++++++++-- tests/unit/test_audit_integration_points.py | 3 +- tests/unit/test_audit_middleware.py | 9 +- tests/unit/test_audit_sink.py | 568 ++++++++++++++++++-- tests/unit/test_audit_wiring.py | 50 +- tests/unit/test_cli_options.py | 54 +- 17 files changed, 1824 insertions(+), 322 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index aa7d5a5b..c8b0691b 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -213,11 +213,13 @@ The detailed explanation for the environment variables can be found on the [GitH | `CB_MCP_OAUTH_SCOPE_READ_LABEL` | Override the OAuth scope label treated as 'read' access (advertised in PRM and matched against the token `scope`/`scp` claim). Use when your IdP can't emit the canonical form | `couchbase-mcp:read` | | `CB_MCP_OAUTH_SCOPE_WRITE_LABEL` | Override the OAuth scope label treated as 'write' access; same semantics as the read label | `couchbase-mcp:write` | | `CB_MCP_AUDIT_LOG_ENABLED` | Enable audit logging — a separate sink from the `CB_MCP_LOG_*` operational logs, with a stable schema and its own retention (see [Auditing](#auditing)) | `false` | -| `CB_MCP_AUDIT_FILE` | Path to the audit log file. Required when auditing is enabled; the hostname and process id are inserted before the extension (`audit.log` → `audit...log`), so containers sharing a volume never share a file. Mount a volume at this path to keep records after the container stops | None | -| `CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB` | Maximum size **in MB** an audit file may reach before it rotates. `0` is invalid and falls back to the default with a startup warning | `1` (1 MB) | -| `CB_MCP_AUDIT_RETENTION_BACKUP_COUNT`| Rotated backups retained per audit file, excluding the live file. Bounds one file, not the directory: each container start creates a new file with its own backups | `1000` | -| `CB_MCP_AUDIT_TOOL_ARGS` | Record tool argument values. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | -| `CB_MCP_AUDIT_DISABLED_EVENTS` | Audit events to suppress: comma-separated numeric event ids (e.g. `61490,61491`), or a file with one id per line. Only filterable events can be suppressed | None | +| `CB_MCP_AUDIT_LOG_SINKS` | Comma-separated audit destinations: `console`, `file`, or both. `console` writes to **stderr** — never stdout, which carries the JSON-RPC protocol on stdio | `console` | +| `CB_MCP_AUDIT_LOG_FILE_PATH` | Path to the audit log file. Required when `file` is in the sinks; the hostname and process id are inserted before the extension (`audit.log` → `audit...log`), so containers sharing a volume never share a file. Mount a volume at this path to keep records after the container stops | None | +| `CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB` | Maximum size **in MB** the live audit file may reach before it rotates. `0` turns size-based rotation **off** | `10` (10 MB) | +| `CB_MCP_AUDIT_LOG_ROTATION_INTERVAL` | Age the live audit file may reach before it rotates, as `` where the unit is `d` (24 hours) or `w` (7 days) — e.g. `1d`, `30d`, `8w`. `0` turns interval-based rotation **off** | `1d` | +| `CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS` | Rotated backups retained per audit file, excluding the live file; they are gzipped. Bounds one file, not the directory: each container start creates a new file with its own backups | `10` | +| `CB_MCP_AUDIT_LOG_TOOL_ARGS` | Record tool argument values. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | +| `CB_MCP_AUDIT_LOG_DISABLED_EVENTS` | Audit events to suppress: comma-separated numeric event ids (e.g. `61490,61491`), or a file with one id per line. Only filterable events can be suppressed | None | ### Disabling Tools @@ -403,12 +405,13 @@ Audit logging records **who did what, and whether it was allowed** — a differe Records are JSON Lines, one immutable record per line, in the same field vocabulary as Couchbase Server and Sync Gateway. -- **`CB_MCP_AUDIT_LOG_ENABLED` / `CB_MCP_AUDIT_FILE`** — both are needed. Enabling auditing without a path leaves the server running with auditing disabled and an error in the log. -- **Mount a volume at the audit path.** The audit file is written inside the container like any other file, so without a volume the records are destroyed when the container is removed — which defeats the purpose of keeping them. This matters more than it does for logs: audit retention defaults to 1000 rotated backups precisely because the records are meant to outlive the process. +- **`CB_MCP_AUDIT_LOG_ENABLED` / `CB_MCP_AUDIT_LOG_SINKS` / `CB_MCP_AUDIT_LOG_FILE_PATH`** — enabling auditing alone writes to the console (stderr). A container that keeps records needs `CB_MCP_AUDIT_LOG_SINKS=file` and a path. Selecting the file sink without a path leaves the server running, with the console sink if one was also selected, and an error in the log. +- **Mount a volume at the audit path.** The audit file is written inside the container like any other file, so without a volume the records are destroyed when the container is removed — which defeats the purpose of keeping them. This matters more than it does for logs: the records are meant to outlive the process. - **One file per container.** The container's hostname and process id are inserted before the extension (`audit.log` → `audit.3f2a91c4b7de.1.log`). The hostname is what makes this safe: this image's `ENTRYPOINT` is exec form, so the server is **PID 1 in every container**, and a name built from the pid alone would give every container sharing a volume the same `audit.1.log` — interleaved lines and rotation races that silently destroy records. Docker assigns each container a distinct hostname by default. If you override `--hostname` to the same value on two containers **and** share one volume between them, give each its own subdirectory instead. Consolidating the files is the operator's job. -- **Retention is per file, not per deployment.** `CB_MCP_AUDIT_RETENTION_BACKUP_COUNT` bounds the backups of *one* audit file. Each new container is a new hostname and so a new file with its own backups, and nothing prunes the files of containers that have exited. On a long-lived mounted volume, total usage grows with the number of container starts — size it accordingly, or sweep old `audit.*.log*` files yourself. -- **Filtering** — `CB_MCP_AUDIT_DISABLED_EVENTS` turns read noise down, taking numeric event ids (e.g. `61490,61491`). Write and security events cannot be disabled; an attempt to do so is refused with a warning. -- **`CB_MCP_AUDIT_TOOL_ARGS`** — off by default, and warns at startup when enabled. There is no redaction in this release, so arguments are written verbatim, including full document bodies. +- **Retention is per file, not per deployment.** `CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS` bounds the backups of *one* audit file. Each new container is a new hostname and so a new file with its own backups, and nothing prunes the files of containers that have exited. On a long-lived mounted volume, total usage grows with the number of container starts — size it accordingly, or sweep old `audit.*.log*` files yourself. +- **Rotation and compression.** Size and age are independent triggers and either can be switched off with `0`: `CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB` caps the live file's size, `CB_MCP_AUDIT_LOG_ROTATION_INTERVAL` caps its age, and whichever falls due first rotates it. Rotated files are named with the UTC rotation time and **gzipped** (`audit.3f2a91c4b7de.1.log.20261006T091422481503Z.gz`), which is what keeps a months-long retention window affordable on a mounted volume; the live file stays plain text so `docker exec ... tail -f` still works. +- **Filtering** — `CB_MCP_AUDIT_LOG_DISABLED_EVENTS` turns read noise down, taking numeric event ids (e.g. `61490,61491`). Write and security events cannot be disabled; an attempt to do so is refused with a warning. +- **`CB_MCP_AUDIT_LOG_TOOL_ARGS`** — off by default, and warns at startup when enabled. There is no redaction in this release, so arguments are written verbatim, including full document bodies. - **Fail-open** — if the audit file cannot be written, the server keeps serving and records are dropped and counted. `get_server_configuration_status` reports the live `written` / `dropped` / `write_errors` counters. Auditing applies to the **operational** server. The `operational-insights` server accepts the same variables and reports them, but records nothing. @@ -418,7 +421,10 @@ docker run --rm \ -e CB_CONNECTION_STRING=couchbases://your-cluster \ -e CB_USERNAME=user -e CB_PASSWORD=password \ -e CB_MCP_AUDIT_LOG_ENABLED=true \ - -e CB_MCP_AUDIT_FILE=/audit/audit.log \ + -e CB_MCP_AUDIT_LOG_SINKS=file \ + -e CB_MCP_AUDIT_LOG_FILE_PATH=/audit/audit.log \ + -e CB_MCP_AUDIT_LOG_ROTATION_INTERVAL=1d \ + -e CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS=90 \ -v "$(pwd)/audit:/audit" \ couchbase/mcp-server-couchbase:latest ``` diff --git a/README.md b/README.md index 1c39b95e..d84ca11f 100644 --- a/README.md +++ b/README.md @@ -308,11 +308,13 @@ The server can be configured using environment variables or command line argumen | `CB_MCP_OAUTH_SCOPE_READ_LABEL` | `--oauth-scope-read-label` | Override the OAuth scope label treated as 'read' access (advertised in PRM and matched against the token's `scope`/`scp` claim). Use when your IdP can't emit the canonical form | `couchbase-mcp:read` | | `CB_MCP_OAUTH_SCOPE_WRITE_LABEL` | `--oauth-scope-write-label` | Override the OAuth scope label treated as 'write' access; same semantics as the read label | `couchbase-mcp:write` | | `CB_MCP_AUDIT_LOG_ENABLED` | `--audit-log-enabled` | Enable audit logging — a separate sink from the `CB_MCP_LOG_*` operational logs, with a stable schema and its own retention (see [Auditing](#auditing)) | `false` | -| `CB_MCP_AUDIT_FILE` | `--audit-file` | Path to the audit log file. Required when auditing is enabled; the hostname and process id are inserted before the extension (`audit.log` → `audit...log`) | None | -| `CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB` | `--audit-rotation-max-size-mb` | Maximum size **in MB** an audit file may reach before it rotates. `0` is invalid and falls back to the default with a startup warning | `1` (1 MB) | -| `CB_MCP_AUDIT_RETENTION_BACKUP_COUNT` | `--audit-retention-backup-count` | Rotated backups retained **per audit file**, excluding the live file. `0` keeps only the live file. This bounds one file, not the directory — see [Auditing](#auditing) | `1000` | -| `CB_MCP_AUDIT_TOOL_ARGS` | `--audit-tool-args` | Record tool argument values in the audit log. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | -| `CB_MCP_AUDIT_DISABLED_EVENTS` | `--audit-disabled-events` | Audit events to suppress: comma-separated numeric event ids (e.g. `61490,61491`), or a file with one id per line. Only filterable events can be suppressed | None | +| `CB_MCP_AUDIT_LOG_SINKS` | `--audit-log-sinks` | Comma-separated audit destinations: `console`, `file`, or both. `console` writes to **stderr** — never stdout, which carries the JSON-RPC protocol on stdio | `console` | +| `CB_MCP_AUDIT_LOG_FILE_PATH` | `--audit-log-file-path` | Path to the audit log file. Required when `file` is in the sinks; the hostname and process id are inserted before the extension (`audit.log` → `audit...log`) | None | +| `CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB` | `--audit-log-rotation-max-size-mb` | Maximum size **in MB** the live audit file may reach before it rotates. `0` turns size-based rotation **off** | `10` (10 MB) | +| `CB_MCP_AUDIT_LOG_ROTATION_INTERVAL` | `--audit-log-rotation-interval` | Age the live audit file may reach before it rotates, as `` where the unit is `d` (24 hours) or `w` (7 days) — e.g. `1d`, `30d`, `8w`. `0` turns interval-based rotation **off** | `1d` | +| `CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS` | `--audit-log-retention-max-backups` | Rotated backups retained **per audit file**, excluding the live file. Backups are gzipped. `0` keeps none. This bounds one file, not the directory — see [Auditing](#auditing) | `10` | +| `CB_MCP_AUDIT_LOG_TOOL_ARGS` | `--audit-log-tool-args` | Record tool argument values in the audit log. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | +| `CB_MCP_AUDIT_LOG_DISABLED_EVENTS` | `--audit-log-disabled-events` | Audit events to suppress: comma-separated numeric event ids (e.g. `61490,61491`), or a file with one id per line. Only filterable events can be suppressed | None | #### Read-Only Mode Configuration @@ -471,7 +473,7 @@ For more details, see the [documentation](https://docs.couchbase.com/mcp-server/ Audit logging answers a different question from the operational logs. Logs tell you **what went wrong**; the audit log tells you **who did what, and whether it was allowed**. It exists because the MCP server is the only layer that can answer either question about an agent: every request reaches the cluster as the single `CB_USERNAME` the server connects with, so Couchbase Server's own audit log cannot tell two callers apart — and decisions such as a scope denial, a read-only block or a declined confirmation never reach the cluster at all. -Auditing is **off by default**. It is enabled with `CB_MCP_AUDIT_LOG_ENABLED` and a path in `CB_MCP_AUDIT_FILE`; enabling it without a path leaves the server running with auditing disabled and an error in the log. +Auditing is **off by default**. `CB_MCP_AUDIT_LOG_ENABLED=true` turns it on, writing to the console (stderr) unless `CB_MCP_AUDIT_LOG_SINKS` says otherwise. A deployment that keeps records sets `CB_MCP_AUDIT_LOG_SINKS=file` and a path in `CB_MCP_AUDIT_LOG_FILE_PATH`; selecting the file sink without a path leaves the server running — with the console sink if one was also selected, otherwise with auditing off — and an error in the log. Records are **JSON Lines** — one immutable record per line — using the same field vocabulary as Couchbase Server and Sync Gateway (`id`, `name`, `description`, `timestamp`, `real_userid`, `server`, `cid`, `outcome`, `reason`), so an existing audit pipeline gets one format across the estate. @@ -482,22 +484,48 @@ Records are **JSON Lines** — one immutable record per line — using the same - **Identity** — `real_userid` records the *caller*, in one of three domains: `oauth` (the bearer token's subject), `local` (the OS process owner, on stdio), or `anonymous` (HTTP with OAuth disabled). Enabling auditing on HTTP without OAuth warns at startup, because every record then resolves to `anonymous`. - **Correlation** — `cid` groups every record produced by one request, so an authorization denial, a guardrail refusal and the eventual tool result join without matching on timestamps. There is deliberately **no session identifier**: MCP is moving to a stateless model in which connection identity must not be read as session continuity. For SQL++, the same `cid` is sent as the query's `client_context_id`, which Couchbase Server records as `clientContextId` — an exact join between the two logs. - **Events** — a fixed catalogue on numbered ID blocks: server lifecycle and configuration, session initialize, token rejected, scope check denied, read-only write blocked, confirmation declined or skipped, and one read/write pair per tool category (cluster, schema, document, query, index, performance, search). Adding a tool needs no new ID and no change to a SIEM rule. The full catalogue ships as `descriptor.json` inside the package. -- **Filtering** — `CB_MCP_AUDIT_DISABLED_EVENTS` turns *read* noise down, taking numeric event ids (the id is the wire contract; event names are prose and may be reworded, so they are not accepted). Write and security events are **not filterable**: an attempt to disable one is refused with a warning, so the record of who changed what cannot be switched off by configuration. -- **Retention** — rotation size and backup count work like the operational logs, but the default retention is far larger (1000 backups) because audit records are reviewed on a quarterly or annual cadence, not for day-to-day triage. +- **Filtering** — `CB_MCP_AUDIT_LOG_DISABLED_EVENTS` turns *read* noise down, taking numeric event ids (the id is the wire contract; event names are prose and may be reworded, so they are not accepted). Write and security events are **not filterable**: an attempt to disable one is refused with a warning, so the record of who changed what cannot be switched off by configuration. +- **Rotation** — two independent triggers, and either may be switched off with `0`. `CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB` rotates the live file when it grows past a size; `CB_MCP_AUDIT_LOG_ROTATION_INTERVAL` rotates it when it grows older than an age (`1d`, `30d`, `1w`, `8w`). Whichever falls due first rotates the file, and with both off the file simply keeps growing. The interval clock is anchored to the live file, not to startup, so a server that was down over the weekend rolls the stale file away rather than appending to it. +- **Retention and compression** — `CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS` keeps that many rotated files beside the live one; `0` keeps none, truncating the live file at each rotation. Backups are named with the UTC rotation time (`audit...log.20261006T091422481503Z.gz`) and **gzipped**, which matters because audit retention is measured in months — JSON Lines compresses roughly ten to one. The live file is deliberately left uncompressed so `tail -f` and `grep` still work during an incident. - **Per-writer files** — the hostname and process id are inserted before the extension (`audit.log` → `audit...log`), because a stdio deployment runs one server process per client and a shared rotating file would interleave and corrupt records. The hostname matters in containers: the published image's `ENTRYPOINT` is exec form, so the server is PID 1 in every container and a pid-only name would collide across containers sharing a volume. - **Retention is per file** — the backup count bounds the rotated copies of *one* audit file. Every process, and so every stdio session and every restart, writes a new file with its own backups, and nothing prunes the files of processes that have exited. Total disk use therefore grows with the number of server starts; size the volume accordingly or sweep old `audit.*.log*` files. - **Fail-open** — if the audit file cannot be written, the server keeps serving and the records are dropped and counted rather than turning an audit outage into a server outage. `get_server_configuration_status` reports the live `written` / `dropped` / `write_errors` counters, and `dropped` is the only way to discover that records were lost. -- **Tool arguments** — recorded only when `CB_MCP_AUDIT_TOOL_ARGS` is enabled, which warns at startup. There is no redaction in this release, so arguments are written verbatim, including the full document bodies passed to document-write tools. +- **Tool arguments** — recorded only when `CB_MCP_AUDIT_LOG_TOOL_ARGS` is enabled, which warns at startup. There is no redaction in this release, so arguments are written verbatim, including the full document bodies passed to document-write tools. Auditing currently applies to the **operational** server. The Operational Insights server accepts the same flags and reports them, but records nothing. ```bash -# Audit to a file, keeping the default 1000 rotated backups -uvx couchbase-mcp-server --audit-log-enabled=true --audit-file=/var/log/cb-mcp/audit.log +# Audit to the console (stderr) — no path needed +uvx couchbase-mcp-server --audit-log-enabled=true + +# Audit to a file: daily rotation, 10 gzipped backups, both by default +uvx couchbase-mcp-server --audit-log-enabled=true \ + --audit-log-sinks=file --audit-log-file-path=/var/log/cb-mcp/audit.log + +# 90 days of daily files, with no size limit on any of them +uvx couchbase-mcp-server --audit-log-enabled=true \ + --audit-log-sinks=file --audit-log-file-path=/var/log/cb-mcp/audit.log \ + --audit-log-rotation-max-size-mb=0 \ + --audit-log-rotation-interval=1d \ + --audit-log-retention-max-backups=90 + +# 100 MB in total: ten 10 MB files, no interval rotation +uvx couchbase-mcp-server --audit-log-enabled=true \ + --audit-log-sinks=file --audit-log-file-path=/var/log/cb-mcp/audit.log \ + --audit-log-rotation-max-size-mb=10 \ + --audit-log-rotation-interval=0 \ + --audit-log-retention-max-backups=9 + +# One file, never rotated — keep everything +uvx couchbase-mcp-server --audit-log-enabled=true \ + --audit-log-sinks=file --audit-log-file-path=/var/log/cb-mcp/audit.log \ + --audit-log-rotation-max-size-mb=0 --audit-log-rotation-interval=0 \ + --audit-log-retention-max-backups=0 # Turn down read noise, keeping every write and security event -uvx couchbase-mcp-server --audit-log-enabled=true --audit-file=/var/log/cb-mcp/audit.log \ - --audit-disabled-events="61490,61491" # document read, query read +uvx couchbase-mcp-server --audit-log-enabled=true \ + --audit-log-sinks=file --audit-log-file-path=/var/log/cb-mcp/audit.log \ + --audit-log-disabled-events="61490,61491" # document read, query read ``` ### Client Specific Configuration diff --git a/server.json b/server.json index 5e143a44..b7816543 100644 --- a/server.json +++ b/server.json @@ -250,41 +250,55 @@ }, { "name": "CB_MCP_AUDIT_LOG_ENABLED", - "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Requires CB_MCP_AUDIT_FILE. Default is false.", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false.", "isRequired": false, "format": "boolean", "isSecret": false }, { - "name": "CB_MCP_AUDIT_FILE", - "description": "Path to the audit log file, required when CB_MCP_AUDIT_LOG_ENABLED is true. Each server process writes its own file with the process id inserted before the extension (audit.log becomes audit.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with auditing disabled and an error recorded.", + "name": "CB_MCP_AUDIT_LOG_SINKS", + "description": "Comma-separated audit destinations: console, file, or both. 'console' writes to stderr, never stdout, because stdout carries the JSON-RPC protocol under the stdio transport. Default is console.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_FILE_PATH", + "description": "Path to the audit log file, required when 'file' is in CB_MCP_AUDIT_LOG_SINKS. Each server process writes its own file with the host and process id inserted before the extension (audit.log becomes audit.myhost.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with the file sink dropped and an error recorded.", "isRequired": false, "format": "filepath", "isSecret": false }, { - "name": "CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB", - "description": "Maximum size in MB an audit file may reach before it rotates. 0 is invalid and falls back to the default with a startup warning. Default is 1 MB.", + "name": "CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB", + "description": "Maximum size in MB the live audit file may reach before it rotates. 0 turns size-based rotation off. Default is 10 MB.", "isRequired": false, "format": "number", "isSecret": false }, { - "name": "CB_MCP_AUDIT_RETENTION_BACKUP_COUNT", - "description": "Number of rotated audit files retained, excluding the live file. Much larger than the operational-log default because audit records are reviewed on a quarterly or annual cadence. Set to 0 to keep only the live file. Default is 1000.", + "name": "CB_MCP_AUDIT_LOG_ROTATION_INTERVAL", + "description": "Age the live audit file may reach before it rotates, as where the unit is 'd' (24 hours) or 'w' (7 days) - for example 1d, 30d, 1w, 8w. 0 turns interval-based rotation off. Size and interval are independent: whichever falls due first rotates the file. Default is 1d.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS", + "description": "Number of rotated audit files retained, excluding the live file. Rotated files are gzipped and named with the UTC rotation time. Set to 0 to keep no backups: the live file is truncated at each rotation. Default is 10.", "isRequired": false, "format": "number", "isSecret": false }, { - "name": "CB_MCP_AUDIT_TOOL_ARGS", + "name": "CB_MCP_AUDIT_LOG_TOOL_ARGS", "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", "isRequired": false, "format": "boolean", "isSecret": false }, { - "name": "CB_MCP_AUDIT_DISABLED_EVENTS", + "name": "CB_MCP_AUDIT_LOG_DISABLED_EVENTS", "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", "isRequired": false, "format": "string", @@ -1343,41 +1357,55 @@ }, { "name": "CB_MCP_AUDIT_LOG_ENABLED", - "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Requires CB_MCP_AUDIT_FILE. Default is false.", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false.", "isRequired": false, "format": "boolean", "isSecret": false }, { - "name": "CB_MCP_AUDIT_FILE", - "description": "Path to the audit log file, required when CB_MCP_AUDIT_LOG_ENABLED is true. Each server process writes its own file with the process id inserted before the extension (audit.log becomes audit.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with auditing disabled and an error recorded.", + "name": "CB_MCP_AUDIT_LOG_SINKS", + "description": "Comma-separated audit destinations: console, file, or both. 'console' writes to stderr, never stdout, because stdout carries the JSON-RPC protocol under the stdio transport. Default is console.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_FILE_PATH", + "description": "Path to the audit log file, required when 'file' is in CB_MCP_AUDIT_LOG_SINKS. Each server process writes its own file with the host and process id inserted before the extension (audit.log becomes audit.myhost.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with the file sink dropped and an error recorded.", "isRequired": false, "format": "filepath", "isSecret": false }, { - "name": "CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB", - "description": "Maximum size in MB an audit file may reach before it rotates. 0 is invalid and falls back to the default with a startup warning. Default is 1 MB.", + "name": "CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB", + "description": "Maximum size in MB the live audit file may reach before it rotates. 0 turns size-based rotation off. Default is 10 MB.", "isRequired": false, "format": "number", "isSecret": false }, { - "name": "CB_MCP_AUDIT_RETENTION_BACKUP_COUNT", - "description": "Number of rotated audit files retained, excluding the live file. Much larger than the operational-log default because audit records are reviewed on a quarterly or annual cadence. Set to 0 to keep only the live file. Default is 1000.", + "name": "CB_MCP_AUDIT_LOG_ROTATION_INTERVAL", + "description": "Age the live audit file may reach before it rotates, as where the unit is 'd' (24 hours) or 'w' (7 days) - for example 1d, 30d, 1w, 8w. 0 turns interval-based rotation off. Size and interval are independent: whichever falls due first rotates the file. Default is 1d.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS", + "description": "Number of rotated audit files retained, excluding the live file. Rotated files are gzipped and named with the UTC rotation time. Set to 0 to keep no backups: the live file is truncated at each rotation. Default is 10.", "isRequired": false, "format": "number", "isSecret": false }, { - "name": "CB_MCP_AUDIT_TOOL_ARGS", + "name": "CB_MCP_AUDIT_LOG_TOOL_ARGS", "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", "isRequired": false, "format": "boolean", "isSecret": false }, { - "name": "CB_MCP_AUDIT_DISABLED_EVENTS", + "name": "CB_MCP_AUDIT_LOG_DISABLED_EVENTS", "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", "isRequired": false, "format": "string", diff --git a/src/cb_mcp/audit/__init__.py b/src/cb_mcp/audit/__init__.py index 69933e17..c78e150b 100644 --- a/src/cb_mcp/audit/__init__.py +++ b/src/cb_mcp/audit/__init__.py @@ -25,7 +25,7 @@ class of decisions — scope denials, read-only blocks, declined or skipped * :mod:`~cb_mcp.audit.identity` — ``real_userid`` resolution * :mod:`~cb_mcp.audit.middleware` — the FastMCP middleware * :mod:`~cb_mcp.audit.record` — the record and its serialisation -* :mod:`~cb_mcp.audit.sink` — the per-process file writer +* :mod:`~cb_mcp.audit.sink` — the per-process file writer and the console sink * :mod:`~cb_mcp.audit.state` — per-request refusal channel """ @@ -36,7 +36,12 @@ class of decisions — scope denials, read-only blocks, declined or skipped resolve_tool_call_event, unclassified_tool_names, ) -from .config import ResolvedAuditConfig, resolve_audit_config +from .config import ( + ResolvedAuditConfig, + parse_audit_sinks, + parse_rotation_interval, + resolve_audit_config, +) from .emitter import AuditLogger, get_audit_logger, init_audit, shutdown_audit from .exceptions import ( AuditableRefusalError, @@ -54,7 +59,13 @@ class of decisions — scope denials, read-only blocks, declined or skipped OUTCOME_SUCCESS, AuditRecord, ) -from .sink import AuditSink, process_scoped_path +from .sink import ( + AuditSink, + AuditSinkProtocol, + CompositeAuditSink, + ConsoleAuditSink, + process_scoped_path, +) __all__ = [ "OUTCOME_BLOCKED", @@ -68,8 +79,11 @@ class of decisions — scope denials, read-only blocks, declined or skipped "AuditMiddleware", "AuditRecord", "AuditSink", + "AuditSinkProtocol", "AuditableRefusalError", + "CompositeAuditSink", "ConfirmationDeclinedError", + "ConsoleAuditSink", "ReadOnlyWriteBlockedError", "ResolvedAuditConfig", "ScopeDeniedError", @@ -78,6 +92,8 @@ class of decisions — scope denials, read-only blocks, declined or skipped "classify_tool", "get_audit_logger", "init_audit", + "parse_audit_sinks", + "parse_rotation_interval", "process_scoped_path", "resolve_audit_config", "resolve_real_userid", diff --git a/src/cb_mcp/audit/config.py b/src/cb_mcp/audit/config.py index 61cb20f4..00160b64 100644 --- a/src/cb_mcp/audit/config.py +++ b/src/cb_mcp/audit/config.py @@ -5,22 +5,38 @@ ``get_server_configuration_status`` and the startup records can report, so the tool output and the audit file always agree on what is running. -Per the PRD, enabling auditing without a file path is an error that is -*reported* rather than fatal: the server still starts, with auditing off. + +**Zero means off, not invalid.** Both rotation triggers accept ``0`` as a +first-class value: ``CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB=0`` turns size-based +rotation off and ``CB_MCP_AUDIT_LOG_ROTATION_INTERVAL=0`` turns interval-based +rotation off. Setting both gives one unbounded file, which is the PRD's Case 2 +("audit logs are important for me and I want to store them all"). Only a +*negative* or unparseable value is a configuration error, and that falls back to +the default with a warning rather than aborting. + +**A broken file sink degrades, it does not abort.** Selecting the file sink +without naming a path is reported and the file sink is dropped; if console was +also selected the server keeps auditing to console, and if it was not, auditing +is off. The server starts either way. """ from __future__ import annotations import logging +import re from dataclasses import dataclass from pathlib import Path from typing import Any from ..utils.constants import ( + ALLOWED_AUDIT_SINKS, + AUDIT_INTERVAL_UNIT_SECONDS, BYTES_PER_MB, - DEFAULT_AUDIT_BACKUP_COUNT, DEFAULT_AUDIT_ENABLED, + DEFAULT_AUDIT_MAX_BACKUPS, + DEFAULT_AUDIT_ROTATION_INTERVAL, DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB, + DEFAULT_AUDIT_SINKS, DEFAULT_AUDIT_TOOL_ARGS, LOGGER_NAMESPACE, ) @@ -29,33 +45,148 @@ logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.config") +SINK_CONSOLE = "console" +SINK_FILE = "file" + +#: ````: one or more digits followed by a single unit letter. +#: Anchored, so "1d2h" and "d1" are rejected rather than half-read. +_INTERVAL_PATTERN = re.compile(r"^(\d+)([a-zA-Z])$") + @dataclass(frozen=True) class ResolvedAuditConfig: """Snapshot of the audit configuration actually in force.""" enabled: bool + sinks: tuple[str, ...] file: str | None process_file: str | None rotation_max_size_mb: float max_bytes: int - retention_backup_count: int + rotation_interval: str + rotation_interval_seconds: int + max_backups: int tool_args: bool disabled_events: tuple[int, ...] + @property + def writes_file(self) -> bool: + """True when a file sink is actually going to be opened.""" + return self.enabled and SINK_FILE in self.sinks and self.file is not None + + @property + def writes_console(self) -> bool: + """True when records are going to stderr.""" + return self.enabled and SINK_CONSOLE in self.sinks + def as_dict(self) -> dict[str, Any]: """JSON-friendly view for the server-configuration tool and records.""" return { "enabled": self.enabled, + "sinks": list(self.sinks), "file": self.file, "process_file": self.process_file, "rotation_max_size_mb": self.rotation_max_size_mb, - "retention_backup_count": self.retention_backup_count, + "rotation_interval": self.rotation_interval, + "max_backups": self.max_backups, "tool_args": self.tool_args, "disabled_events": list(self.disabled_events), } +def parse_audit_sinks(raw: str | None) -> tuple[str, ...]: + """Parse ``CB_MCP_AUDIT_LOG_SINKS`` into an ordered tuple of sink names. + + Same shape as ``CB_MCP_LOG_SINKS``: comma-separated, case-insensitive, + whitespace trimmed. Unknown tokens are warned about and ignored; if nothing + valid survives the default sink is used, so enabling auditing always + produces output somewhere rather than silently producing none. + + The return is ordered rather than a set so the status tool and the ``audit + configuration changed`` record report sinks in a stable order. + """ + if raw is None: + raw = DEFAULT_AUDIT_SINKS + + selected: list[str] = [] + invalid: list[str] = [] + for part in raw.split(","): + token = part.strip() + if not token: + continue + normalised = token.lower() + if normalised not in ALLOWED_AUDIT_SINKS: + invalid.append(token) + elif normalised not in selected: + selected.append(normalised) + + if invalid: + logger.warning( + "Ignored invalid audit sink value(s) %s in " + "--audit-log-sinks/CB_MCP_AUDIT_LOG_SINKS; allowed values are %s.", + sorted(invalid), + list(ALLOWED_AUDIT_SINKS), + ) + if not selected: + logger.warning( + "No valid audit sink configured; falling back to the default of %r.", + DEFAULT_AUDIT_SINKS, + ) + selected.append(DEFAULT_AUDIT_SINKS) + + # Canonical order, independent of how the operator happened to type it. + return tuple(name for name in ALLOWED_AUDIT_SINKS if name in selected) + + +def parse_rotation_interval(raw: str | None) -> int | None: + """Parse ```` into seconds. ``0`` means off; ``None`` invalid. + + Accepts the PRD's vocabulary exactly: an integer followed by ``d`` (24 + hours) or ``w`` (7 days), plus the bare ``0`` that turns interval rotation + off. Returns the interval in seconds, ``0`` for off, or ``None`` when the + value cannot be read — the caller decides what to do about that, because + the right response differs between a Click callback and a direct call. + """ + if raw is None: + return None + entry = raw.strip().lower() + if not entry: + return None + match = _INTERVAL_PATTERN.match(entry) + if match is None: + return 0 if entry.isdigit() and int(entry) == 0 else None + value, unit = int(match.group(1)), match.group(2) + if unit not in AUDIT_INTERVAL_UNIT_SECONDS: + return None + return value * AUDIT_INTERVAL_UNIT_SECONDS[unit] + + +def _normalise_interval(raw: str | None) -> tuple[str, int]: + """Resolve the interval setting to its canonical text and its seconds. + + The canonical text is what the status tool reports, so an operator reading + it back sees the same vocabulary they configured. + """ + if raw is None: + raw = DEFAULT_AUDIT_ROTATION_INTERVAL + + seconds = parse_rotation_interval(raw) + if seconds is None: + default_seconds = parse_rotation_interval(DEFAULT_AUDIT_ROTATION_INTERVAL) + logger.warning( + "Invalid audit rotation interval %r; expected where " + "the unit is 'd' (days) or 'w' (weeks), e.g. '1d', '30d', '8w', or " + "'0' to turn interval-based rotation off. Falling back to the " + "default of %r.", + raw, + DEFAULT_AUDIT_ROTATION_INTERVAL, + ) + return DEFAULT_AUDIT_ROTATION_INTERVAL, int(default_seconds or 0) + if seconds == 0: + return "0", 0 + return raw.strip().lower(), seconds + + def _read_event_tokens(value: str) -> list[str] | None: """Split the raw setting into entries, from a file or a comma-separated list. @@ -107,7 +238,7 @@ def _resolve_event_id(token: str) -> int | None: def parse_disabled_events(raw: str | None) -> set[int]: - """Parse ``CB_MCP_AUDIT_DISABLED_EVENTS`` into a set of event ids. + """Parse ``CB_MCP_AUDIT_LOG_DISABLED_EVENTS`` into a set of event ids. Accepts a comma-separated list, or a path to a file with one entry per line (``#`` comments allowed), matching how ``--disabled-tools`` already works. @@ -145,7 +276,7 @@ def parse_disabled_events(raw: str | None) -> set[int]: if unknown: logger.warning( - "Ignored unknown audit event(s) in CB_MCP_AUDIT_DISABLED_EVENTS: %s.", + "Ignored unknown audit event(s) in CB_MCP_AUDIT_LOG_DISABLED_EVENTS: %s.", sorted(unknown), ) if not_filterable: @@ -157,60 +288,92 @@ def parse_disabled_events(raw: str | None) -> set[int]: return resolved +def _resolve_size_mb(raw: float | None) -> float: + """Rotation size in MB. ``0`` is off; negative falls back with a warning.""" + if raw is None: + return DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB + size_mb = float(raw) + if size_mb < 0: + logger.warning( + "Invalid audit rotation size %s MB; falling back to the default of " + "%s MB. Use 0 to turn size-based rotation off.", + raw, + DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB, + ) + return DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB + return size_mb + + +def _resolve_max_backups(raw: int | None) -> int: + """Retained backups. ``0`` keeps only the live file; negative falls back.""" + if raw is None: + return DEFAULT_AUDIT_MAX_BACKUPS + backups = int(raw) + if backups < 0: + logger.warning( + "Invalid audit retention max backups %s; falling back to the " + "default of %s.", + raw, + DEFAULT_AUDIT_MAX_BACKUPS, + ) + return DEFAULT_AUDIT_MAX_BACKUPS + return backups + + def resolve_audit_config( *, enabled: bool | None, + sinks: str | None = None, file: str | None, rotation_max_size_mb: float | None, - retention_backup_count: int | None, + rotation_interval: str | None = None, + max_backups: int | None, tool_args: bool | None, disabled_events: str | None, ) -> ResolvedAuditConfig: """Turn raw CLI/env values into the configuration actually in force.""" is_enabled = DEFAULT_AUDIT_ENABLED if enabled is None else bool(enabled) + selected_sinks = parse_audit_sinks(sinks) + trimmed_file = file.strip() if isinstance(file, str) else None trimmed_file = trimmed_file or None - if is_enabled and trimmed_file is None: + if is_enabled and SINK_FILE in selected_sinks and trimmed_file is None: + remaining = tuple(name for name in selected_sinks if name != SINK_FILE) logger.error( - "Audit logging is enabled but no audit file path is configured. " - "Set --audit-file / CB_MCP_AUDIT_FILE to enable auditing. " - "The server will start with audit logging disabled." - ) - is_enabled = False - - size_mb = ( - DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB - if rotation_max_size_mb is None - else float(rotation_max_size_mb) - ) - if size_mb <= 0: - logger.warning( - "Invalid audit rotation size %s MB; falling back to the default of %s MB.", - rotation_max_size_mb, - DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB, + "The 'file' audit sink is selected but no audit file path is " + "configured. Set --audit-log-file-path / " + "CB_MCP_AUDIT_LOG_FILE_PATH. The server will start with %s.", + ( + f"audit records going to {', '.join(remaining)} only" + if remaining + else "audit logging disabled" + ), ) - size_mb = DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB - - backup_count = ( - DEFAULT_AUDIT_BACKUP_COUNT - if retention_backup_count is None - else int(retention_backup_count) - ) - if backup_count < 0: + selected_sinks = remaining + if not selected_sinks: + is_enabled = False + + if is_enabled and trimmed_file is not None and SINK_FILE not in selected_sinks: + # The opposite mistake, and a quieter one: a path is configured but the + # file sink was never selected, so nothing is written to it and the + # operator finds an empty directory where they expected records. logger.warning( - "Invalid audit retention backup count %s; falling back to the " - "default of %s.", - retention_backup_count, - DEFAULT_AUDIT_BACKUP_COUNT, + "An audit file path is configured (%s) but 'file' is not in " + "--audit-log-sinks/CB_MCP_AUDIT_LOG_SINKS (sinks=%s), so no audit " + "file will be written. Add 'file' to the sinks to use the path.", + trimmed_file, + ",".join(selected_sinks), ) - backup_count = DEFAULT_AUDIT_BACKUP_COUNT + size_mb = _resolve_size_mb(rotation_max_size_mb) + interval_text, interval_seconds = _normalise_interval(rotation_interval) + backups = _resolve_max_backups(max_backups) include_args = DEFAULT_AUDIT_TOOL_ARGS if tool_args is None else bool(tool_args) if include_args and is_enabled: logger.warning( - "CB_MCP_AUDIT_TOOL_ARGS is enabled: tool arguments will be " + "CB_MCP_AUDIT_LOG_TOOL_ARGS is enabled: tool arguments will be " "recorded verbatim in the audit log, including full document " "bodies passed to document-write tools. There is no redaction " "capability in this release. Ensure the audit file's retention and " @@ -218,25 +381,34 @@ def resolve_audit_config( ) filtered = parse_disabled_events(disabled_events) if is_enabled else set() + writes_file = ( + is_enabled and SINK_FILE in selected_sinks and trimmed_file is not None + ) return ResolvedAuditConfig( enabled=is_enabled, + sinks=selected_sinks, file=trimmed_file, - process_file=( - str(process_scoped_path(trimmed_file)) - if is_enabled and trimmed_file - else None - ), + process_file=(str(process_scoped_path(trimmed_file)) if writes_file else None), rotation_max_size_mb=size_mb, - max_bytes=max(1, int(size_mb * BYTES_PER_MB)), - retention_backup_count=backup_count, + # 0 stays 0 — the sink reads that as "no size-based rotation". Any + # positive size is at least one byte, so a sub-byte setting cannot + # round down into the off switch. + max_bytes=0 if size_mb == 0 else max(1, int(size_mb * BYTES_PER_MB)), + rotation_interval=interval_text, + rotation_interval_seconds=interval_seconds, + max_backups=backups, tool_args=include_args, disabled_events=tuple(sorted(filtered)), ) __all__ = [ + "SINK_CONSOLE", + "SINK_FILE", "ResolvedAuditConfig", + "parse_audit_sinks", "parse_disabled_events", + "parse_rotation_interval", "resolve_audit_config", ] diff --git a/src/cb_mcp/audit/emitter.py b/src/cb_mcp/audit/emitter.py index 75eb24d9..aa565846 100644 --- a/src/cb_mcp/audit/emitter.py +++ b/src/cb_mcp/audit/emitter.py @@ -14,13 +14,19 @@ import atexit import logging +from dataclasses import replace from typing import Any from ..utils.constants import LOGGER_NAMESPACE from .catalog import SERVICE_PACKAGE, AuditEvent, ToolCallEvent -from .config import ResolvedAuditConfig +from .config import SINK_FILE, ResolvedAuditConfig from .record import AuditRecord, ServerContext -from .sink import AuditSink +from .sink import ( + AuditSink, + AuditSinkProtocol, + CompositeAuditSink, + ConsoleAuditSink, +) logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit") @@ -28,7 +34,9 @@ class AuditLogger: """Formats catalogue events into records and hands them to the sink.""" - def __init__(self, config: ResolvedAuditConfig, sink: AuditSink | None) -> None: + def __init__( + self, config: ResolvedAuditConfig, sink: AuditSinkProtocol | None + ) -> None: self.config = config self._sink = sink self._server = ServerContext.detect() @@ -121,11 +129,14 @@ def close(self) -> None: _DISABLED = AuditLogger( ResolvedAuditConfig( enabled=False, + sinks=(), file=None, process_file=None, rotation_max_size_mb=0.0, max_bytes=0, - retention_backup_count=0, + rotation_interval="0", + rotation_interval_seconds=0, + max_backups=0, tool_args=False, disabled_events=(), ), @@ -140,46 +151,92 @@ def get_audit_logger() -> AuditLogger: return _active -def init_audit(config: ResolvedAuditConfig) -> AuditLogger: - """Build and install the audit logger for this process. +def _open_file_sink(config: ResolvedAuditConfig) -> AuditSink | None: + """Open the file sink, or report why it could not be opened. - A sink that cannot be opened is reported as an error and auditing is left - off — per the PRD, a bad audit path must not prevent the server starting. + A sink that cannot be opened is reported as an error and left out — per the + PRD, a bad audit path must not prevent the server starting. Any console + sink the operator also selected still runs, so the records go somewhere. """ - global _active # noqa: PLW0603 - - if not config.enabled or config.file is None: - _active = AuditLogger(config, sink=None) - return _active - + if config.file is None: + return None try: sink = AuditSink( config.file, max_bytes=config.max_bytes, - backup_count=config.retention_backup_count, + max_backups=config.max_backups, + interval_seconds=config.rotation_interval_seconds, ) - sink.start() except (OSError, ValueError) as exc: logger.error( - "Failed to open the audit file %r: %s. The server will start with " - "audit logging disabled.", + "Failed to open the audit file %r: %s. The server will start " + "without the file audit sink.", config.file, exc, ) + return None + return sink + + +def _describe_rotation(config: ResolvedAuditConfig) -> str: + """How the live file will roll over, in the operator's own vocabulary.""" + triggers = [] + if config.max_bytes > 0: + triggers.append(f"size>{config.rotation_max_size_mb:g}MB") + if config.rotation_interval_seconds > 0: + triggers.append(f"age>{config.rotation_interval}") + if not triggers: + return "never (both size and interval rotation are off)" + return " or ".join(triggers) + + +def init_audit(config: ResolvedAuditConfig) -> AuditLogger: + """Build and install the audit logger for this process.""" + global _active # noqa: PLW0603 + + if not config.enabled or not config.sinks: _active = AuditLogger(config, sink=None) return _active + sinks: list[AuditSinkProtocol] = [] + file_sink: AuditSink | None = None + if config.writes_console: + sinks.append(ConsoleAuditSink()) + if config.writes_file: + file_sink = _open_file_sink(config) + if file_sink is not None: + sinks.append(file_sink) + else: + # The snapshot is not cosmetic: it is reported by + # ``get_server_configuration_status`` and written into the ``audit + # configuration changed`` record, where it is permanent. A config + # still claiming a file sink that failed to open would tell an + # auditor records were being kept on disk when they were not. + config = replace( + config, + sinks=tuple(name for name in config.sinks if name != SINK_FILE), + process_file=None, + ) + + if not sinks: + _active = AuditLogger(replace(config, enabled=False), sink=None) + return _active + + sink: AuditSinkProtocol = sinks[0] if len(sinks) == 1 else CompositeAuditSink(sinks) + sink.start() _active = AuditLogger(config, sink=sink) + logger.info( - "Audit logging enabled. Writing to %s (service_package=%s, " - "max_bytes=%d, backups=%d, tool_args=%s, disabled_events=%s). " - "Each server process writes its own file; the configured path has the " - "process id inserted so concurrent stdio servers cannot corrupt one " - "another's records.", - sink.path, + "Audit logging enabled. sinks=%s, service_package=%s, file=%s, " + "rotates=%s, max_backups=%d, backups_compressed=yes, tool_args=%s, " + "disabled_events=%s. Each server process writes its own file; the " + "configured path has the host and process id inserted so concurrent " + "stdio servers cannot corrupt one another's records.", + ",".join(config.sinks), SERVICE_PACKAGE, - config.max_bytes, - config.retention_backup_count, + file_sink.path if file_sink is not None else "none", + _describe_rotation(config), + config.max_backups, config.tool_args, list(config.disabled_events) or "none", ) diff --git a/src/cb_mcp/audit/sink.py b/src/cb_mcp/audit/sink.py index 28c5b132..d7a532b4 100644 --- a/src/cb_mcp/audit/sink.py +++ b/src/cb_mcp/audit/sink.py @@ -1,13 +1,13 @@ -"""The audit sink: a per-process JSON-Lines file with size-based rotation. +"""The audit sinks: a per-process JSON-Lines file, and stderr. -Two properties drive this design. +Three properties drive the file sink's design. **No shared-file rotation.** ``logging.handlers.RotatingFileHandler`` is not multi-process safe, and the default stdio deployment is inherently multi-process: every MCP client spawns its own server process, and they all -read the same ``CB_MCP_AUDIT_FILE`` from the environment. Sharing one rotating -file across them produces interleaved partial lines and rotation races that -silently destroy records. So each writer gets its **own** file, named +read the same ``CB_MCP_AUDIT_LOG_FILE_PATH`` from the environment. Sharing one +rotating file across them produces interleaved partial lines and rotation races +that silently destroy records. So each writer gets its **own** file, named ``audit...log``. No locking is needed because no two processes ever touch the same file, and it behaves identically on every platform. This matches the PRD's own position that auditing is per-node and consolidation is the @@ -23,11 +23,19 @@ hostname and the same volume will still collide; that is documented rather than defended against. +**Two independent rotation triggers.** Size and age are separate switches, +either of which may be off (``0``). The stdlib cannot do both — ``Rotating-`` +and ``TimedRotatingFileHandler`` are alternatives, which is exactly what the +PRD discussion concluded — but this sink owns its own rotation, so honouring +both is a two-line condition rather than a library swap. Whichever trigger fires +first rotates the file; with both off the file simply grows, which is the PRD's +"store them all" case. + **Never block the event loop.** ``emit`` only puts a formatted line on a -bounded queue; a dedicated daemon thread does the writing and flushing. A -synchronous write from async middleware would stall the whole server on every -tool call. When the queue is full, records are dropped and counted rather than -applying back-pressure to tool execution. +bounded queue; a dedicated daemon thread does the writing, rotating, flushing +and compressing. A synchronous write from async middleware would stall the whole +server on every tool call. When the queue is full, records are dropped and +counted rather than applying back-pressure to tool execution. Runtime failures — disk full, permissions revoked, the file unmounted — are reported loudly to the operational log as errors and counted, and the server @@ -38,14 +46,19 @@ from __future__ import annotations import contextlib +import gzip import logging import os import queue import re +import shutil import socket +import sys import threading import time +from datetime import datetime, timezone from pathlib import Path +from typing import IO, Protocol from ..utils.constants import LOGGER_NAMESPACE @@ -67,13 +80,29 @@ DEFAULT_CLOSE_TIMEOUT = 5.0 #: How long the writer blocks on an empty queue before re-checking whether the -#: sink has been closed. The sentinel normally wakes it immediately; this is the -#: fallback for the one case the sentinel cannot cover — a queue already full -#: when ``close`` runs, so the sentinel could not be posted. +#: sink has been closed, and whether an interval rotation has fallen due. The +#: sentinel normally wakes it immediately; this is the fallback for the one case +#: the sentinel cannot cover — a queue already full when ``close`` runs, so the +#: sentinel could not be posted — and it is also what lets a completely idle +#: server still roll its file over at the configured interval. _POLL_SECONDS = 0.2 _SENTINEL = object() +#: Timestamp appended to a rotated file. Compact ISO 8601 basic format in UTC, +#: to the microsecond: filename-safe on every platform and fixed-width. The +#: sub-second part is not decoration — a small size cap rotates many times a +#: second, and a second-resolution stamp would make those backups +#: indistinguishable. +_BACKUP_TIMESTAMP_FORMAT = "%Y%m%dT%H%M%S%fZ" + +#: Matches the suffix this sink appends to a rotated file: the timestamp, an +#: optional ``-N`` disambiguator, and an optional ``.gz``. Used both to find +#: *this* sink's backups when pruning — so an unrelated file sitting beside +#: them is never deleted — and to read their age back out, which is what orders +#: them for retention. +_BACKUP_SUFFIX = re.compile(r"\.(?P\d{8}T\d{12}Z)(?:-(?P\d+))?(?:\.gz)?$") + #: Characters kept from a hostname. Anything else — dots that would confuse the #: suffix split, path separators, whitespace — collapses to a single dash. @@ -114,6 +143,126 @@ def process_scoped_path( return resolved.with_name(f"{resolved.name}.{scope}") +class AuditSinkProtocol(Protocol): + """What the emitter needs from a sink, whatever it writes to.""" + + def start(self) -> None: ... + + def emit(self, line: str) -> None: ... + + def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: ... + + @property + def stats(self) -> dict[str, int]: ... + + +class ConsoleAuditSink: + """Writes audit lines to stderr, synchronously. + + **stderr, never stdout.** Under the stdio transport stdout *is* the JSON-RPC + channel: one audit line written there corrupts the client's stream and takes + the session down. The PRD calls this sink "console"; the stream is stderr, + and that is not configurable. + + Unlike the file sink this writes inline rather than on a thread. A console + is a pipe or a terminal, not a disk that can fill: the write is a buffer + copy, there is no rotation, retention or compression to do, and giving it a + thread would add a second ordering of records against the operational log + for no benefit. If stderr itself is wedged, that is already fatal to the + operational log too. + """ + + def __init__(self, stream: IO[str] | None = None) -> None: + self._stream = stream + self._written = 0 + self._dropped = 0 + self._errors = 0 + self._closed = False + self._lock = threading.Lock() + self._last_error_log = 0.0 + + def start(self) -> None: + """Nothing to start; present so every sink has one lifecycle.""" + + def emit(self, line: str) -> None: + """Write one already-formatted line. Never raises.""" + if self._closed: + self._dropped += 1 + return + stream = self._stream if self._stream is not None else sys.stderr + try: + # Held across write+flush so a record cannot be split by another + # thread's line. Tool calls are concurrent; audit lines are not. + with self._lock: + stream.write(line) + stream.flush() + self._written += 1 + except (OSError, ValueError) as exc: + self._errors += 1 + self._dropped += 1 + now = time.monotonic() + if ( + not self._last_error_log + or now - self._last_error_log >= _ERROR_LOG_INTERVAL_SECONDS + ): + self._last_error_log = now + logger.error( + "Audit console sink failure: %s (dropped=%d). Audit records " + "are being lost; the server continues to serve.", + exc, + self._dropped, + ) + + def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: + """Stop accepting records. The stream itself is not ours to close.""" + self._closed = True + + @property + def stats(self) -> dict[str, int]: + return { + "written": self._written, + "dropped": self._dropped, + "write_errors": self._errors, + } + + +class CompositeAuditSink: + """Fans every record out to several sinks. + + One failing sink must not stop the others: each is given the line + independently, and no sink's ``emit`` is allowed to raise anyway. Stats are + summed, so an operator reading ``get_server_configuration_status`` sees the + total records lost across destinations rather than having to add up a list. + """ + + def __init__(self, sinks: list[AuditSinkProtocol]) -> None: + self._sinks = list(sinks) + + @property + def sinks(self) -> tuple[AuditSinkProtocol, ...]: + return tuple(self._sinks) + + def start(self) -> None: + for sink in self._sinks: + sink.start() + + def emit(self, line: str) -> None: + for sink in self._sinks: + sink.emit(line) + + def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: + for sink in self._sinks: + sink.close(timeout=timeout) + + @property + def stats(self) -> dict[str, int]: + totals = {"written": 0, "dropped": 0, "write_errors": 0} + for sink in self._sinks: + for key, value in sink.stats.items(): + totals[key] = totals.get(key, 0) + value + return totals + + class AuditSink: """Writes audit lines to a per-process file on a background thread.""" @@ -122,24 +271,37 @@ def __init__( path: str | os.PathLike[str], *, max_bytes: int, - backup_count: int, + max_backups: int, + interval_seconds: int = 0, + compress: bool = True, queue_size: int = DEFAULT_QUEUE_SIZE, ) -> None: """Open the sink's file, creating parent directories as needed. + ``max_bytes`` of 0 turns size-based rotation off, ``interval_seconds`` + of 0 turns age-based rotation off, and both off means one file that + grows for as long as the process runs. Only negative values are + rejected — they are typos, not instructions. + Raises: OSError: if the file cannot be created or opened. The caller is expected to report this and continue without auditing rather than abort startup. """ - if max_bytes <= 0: - raise ValueError(f"max_bytes must be positive, got {max_bytes}.") - if backup_count < 0: - raise ValueError(f"backup_count must not be negative, got {backup_count}.") + if max_bytes < 0: + raise ValueError(f"max_bytes must not be negative, got {max_bytes}.") + if max_backups < 0: + raise ValueError(f"max_backups must not be negative, got {max_backups}.") + if interval_seconds < 0: + raise ValueError( + f"interval_seconds must not be negative, got {interval_seconds}." + ) self.path = process_scoped_path(path) self._max_bytes = max_bytes - self._backup_count = backup_count + self._max_backups = max_backups + self._interval = interval_seconds + self._compress = compress self._queue: queue.Queue = queue.Queue(maxsize=queue_size) self._thread: threading.Thread | None = None self._closed = threading.Event() @@ -152,6 +314,7 @@ def __init__( self._dropped = 0 self._errors = 0 self._last_error_log = 0.0 + self._last_producer_error_log = 0.0 self.path.parent.mkdir(parents=True, exist_ok=True) # Line-buffered text append. Opened eagerly so a misconfigured path @@ -162,6 +325,31 @@ def __init__( # per record would defeat both the batching and the append semantics. self._stream = open(self.path, "a", encoding="utf-8") # noqa: SIM115 self._size = self.path.stat().st_size + self._rotate_at = self._next_rotation_deadline(from_existing_file=True) + + def _next_rotation_deadline(self, *, from_existing_file: bool = False) -> float: + """When the interval trigger next falls due, or ``inf`` when it is off. + + On open the clock is anchored to the file's **last modification time** + rather than to now, so a restart does not hand the live file a fresh + interval it has not earned. A file last written longer ago than the + interval is therefore already due and rolls over on the first record — + which is what an operator asking for daily files expects after the + server was down for a week. Once running, each rotation sets the next + deadline from the clock, so a busy file cannot push its own deadline + forward by being written to. + """ + if self._interval <= 0: + return float("inf") + anchor = time.time() + if from_existing_file: + try: + stat = self.path.stat() + if stat.st_size > 0: + anchor = stat.st_mtime + except OSError: # pragma: no cover - file was just opened + pass + return anchor + self._interval # -- lifecycle -------------------------------------------------------- @@ -196,7 +384,12 @@ def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: if not self._stream.closed: self._stream.flush() self._stream.close() - except OSError: # pragma: no cover - nothing useful left to do + except (OSError, ValueError): # pragma: no cover - nothing left to do + # ValueError is "I/O operation on closed file". The writer may still + # be draining past the join timeout and may close the stream between + # the check above and the flush; letting that escape would abort + # shutdown — including the sibling sinks a composite is still + # closing, and the atexit hook. pass # -- producer side ---------------------------------------------------- @@ -210,7 +403,7 @@ def emit(self, line: str) -> None: self._queue.put_nowait(line) except queue.Full: self._dropped += 1 - self._log_failure("audit queue is full") + self._log_failure("audit queue is full", producer=True) @property def stats(self) -> dict[str, int]: @@ -237,8 +430,14 @@ def _run(self) -> None: # post the sentinel into a full queue. if self._closed.is_set(): return + # An idle server still ages its file. Without this, a daily + # rotation on a quiet deployment would not happen until the + # next record arrived — possibly days late, with a day's worth + # of records already in the wrong file. + self._rotate_on_idle() continue if item is _SENTINEL: + self._drain_remaining() return batch = [item] # Coalesce whatever else is already waiting into one write+flush. @@ -249,10 +448,31 @@ def _run(self) -> None: break if nxt is _SENTINEL: self._write_batch(batch) + self._drain_remaining() return batch.append(nxt) self._write_batch(batch) + def _drain_remaining(self) -> None: + """Write anything queued behind the sentinel before the writer exits. + + ``emit`` checks the closed flag and then queues; ``close`` sets the flag + and then posts the sentinel. A record that passes the check just before + the flag is set lands *behind* the sentinel, and without this drain the + writer would return without it — unwritten and uncounted, which is the + one outcome an audit sink must not produce quietly. + """ + leftovers: list[str] = [] + while True: + try: + item = self._queue.get_nowait() + except queue.Empty: + break + if item is not _SENTINEL: + leftovers.append(item) + if leftovers: + self._write_batch(leftovers) + def _reopen_if_closed(self) -> None: """Re-establish the stream if it is not open. @@ -298,52 +518,209 @@ def _write_batch(self, lines: list[str]) -> None: self._errors += 1 self._log_failure(f"failed to flush audit file: {exc}") + def _interval_is_due(self) -> bool: + """Whether the interval has elapsed, skipping the period if nothing was written. + + An empty live file is never rotated — there is nothing to keep, and a + zero-byte backup would spend a retention slot — but the deadline must + still move on. Leaving it in the past would mean the *first* record + after a quiet period arrives already overdue and is immediately split + off into a backup of its own: with ``max_backups=0`` that record is + truncated away by the very next one, counted as written and never seen + again. + """ + if self._interval <= 0 or time.time() < self._rotate_at: + return False + if self._size == 0: + self._rotate_at = self._next_rotation_deadline() + return False + return True + + def _rotate_on_idle(self) -> None: + """Roll over on the interval alone, with no record waiting to be written.""" + if not self._interval_is_due(): + return + try: + self._reopen_if_closed() + self._rotate() + except (OSError, ValueError) as exc: + self._errors += 1 + self._log_failure(f"failed to rotate the audit file: {exc}") + def _rotate_if_needed(self, incoming_bytes: int) -> None: - """Roll the file over when the next line would exceed ``max_bytes``. + """Roll the file over when either trigger has fallen due. - A single line larger than ``max_bytes`` is still written rather than + The two triggers are independent and either may be off. For size, a + single line larger than ``max_bytes`` is still written rather than discarded — losing the record would be worse than briefly exceeding the cap — but it lands in a file of its own on the next rotation. """ - if self._size == 0 or self._size + incoming_bytes <= self._max_bytes: + # Asked first, and unconditionally: it is what advances the deadline + # past a period in which nothing was written. + due_on_interval = self._interval_is_due() + if self._size == 0: + return + due_on_size = ( + self._max_bytes > 0 and self._size + incoming_bytes > self._max_bytes + ) + if not (due_on_size or due_on_interval): return + self._rotate() + + def _rotate(self) -> None: + """Close the live file, preserve it per the retention policy, reopen.""" self._stream.flush() self._stream.close() - if self._backup_count == 0: - # Retention of zero still means the live file stays bounded, so it - # is truncated rather than allowed to grow without limit. + if self._max_backups == 0: + # Retention of zero means no backups at all: the live file is + # truncated, which is the PRD's "limited space, single file" case — + # at the cap, writing starts again from zero and what was there is + # gone. self._stream = open(self.path, "w", encoding="utf-8") # noqa: SIM115 self._size = 0 + self._rotate_at = self._next_rotation_deadline() return - # Shift existing backups down, oldest first, then move the live file - # into slot 1. ``Path.replace`` overwrites atomically on POSIX and - # Windows alike. - oldest = self.path.with_name(f"{self.path.name}.{self._backup_count}") - if oldest.exists(): - oldest.unlink() - for index in range(self._backup_count - 1, 0, -1): - source = self.path.with_name(f"{self.path.name}.{index}") - if source.exists(): - source.replace(self.path.with_name(f"{self.path.name}.{index + 1}")) - self.path.replace(self.path.with_name(f"{self.path.name}.1")) - self._stream = open(self.path, "a", encoding="utf-8") # noqa: SIM115 + backup = self._reserve_backup_path() + self.path.replace(backup) self._size = 0 + self._rotate_at = self._next_rotation_deadline() + try: + self._stream = open(self.path, "a", encoding="utf-8") # noqa: SIM115 + finally: + # Compress and prune in a ``finally`` so a failed reopen — a disk + # that filled at exactly the wrong moment — still leaves the + # retired file compressed and the retention count honoured. The + # caller sees the OSError, counts it and recovers on the next + # record; what it must not find later is an uncompressed orphan + # holding a retention slot for good. + compressed = self._compress_backup(backup) if self._compress else backup + self._prune_backups(keep=compressed) + + def _reserve_backup_path(self) -> Path: + """A free ``.`` path for the file being retired. + + The timestamp is when the rotation happened, in UTC to the microsecond. + Two rotations inside one microsecond — which a small size cap makes + conceivable — would otherwise collide, so a ``-N`` disambiguator is + appended rather than overwriting a backup that was just made. The + search starts above the highest index already present rather than at + the first free one: reusing an index freed by pruning would give a + brand-new backup the sort position of the file it replaced. + """ + stamp = datetime.now(timezone.utc).strftime(_BACKUP_TIMESTAMP_FORMAT) + candidate = self.path.with_name(f"{self.path.name}.{stamp}") + if not candidate.exists() and not _with_gz(candidate).exists(): + return candidate + index = max( + ( + taken + for existing in self._existing_backups() + for taken in (_backup_sort_key(existing)[1],) + if _backup_sort_key(existing)[0] == stamp + ), + default=0, + ) + while True: + index += 1 + candidate = self.path.with_name(f"{self.path.name}.{stamp}-{index}") + if not candidate.exists() and not _with_gz(candidate).exists(): + return candidate + + def _compress_backup(self, backup: Path) -> Path: + """Gzip a rotated file, returning the path that now holds it. + + Audit retention is measured in months, so the backups are the bulk of + what an operator stores; JSON Lines compresses roughly ten to one. The + **live** file is deliberately left uncompressed — ``tail -f`` and + ``grep`` on the current file are how an incident actually gets + investigated. + + A compression failure is reported and the uncompressed backup is kept. + Losing an audit file to save space would invert the entire point. + """ + target = _with_gz(backup) + try: + with ( + open(backup, "rb") as source, + gzip.open(target, "wb") as destination, + ): + shutil.copyfileobj(source, destination) + backup.unlink() + return target + except OSError as exc: + self._errors += 1 + self._log_failure(f"failed to compress the rotated audit file: {exc}") + with contextlib.suppress(OSError): + if target.exists(): + target.unlink() + return backup + + def _existing_backups(self) -> list[Path]: + """This file's own rotated backups, oldest first. + + Matching is deliberately narrow: the glob is anchored to *this* + process's live filename and the suffix must be one this sink wrote. + Per-process filenames mean another server's backups never match, and + the suffix check means an operator's own ``audit.log.notes`` beside them + is never a deletion candidate. + + Ordering is by the *parsed* timestamp and index, not by the raw + filename. Sorting the names would be wrong, and silently so: ``-`` + sorts before ``.``, so ``…Z-1.gz`` precedes ``…Z.gz`` even though it is + the newer file, and ``-10`` precedes ``-2``. Retention would then delete + the most recent history and keep the oldest — the exact inverse of what + every one of the PRD's retention cases asks for. + """ + parent = self.path.parent + try: + candidates = list(parent.glob(f"{self.path.name}.*")) + except OSError: # pragma: no cover - directory vanished mid-rotation + return [] + return sorted( + (path for path in candidates if _BACKUP_SUFFIX.search(path.name)), + key=_backup_sort_key, + ) + + def _prune_backups(self, *, keep: Path) -> None: + """Delete the oldest backups beyond ``max_backups``. + + ``keep`` is the backup just created; it is never a deletion candidate + even if the retention count is somehow already exceeded, because + deleting the records that were just rotated out would lose the most + recent history rather than the oldest. + """ + backups = [path for path in self._existing_backups() if path != keep] + excess = len(backups) - (self._max_backups - 1) + for path in backups[:excess] if excess > 0 else []: + try: + path.unlink() + except OSError as exc: # pragma: no cover - racing external deletion + self._log_failure( + f"failed to delete the old audit backup {path}: {exc}" + ) - def _log_failure(self, message: str) -> None: + def _log_failure(self, message: str, *, producer: bool = False) -> None: """Report a sink failure as an error, rate-limited. The first failure is always reported; subsequent ones at most once per interval, carrying the running totals so the operational log shows the scale of the gap without being flooded by it. + + The producer side (a full queue) and the writer side (a failed write, + rotation or compression) are rate-limited **separately**. They are + different faults with different fixes, and sharing one timer meant a + burst of queue-full reports could hide a disk failure for a minute. """ now = time.monotonic() - if self._last_error_log and now - self._last_error_log < ( - _ERROR_LOG_INTERVAL_SECONDS - ): + last = self._last_producer_error_log if producer else self._last_error_log + if last and now - last < _ERROR_LOG_INTERVAL_SECONDS: return - self._last_error_log = now + if producer: + self._last_producer_error_log = now + else: + self._last_error_log = now logger.error( "Audit sink failure: %s (file=%s, dropped=%d, write_errors=%d). " "Audit records are being lost; the server continues to serve.", @@ -354,9 +731,33 @@ def _log_failure(self, message: str) -> None: ) +def _with_gz(path: Path) -> Path: + """The compressed name for a backup path.""" + return path.with_name(f"{path.name}.gz") + + +def _backup_sort_key(path: Path) -> tuple[str, int]: + """``(timestamp, index)`` for a rotated file — chronological order. + + The index is the same-microsecond disambiguator, read as a number so ``-2`` + orders before ``-10``, and ``0`` for the undisambiguated first file of its + microsecond so it orders before ``-1``. A path that somehow does not match + sorts first, which makes it the first candidate for deletion rather than + the last — the conservative direction for something unrecognised sitting + among the backups. + """ + match = _BACKUP_SUFFIX.search(path.name) + if match is None: # pragma: no cover - callers filter on the same regex + return ("", 0) + return (match.group("stamp"), int(match.group("index") or 0)) + + __all__ = [ "DEFAULT_CLOSE_TIMEOUT", "DEFAULT_QUEUE_SIZE", "AuditSink", + "AuditSinkProtocol", + "CompositeAuditSink", + "ConsoleAuditSink", "process_scoped_path", ] diff --git a/src/cb_mcp/core/cli/options.py b/src/cb_mcp/core/cli/options.py index 1c791b41..0f30549d 100644 --- a/src/cb_mcp/core/cli/options.py +++ b/src/cb_mcp/core/cli/options.py @@ -27,6 +27,7 @@ ALLOWED_TRANSPORTS, DEFAULT_AUDIT_ENABLED, DEFAULT_AUDIT_FILE, + DEFAULT_AUDIT_SINKS, DEFAULT_AUDIT_TOOL_ARGS, DEFAULT_HOST, DEFAULT_LOG_BACKUP_COUNT, @@ -405,47 +406,70 @@ def logging_options(*, default_log_file: str) -> Callable: default=DEFAULT_AUDIT_ENABLED, help="Enable audit logging. Applies to both stdio and http transports; the " "set of applicable events differs by transport (authorization events are " - "http-only). Requires --audit-file. Audit output is a separate sink from " - "the --log-* operational logs, with a stable schema and its own retention. " - "Honored only by servers that declare an audit package; on a server that " - "does not, the flag resolves and is reported but nothing is recorded.", + "http-only). Audit output is a separate sink from the --log-* operational " + "logs, with a stable schema and its own retention. Honored only by servers " + "that declare an audit package; on a server that does not, the flag " + "resolves and is reported but nothing is recorded.", ), click.option( - "--audit-file", - "audit_file", - envvar="CB_MCP_AUDIT_FILE", + "--audit-log-sinks", + "audit_log_sinks", + envvar="CB_MCP_AUDIT_LOG_SINKS", + default=DEFAULT_AUDIT_SINKS, + help="Comma-separated list of audit sinks. Allowed values: console, file. " + "Default is console. 'console' writes to stderr — never stdout, which " + "carries the JSON-RPC protocol under the stdio transport. Include 'file' " + "with --audit-log-file-path for a deployment that retains records.", + ), + click.option( + "--audit-log-file-path", + "audit_log_file_path", + envvar="CB_MCP_AUDIT_LOG_FILE_PATH", default=DEFAULT_AUDIT_FILE, - help="Path to the audit log file. Required when --audit-log-enabled is " - "true; if it is missing the server still starts, with auditing disabled and " - "an error recorded. Each server process writes its own file with the " - "process id inserted before the extension (audit.log -> audit.1234.log), " - "because a single stdio deployment runs one server process per client and " - "sharing one file between them would corrupt records.", + help="Path to the audit log file. Required when 'file' is in " + "--audit-log-sinks; if it is missing the server still starts, with the " + "file sink dropped and an error recorded. Each server process writes its " + "own file with the host and process id inserted before the extension " + "(audit.log -> audit.myhost.1234.log), because a single stdio deployment " + "runs one server process per client and sharing one file between them " + "would corrupt records.", ), click.option( - "--audit-rotation-max-size-mb", - "audit_rotation_max_size_mb", - envvar="CB_MCP_AUDIT_ROTATION_MAX_SIZE_MB", + "--audit-log-rotation-max-size-mb", + "audit_log_rotation_max_size_mb", + envvar="CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB", type=click.FloatRange(min=0), default=None, - help="Maximum size in MB an audit file may reach before it rotates. " - "Default is 1 MB. 0 is invalid and falls back to the default with a " - "startup warning.", + help="Maximum size in MB the live audit file may reach before it rotates. " + "Default is 10 MB. 0 turns size-based rotation off; combined with an " + "interval of 0 that gives a single unbounded file.", + ), + click.option( + "--audit-log-rotation-interval", + "audit_log_rotation_interval", + envvar="CB_MCP_AUDIT_LOG_ROTATION_INTERVAL", + default=None, + help="Age the live audit file may reach before it rotates, as " + " where the unit is 'd' (24 hours) or 'w' (7 days) — e.g. " + "1d, 30d, 1w, 8w. Default is 1d. 0 turns interval-based rotation off. " + "Size and interval are independent: whichever falls due first rotates " + "the file.", ), click.option( - "--audit-retention-backup-count", - "audit_retention_backup_count", - envvar="CB_MCP_AUDIT_RETENTION_BACKUP_COUNT", + "--audit-log-retention-max-backups", + "audit_log_retention_max_backups", + envvar="CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS", type=click.IntRange(min=0), default=None, help="Number of rotated audit files retained, excluding the live file. " - "Default is 1000. Set to 0 to keep only the live file, which stays bounded " - "by the rotation size.", + "Default is 10. Rotated files are gzipped and named with the UTC rotation " + "time. Set to 0 to keep no backups at all: the live file is truncated at " + "each rotation, so it stays bounded and nothing older is kept.", ), click.option( - "--audit-tool-args", - "audit_tool_args", - envvar="CB_MCP_AUDIT_TOOL_ARGS", + "--audit-log-tool-args", + "audit_log_tool_args", + envvar="CB_MCP_AUDIT_LOG_TOOL_ARGS", type=bool, default=DEFAULT_AUDIT_TOOL_ARGS, help="Record tool argument values in the audit log. Off by default: there " @@ -455,9 +479,9 @@ def logging_options(*, default_log_file: str) -> Callable: "controls before enabling it.", ), click.option( - "--audit-disabled-events", - "audit_disabled_events", - envvar="CB_MCP_AUDIT_DISABLED_EVENTS", + "--audit-log-disabled-events", + "audit_log_disabled_events", + envvar="CB_MCP_AUDIT_LOG_DISABLED_EVENTS", default=None, help="Audit events to suppress. Accepts comma-separated numeric event " "ids (e.g. '61490,61491'), or a file path with one id per line. Ids are " diff --git a/src/cb_mcp/utils/__init__.py b/src/cb_mcp/utils/__init__.py index ebcfb3cf..d7043a3b 100644 --- a/src/cb_mcp/utils/__init__.py +++ b/src/cb_mcp/utils/__init__.py @@ -22,14 +22,17 @@ # Constants from .constants import ( + ALLOWED_AUDIT_SINKS, ALLOWED_LOG_LEVELS, ALLOWED_LOG_SINKS, ALLOWED_OAUTH_ALGORITHMS, ALLOWED_TRANSPORTS, - DEFAULT_AUDIT_BACKUP_COUNT, DEFAULT_AUDIT_ENABLED, DEFAULT_AUDIT_FILE, + DEFAULT_AUDIT_MAX_BACKUPS, + DEFAULT_AUDIT_ROTATION_INTERVAL, DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB, + DEFAULT_AUDIT_SINKS, DEFAULT_AUDIT_TOOL_ARGS, DEFAULT_HOST, DEFAULT_LOG_BACKUP_COUNT, @@ -97,10 +100,13 @@ "LOGGER_NAMESPACE", "LOGGER_ROOT", "DEFAULT_READ_ONLY_MODE", + "ALLOWED_AUDIT_SINKS", "DEFAULT_AUDIT_ENABLED", "DEFAULT_AUDIT_FILE", "DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB", - "DEFAULT_AUDIT_BACKUP_COUNT", + "DEFAULT_AUDIT_ROTATION_INTERVAL", + "DEFAULT_AUDIT_MAX_BACKUPS", + "DEFAULT_AUDIT_SINKS", "DEFAULT_AUDIT_TOOL_ARGS", "DEFAULT_TRANSPORT", "DEFAULT_LOG_LEVEL", diff --git a/src/cb_mcp/utils/cli_params.py b/src/cb_mcp/utils/cli_params.py index c2933dd5..bbacbe20 100644 --- a/src/cb_mcp/utils/cli_params.py +++ b/src/cb_mcp/utils/cli_params.py @@ -329,11 +329,13 @@ def from_click( # sinks rather than on the last-resort handler. audit=resolve_audit_config( enabled=params.get("audit_log_enabled"), - file=params.get("audit_file"), - rotation_max_size_mb=params.get("audit_rotation_max_size_mb"), - retention_backup_count=params.get("audit_retention_backup_count"), - tool_args=params.get("audit_tool_args"), - disabled_events=params.get("audit_disabled_events"), + sinks=params.get("audit_log_sinks"), + file=params.get("audit_log_file_path"), + rotation_max_size_mb=params.get("audit_log_rotation_max_size_mb"), + rotation_interval=params.get("audit_log_rotation_interval"), + max_backups=params.get("audit_log_retention_max_backups"), + tool_args=params.get("audit_log_tool_args"), + disabled_events=params.get("audit_log_disabled_events"), ), ) diff --git a/src/cb_mcp/utils/constants.py b/src/cb_mcp/utils/constants.py index 65ed093c..11c9abc5 100644 --- a/src/cb_mcp/utils/constants.py +++ b/src/cb_mcp/utils/constants.py @@ -73,23 +73,42 @@ # Audit Logging Configuration # Audit is a separate sink from the CB_MCP_LOG_* operational logs: it # guarantees a stable schema, completeness for non-filterable events, and is -# sensitive by default. Opt-in — an operator must both enable it and name a -# file. Retention is expressed in bytes (rotation size x backup count), matching -# the operational log configuration. +# sensitive by default. Opt-in — an operator must enable it, and name a file if +# the file sink is selected. DEFAULT_AUDIT_ENABLED = False -# No default path: enabling audit without naming a file is a configuration -# error that is reported at startup, leaving audit off rather than aborting. +# Where audit records go. Comma-separated, same shape as CB_MCP_LOG_SINKS. +# "console" is the default because it needs no configuration and makes an +# enabled audit visible immediately; "file" is what a real deployment uses. +# +# "console" writes to **stderr**, never stdout: under the stdio transport +# stdout carries the JSON-RPC protocol, and a single audit line written there +# would corrupt the client's stream. The name follows the PRD; the stream +# follows the protocol. +ALLOWED_AUDIT_SINKS = ("console", "file") +DEFAULT_AUDIT_SINKS = "console" +# No default path: selecting the file sink without naming a file is a +# configuration error that is reported at startup, leaving the file sink off +# rather than aborting. DEFAULT_AUDIT_FILE = None -# Rotation size per audit file, in MB. -DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB = 1.0 -# Rotated backups retained per audit file, excluding the live file. Much larger -# than the operational-log default because audits are reviewed on a quarterly -# or annual cadence, not for day-to-day triage. -DEFAULT_AUDIT_BACKUP_COUNT = 1000 +# Rotation size per audit file, in MB. 0 is valid and turns size-based rotation +# off entirely. +DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB = 10.0 +# Age the live audit file may reach before it rotates, as where +# the unit is "d" (24 hours) or "w" (7 days). 0 is valid and turns +# interval-based rotation off entirely. Size and interval rotation are +# independent: either, both, or neither may be active. +DEFAULT_AUDIT_ROTATION_INTERVAL = "1d" +# Rotated backups retained per audit file, excluding the live file. 0 is valid +# and keeps only the live file. +DEFAULT_AUDIT_MAX_BACKUPS = 10 # Tool argument values are sensitive: document-write tools carry entire # document bodies. There is no redaction capability in this release, so the # default is off and enabling it emits a startup warning. DEFAULT_AUDIT_TOOL_ARGS = False +# Seconds per interval unit. "d" is 24 hours and "w" is 7 days, exactly as the +# PRD defines them; no month or year unit exists, because neither has a fixed +# length and an audit retention window must be arithmetic, not calendrical. +AUDIT_INTERVAL_UNIT_SECONDS = {"d": 86_400, "w": 604_800} # OAuth Scopes # Tokens carrying SCOPE_READ may call read-only tools (including SQL++ query, diff --git a/tests/unit/test_audit_config.py b/tests/unit/test_audit_config.py index f0b86f49..9bf7362c 100644 --- a/tests/unit/test_audit_config.py +++ b/tests/unit/test_audit_config.py @@ -2,14 +2,19 @@ Coverage map: - defaults when nothing is configured -- enabling without a file is reported and leaves auditing off, not fatal -- rotation size and backup count fallbacks on invalid input +- sink parsing: console, file, both, invalid, empty +- selecting the file sink without a path is reported and degrades, not fatal +- a path with no file sink is warned about +- rotation size: 0 is off, negative falls back +- rotation interval parsing: units, 0, invalid +- retention: 0 honoured, negative falls back - MB to bytes conversion - disabled-events parsing: ids only, files, comments, whitespace - non-filterable events cannot be suppressed - unknown events are ignored with a warning - the tool-args warning fires only when it can matter - the snapshot round-trips for the status tool +- one test per configuration case in the PRD """ from __future__ import annotations @@ -20,18 +25,25 @@ from cb_mcp.audit.config import ( ResolvedAuditConfig, + parse_audit_sinks, parse_disabled_events, + parse_rotation_interval, resolve_audit_config, ) from cb_mcp.utils.constants import BYTES_PER_MB +DAY = 86_400 +WEEK = 604_800 + def _resolve(**overrides) -> ResolvedAuditConfig: options = { "enabled": None, + "sinks": None, "file": None, "rotation_max_size_mb": None, - "retention_backup_count": None, + "rotation_interval": None, + "max_backups": None, "tool_args": None, "disabled_events": None, } @@ -39,6 +51,17 @@ def _resolve(**overrides) -> ResolvedAuditConfig: return resolve_audit_config(**options) +def _file_audit(tmp_path, **overrides) -> ResolvedAuditConfig: + """An enabled, file-sinked configuration — the shape a deployment uses.""" + options = { + "enabled": True, + "sinks": "file", + "file": str(tmp_path / "audit.log"), + } + options.update(overrides) + return _resolve(**options) + + # --------------------------------------------------------------------------- # defaults # --------------------------------------------------------------------------- @@ -47,81 +70,146 @@ def _resolve(**overrides) -> ResolvedAuditConfig: def test_defaults_are_off_and_match_the_prd(): config = _resolve() assert config.enabled is False + assert config.sinks == ("console",) assert config.file is None assert config.process_file is None - assert config.rotation_max_size_mb == 1.0 - assert config.retention_backup_count == 1000 + assert config.rotation_max_size_mb == 10.0 + assert config.rotation_interval == "1d" + assert config.rotation_interval_seconds == DAY + assert config.max_backups == 10 assert config.tool_args is False assert config.disabled_events == () def test_enabled_with_a_file_resolves_the_process_scoped_path(tmp_path): - config = _resolve(enabled=True, file=str(tmp_path / "audit.log")) + config = _file_audit(tmp_path) assert config.enabled is True + assert config.writes_file is True + assert config.writes_console is False assert config.process_file is not None assert config.process_file.endswith(".log") assert "audit." in config.process_file def test_mb_is_converted_to_bytes(tmp_path): - config = _resolve( - enabled=True, file=str(tmp_path / "a.log"), rotation_max_size_mb=2.5 - ) + config = _file_audit(tmp_path, rotation_max_size_mb=2.5) assert config.max_bytes == int(2.5 * BYTES_PER_MB) +# --------------------------------------------------------------------------- +# sinks +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("console", ("console",)), + ("file", ("file",)), + ("console,file", ("console", "file")), + # Order, case and spacing are the operator's; the result is canonical. + ("FILE, console", ("console", "file")), + ("file,file", ("file",)), + ], +) +def test_sinks_parse_like_the_logging_sinks(raw, expected): + assert parse_audit_sinks(raw) == expected + + +def test_invalid_sink_tokens_are_warned_about_and_ignored(caplog): + with caplog.at_level(logging.WARNING): + assert parse_audit_sinks("file,syslog") == ("file",) + assert "invalid audit sink" in caplog.text.lower() + + +@pytest.mark.parametrize("raw", ["", " ", ",", "nowhere"]) +def test_no_valid_sink_falls_back_to_the_default(raw, caplog): + """Enabling auditing must always produce output somewhere.""" + with caplog.at_level(logging.WARNING): + assert parse_audit_sinks(raw) == ("console",) + + +def test_console_only_needs_no_file(caplog): + with caplog.at_level(logging.ERROR): + config = _resolve(enabled=True, sinks="console") + assert config.enabled is True + assert config.writes_console is True + assert config.writes_file is False + assert caplog.text == "" + + # --------------------------------------------------------------------------- # error and fallback handling # --------------------------------------------------------------------------- -def test_enabled_without_a_file_is_reported_and_disables_auditing(caplog): +def test_file_sink_without_a_path_disables_only_that_sink(caplog): + """The PRD is explicit: the server must still start.""" with caplog.at_level(logging.ERROR): - config = _resolve(enabled=True, file=None) - # The PRD is explicit: the server must still start. - assert config.enabled is False + config = _resolve(enabled=True, sinks="console,file", file=None) + # Console was also selected, so records still go somewhere. + assert config.enabled is True + assert config.sinks == ("console",) assert config.process_file is None assert "no audit file path is configured" in caplog.text +def test_file_only_without_a_path_leaves_auditing_off(caplog): + with caplog.at_level(logging.ERROR): + config = _resolve(enabled=True, sinks="file", file=None) + assert config.enabled is False + assert config.sinks == () + assert "audit logging disabled" in caplog.text + + def test_blank_file_is_treated_as_absent(caplog): with caplog.at_level(logging.ERROR): - config = _resolve(enabled=True, file=" ") + config = _resolve(enabled=True, sinks="file", file=" ") assert config.enabled is False assert config.file is None -@pytest.mark.parametrize("bad_size", [0, 0.0, -5]) -def test_invalid_rotation_size_falls_back_with_a_warning(tmp_path, caplog, bad_size): +def test_a_path_with_no_file_sink_is_warned_about(tmp_path, caplog): + """The quiet mistake: a path is set, nothing is written to it.""" with caplog.at_level(logging.WARNING): config = _resolve( - enabled=True, - file=str(tmp_path / "a.log"), - rotation_max_size_mb=bad_size, + enabled=True, sinks="console", file=str(tmp_path / "audit.log") ) - assert config.rotation_max_size_mb == 1.0 + assert config.writes_file is False + assert config.process_file is None + assert "no audit file will be written" in caplog.text + + +def test_zero_rotation_size_turns_size_rotation_off(tmp_path, caplog): + """0 is a documented instruction, not invalid input.""" + with caplog.at_level(logging.WARNING): + config = _file_audit(tmp_path, rotation_max_size_mb=0) + assert config.rotation_max_size_mb == 0 + assert config.max_bytes == 0 + assert "falling back" not in caplog.text + + +def test_negative_rotation_size_falls_back_with_a_warning(tmp_path, caplog): + with caplog.at_level(logging.WARNING): + config = _file_audit(tmp_path, rotation_max_size_mb=-5) + assert config.rotation_max_size_mb == 10.0 assert "falling back to the default" in caplog.text -def test_negative_backup_count_falls_back_with_a_warning(tmp_path, caplog): +def test_negative_max_backups_falls_back_with_a_warning(tmp_path, caplog): with caplog.at_level(logging.WARNING): - config = _resolve( - enabled=True, file=str(tmp_path / "a.log"), retention_backup_count=-3 - ) - assert config.retention_backup_count == 1000 - assert "retention backup count" in caplog.text + config = _file_audit(tmp_path, max_backups=-3) + assert config.max_backups == 10 + assert "max backups" in caplog.text -def test_zero_backup_count_is_honoured(tmp_path): - config = _resolve( - enabled=True, file=str(tmp_path / "a.log"), retention_backup_count=0 - ) - assert config.retention_backup_count == 0 +def test_zero_max_backups_is_honoured(tmp_path): + assert _file_audit(tmp_path, max_backups=0).max_backups == 0 def test_tool_args_warns_only_when_auditing_is_active(tmp_path, caplog): with caplog.at_level(logging.WARNING): - _resolve(enabled=True, file=str(tmp_path / "a.log"), tool_args=True) + _file_audit(tmp_path, tool_args=True) assert "recorded verbatim" in caplog.text caplog.clear() @@ -131,6 +219,62 @@ def test_tool_args_warns_only_when_auditing_is_active(tmp_path, caplog): assert "recorded verbatim" not in caplog.text +# --------------------------------------------------------------------------- +# rotation interval +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "seconds"), + [ + ("1d", DAY), + ("30d", 30 * DAY), + ("1w", WEEK), + ("8w", 8 * WEEK), + # The PRD writes "1W" in one of its cases; case must not matter. + ("1W", WEEK), + (" 1d ", DAY), + ("0", 0), + ("0d", 0), + ], +) +def test_interval_parsing_accepts_the_prd_vocabulary(raw, seconds): + assert parse_rotation_interval(raw) == seconds + + +@pytest.mark.parametrize( + "raw", + [ + "1h", # hours are not a unit: the PRD defines d and w only + "1m", + "d", + "1", # a bare number other than 0 has no unit to apply + "1d2h", + "-1d", + "one day", + "", + ], +) +def test_interval_parsing_rejects_everything_else(raw): + assert parse_rotation_interval(raw) is None + + +def test_invalid_interval_falls_back_to_the_default_with_a_warning(tmp_path, caplog): + with caplog.at_level(logging.WARNING): + config = _file_audit(tmp_path, rotation_interval="1h") + assert config.rotation_interval == "1d" + assert config.rotation_interval_seconds == DAY + assert "Invalid audit rotation interval" in caplog.text + + +def test_zero_interval_turns_interval_rotation_off(tmp_path, caplog): + with caplog.at_level(logging.WARNING): + config = _file_audit(tmp_path, rotation_interval="0") + assert config.rotation_interval == "0" + assert config.rotation_interval_seconds == 0 + assert "Invalid" not in caplog.text + + # --------------------------------------------------------------------------- # disabled events # --------------------------------------------------------------------------- @@ -200,35 +344,94 @@ def test_filters_are_not_parsed_when_auditing_is_disabled(): def test_session_initialized_is_filterable(tmp_path): - config = _resolve( - enabled=True, - file=str(tmp_path / "a.log"), - disabled_events="57360", - ) + config = _file_audit(tmp_path, disabled_events="57360") assert config.disabled_events == (57360,) +# --------------------------------------------------------------------------- +# the PRD's configuration cases +# --------------------------------------------------------------------------- + + +def test_case_1_audit_logs_switched_off(): + config = _resolve(enabled=False) + assert config.enabled is False + assert config.writes_console is False + assert config.writes_file is False + + +def test_case_2_single_file_with_no_limits(tmp_path): + """'I don't care about the size. I want to store them all.'""" + config = _file_audit( + tmp_path, rotation_max_size_mb=0, rotation_interval="0", max_backups=0 + ) + assert config.max_bytes == 0 + assert config.rotation_interval_seconds == 0 + assert config.max_backups == 0 + + +def test_case_3_single_file_with_limited_space(tmp_path): + config = _file_audit( + tmp_path, rotation_max_size_mb=10, rotation_interval="0", max_backups=0 + ) + assert config.max_bytes == 10 * BYTES_PER_MB + assert config.rotation_interval_seconds == 0 + assert config.max_backups == 0 + + +def test_case_4_limited_space_across_multiple_files(tmp_path): + """10 MB a file and 9 backups: 100 MB in total, as the PRD computes it.""" + config = _file_audit( + tmp_path, rotation_max_size_mb=10, rotation_interval="0", max_backups=9 + ) + assert config.max_bytes == 10 * BYTES_PER_MB + assert config.rotation_interval_seconds == 0 + assert config.max_backups == 9 + assert config.max_bytes * (config.max_backups + 1) == 100 * BYTES_PER_MB + + +def test_case_5_ninety_days_of_daily_logs(tmp_path): + config = _file_audit( + tmp_path, rotation_max_size_mb=0, rotation_interval="1d", max_backups=90 + ) + assert config.max_bytes == 0 + assert config.rotation_interval_seconds == DAY + assert config.max_backups == 90 + + +def test_case_6_ten_weeks_of_weekly_logs(tmp_path): + config = _file_audit( + tmp_path, rotation_max_size_mb=0, rotation_interval="1W", max_backups=9 + ) + assert config.max_bytes == 0 + assert config.rotation_interval_seconds == WEEK + assert config.max_backups == 9 + + # --------------------------------------------------------------------------- # snapshot # --------------------------------------------------------------------------- def test_as_dict_is_json_friendly_and_complete(tmp_path): - config = _resolve( - enabled=True, - file=str(tmp_path / "audit.log"), + config = _file_audit( + tmp_path, + sinks="console,file", rotation_max_size_mb=4, - retention_backup_count=7, + rotation_interval="2w", + max_backups=7, tool_args=True, disabled_events="61490", ) snapshot = config.as_dict() assert snapshot == { "enabled": True, + "sinks": ["console", "file"], "file": str(tmp_path / "audit.log"), "process_file": config.process_file, "rotation_max_size_mb": 4.0, - "retention_backup_count": 7, + "rotation_interval": "2w", + "max_backups": 7, "tool_args": True, "disabled_events": [61490], } diff --git a/tests/unit/test_audit_integration_points.py b/tests/unit/test_audit_integration_points.py index 1768b433..f4aedf39 100644 --- a/tests/unit/test_audit_integration_points.py +++ b/tests/unit/test_audit_integration_points.py @@ -547,9 +547,10 @@ def test_audit_is_shut_down_when_provider_startup_fails(tmp_path): """ audit_config = resolve_audit_config( enabled=True, + sinks="file", file=str(tmp_path / "audit.log"), rotation_max_size_mb=None, - retention_backup_count=None, + max_backups=None, tool_args=None, disabled_events=None, ) diff --git a/tests/unit/test_audit_middleware.py b/tests/unit/test_audit_middleware.py index 7da7d8b8..b8f2dca2 100644 --- a/tests/unit/test_audit_middleware.py +++ b/tests/unit/test_audit_middleware.py @@ -53,8 +53,10 @@ def _build_logger(tmp_path: Path, **overrides) -> AuditLogger: options = { "enabled": True, "file": str(tmp_path / "audit.log"), + "sinks": "file", "rotation_max_size_mb": 8.0, - "retention_backup_count": 2, + "rotation_interval": "0", + "max_backups": 2, "tool_args": False, "disabled_events": None, } @@ -63,7 +65,8 @@ def _build_logger(tmp_path: Path, **overrides) -> AuditLogger: sink = AuditSink( config.file, max_bytes=config.max_bytes, - backup_count=config.retention_backup_count, + max_backups=config.max_backups, + interval_seconds=config.rotation_interval_seconds, ) sink.start() return AuditLogger(config, sink) @@ -410,7 +413,7 @@ async def test_inactive_logger_writes_nothing_and_does_not_break_calls(tmp_path) enabled=False, file=None, rotation_max_size_mb=None, - retention_backup_count=None, + max_backups=None, tool_args=None, disabled_events=None, ) diff --git a/tests/unit/test_audit_sink.py b/tests/unit/test_audit_sink.py index cabfca7a..8ddf81b2 100644 --- a/tests/unit/test_audit_sink.py +++ b/tests/unit/test_audit_sink.py @@ -1,28 +1,37 @@ -"""Tests for the audit sink. +"""Tests for the audit sinks. -The sink deliberately does not use ``logging.handlers.RotatingFileHandler``. +The file sink deliberately does not use ``logging.handlers.RotatingFileHandler``. A single stdio deployment runs one server process per MCP client, and they all -read the same ``CB_MCP_AUDIT_FILE``; sharing one rotating file between them -produces interleaved partial lines and rotation races. Each process therefore -writes its own file, with the pid inserted before the extension. +read the same ``CB_MCP_AUDIT_LOG_FILE_PATH``; sharing one rotating file between +them produces interleaved partial lines and rotation races. Each process +therefore writes its own file, with the host and pid inserted before the +extension. Owning the rotation is also what lets the size and interval triggers +both be live at once, which the stdlib handlers cannot do. Coverage map: - per-process filename derivation, with and without a suffix - lines are written and flushed by the background thread -- rotation at the size threshold, and backup shifting -- backup_count=0 truncates rather than growing without limit -- retention: files beyond backup_count are removed +- rotation on size, on interval, on both, and on neither +- the interval clock is anchored to the live file, not to startup +- an idle server still rotates on the interval +- backups are timestamped, gzipped and pruned to max_backups +- a compression failure keeps the uncompressed backup +- max_backups=0 truncates rather than growing without limit - an oversized single line is written rather than lost -- constructor validation +- constructor validation: zero is accepted, negatives are not - drop counter when the queue is full - close() is idempotent and drains - two concurrent sinks never share a file +- the console sink writes to stderr, and the composite fans out """ from __future__ import annotations +import gzip +import io import json import os +import re import threading import time from pathlib import Path @@ -30,7 +39,13 @@ import pytest -from cb_mcp.audit.sink import AuditSink, process_scoped_path +from cb_mcp.audit.sink import ( + _SENTINEL, + AuditSink, + CompositeAuditSink, + ConsoleAuditSink, + process_scoped_path, +) def _drain(sink: AuditSink) -> None: @@ -94,7 +109,7 @@ def test_host_token_is_filename_safe(): def test_sink_writes_to_the_process_scoped_path(tmp_path): - sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, backup_count=1) + sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, max_backups=1) try: assert sink.path == process_scoped_path(tmp_path / "audit.log") assert sink.path.exists() @@ -108,7 +123,7 @@ def test_sink_writes_to_the_process_scoped_path(tmp_path): def test_lines_are_written_and_flushed(tmp_path): - sink = AuditSink(tmp_path / "audit.log", max_bytes=1_000_000, backup_count=1) + sink = AuditSink(tmp_path / "audit.log", max_bytes=1_000_000, max_backups=1) sink.start() for index in range(50): sink.emit(json.dumps({"n": index}) + "\n") @@ -126,7 +141,7 @@ def test_existing_file_is_appended_not_truncated(tmp_path): path = process_scoped_path(tmp_path / "audit.log") path.write_text('{"pre":true}\n', encoding="utf-8") - sink = AuditSink(tmp_path / "audit.log", max_bytes=1_000_000, backup_count=1) + sink = AuditSink(tmp_path / "audit.log", max_bytes=1_000_000, max_backups=1) sink.start() sink.emit('{"post":true}\n') _drain(sink) @@ -141,32 +156,91 @@ def test_existing_file_is_appended_not_truncated(tmp_path): # --------------------------------------------------------------------------- -def test_rotation_shifts_backups_and_honours_retention(tmp_path): +def _backups(sink: AuditSink) -> list[Path]: + """Rotated files beside the live one, oldest first.""" + return sorted( + path + for path in sink.path.parent.iterdir() + if path.name.startswith(sink.path.name + ".") + ) + + +def _all_records(sink: AuditSink) -> str: + """Everything the sink has written, live file and backups, decompressed.""" + text = sink.path.read_text(encoding="utf-8") + for path in _backups(sink): + if path.suffix == ".gz": + with gzip.open(path, "rt", encoding="utf-8") as handle: + text += handle.read() + else: + text += path.read_text(encoding="utf-8") + return text + + +def test_rotation_names_backups_by_time_and_honours_retention(tmp_path): line = json.dumps({"payload": "x" * 80}) + "\n" # Room for roughly two lines before rotating. - sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, backup_count=2) + sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, max_backups=2) sink.start() for _ in range(12): sink.emit(line) _drain(sink) - live = sink.path - first = live.with_name(live.name + ".1") - second = live.with_name(live.name + ".2") - third = live.with_name(live.name + ".3") + backups = _backups(sink) + assert sink.path.exists() + # Retention is two backups, so a third must never survive. + assert len(backups) == 2, [p.name for p in backups] + for path in backups: + # .[-N].gz — readable without opening the file, + # and sortable because the timestamp is fixed-width. + assert re.fullmatch( + re.escape(sink.path.name) + r"\.\d{8}T\d{12}Z(-\d+)?\.gz", path.name + ), path.name + assert sink.path.stat().st_size <= len(line) * 2 + + +def test_rotated_backups_are_gzipped_and_still_readable(tmp_path): + line = json.dumps({"payload": "compress me"}) + "\n" + sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, max_backups=5) + sink.start() + for _ in range(6): + sink.emit(line) + _drain(sink) + + backups = _backups(sink) + assert backups, "nothing rotated" + for path in backups: + assert path.suffix == ".gz" + # Real gzip, not a renamed plain file: decompression must round-trip. + with gzip.open(path, "rt", encoding="utf-8") as handle: + assert "compress me" in handle.read() + # The live file stays plain text. tail -f and grep on the current file are + # how an incident is actually investigated. + assert "compress me" in sink.path.read_text(encoding="utf-8") + + +def test_compression_failure_keeps_the_uncompressed_backup(tmp_path): + """Losing an audit file to save space would invert the whole point.""" + line = json.dumps({"payload": "k" * 60}) + "\n" + sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, max_backups=3) + sink.start() + with patch( + "cb_mcp.audit.sink.gzip.open", side_effect=OSError("no space for the gzip") + ): + for _ in range(4): + sink.emit(line) + _settle(sink) + _drain(sink) - assert live.exists() - assert first.exists() - assert second.exists() - # Retention is two backups, so a third must never appear. - assert not third.exists() - for candidate in (live, first, second): - assert candidate.stat().st_size <= len(line) * 2 + backups = _backups(sink) + assert backups, "the rotation did not keep the retired file" + assert all(path.suffix != ".gz" for path in backups) + assert "k" * 60 in _all_records(sink) -def test_backup_count_zero_truncates_the_live_file(tmp_path): +def test_max_backups_zero_truncates_the_live_file(tmp_path): line = json.dumps({"payload": "y" * 80}) + "\n" - sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, backup_count=0) + sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, max_backups=0) sink.start() for _ in range(20): sink.emit(line) @@ -174,49 +248,190 @@ def test_backup_count_zero_truncates_the_live_file(tmp_path): # Retention of zero still means the rotation size caps the live file. assert sink.path.stat().st_size <= len(line) * 2 - assert not sink.path.with_name(sink.path.name + ".1").exists() + assert _backups(sink) == [] def test_a_single_oversized_line_is_written_not_dropped(tmp_path): - sink = AuditSink(tmp_path / "audit.log", max_bytes=64, backup_count=1) + sink = AuditSink(tmp_path / "audit.log", max_bytes=64, max_backups=1) sink.start() huge = json.dumps({"payload": "z" * 500}) + "\n" sink.emit(huge) _drain(sink) - written = sink.path.read_text(encoding="utf-8") + "".join( - p.read_text(encoding="utf-8") - for p in tmp_path.iterdir() - if p.name.startswith(sink.path.name + ".") - ) # Losing the record would be worse than briefly exceeding the size cap. - assert "z" * 500 in written + assert "z" * 500 in _all_records(sink) assert sink.stats["dropped"] == 0 +# --------------------------------------------------------------------------- +# rotation triggers: size, interval, both, neither +# --------------------------------------------------------------------------- + + +def test_zero_max_bytes_never_rotates_on_size(tmp_path): + """PRD case 2: 'I don't care about the size, store them all.'""" + line = json.dumps({"payload": "q" * 200}) + "\n" + sink = AuditSink(tmp_path / "audit.log", max_bytes=0, max_backups=5) + sink.start() + for _ in range(50): + sink.emit(line) + _drain(sink) + + assert _backups(sink) == [], "size rotation fired with the size trigger off" + assert len(sink.path.read_text(encoding="utf-8").splitlines()) == 50 + + +def test_interval_rotation_fires_when_the_file_is_old_enough(tmp_path): + sink = AuditSink( + tmp_path / "audit.log", max_bytes=0, max_backups=3, interval_seconds=86_400 + ) + sink.start() + sink.emit('{"before":true}\n') + _settle(sink) + assert _backups(sink) == [], "rotated before the interval fell due" + + # Bring the deadline into the past rather than waiting a day for it. + sink._rotate_at = time.time() - 1 + sink.emit('{"after":true}\n') + _settle(sink) + _drain(sink) + + backups = _backups(sink) + assert len(backups) == 1, [p.name for p in backups] + with gzip.open(backups[0], "rt", encoding="utf-8") as handle: + assert "before" in handle.read() + assert "after" in sink.path.read_text(encoding="utf-8") + + +def test_an_idle_server_still_rotates_on_the_interval(tmp_path): + """The reason the writer checks the clock on an empty queue. + + A quiet deployment writes nothing for hours. Rotating only when the next + record arrives would put a day's worth of records in a file stamped for the + previous period, which is precisely what a daily-rotation operator is + trying to avoid. + """ + sink = AuditSink( + tmp_path / "audit.log", max_bytes=0, max_backups=3, interval_seconds=86_400 + ) + sink.start() + sink.emit('{"only":true}\n') + _settle(sink) + + sink._rotate_at = time.time() - 1 + # No further records: the rotation must come from the idle poll alone. + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline and not _backups(sink): + time.sleep(0.05) + _drain(sink) + + assert len(_backups(sink)) == 1, "an idle sink never rotated" + assert sink.path.stat().st_size == 0 + + +def test_both_triggers_are_live_at_once(tmp_path): + """Whichever falls due first rotates: stdlib cannot do this, so we do.""" + line = json.dumps({"payload": "b" * 80}) + "\n" + sink = AuditSink( + tmp_path / "audit.log", + max_bytes=len(line) * 2, + max_backups=10, + interval_seconds=86_400, + ) + sink.start() + # Size alone, with the interval deadline far in the future. + for _ in range(6): + sink.emit(line) + _settle(sink) + after_size = len(_backups(sink)) + assert after_size >= 2, "the size trigger did not fire while an interval was set" + + # Now the interval, with the file nowhere near the size cap. + sink._rotate_at = time.time() - 1 + sink.emit(line) + _settle(sink) + _drain(sink) + assert len(_backups(sink)) > after_size, "the interval trigger never fired" + + +def test_neither_trigger_means_one_unbounded_file(tmp_path): + sink = AuditSink( + tmp_path / "audit.log", max_bytes=0, max_backups=0, interval_seconds=0 + ) + sink.start() + sink._rotate_at = time.time() - 1 # would rotate if the interval were live + for index in range(100): + sink.emit(json.dumps({"n": index}) + "\n") + _drain(sink) + + assert _backups(sink) == [] + assert len(sink.path.read_text(encoding="utf-8").splitlines()) == 100 + + +def test_the_interval_clock_is_anchored_to_the_live_file_not_to_startup(tmp_path): + """A restart must not hand a stale file a fresh interval it has not earned. + + An operator asking for daily files, whose server was down for a week, wants + the week-old file rolled away — not another day of records appended to it. + """ + path = process_scoped_path(tmp_path / "audit.log") + path.write_text('{"old":true}\n', encoding="utf-8") + week_ago = time.time() - 7 * 86_400 + os.utime(path, (week_ago, week_ago)) + + sink = AuditSink( + tmp_path / "audit.log", max_bytes=0, max_backups=2, interval_seconds=86_400 + ) + try: + assert sink._rotate_at < time.time(), ( + "a file older than the interval was given a fresh deadline" + ) + finally: + _drain(sink) + + # ... while a file that did not exist a moment ago gets its full interval. + fresh = AuditSink( + tmp_path / "fresh.log", max_bytes=0, max_backups=2, interval_seconds=86_400 + ) + try: + assert fresh._rotate_at > time.time() + 86_000 + finally: + _drain(fresh) + + # --------------------------------------------------------------------------- # validation and failure handling # --------------------------------------------------------------------------- @pytest.mark.parametrize( - ("max_bytes", "backup_count", "match"), + ("kwargs", "match"), [ - (0, 1, "max_bytes must be positive"), - (-1, 1, "max_bytes must be positive"), - (10, -1, "backup_count must not be negative"), + ({"max_bytes": -1, "max_backups": 1}, "max_bytes must not be negative"), + ({"max_bytes": 10, "max_backups": -1}, "max_backups must not be negative"), + ( + {"max_bytes": 10, "max_backups": 1, "interval_seconds": -1}, + "interval_seconds must not be negative", + ), ], ) -def test_constructor_validates_its_arguments(tmp_path, max_bytes, backup_count, match): +def test_constructor_rejects_negatives_but_not_zero(tmp_path, kwargs, match): + """Zero is an instruction — 'this trigger is off' — and a typo is not.""" with pytest.raises(ValueError, match=match): + AuditSink(tmp_path / "audit.log", **kwargs) + + # The zero the PRD requires to be accepted, in the same test so a future + # tightening of the validation cannot pass by rejecting both. + _drain( AuditSink( - tmp_path / "audit.log", max_bytes=max_bytes, backup_count=backup_count + tmp_path / "audit.log", max_bytes=0, max_backups=0, interval_seconds=0 ) + ) def test_parent_directories_are_created(tmp_path): nested = tmp_path / "deep" / "deeper" / "audit.log" - sink = AuditSink(nested, max_bytes=4096, backup_count=1) + sink = AuditSink(nested, max_bytes=4096, max_backups=1) try: assert sink.path.parent.is_dir() finally: @@ -229,13 +444,13 @@ def test_unwritable_path_raises_so_startup_can_report_it(tmp_path): collision = process_scoped_path(tmp_path / "audit.log") collision.mkdir() with pytest.raises(OSError): - AuditSink(tmp_path / "audit.log", max_bytes=4096, backup_count=1) + AuditSink(tmp_path / "audit.log", max_bytes=4096, max_backups=1) def test_full_queue_drops_and_counts_rather_than_blocking(tmp_path): # No writer thread started, so nothing drains the queue. sink = AuditSink( - tmp_path / "audit.log", max_bytes=1_000_000, backup_count=1, queue_size=4 + tmp_path / "audit.log", max_bytes=1_000_000, max_backups=1, queue_size=4 ) for _ in range(10): sink.emit('{"x":1}\n') @@ -244,7 +459,7 @@ def test_full_queue_drops_and_counts_rather_than_blocking(tmp_path): def test_emit_after_close_is_counted_not_raised(tmp_path): - sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, backup_count=1) + sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, max_backups=1) sink.start() _drain(sink) sink.emit('{"late":true}\n') @@ -252,14 +467,14 @@ def test_emit_after_close_is_counted_not_raised(tmp_path): def test_close_is_idempotent(tmp_path): - sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, backup_count=1) + sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, max_backups=1) sink.start() sink.close() sink.close() # must not raise def test_start_is_idempotent(tmp_path): - sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, backup_count=1) + sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, max_backups=1) sink.start() sink.start() try: @@ -271,7 +486,7 @@ def test_start_is_idempotent(tmp_path): def test_emit_from_many_threads_loses_nothing(tmp_path): """The producer side must be safe to call from any thread.""" - sink = AuditSink(tmp_path / "audit.log", max_bytes=10_000_000, backup_count=1) + sink = AuditSink(tmp_path / "audit.log", max_bytes=10_000_000, max_backups=1) sink.start() def worker(worker_id: int) -> None: @@ -300,7 +515,7 @@ def test_sink_recovers_after_a_failed_rotation(tmp_path): a closed handle and never wrote again, even after the condition cleared. Silent and permanent is the worst failure mode an audit log can have. """ - sink = AuditSink(tmp_path / "a.log", max_bytes=200, backup_count=0) + sink = AuditSink(tmp_path / "a.log", max_bytes=200, max_backups=0) sink.start() line = '{"pad":"' + "x" * 80 + '"}\n' @@ -356,7 +571,7 @@ def test_writer_exits_when_the_queue_is_full_at_close(tmp_path): shut down faster, so the writer polls the closed flag instead. Before that, it blocked on an empty get() forever and the thread leaked. """ - sink = AuditSink(tmp_path / "b.log", max_bytes=10_000, backup_count=1, queue_size=4) + sink = AuditSink(tmp_path / "b.log", max_bytes=10_000, max_backups=1, queue_size=4) sink.start() time.sleep(0.05) @@ -385,7 +600,7 @@ def test_a_draining_writer_does_not_reopen_the_file_after_close(tmp_path): timeout exists for — would otherwise reopen a handle nobody closes again, and append records dated after "server stopped". It must drop them instead. """ - sink = AuditSink(tmp_path / "c.log", max_bytes=100_000, backup_count=1) + sink = AuditSink(tmp_path / "c.log", max_bytes=100_000, max_backups=1) sink.start() released = threading.Event() real_write = sink._write_batch @@ -403,3 +618,252 @@ def slow(lines): time.sleep(0.4) assert sink._stream.closed, "the writer reopened the stream after close" + + +# --------------------------------------------------------------------------- +# console and composite sinks +# --------------------------------------------------------------------------- + + +def test_console_sink_writes_lines_to_its_stream(): + stream = io.StringIO() + sink = ConsoleAuditSink(stream) + sink.start() + sink.emit('{"a":1}\n') + sink.emit('{"b":2}\n') + + assert stream.getvalue() == '{"a":1}\n{"b":2}\n' + assert sink.stats == {"written": 2, "dropped": 0, "write_errors": 0} + + +def test_console_sink_defaults_to_stderr_never_stdout(capsys): + """stdout carries the JSON-RPC protocol under the stdio transport. + + One audit line written there corrupts the client's stream and takes the + session down, so the default stream is not merely a preference. + """ + sink = ConsoleAuditSink() + sink.start() + sink.emit('{"audited":true}\n') + + captured = capsys.readouterr() + assert captured.err == '{"audited":true}\n' + assert captured.out == "" + + +def test_console_sink_counts_a_broken_stream_rather_than_raising(): + class Broken(io.StringIO): + def write(self, _text): + raise OSError("stream is gone") + + sink = ConsoleAuditSink(Broken()) + sink.emit('{"x":1}\n') + + assert sink.stats["dropped"] == 1 + assert sink.stats["write_errors"] == 1 + + +def test_console_sink_emits_whole_lines_under_concurrency(): + stream = io.StringIO() + sink = ConsoleAuditSink(stream) + + def worker(worker_id: int) -> None: + for index in range(50): + sink.emit(json.dumps({"w": worker_id, "i": index}) + "\n") + + threads = [threading.Thread(target=worker, args=(w,)) for w in range(6)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + lines = stream.getvalue().splitlines() + assert len(lines) == 300 + # Every line must be complete JSON: no record split by another thread. + assert ( + len({(json.loads(line)["w"], json.loads(line)["i"]) for line in lines}) == 300 + ) + + +def test_composite_writes_to_every_sink_and_sums_their_stats(tmp_path): + stream = io.StringIO() + console = ConsoleAuditSink(stream) + file_sink = AuditSink(tmp_path / "audit.log", max_bytes=0, max_backups=0) + composite = CompositeAuditSink([console, file_sink]) + composite.start() + composite.emit('{"both":true}\n') + composite.close(timeout=10.0) + + assert stream.getvalue() == '{"both":true}\n' + assert file_sink.path.read_text(encoding="utf-8") == '{"both":true}\n' + assert composite.stats["written"] == 2 + + +def test_one_failing_sink_does_not_stop_the_others(tmp_path): + """A full disk must not also cost the operator their console records.""" + + class Broken(io.StringIO): + def write(self, _text): + raise OSError("stream is gone") + + file_sink = AuditSink(tmp_path / "audit.log", max_bytes=0, max_backups=0) + composite = CompositeAuditSink([ConsoleAuditSink(Broken()), file_sink]) + composite.start() + composite.emit('{"survives":true}\n') + composite.close(timeout=10.0) + + assert file_sink.path.read_text(encoding="utf-8") == '{"survives":true}\n' + assert composite.stats["written"] == 1 + assert composite.stats["dropped"] == 1 + + +# --------------------------------------------------------------------------- +# retention keeps the newest, end to end +# --------------------------------------------------------------------------- + + +def _numbered(sink: AuditSink) -> list[int]: + """Every surviving record's ``n``, across backups and the live file.""" + numbers: list[int] = [] + for path in [*_backups(sink), sink.path]: + if path.suffix == ".gz": + with gzip.open(path, "rt", encoding="utf-8") as handle: + text = handle.read() + else: + text = path.read_text(encoding="utf-8") + numbers += [json.loads(line)["n"] for line in text.splitlines() if line.strip()] + return sorted(numbers) + + +def test_retention_discards_the_oldest_records_not_the_newest(tmp_path): + """Counting the backups is not enough: *which* ones survive is the point. + + Every retention case in the PRD — 100 MB across ten files, 90 days of daily + logs — means a sliding window over the most recent history. An earlier + version of this sink ordered backups by filename, and because ``-`` sorts + before ``.`` the same-second disambiguator inverted the order: it deleted + the newest backups and kept the oldest, while still reporting exactly + ``max_backups`` files. The count was right and the contents were useless. + """ + line = json.dumps({"n": 0}) + "\n" + # Two records a file, many rotations, several of them inside one second. + sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, max_backups=9) + sink.start() + for index in range(60): + sink.emit(json.dumps({"n": index}) + "\n") + _drain(sink) + + survivors = _numbered(sink) + assert len(_backups(sink)) == 9 + # The window is the tail of the sequence: contiguous, and ending at the + # last record written. + assert survivors[-1] == 59 + assert survivors == list(range(survivors[0], 60)), survivors + # Ten files of two records each; nothing older may have been kept. + assert survivors[0] >= 40, f"stale records survived: {survivors}" + + +def test_backups_are_ordered_chronologically_not_alphabetically(tmp_path): + """The ordering itself, isolated from the rotation that produces it.""" + sink = AuditSink(tmp_path / "audit.log", max_bytes=0, max_backups=5) + try: + # Written out of order, and spanning the disambiguators that broke a + # naive name sort: '-' < '.', and '-10' < '-2' as strings. + stamps = [ + "20261006T120000000000Z-2", + "20261006T120000000000Z", + "20261006T120000000000Z-10", + "20261006T120000000000Z-1", + "20261006T115959000000Z", + ] + for stamp in stamps: + sink.path.with_name(f"{sink.path.name}.{stamp}.gz").write_bytes(b"") + ordered = [path.name for path in sink._existing_backups()] + finally: + _drain(sink) + + assert ordered == [ + f"{sink.path.name}.20261006T115959000000Z.gz", + f"{sink.path.name}.20261006T120000000000Z.gz", + f"{sink.path.name}.20261006T120000000000Z-1.gz", + f"{sink.path.name}.20261006T120000000000Z-2.gz", + f"{sink.path.name}.20261006T120000000000Z-10.gz", + ], ordered + + +def test_an_idle_period_does_not_cost_a_record_or_a_retention_slot(tmp_path): + """A deadline that passed while the file was empty must move on. + + Left in the past, it makes the first record after a quiet spell arrive + already overdue: it is split into a backup of its own, and with + ``max_backups=0`` the next record truncates it away — written, counted, and + gone. That is the one failure an audit sink may not have. + """ + sink = AuditSink( + tmp_path / "audit.log", max_bytes=0, max_backups=0, interval_seconds=86_400 + ) + sink.start() + # A deadline that fell due while nothing was being written. + sink._rotate_at = time.time() - 1 + time.sleep(0.4) # let the idle poll see it + + for index in range(3): + sink.emit(json.dumps({"n": index}) + "\n") + _drain(sink) + + assert _numbered(sink) == [0, 1, 2] + assert sink.stats["written"] == 3 + assert sink.stats["dropped"] == 0 + + +def test_a_record_queued_as_the_sink_closes_is_still_written(tmp_path): + """``emit`` can land behind the sentinel; the writer must still drain it.""" + sink = AuditSink(tmp_path / "audit.log", max_bytes=0, max_backups=0) + sink.start() + # Post the sentinel first, then a record behind it — the ordering the + # emit/close race produces, without having to win the race. + sink._queue.put_nowait(_SENTINEL) + sink._queue.put_nowait(json.dumps({"n": 7}) + "\n") + sink.close(timeout=10.0) + + assert _numbered(sink) == [7] + + +@pytest.mark.parametrize( + ("name", "max_bytes", "interval", "max_backups", "records", "expected_files"), + [ + # PRD case 4: 10 MB a file, 9 backups -> 100 MB across ten files. + ("case4", 2, 0, 9, 40, 10), + # PRD case 5 / 6 shape: interval only, retention by count. + ("case5", 0, 86_400, 3, 12, 4), + ], +) +def test_prd_retention_cases_end_to_end( + tmp_path, name, max_bytes, interval, max_backups, records, expected_files +): + """The configuration cases as the sink actually behaves, not as arithmetic. + + ``test_audit_config`` checks that each case resolves to the right numbers; + this checks that those numbers produce the right files on disk, with the + most recent records in them. + """ + line = json.dumps({"n": 0}) + "\n" + sink = AuditSink( + tmp_path / f"{name}.log", + max_bytes=len(line) * max_bytes if max_bytes else 0, + max_backups=max_backups, + interval_seconds=interval, + ) + sink.start() + for index in range(records): + sink.emit(json.dumps({"n": index}) + "\n") + if interval: + # Force the interval trigger rather than waiting a day for it. + _settle(sink, timeout=0.5) + sink._rotate_at = time.time() - 1 + _drain(sink) + + assert len(_backups(sink)) + 1 == expected_files + survivors = _numbered(sink) + assert survivors[-1] == records - 1, "the newest record was not retained" + assert survivors == list(range(survivors[0], records)), survivors diff --git a/tests/unit/test_audit_wiring.py b/tests/unit/test_audit_wiring.py index cc01009e..73978635 100644 --- a/tests/unit/test_audit_wiring.py +++ b/tests/unit/test_audit_wiring.py @@ -27,6 +27,7 @@ from cb_mcp.audit.config import resolve_audit_config from cb_mcp.audit.emitter import get_audit_logger, init_audit, shutdown_audit +from cb_mcp.audit.sink import process_scoped_path from cb_mcp.auth import CouchbaseJWTVerifier from cb_mcp.core.app import build_app @@ -44,9 +45,11 @@ def _records(directory: Path) -> list[dict]: def _audit_config(tmp_path: Path, **overrides): options = { "enabled": True, + # The file sink specifically: these tests assert on what reaches disk. + "sinks": "file", "file": str(tmp_path / "audit.log"), "rotation_max_size_mb": None, - "retention_backup_count": None, + "max_backups": None, "tool_args": None, "disabled_events": None, } @@ -218,3 +221,48 @@ async def test_an_accepted_token_emits_no_record(tmp_path): shutdown_audit() assert [r for r in _records(tmp_path) if r["name"] == "token rejected"] == [] + + +def test_a_file_sink_that_cannot_be_opened_is_removed_from_the_snapshot(tmp_path): + """The reported configuration must match what is actually being written. + + ``init_audit`` reports an unopenable file and keeps serving — but the + snapshot it hands the logger is reported by + ``get_server_configuration_status`` *and* written into the permanent + ``audit configuration changed`` record. A snapshot still naming a file sink + that failed to open would tell an auditor, for as long as the record is + kept, that records were being written to disk when none were. + """ + # A directory where the process-scoped file should be: the portable way to + # make open() fail without depending on the test user's privileges. + blocked = tmp_path / "blocked" + blocked.mkdir() + config = _audit_config( + blocked, sinks="console,file", file=str(blocked / "audit.log") + ) + process_scoped_path(config.file).mkdir() + + try: + audit = init_audit(config) + # The console sink survives, so auditing is still active... + assert audit.active is True + snapshot = audit.config.as_dict() + # ... but nothing claims a file is being written. + assert snapshot["sinks"] == ["console"] + assert snapshot["process_file"] is None + finally: + shutdown_audit() + + +def test_auditing_reports_itself_off_when_every_sink_fails(tmp_path): + blocked = tmp_path / "blocked" + blocked.mkdir() + config = _audit_config(blocked, sinks="file", file=str(blocked / "audit.log")) + process_scoped_path(config.file).mkdir() + + try: + audit = init_audit(config) + assert audit.active is False + assert audit.config.as_dict()["enabled"] is False + finally: + shutdown_audit() diff --git a/tests/unit/test_cli_options.py b/tests/unit/test_cli_options.py index a5546d09..0e90084a 100644 --- a/tests/unit/test_cli_options.py +++ b/tests/unit/test_cli_options.py @@ -261,22 +261,29 @@ def test_flags_reach_the_resolved_configuration(self, tmp_path): [ "--audit-log-enabled", "true", - "--audit-file", + "--audit-log-sinks", + "console,file", + "--audit-log-file-path", str(tmp_path / "audit.log"), - "--audit-rotation-max-size-mb", + "--audit-log-rotation-max-size-mb", "4", - "--audit-retention-backup-count", + "--audit-log-rotation-interval", + "2w", + "--audit-log-retention-max-backups", "7", - "--audit-tool-args", + "--audit-log-tool-args", "true", - "--audit-disabled-events", + "--audit-log-disabled-events", "61490,61491", ] ) assert config.enabled is True + assert config.sinks == ("console", "file") assert config.file == str(tmp_path / "audit.log") assert config.rotation_max_size_mb == 4.0 - assert config.retention_backup_count == 7 + assert config.rotation_interval == "2w" + assert config.rotation_interval_seconds == 2 * 604_800 + assert config.max_backups == 7 assert config.tool_args is True assert config.disabled_events == (61490, 61491) # The process-scoped path is resolved once, here, so startup can log @@ -288,19 +295,34 @@ def test_env_vars_are_honoured(self, tmp_path): [], env={ "CB_MCP_AUDIT_LOG_ENABLED": "true", - "CB_MCP_AUDIT_FILE": str(tmp_path / "from-env.log"), - "CB_MCP_AUDIT_TOOL_ARGS": "true", + "CB_MCP_AUDIT_LOG_SINKS": "file", + "CB_MCP_AUDIT_LOG_FILE_PATH": str(tmp_path / "from-env.log"), + "CB_MCP_AUDIT_LOG_ROTATION_INTERVAL": "30d", + "CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS": "3", + "CB_MCP_AUDIT_LOG_TOOL_ARGS": "true", }, ) assert config.enabled is True + assert config.sinks == ("file",) assert config.file == str(tmp_path / "from-env.log") + assert config.rotation_interval == "30d" + assert config.max_backups == 3 assert config.tool_args is True - def test_enabling_without_a_file_starts_the_server_with_auditing_off(self): + def test_enabling_the_file_sink_without_a_path_still_starts_the_server(self): """Per the PRD this is reported, not fatal: the server still starts.""" - config = self._resolved(["--audit-log-enabled", "true"]) + config = self._resolved( + ["--audit-log-enabled", "true", "--audit-log-sinks", "file"] + ) assert config.enabled is False + def test_enabling_without_a_file_defaults_to_the_console_sink(self): + """The default sink needs no path, so this is a working configuration.""" + config = self._resolved(["--audit-log-enabled", "true"]) + assert config.enabled is True + assert config.sinks == ("console",) + assert config.writes_file is False + def test_both_subcommands_accept_the_audit_flags(self): for command in ("operational", "operational-insights"): result = CliRunner().invoke( @@ -309,10 +331,12 @@ def test_both_subcommands_accept_the_audit_flags(self): assert result.exit_code == 0 for flag in ( "--audit-log-enabled", - "--audit-file", - "--audit-rotation-max-size-mb", - "--audit-retention-backup-count", - "--audit-tool-args", - "--audit-disabled-events", + "--audit-log-sinks", + "--audit-log-file-path", + "--audit-log-rotation-max-size-mb", + "--audit-log-rotation-interval", + "--audit-log-retention-max-backups", + "--audit-log-tool-args", + "--audit-log-disabled-events", ): assert flag in result.output, f"{flag} missing from {command} --help" From 63d247bef727c13fc5ba163c1c438e4d961a1928 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Tue, 6 Oct 2026 23:45:36 +0530 Subject: [PATCH 10/27] Sync up changes with main --- src/cb_mcp/audit/classification.py | 60 +++++++++++++++---------- tests/unit/test_audit_classification.py | 18 ++++++++ 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/cb_mcp/audit/classification.py b/src/cb_mcp/audit/classification.py index f138c1bd..f203a3e7 100644 --- a/src/cb_mcp/audit/classification.py +++ b/src/cb_mcp/audit/classification.py @@ -15,11 +15,15 @@ about the category, and :func:`unclassified_tool_names` lets startup report the gap rather than leaving it to be discovered from an audit file months later. -Entries marked ``PENDING PRD`` are classified here from the repository's own -categorisation (the ``*_WRITE_TOOLS`` lists in :mod:`cb_mcp.tools` and the -``readOnlyHint`` annotations) because they ship today while the PRD's -example-tool tables have not yet been updated to name them. The classification -is not in doubt; the PRD text is behind. Re-verify when those rows land. +Every registered tool is classified here, whether or not the PRD's +example-tool tables happen to name it. The PRD fixes the *id space* — the +categories and their read/write ids — and names a few tools per category by way +of illustration; it was never meant to be the tool inventory, and a tool list in +a document would be stale the week after it was written. The authority for an +individual tool is the repository itself: the ``read``/``write`` split in +:mod:`cb_mcp.tools` and the ``readOnlyHint`` annotations, which are also what +decide ``required_scope``. Classifying from those is what keeps the audit id and +the scope check from ever disagreeing. """ from __future__ import annotations @@ -50,31 +54,36 @@ "get_server_configuration_status": ("cluster", "read"), "test_cluster_connection": ("cluster", "read"), "get_cluster_health_and_services": ("cluster", "read"), - "get_cluster_diagnostics_report": ("cluster", "read"), # PENDING PRD - "get_cluster_metrics": ("cluster", "read"), # PENDING PRD + "get_cluster_diagnostics_report": ("cluster", "read"), + "get_cluster_metrics": ("cluster", "read"), + # Both read the cluster's own REST endpoints (/pools/default/tasks and + # the node topology) and need only ro_admin, so they are cluster reads + # like their neighbours above. + "get_cluster_tasks": ("cluster", "read"), + "get_cluster_health_snapshot": ("cluster", "read"), # Reads bundled reference data rather than the cluster, so it touches # no keyspace and has no service of its own. Booked as a cluster read: # it is a server-level informational call, which is what that category # already covers. - "discover_tool_input_values": ("cluster", "read"), # PENDING PRD + "discover_tool_input_values": ("cluster", "read"), # -- schema / discovery -------------------------------------------- "get_buckets_in_cluster": ("schema", "read"), "get_scopes_in_bucket": ("schema", "read"), "get_collections_in_scope": ("schema", "read"), "get_scopes_and_collections_in_bucket": ("schema", "read"), "get_schema_for_collection": ("schema", "read"), - "create_scope": ("schema", "write"), # PENDING PRD - "create_collection": ("schema", "write"), # PENDING PRD - "delete_scope": ("schema", "write"), # PENDING PRD - "delete_collection": ("schema", "write"), # PENDING PRD + "create_scope": ("schema", "write"), + "create_collection": ("schema", "write"), + "delete_scope": ("schema", "write"), + "delete_collection": ("schema", "write"), # -- kv (document) -------------------------------------------------- "get_document_by_id": ("kv", "read"), - "lookup_subdocument": ("kv", "read"), # PENDING PRD + "lookup_subdocument": ("kv", "read"), "upsert_document_by_id": ("kv", "write"), "insert_document_by_id": ("kv", "write"), "replace_document_by_id": ("kv", "write"), "delete_document_by_id": ("kv", "write"), - "mutate_subdocument": ("kv", "write"), # PENDING PRD + "mutate_subdocument": ("kv", "write"), # -- query (SQL++) --------------------------------------------------- # run_sql_plus_plus_query is re-classified per statement at invocation; # the entry here is the class used when the statement was not inspected. @@ -83,10 +92,10 @@ # -- index ----------------------------------------------------------- "list_indexes": ("index", "read"), "get_index_advisor_recommendations": ("index", "read"), - "get_index_stats": ("index", "read"), # PENDING PRD - "create_index": ("index", "write"), # PENDING PRD - "build_index": ("index", "write"), # PENDING PRD - "drop_index": ("index", "write"), # PENDING PRD + "get_index_stats": ("index", "read"), + "create_index": ("index", "write"), + "build_index": ("index", "write"), + "drop_index": ("index", "write"), # -- performance ----------------------------------------------------- "get_longest_running_queries": ("performance", "read"), "get_most_frequent_queries": ("performance", "read"), @@ -98,12 +107,15 @@ # -- search (FTS) ------------------------------------------------------ # Its own category rather than folded into index: run_fts_query is a # query, so a reviewer filtering on `index read` should not get search - # traffic mixed in. All three tools ship read-only; the search *write* - # id (61528) exists in the catalogue for when FTS index management - # lands, so adding it will need no new id. - "list_fts_indexes": ("search", "read"), # PENDING PRD - "get_fts_index_definition": ("search", "read"), # PENDING PRD - "run_fts_query": ("search", "read"), # PENDING PRD + # traffic mixed in. FTS index management has since landed and takes the + # search *write* id (61528) that the catalogue already reserved — the + # extension point working as designed: a new tool is a map entry, not a + # new id and not a SIEM rule change. + "list_fts_indexes": ("search", "read"), + "get_fts_index_definition": ("search", "read"), + "run_fts_query": ("search", "read"), + "upsert_fts_index": ("search", "write"), + "drop_fts_index": ("search", "write"), }, } diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py index e7bc8233..969a5d90 100644 --- a/tests/unit/test_audit_classification.py +++ b/tests/unit/test_audit_classification.py @@ -91,6 +91,8 @@ def test_read_only_tools_are_classified_read(tool_name): ("delete_document_by_id", 61522), ("get_cluster_diagnostics_report", 61488), ("test_cluster_connection", 61488), + ("get_cluster_tasks", 61488), + ("get_cluster_health_snapshot", 61488), ("get_buckets_in_cluster", 61489), ("get_schema_for_collection", 61489), ("create_scope", 61521), @@ -102,9 +104,25 @@ def test_read_only_tools_are_classified_read(tool_name): ("drop_index", 61524), ("get_longest_running_queries", 61493), ("get_queries_not_selective", 61493), + # Search keeps its own category rather than folding into index: a + # reviewer filtering on `index read` must not get FTS traffic, and + # `run_fts_query` is a query, not an index operation. + ("list_fts_indexes", 61496), + ("run_fts_query", 61496), + ("upsert_fts_index", 61528), + ("drop_fts_index", 61528), ], ) def test_tool_maps_to_the_expected_event_id(tool_name, expected_id): + """Pins the *category*, which the read/write tests above cannot. + + ``test_repository_write_tools_are_classified_write`` checks the operation + class against the server's own read/write split, so a tool filed under the + wrong category — an FTS write booked as an index write — satisfies it + while writing the wrong id into every record for that tool. The id is the + wire contract a SIEM rule is written against; only an explicit expectation + holds it still. + """ assert resolve_tool_call_event(tool_name).id == expected_id From 80857641c5ec44c08b16ac24183b06889d3c209a Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Tue, 6 Oct 2026 23:53:46 +0530 Subject: [PATCH 11/27] Added docstring as body --- src/cb_mcp/audit/sink.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/cb_mcp/audit/sink.py b/src/cb_mcp/audit/sink.py index d7a532b4..ffe5210b 100644 --- a/src/cb_mcp/audit/sink.py +++ b/src/cb_mcp/audit/sink.py @@ -144,16 +144,32 @@ def process_scoped_path( class AuditSinkProtocol(Protocol): - """What the emitter needs from a sink, whatever it writes to.""" + """What the emitter needs from a sink, whatever it writes to. + + Each method below is bodied by its docstring alone. An ellipsis would be + an expression statement with no effect, which static analysis flags — and + rightly, since nothing distinguishes a deliberate protocol stub from a + line someone left unfinished. The docstring is a real body, says what an + implementation owes the emitter, and leaves no empty statement behind. + + Not ``runtime_checkable``, matching :mod:`cb_mcp.core.contracts`: such a + protocol checks method *names* only, never signatures, so an ``isinstance`` + against it would be more misleading than useful. Nothing branches on a + sink's type — ``init_audit`` builds the set it was configured to build. + """ - def start(self) -> None: ... + def start(self) -> None: + """Begin accepting records. Idempotent; may be a no-op.""" - def emit(self, line: str) -> None: ... + def emit(self, line: str) -> None: + """Write one already-formatted JSON line. Must never raise or block.""" - def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: ... + def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: + """Flush and stop, within ``timeout`` seconds. Safe to call twice.""" @property - def stats(self) -> dict[str, int]: ... + def stats(self) -> dict[str, int]: + """``written`` / ``dropped`` / ``write_errors`` for the status tool.""" class ConsoleAuditSink: From 1e956e28a9591ebf1756e9f2d89aedd7c5eab9fc Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Wed, 7 Oct 2026 18:19:38 +0530 Subject: [PATCH 12/27] Remove file reference --- src/cb_mcp/audit/emitter.py | 4 ++-- src/cb_mcp/audit/identity.py | 3 +-- src/cb_mcp/auth.py | 6 ++++-- src/cb_mcp/tools/operational/query.py | 5 +++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/cb_mcp/audit/emitter.py b/src/cb_mcp/audit/emitter.py index aa565846..ec62e052 100644 --- a/src/cb_mcp/audit/emitter.py +++ b/src/cb_mcp/audit/emitter.py @@ -257,8 +257,8 @@ def _shutdown_at_exit() -> None: A stdio client that kills the server, or an unhandled exit, would otherwise lose whatever is still queued. This cannot help on ``SIGKILL``; a missing - ``server stopped`` record therefore means "not a clean shutdown", not - "tampered with", and ``AUDIT.md`` says so. + ``server stopped`` record therefore means "not a clean shutdown" — it is + not evidence of tampering, and must not be read as such. """ shutdown_audit() diff --git a/src/cb_mcp/audit/identity.py b/src/cb_mcp/audit/identity.py index 2ebced87..8ebb43f3 100644 --- a/src/cb_mcp/audit/identity.py +++ b/src/cb_mcp/audit/identity.py @@ -21,8 +21,7 @@ is meant to solve. :func:`warn_on_unauthenticated_http` reports this at startup. * ``local`` inside a container resolves to the image's user (often ``root``), - which looks like an identity but identifies nothing. Documented in - ``AUDIT.md``. + which looks like an identity but identifies nothing. """ from __future__ import annotations diff --git a/src/cb_mcp/auth.py b/src/cb_mcp/auth.py index 32c8c1c7..4fad46ce 100644 --- a/src/cb_mcp/auth.py +++ b/src/cb_mcp/auth.py @@ -128,8 +128,10 @@ async def verify_token(self, token: str) -> AccessToken | None: ``token_invalid`` reason is the honest record. ``real_userid`` is ``anonymous`` because a rejected token establishes no identity. - A token that is *missing* entirely is refused before this method is - reached and is therefore not audited in this release — see ``AUDIT.md``. + A token that is *missing* entirely is refused by the ASGI auth layer + before this method is reached, so it produces no ``token rejected`` + record in this release. An unauthenticated caller is therefore visible + in the audit file only as the absence of records, not as a denial. Correlation: this record carries a freshly minted ``cid``, so that grouping an audit file by ``cid`` works unconditionally, and carries no diff --git a/src/cb_mcp/tools/operational/query.py b/src/cb_mcp/tools/operational/query.py index b4995cf7..bc6843d9 100644 --- a/src/cb_mcp/tools/operational/query.py +++ b/src/cb_mcp/tools/operational/query.py @@ -42,8 +42,9 @@ def _query_correlation_options() -> dict[str, str]: was before. Only the query service can do this. The KV protocol exposes no - client-supplied correlation field, so a document-level join stays - approximate — see ``AUDIT.md``. + client-supplied correlation field, so a document-level join between an + MCP record and a Couchbase Server audit record stays approximate: it has + to be made on timestamp and keyspace rather than on a shared id. """ cid = audit_state.get_cid() return {"client_context_id": cid} if cid else {} From 39ec7928df5b12d190ca22e39dc5ed197c2613d9 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Thu, 8 Oct 2026 19:15:45 +0530 Subject: [PATCH 13/27] DA-2038: Clean Code Fix sinks flush accounting --- README.md | 2 +- server.json | 112 +++++ src/cb_mcp/audit/emitter.py | 109 +++- src/cb_mcp/audit/middleware.py | 36 +- src/cb_mcp/audit/record.py | 39 +- src/cb_mcp/audit/sink.py | 474 ++++++++++-------- src/cb_mcp/core/app.py | 37 +- src/cb_mcp/utils/cli_params.py | 81 ++- tests/unit/test_audit_integration_points.py | 101 ++++ tests/unit/test_audit_middleware.py | 216 ++++++++ tests/unit/test_audit_record.py | 55 ++ tests/unit/test_audit_sink.py | 215 +++++++- tests/unit/test_audit_wiring.py | 236 ++++++++- tests/unit/test_cli_options.py | 88 +++- .../test_server_configuration_status_tool.py | 109 +++- 15 files changed, 1662 insertions(+), 248 deletions(-) diff --git a/README.md b/README.md index ac9260cb..f9ca768e 100644 --- a/README.md +++ b/README.md @@ -556,7 +556,7 @@ uvx couchbase-mcp-server --audit-log-enabled=true \ --audit-log-rotation-interval=1d \ --audit-log-retention-max-backups=90 -# 100 MB in total: ten 10 MB files, no interval rotation +# ~100 MB of records, ~20 MB on disk once gzipped uvx couchbase-mcp-server --audit-log-enabled=true \ --audit-log-sinks=file --audit-log-file-path=/var/log/cb-mcp/audit.log \ --audit-log-rotation-max-size-mb=10 \ diff --git a/server.json b/server.json index 7293f034..2f5f690b 100644 --- a/server.json +++ b/server.json @@ -881,6 +881,62 @@ "format": "number", "isSecret": false }, + { + "name": "CB_MCP_AUDIT_LOG_ENABLED", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false. This server does not emit audit records in this release: the setting is accepted and reported so one configuration can be shared with the operational server, which does.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_SINKS", + "description": "Comma-separated audit destinations: console, file, or both. 'console' writes to stderr, never stdout, because stdout carries the JSON-RPC protocol under the stdio transport. Default is console.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_FILE_PATH", + "description": "Path to the audit log file, required when 'file' is in CB_MCP_AUDIT_LOG_SINKS. Each server process writes its own file with the host and process id inserted before the extension (audit.log becomes audit.myhost.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with the file sink dropped and an error recorded.", + "isRequired": false, + "format": "filepath", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB", + "description": "Maximum size in MB the live audit file may reach before it rotates. 0 turns size-based rotation off. Default is 10 MB.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ROTATION_INTERVAL", + "description": "Age the live audit file may reach before it rotates, as where the unit is 'd' (24 hours) or 'w' (7 days) - for example 1d, 30d, 1w, 8w. 0 turns interval-based rotation off. Size and interval are independent: whichever falls due first rotates the file. Default is 1d.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS", + "description": "Number of rotated audit files retained, excluding the live file. Rotated files are gzipped and named with the UTC rotation time. Set to 0 to keep no backups: the live file is truncated at each rotation. Default is 10.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_TOOL_ARGS", + "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_DISABLED_EVENTS", + "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "isRequired": false, + "format": "string", + "isSecret": false + }, { "name": "CB_MCP_OAUTH_JWT_JWKS_URI", "description": "JWKS endpoint of the identity provider used to verify bearer JWT signatures. Required to enable OAuth, along with CB_MCP_OAUTH_JWT_ISSUER and CB_MCP_OAUTH_JWT_AUDIENCE. Only honored when CB_MCP_TRANSPORT=http.", @@ -1772,6 +1828,62 @@ "format": "number", "isSecret": false }, + { + "name": "CB_MCP_AUDIT_LOG_ENABLED", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false. This server does not emit audit records in this release: the setting is accepted and reported so one configuration can be shared with the operational server, which does.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_SINKS", + "description": "Comma-separated audit destinations: console, file, or both. 'console' writes to stderr, never stdout, because stdout carries the JSON-RPC protocol under the stdio transport. Default is console.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_FILE_PATH", + "description": "Path to the audit log file, required when 'file' is in CB_MCP_AUDIT_LOG_SINKS. Each server process writes its own file with the host and process id inserted before the extension (audit.log becomes audit.myhost.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with the file sink dropped and an error recorded.", + "isRequired": false, + "format": "filepath", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB", + "description": "Maximum size in MB the live audit file may reach before it rotates. 0 turns size-based rotation off. Default is 10 MB.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ROTATION_INTERVAL", + "description": "Age the live audit file may reach before it rotates, as where the unit is 'd' (24 hours) or 'w' (7 days) - for example 1d, 30d, 1w, 8w. 0 turns interval-based rotation off. Size and interval are independent: whichever falls due first rotates the file. Default is 1d.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS", + "description": "Number of rotated audit files retained, excluding the live file. Rotated files are gzipped and named with the UTC rotation time. Set to 0 to keep no backups: the live file is truncated at each rotation. Default is 10.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_TOOL_ARGS", + "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_DISABLED_EVENTS", + "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "isRequired": false, + "format": "string", + "isSecret": false + }, { "name": "CB_MCP_OAUTH_JWT_JWKS_URI", "description": "JWKS endpoint of the identity provider used to verify bearer JWT signatures. Required to enable OAuth, along with CB_MCP_OAUTH_JWT_ISSUER and CB_MCP_OAUTH_JWT_AUDIENCE. Only honored when CB_MCP_TRANSPORT=http.", diff --git a/src/cb_mcp/audit/emitter.py b/src/cb_mcp/audit/emitter.py index ec62e052..c9ef5767 100644 --- a/src/cb_mcp/audit/emitter.py +++ b/src/cb_mcp/audit/emitter.py @@ -14,13 +14,17 @@ import atexit import logging +import os +import signal +import threading +import types from dataclasses import replace from typing import Any from ..utils.constants import LOGGER_NAMESPACE from .catalog import SERVICE_PACKAGE, AuditEvent, ToolCallEvent from .config import SINK_FILE, ResolvedAuditConfig -from .record import AuditRecord, ServerContext +from .record import OUTCOME_SUCCESS, AuditRecord, ServerContext from .sink import ( AuditSink, AuditSinkProtocol, @@ -225,6 +229,9 @@ def init_audit(config: ResolvedAuditConfig) -> AuditLogger: sink: AuditSinkProtocol = sinks[0] if len(sinks) == 1 else CompositeAuditSink(sinks) sink.start() _active = AuditLogger(config, sink=sink) + # Only once auditing is actually running: a server with auditing off has no + # reason to touch the process's signal disposition. + _install_sigterm_handler() logger.info( "Audit logging enabled. sinks=%s, service_package=%s, file=%s, " @@ -263,6 +270,106 @@ def _shutdown_at_exit() -> None: shutdown_audit() +#: The SIGTERM handler that was in place before auditing installed its own, so +#: it can still run. ``None`` means nothing has been installed yet. +_previous_sigterm: Any = None +_sigterm_installed = False + +#: How long to wait for the audit trail to close on a SIGTERM that nothing else +#: is handling. Bounded because a shutdown must not hang; short because the +#: process is about to terminate either way. +_SIGTERM_DRAIN_SECONDS = 3.0 + + +def _close_audit_for_signal() -> None: + """Record the stop and close the sink. Runs on a helper thread, not inline. + + A signal handler runs on the main thread — the same thread that calls + ``emit`` and therefore the one that holds the sink queue's lock while it + does. That lock is not reentrant, so queueing a record from inside the + handler could deadlock a shutdown that must not hang. On a helper thread the + worst case is waiting for the main thread's lock, which the bounded join + below turns into a missing record rather than a wedged process. + """ + audit = get_audit_logger() + if audit.active: + audit.emit_event( + AuditEvent.SERVER_STOPPED, + outcome=OUTCOME_SUCCESS, + shutdown_signal="SIGTERM", + ) + shutdown_audit() + + +def _handle_sigterm(signum: int, frame: types.FrameType | None) -> None: + """Close the audit trail when, and only when, nothing else will. + + ``docker stop`` sends SIGTERM, and Python's default disposition terminates + the process without running ``atexit`` hooks or unwinding the lifespan. So + under stdio the most ordinary shutdown a container has produced no ``server + stopped`` record at all — while the catalogue says a missing one means the + shutdown was not clean. An operator restarting a container would have read + an unclean shutdown on every single restart, which makes the signal + worthless for spotting a real one. + + **When another handler already owns SIGTERM, this one does nothing but hand + over.** Under the http transport that handler is uvicorn's, which sets a + flag and then *drains in-flight requests* before exiting. Closing the audit + trail here would leave every one of those calls unrecorded — the middleware + reads the process-wide logger, finds it inactive, and emits nothing, not + even a dropped count. A write cancelled by a rolling restart would vanish, + which is precisely what recording cancelled calls exists to prevent. The + lifespan already emits ``server stopped`` when uvicorn unwinds it, so + deferring loses nothing and keeps the records that are still arriving. + """ + previous = _previous_sigterm + if previous is signal.SIG_IGN: + return + if callable(previous): + previous(signum, frame) + return + + # SIG_DFL (or an unreadable handler): nothing else will unwind anything, so + # this is the only chance to close the trail. + worker = threading.Thread( + target=_close_audit_for_signal, name="cb-mcp-audit-sigterm", daemon=True + ) + worker.start() + worker.join(timeout=_SIGTERM_DRAIN_SECONDS) + + # Terminate with the conventional status for the signal rather than exiting + # 0 and hiding that we were killed. + signal.signal(signal.SIGTERM, signal.SIG_DFL) + os.kill(os.getpid(), signum) + + +def _install_sigterm_handler() -> None: + """Install :func:`_handle_sigterm`, once, when it is possible to do so. + + ``signal.signal`` only works on the main thread of the main interpreter, so + an embedding host that builds the app on a worker thread simply does not get + this; auditing is unaffected otherwise. + """ + global _sigterm_installed, _previous_sigterm # noqa: PLW0603 + if _sigterm_installed: + return + try: + previous = signal.getsignal(signal.SIGTERM) + # Published before the handler is installed: a SIGTERM delivered + # between the two would otherwise read ``None`` and take the default + # path, skipping the handler that actually owns the shutdown. + _previous_sigterm = previous + signal.signal(signal.SIGTERM, _handle_sigterm) + except (ValueError, OSError, AttributeError): + logger.debug( + "Could not install the audit SIGTERM handler; a SIGTERM shutdown " + "will not record a 'server stopped' event.", + exc_info=True, + ) + return + _sigterm_installed = True + + __all__ = [ "AuditLogger", "get_audit_logger", diff --git a/src/cb_mcp/audit/middleware.py b/src/cb_mcp/audit/middleware.py index 4575f11c..fd49825e 100644 --- a/src/cb_mcp/audit/middleware.py +++ b/src/cb_mcp/audit/middleware.py @@ -46,7 +46,7 @@ from contextlib import contextmanager from typing import Any -from fastmcp.exceptions import NotFoundError +from fastmcp.exceptions import NotFoundError, ValidationError from fastmcp.server.middleware import Middleware from ..utils.constants import LOGGER_NAMESPACE @@ -69,6 +69,7 @@ OUTCOME_ERROR, OUTCOME_SUCCESS, REASON_EXECUTION_ERROR, + REASON_INVALID_ARGUMENTS, ) logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.middleware") @@ -228,7 +229,15 @@ async def on_call_tool(self, context, call_next): # type: ignore[no-untyped-def cid = request_state.get("cid") try: result = await call_next(context) - except Exception as exc: + except BaseException as exc: + # ``BaseException``, not ``Exception``: ``asyncio.CancelledError`` + # is not an ``Exception``, and a cancelled call is exactly the + # one that must still be recorded. The tool may already have + # mutated the cluster before the client timed out, disconnected + # or the server began shutting down — and a caller able to drop + # the record of a write by cancelling would have a way to act + # without leaving a trace, on events the catalogue makes + # non-filterable precisely so they cannot be suppressed. self._emit_outcome( audit, tool_name=tool_name, @@ -280,7 +289,15 @@ def _emit_outcome( return confirmation = state.get_confirmation() - if confirmation == state.CONFIRMATION_SKIPPED: + refusal = state.get_refusal() + + # The confirmation wrapper sits *outside* the tool and the SQL++ + # gates sit inside it, so a call can skip confirmation and then be + # refused anyway. 57491 says the tool "executed without + # confirmation" and carries ``outcome=success``; emitting it for a + # call a gate then blocked asserts an execution that never + # happened. The refusal below is the whole truth about that call. + if confirmation == state.CONFIRMATION_SKIPPED and refusal is None: # Not ``blocked``: the tool *ran*, unconfirmed, which is what # the catalogue entry describes. Recording it as blocked would # make a reviewer counting ``outcome=blocked`` read unconfirmed @@ -296,7 +313,6 @@ def _emit_outcome( confirmation="skipped", ) - refusal = state.get_refusal() if refusal is not None: self._emit_refusal( audit, @@ -390,7 +406,17 @@ def _emit_tool_call( reason: str | None = None if failure is not None: outcome = OUTCOME_ERROR - reason = REASON_EXECUTION_ERROR + # Malformed input and a failed execution are different facts, and + # the PRD gives them different reasons. FastMCP rejects arguments + # against the tool's schema *before* the body runs, so + # ``execution_error`` on one of those asserts an execution that + # never began — on a record that also carries a keyspace and a + # required scope taken from arguments the server refused. + reason = ( + REASON_INVALID_ARGUMENTS + if isinstance(failure, ValidationError) + else REASON_EXECUTION_ERROR + ) elif getattr(result, "is_error", False): # A tool that reports failure through the result rather than by # raising is still a failed operation as far as an auditor cares. diff --git a/src/cb_mcp/audit/record.py b/src/cb_mcp/audit/record.py index bbf3370e..2e573bfc 100644 --- a/src/cb_mcp/audit/record.py +++ b/src/cb_mcp/audit/record.py @@ -69,6 +69,22 @@ def utc_timestamp() -> str: return f"{now.strftime('%Y-%m-%dT%H:%M:%S')}.{now.microsecond // 1000:03d}Z" +#: The three characters Python's ``str.splitlines`` treats as line terminators +#: and ``json.dumps`` leaves raw under ``ensure_ascii=False``. Mapped back to +#: their escape sequences so one record is always exactly one line. +#: +#: Only three, not the whole ``splitlines`` set: ``json.dumps`` already escapes +#: every character below ``0x20``, so VT, FF, FS, GS and RS can never reach the +#: output raw and listing them would imply a guard that does nothing. +_LINE_TERMINATOR_ESCAPES = str.maketrans( + { + "\u2028": "\\u2028", + "\u2029": "\\u2029", + "\x85": "\\u0085", + } +) + + @dataclass(frozen=True) class ServerContext: """Static per-process context, resolved once at startup.""" @@ -140,16 +156,23 @@ def to_json_line(self, server: ServerContext) -> str: to serialise would be a silently missing audit entry — strictly worse than a stringified value. ``ensure_ascii=False`` keeps non-Latin document ids and keyspace names readable. + + **One record is one line.** That is the whole contract of JSON Lines, + and a caller must not be able to break it. ``ensure_ascii=False`` leaves + U+2028 and U+2029 as raw bytes, and Python's ``str.splitlines`` — used + by this repo's own readers, and by many log shippers — treats both as + line terminators. A bucket or document name containing one would split + its own record into unparseable fragments, destroying the very entry + that recorded the call. They are escaped back to their ``\\u`` form, + which is still valid JSON and reads identically once parsed. """ - return ( - json.dumps( - self.as_dict(server), - ensure_ascii=False, - separators=(",", ":"), - default=str, - ) - + "\n" + line = json.dumps( + self.as_dict(server), + ensure_ascii=False, + separators=(",", ":"), + default=str, ) + return line.translate(_LINE_TERMINATOR_ESCAPES) + "\n" __all__ = [ diff --git a/src/cb_mcp/audit/sink.py b/src/cb_mcp/audit/sink.py index ffe5210b..f96ca50c 100644 --- a/src/cb_mcp/audit/sink.py +++ b/src/cb_mcp/audit/sink.py @@ -46,6 +46,7 @@ from __future__ import annotations import contextlib +import glob import gzip import logging import os @@ -172,75 +173,265 @@ def stats(self) -> dict[str, int]: """``written`` / ``dropped`` / ``write_errors`` for the status tool.""" -class ConsoleAuditSink: - """Writes audit lines to stderr, synchronously. +class _QueuedSink: + """Producer/consumer plumbing for a sink that must never block its caller. - **stderr, never stdout.** Under the stdio transport stdout *is* the JSON-RPC - channel: one audit line written there corrupts the client's stream and takes - the session down. The PRD calls this sink "console"; the stream is stderr, - and that is not configurable. + ``emit`` only puts a line on a bounded queue; a dedicated daemon thread does + the writing. **Every** destination needs this, not just the file: the caller + is the asyncio event loop, and a synchronous write there — to a disk, a pipe + or a terminal — stalls every in-flight tool call and the JSON-RPC reader + with it. When the queue is full, records are dropped and counted rather than + applying back-pressure to tool execution. That is the deliberate trade: + audit unavailability must not become an outage. - Unlike the file sink this writes inline rather than on a thread. A console - is a pipe or a terminal, not a disk that can fill: the write is a buffer - copy, there is no rotation, retention or compression to do, and giving it a - thread would add a second ordering of records against the operational log - for no benefit. If stderr itself is wedged, that is already fatal to the - operational log too. + Subclasses implement :meth:`_write_batch`, and may override :meth:`_on_idle` + (work to do while no records are arriving) and :meth:`_on_close` (releasing + whatever the subclass owns). """ - def __init__(self, stream: IO[str] | None = None) -> None: - self._stream = stream + #: Writer thread name, per sink, so a stack dump says which destination is + #: stuck. + _THREAD_NAME = "cb-mcp-audit-writer" + + def __init__(self, *, queue_size: int = DEFAULT_QUEUE_SIZE) -> None: + self._queue: queue.Queue = queue.Queue(maxsize=queue_size) + self._thread: threading.Thread | None = None + self._closed = threading.Event() + + # Counters are only mutated by the writer thread, except ``dropped`` + # which is incremented by the caller. Both are plain ints guarded by + # the GIL for a single increment; exactness under contention is not + # required for a diagnostic counter. self._written = 0 self._dropped = 0 self._errors = 0 - self._closed = False - self._lock = threading.Lock() self._last_error_log = 0.0 + self._last_producer_error_log = 0.0 + + # -- lifecycle -------------------------------------------------------- def start(self) -> None: - """Nothing to start; present so every sink has one lifecycle.""" + """Start the writer thread. Idempotent.""" + if self._thread is not None: + return + self._thread = threading.Thread( + target=self._run, name=self._THREAD_NAME, daemon=True + ) + self._thread.start() + + def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: + """Drain the queue, stop the writer thread, release the destination. + + Safe to call more than once. Best-effort: a wedged destination cannot be + allowed to hang shutdown, so the drain is bounded by ``timeout``. + """ + if self._closed.is_set(): + return + self._closed.set() + if self._thread is not None: + # Best-effort: on a full queue the sentinel cannot be posted, and + # dropping a queued record to make room for it would lose an audit + # line purely to shut down faster. The writer polls the closed flag + # instead, so it still exits once it has drained what it has. + with contextlib.suppress(queue.Full): + self._queue.put_nowait(_SENTINEL) + self._thread.join(timeout=timeout) + self._thread = None + else: + # Never started. Records queued before ``start`` would otherwise be + # discarded in silence, with neither ``written`` nor ``dropped`` + # accounting for them. + self._drain_remaining() + + abandoned = self._queue.qsize() + if abandoned: + # The join timed out on a wedged destination. Those records are + # gone; say so, because an audit gap nobody can see is the one + # outcome this sink exists to avoid. + self._dropped += abandoned + self._log_failure( + f"{abandoned} audit record(s) still queued when the writer was " + f"given up on after {timeout:g}s" + ) + self._on_close() + + # -- producer side ---------------------------------------------------- def emit(self, line: str) -> None: - """Write one already-formatted line. Never raises.""" - if self._closed: + """Queue one already-formatted line. Never raises, never blocks.""" + if self._closed.is_set(): self._dropped += 1 return - stream = self._stream if self._stream is not None else sys.stderr try: - # Held across write+flush so a record cannot be split by another - # thread's line. Tool calls are concurrent; audit lines are not. - with self._lock: - stream.write(line) - stream.flush() - self._written += 1 - except (OSError, ValueError) as exc: - self._errors += 1 + self._queue.put_nowait(line) + except queue.Full: self._dropped += 1 - now = time.monotonic() - if ( - not self._last_error_log - or now - self._last_error_log >= _ERROR_LOG_INTERVAL_SECONDS - ): - self._last_error_log = now - logger.error( - "Audit console sink failure: %s (dropped=%d). Audit records " - "are being lost; the server continues to serve.", - exc, - self._dropped, - ) - - def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: - """Stop accepting records. The stream itself is not ours to close.""" - self._closed = True + self._log_failure("audit queue is full", producer=True) @property def stats(self) -> dict[str, int]: + """Counters for ``get_server_configuration_status``. + + ``dropped`` is the number that matters: it is the only way an operator + learns that audit records were lost. + """ return { "written": self._written, "dropped": self._dropped, "write_errors": self._errors, } + # -- writer thread ---------------------------------------------------- + + def _run(self) -> None: + while True: + try: + item = self._queue.get(timeout=_POLL_SECONDS) + except queue.Empty: + # Nothing left to write. Exit only once the sink is closed, + # which is what lets the thread finish when ``close`` could not + # post the sentinel into a full queue. + if self._closed.is_set(): + return + self._on_idle() + continue + if item is _SENTINEL: + self._drain_remaining() + return + batch = [item] + # Coalesce whatever else is already waiting into one write+flush. + while len(batch) < _WRITE_BATCH: + try: + nxt = self._queue.get_nowait() + except queue.Empty: + break + if nxt is _SENTINEL: + self._write_batch(batch) + self._drain_remaining() + return + batch.append(nxt) + self._write_batch(batch) + + def _drain_remaining(self) -> None: + """Write anything queued behind the sentinel before the writer exits. + + ``emit`` checks the closed flag and then queues; ``close`` sets the flag + and then posts the sentinel. A record that passes the check just before + the flag is set lands *behind* the sentinel, and without this drain the + writer would return without it — unwritten and uncounted, which is the + one outcome an audit sink must not produce quietly. + + It narrows that window rather than closing it: a record queued after the + final ``get_nowait`` below is still lost, and still uncounted. Closing it + completely would mean holding a lock across ``emit``, which is what the + whole queue design exists to avoid. + """ + leftovers: list[str] = [] + while True: + try: + item = self._queue.get_nowait() + except queue.Empty: + break + if item is not _SENTINEL: + leftovers.append(item) + if leftovers: + self._write_batch(leftovers) + + # -- subclass hooks --------------------------------------------------- + + def _write_batch(self, lines: list[str]) -> None: + raise NotImplementedError + + def _on_idle(self) -> None: + """Called on the writer thread whenever the queue is empty.""" + + def _on_close(self) -> None: + """Release whatever the subclass owns, once the writer has stopped.""" + + def _describe(self) -> str: + """Short identifier for failure reports.""" + return type(self).__name__ + + def _log_failure(self, message: str, *, producer: bool = False) -> None: + """Report a sink failure as an error, rate-limited. + + The first failure is always reported; subsequent ones at most once per + interval, carrying the running totals so the operational log shows the + scale of the gap without being flooded by it. + + The producer side (a full queue) and the writer side (a failed write, + rotation or compression) are rate-limited **separately**. They are + different faults with different fixes, and sharing one timer meant a + burst of queue-full reports could hide a disk failure for a minute. + """ + now = time.monotonic() + last = self._last_producer_error_log if producer else self._last_error_log + if last and now - last < _ERROR_LOG_INTERVAL_SECONDS: + return + if producer: + self._last_producer_error_log = now + else: + self._last_error_log = now + logger.error( + "Audit sink failure: %s (sink=%s, dropped=%d, write_errors=%d). " + "Audit records are being lost; the server continues to serve.", + message, + self._describe(), + self._dropped, + self._errors, + ) + + +class ConsoleAuditSink(_QueuedSink): + """Writes audit lines to stderr, on a background thread. + + **stderr, never stdout.** Under the stdio transport stdout *is* the JSON-RPC + channel: one audit line written there corrupts the client's stream and takes + the session down. The PRD calls this sink "console"; the stream is stderr, + and that is not configurable. + + **Queued, like the file sink.** This wrote inline once, reasoning that a + console is a pipe or a terminal rather than a disk that can fill. A pipe is + exactly what fills: under stdio, stderr belongs to the MCP client, and a + client that does not drain it blocks the writer at the 64 KiB pipe buffer — + which, written inline, is the whole event loop. Since ``console`` is also + the *default* sink, that made enabling auditing alone enough to hang the + server. The queue bounds the damage to dropped records, which are counted. + """ + + _THREAD_NAME = "cb-mcp-audit-console" + + def __init__( + self, stream: IO[str] | None = None, *, queue_size: int = DEFAULT_QUEUE_SIZE + ) -> None: + super().__init__(queue_size=queue_size) + self._stream = stream + + def _describe(self) -> str: + return "console" + + def _write_batch(self, lines: list[str]) -> None: + # Resolved per batch rather than held: pytest's capsys, and a host that + # redirects output, both replace ``sys.stderr`` after construction. + stream = self._stream if self._stream is not None else sys.stderr + buffered = 0 + try: + for line in lines: + stream.write(line) + buffered += 1 + stream.flush() + except (OSError, ValueError) as exc: + # Every line in this batch is lost, not just the ones that had not + # been handed to ``write`` yet: a buffered line that is never + # flushed never reaches the console. ``dropped`` is documented as + # the only way an operator learns of a gap, so it must account for + # all of them. + self._errors += 1 + self._dropped += len(lines) + self._log_failure(f"failed to write to the console: {exc}") + return + self._written += buffered + class CompositeAuditSink: """Fans every record out to several sinks. @@ -279,9 +470,11 @@ def stats(self) -> dict[str, int]: return totals -class AuditSink: +class AuditSink(_QueuedSink): """Writes audit lines to a per-process file on a background thread.""" + _THREAD_NAME = "cb-mcp-audit-writer" + def __init__( self, path: str | os.PathLike[str], @@ -313,29 +506,18 @@ def __init__( f"interval_seconds must not be negative, got {interval_seconds}." ) + super().__init__(queue_size=queue_size) self.path = process_scoped_path(path) self._max_bytes = max_bytes self._max_backups = max_backups self._interval = interval_seconds self._compress = compress - self._queue: queue.Queue = queue.Queue(maxsize=queue_size) - self._thread: threading.Thread | None = None - self._closed = threading.Event() - - # Counters are only mutated by the writer thread, except ``dropped`` - # which is incremented by the caller. Both are plain ints guarded by - # the GIL for a single increment; exactness under contention is not - # required for a diagnostic counter. - self._written = 0 - self._dropped = 0 - self._errors = 0 - self._last_error_log = 0.0 - self._last_producer_error_log = 0.0 self.path.parent.mkdir(parents=True, exist_ok=True) - # Line-buffered text append. Opened eagerly so a misconfigured path - # fails at startup, where it can be reported, rather than on the first - # audited operation. + # Block-buffered text append: durability comes from the explicit flush + # at the end of each batch, not from the open mode. Opened eagerly so a + # misconfigured path fails at startup, where it can be reported, rather + # than on the first audited operation. # Deliberately not a context manager: the stream is owned for the # lifetime of the sink and written by the background thread. Closing it # per record would defeat both the batching and the append semantics. @@ -367,35 +549,22 @@ def _next_rotation_deadline(self, *, from_existing_file: bool = False) -> float: pass return anchor + self._interval - # -- lifecycle -------------------------------------------------------- + # -- file-specific hooks ---------------------------------------------- - def start(self) -> None: - """Start the writer thread. Idempotent.""" - if self._thread is not None: - return - self._thread = threading.Thread( - target=self._run, name="cb-mcp-audit-writer", daemon=True - ) - self._thread.start() + def _describe(self) -> str: + return str(self.path) - def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: - """Drain the queue, stop the writer thread and close the file. + def _on_idle(self) -> None: + """An idle server still ages its file. - Safe to call more than once. Best-effort: a wedged filesystem cannot be - allowed to hang shutdown, so the drain is bounded by ``timeout``. + Without this, a daily rotation on a quiet deployment would not happen + until the next record arrived — possibly days late, with a day's worth + of records already in the wrong file. """ - if self._closed.is_set(): - return - self._closed.set() - if self._thread is not None: - # Best-effort: on a full queue the sentinel cannot be posted, and - # dropping a queued record to make room for it would lose an audit - # line purely to shut down faster. The writer polls the closed flag - # instead, so it still exits once it has drained what it has. - with contextlib.suppress(queue.Full): - self._queue.put_nowait(_SENTINEL) - self._thread.join(timeout=timeout) - self._thread = None + self._rotate_on_idle() + + def _on_close(self) -> None: + """Flush and close the live file once the writer has stopped.""" try: if not self._stream.closed: self._stream.flush() @@ -408,87 +577,6 @@ def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: # closing, and the atexit hook. pass - # -- producer side ---------------------------------------------------- - - def emit(self, line: str) -> None: - """Queue one already-formatted line. Never raises, never blocks.""" - if self._closed.is_set(): - self._dropped += 1 - return - try: - self._queue.put_nowait(line) - except queue.Full: - self._dropped += 1 - self._log_failure("audit queue is full", producer=True) - - @property - def stats(self) -> dict[str, int]: - """Counters for ``get_server_configuration_status``. - - ``dropped`` is the number that matters: it is the only way an operator - learns that audit records were lost. - """ - return { - "written": self._written, - "dropped": self._dropped, - "write_errors": self._errors, - } - - # -- writer thread ---------------------------------------------------- - - def _run(self) -> None: - while True: - try: - item = self._queue.get(timeout=_POLL_SECONDS) - except queue.Empty: - # Nothing left to write. Exit only once the sink is closed, - # which is what lets the thread finish when ``close`` could not - # post the sentinel into a full queue. - if self._closed.is_set(): - return - # An idle server still ages its file. Without this, a daily - # rotation on a quiet deployment would not happen until the - # next record arrived — possibly days late, with a day's worth - # of records already in the wrong file. - self._rotate_on_idle() - continue - if item is _SENTINEL: - self._drain_remaining() - return - batch = [item] - # Coalesce whatever else is already waiting into one write+flush. - while len(batch) < _WRITE_BATCH: - try: - nxt = self._queue.get_nowait() - except queue.Empty: - break - if nxt is _SENTINEL: - self._write_batch(batch) - self._drain_remaining() - return - batch.append(nxt) - self._write_batch(batch) - - def _drain_remaining(self) -> None: - """Write anything queued behind the sentinel before the writer exits. - - ``emit`` checks the closed flag and then queues; ``close`` sets the flag - and then posts the sentinel. A record that passes the check just before - the flag is set lands *behind* the sentinel, and without this drain the - writer would return without it — unwritten and uncounted, which is the - one outcome an audit sink must not produce quietly. - """ - leftovers: list[str] = [] - while True: - try: - item = self._queue.get_nowait() - except queue.Empty: - break - if item is not _SENTINEL: - leftovers.append(item) - if leftovers: - self._write_batch(leftovers) - def _reopen_if_closed(self) -> None: """Re-establish the stream if it is not open. @@ -513,13 +601,15 @@ def _reopen_if_closed(self) -> None: self._size = self.path.stat().st_size def _write_batch(self, lines: list[str]) -> None: + buffered = 0 for line in lines: try: + encoded_length = len(line.encode("utf-8")) self._reopen_if_closed() - self._rotate_if_needed(len(line.encode("utf-8"))) + self._rotate_if_needed(encoded_length) self._stream.write(line) - self._size += len(line.encode("utf-8")) - self._written += 1 + self._size += encoded_length + buffered += 1 except (OSError, ValueError) as exc: # ValueError is "I/O operation on closed file": reachable when # ``close`` closes the stream while this thread is still @@ -531,8 +621,16 @@ def _write_batch(self, lines: list[str]) -> None: if not self._stream.closed: self._stream.flush() except (OSError, ValueError) as exc: + # The records counted as buffered above never reached the disk. A + # full disk fails here, not at ``write`` — so counting them as + # written at write time reported a clean audit trail for records + # that were lost, and ``dropped`` is documented as the only way an + # operator discovers a gap. self._errors += 1 + self._dropped += buffered + buffered = 0 self._log_failure(f"failed to flush audit file: {exc}") + self._written += buffered def _interval_is_due(self) -> bool: """Whether the interval has elapsed, skipping the period if nothing was written. @@ -691,7 +789,12 @@ def _existing_backups(self) -> list[Path]: """ parent = self.path.parent try: - candidates = list(parent.glob(f"{self.path.name}.*")) + # The live filename is escaped before it becomes a glob pattern. + # Unescaped, a path like ``audit[1].log`` reads ``[1]`` as a + # character class, matches nothing, and retention silently stops + # pruning — the directory grows without limit while max_backups + # claims to bound it. + candidates = list(parent.glob(f"{glob.escape(self.path.name)}.*")) except OSError: # pragma: no cover - directory vanished mid-rotation return [] return sorted( @@ -717,35 +820,6 @@ def _prune_backups(self, *, keep: Path) -> None: f"failed to delete the old audit backup {path}: {exc}" ) - def _log_failure(self, message: str, *, producer: bool = False) -> None: - """Report a sink failure as an error, rate-limited. - - The first failure is always reported; subsequent ones at most once per - interval, carrying the running totals so the operational log shows the - scale of the gap without being flooded by it. - - The producer side (a full queue) and the writer side (a failed write, - rotation or compression) are rate-limited **separately**. They are - different faults with different fixes, and sharing one timer meant a - burst of queue-full reports could hide a disk failure for a minute. - """ - now = time.monotonic() - last = self._last_producer_error_log if producer else self._last_error_log - if last and now - last < _ERROR_LOG_INTERVAL_SECONDS: - return - if producer: - self._last_producer_error_log = now - else: - self._last_error_log = now - logger.error( - "Audit sink failure: %s (file=%s, dropped=%d, write_errors=%d). " - "Audit records are being lost; the server continues to serve.", - message, - self.path, - self._dropped, - self._errors, - ) - def _with_gz(path: Path) -> Path: """The compressed name for a backup path.""" diff --git a/src/cb_mcp/core/app.py b/src/cb_mcp/core/app.py index d076c59f..c7d037e6 100644 --- a/src/cb_mcp/core/app.py +++ b/src/cb_mcp/core/app.py @@ -55,15 +55,20 @@ def _start_audit( read_only_mode: bool, registered_tool_names: list[str], settings: Mapping[str, Any], -) -> None: +) -> ResolvedAuditConfig: """Initialise auditing and write the two startup records. Emits nothing at all when the server declares no audit package, or when auditing is configured off or its sink could not be opened. + + Returns the configuration **actually in force**, which is not always the + one passed in: a file sink whose path cannot be opened is dropped at this + point. Everything that reports the audit configuration must use the value + returned here, never the requested one. """ audit = init_audit(audit_config) if not audit.active: - return + return audit.config warn_on_unauthenticated_http(transport, oauth_enabled) @@ -97,8 +102,12 @@ def _start_audit( confirmation_required_tools=sorted( settings.get("confirmation_required_tools", ()) ), - audit_config=audit_config.as_dict(), + # The configuration in force, not the one requested: this record is + # permanent, and claiming a file sink that failed to open would tell an + # auditor records were on disk when none were. + audit_config=audit.config.as_dict(), ) + return audit.config def build_app( @@ -143,14 +152,30 @@ def build_app( """ audits = audit_config is not None and spec.audit_package is not None + if audit_config is not None and audit_config.enabled and spec.audit_package is None: + # Silence here is indistinguishable from a broken audit sink. An + # operator who set CB_MCP_AUDIT_LOG_ENABLED=true on this server would + # otherwise find an empty audit file, or none at all, with the only + # signal being ``active: false`` buried in the status tool. + logger.warning( + "Audit logging is enabled but server %r declares no audit package, " + "so no audit records will be written. Auditing currently applies to " + "the operational server only; the settings are accepted and " + "reported so a shared configuration can be used unchanged.", + spec.id, + ) + @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: """Build the lifespan AppContext from host-resolved configuration.""" transport = settings.get("transport") # Audit is initialised first so the server-started record is the first # line in the file and every later record shares its static context. + # What auditing is actually doing, which differs from what was asked + # for when a sink could not be opened. The status tool reports this. + effective_audit_config = audit_config if audits: - _start_audit( + effective_audit_config = _start_audit( spec, audit_config, # type: ignore[arg-type] transport=str(transport), @@ -187,7 +212,9 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: read_only_mode=read_only_mode, logging_config=logging_config, audit_config=( - audit_config.as_dict() if audit_config is not None else None + effective_audit_config.as_dict() + if effective_audit_config is not None + else None ), server_id=spec.id, server_name=spec.fastmcp_name, diff --git a/src/cb_mcp/utils/cli_params.py b/src/cb_mcp/utils/cli_params.py index c755b0b4..b76ef469 100644 --- a/src/cb_mcp/utils/cli_params.py +++ b/src/cb_mcp/utils/cli_params.py @@ -37,7 +37,7 @@ """ from collections.abc import Callable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, NamedTuple import click @@ -338,6 +338,21 @@ def apply(self, *, sdk_log_hook: Callable[[str, int], None] | None) -> None: ) +#: The Click destinations :attr:`CliParams.audit` resolves from. Listed +#: explicitly so the raw params mapping — which also holds the cluster password +#: — is not retained wholesale. +_AUDIT_PARAM_KEYS = ( + "audit_log_enabled", + "audit_log_sinks", + "audit_log_file_path", + "audit_log_rotation_max_size_mb", + "audit_log_rotation_interval", + "audit_log_retention_max_backups", + "audit_log_tool_args", + "audit_log_disabled_events", +) + + @dataclass(frozen=True) class CliParams: """Everything one invocation configured, grouped by the flag stack it came from. @@ -353,8 +368,48 @@ class CliParams: transport: TransportParams gating: GatingParams oauth: OAuthParams - audit: ResolvedAuditConfig embedding: EmbeddingParams + #: Raw audit flags, resolved lazily by :attr:`audit`. + audit_params: Mapping[str, Any] = field(default_factory=dict, repr=False) + #: One-slot cache for :attr:`audit`. A list because this dataclass is + #: frozen: the list is mutated, the field is not reassigned. + _audit_cache: list[ResolvedAuditConfig] = field( + default_factory=list, repr=False, compare=False + ) + + @property + def audit(self) -> ResolvedAuditConfig: + """The resolved audit configuration, computed on **first access**. + + Deliberately not resolved in :meth:`from_click`. Resolution is where + the "file sink selected without a path" error and the tool-args warning + are emitted, and ``from_click`` runs two lines before + ``cli.logging.apply()`` in ``_start_server`` — so eager resolution sent + every one of those diagnostics to ``logging.lastResort`` instead of the + operator's configured sinks, while README and DOCKER.md both promised + "an error in the log". Resolving on first access puts them where they + were documented to be, because the first access is after logging is up. + """ + if not self._audit_cache: + self._audit_cache.append( + resolve_audit_config( + enabled=self.audit_params.get("audit_log_enabled"), + sinks=self.audit_params.get("audit_log_sinks"), + file=self.audit_params.get("audit_log_file_path"), + rotation_max_size_mb=self.audit_params.get( + "audit_log_rotation_max_size_mb" + ), + rotation_interval=self.audit_params.get( + "audit_log_rotation_interval" + ), + max_backups=self.audit_params.get( + "audit_log_retention_max_backups" + ), + tool_args=self.audit_params.get("audit_log_tool_args"), + disabled_events=self.audit_params.get("audit_log_disabled_events"), + ) + ) + return self._audit_cache[0] @classmethod def from_click( @@ -366,22 +421,14 @@ def from_click( transport=TransportParams.from_click(params), gating=GatingParams.from_click(params), oauth=OAuthParams.from_click(params), - # Resolved eagerly like the rest, but note the ordering contract in - # ``_start_server``: ``cli.logging.apply()`` runs before anything - # reads ``cli.audit``, so the "audit enabled without a file" error - # and the tool-args warning land in the operator's configured log - # sinks rather than on the last-resort handler. - audit=resolve_audit_config( - enabled=params.get("audit_log_enabled"), - sinks=params.get("audit_log_sinks"), - file=params.get("audit_log_file_path"), - rotation_max_size_mb=params.get("audit_log_rotation_max_size_mb"), - rotation_interval=params.get("audit_log_rotation_interval"), - max_backups=params.get("audit_log_retention_max_backups"), - tool_args=params.get("audit_log_tool_args"), - disabled_events=params.get("audit_log_disabled_events"), - ), embedding=EmbeddingParams.from_click(params), + # Carried raw, not resolved: see :attr:`audit` for why resolution + # must happen after logging is configured. Narrowed to the audit + # flags rather than the whole mapping, which would keep the cluster + # password alive on this object for the life of the process. + audit_params={ + key: params.get(key) for key in _AUDIT_PARAM_KEYS if key in params + }, ) def resolve_auth(self, spec: ServerSpec) -> AuthProvider | None: diff --git a/tests/unit/test_audit_integration_points.py b/tests/unit/test_audit_integration_points.py index f4aedf39..1e390540 100644 --- a/tests/unit/test_audit_integration_points.py +++ b/tests/unit/test_audit_integration_points.py @@ -28,6 +28,7 @@ import json import logging from types import SimpleNamespace +from typing import cast from unittest.mock import MagicMock, patch import pytest @@ -53,6 +54,7 @@ from cb_mcp.servers.operational.spec import SPEC from cb_mcp.tools.operational.query import ( _query_correlation_options, + run_cluster_query, run_sql_plus_plus_query, ) from cb_mcp.utils.constants import SCOPE_READ, SCOPE_WRITE @@ -605,3 +607,102 @@ async def drive(): stopped = by_name["server stopped"] assert stopped["outcome"] == "error", stopped assert stopped["reason"] == "lifespan_error", stopped + + +# --------------------------------------------------------------------------- +# the correlation id must actually reach the SDK +# --------------------------------------------------------------------------- + + +def _ctx_with(**lifespan) -> Context: + """A Context exposing just the lifespan attributes the query tool reads.""" + return cast( + Context, + SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=SimpleNamespace(**lifespan) + ) + ), + ) + + +def test_scope_query_is_given_the_requests_correlation_id(): + """The MCP-to-Server audit join depends on this argument being passed. + + ``_query_correlation_options`` was tested in isolation, so nothing noticed + whether the tool handed the result to the SDK. Replacing the call with + ``options = {}`` passed the whole suite while the join advertised in + README silently stopped working — and the correlation only exists in + historical data if it was written at the time. + """ + captured: dict = {} + + class FakeScope: + def query(self, statement, **kwargs): + captured["statement"] = statement + captured["kwargs"] = kwargs + return [] + + class FakeBucket: + def scope(self, _name): + return FakeScope() + + cid = "11111111-2222-3333-4444-555555555555" + state_dict = {"cid": cid} + token = audit_state._AUDIT_STATE.set(state_dict) + try: + with ( + patch( + "cb_mcp.tools.operational.query.get_cluster_connection", + return_value=SimpleNamespace(), + ), + patch( + "cb_mcp.tools.operational.query.connect_to_bucket", + return_value=FakeBucket(), + ), + patch( + "cb_mcp.tools.operational.query.get_audit_logger", + return_value=SimpleNamespace(active=False), + ), + patch( + "cb_mcp.tools.operational.query.get_access_token", + return_value=None, + ), + ): + run_sql_plus_plus_query( + _ctx_with(read_only_mode=False), + bucket_name="travel-sample", + scope_name="inventory", + query="SELECT 1", + ) + finally: + audit_state._AUDIT_STATE.reset(token) + + assert captured["kwargs"].get("client_context_id") == cid, captured + + +def test_an_explicit_client_context_id_is_not_overwritten(): + """A caller passing their own id keeps it, as the comment promises.""" + captured: dict = {} + + class FakeCluster: + def query(self, statement, **kwargs): + captured["kwargs"] = kwargs + return [] + + state_dict = {"cid": "audit-minted-id"} + token = audit_state._AUDIT_STATE.set(state_dict) + try: + with patch( + "cb_mcp.tools.operational.query.get_cluster_connection", + return_value=FakeCluster(), + ): + run_cluster_query( + _ctx_with(read_only_mode=False), + "SELECT 1", + client_context_id="callers-own-id", + ) + finally: + audit_state._AUDIT_STATE.reset(token) + + assert captured["kwargs"]["client_context_id"] == "callers-own-id" diff --git a/tests/unit/test_audit_middleware.py b/tests/unit/test_audit_middleware.py index b8f2dca2..efddc851 100644 --- a/tests/unit/test_audit_middleware.py +++ b/tests/unit/test_audit_middleware.py @@ -29,9 +29,12 @@ from __future__ import annotations import asyncio +import contextlib import json from dataclasses import dataclass, field from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch import pytest import uvicorn @@ -41,10 +44,13 @@ from cb_mcp.audit import state as audit_state from cb_mcp.audit.catalog import AuditEvent +from cb_mcp.audit.classification import resolve_tool_call_event from cb_mcp.audit.config import resolve_audit_config from cb_mcp.audit.emitter import AuditLogger from cb_mcp.audit.exceptions import ReadOnlyWriteBlockedError, ScopeDeniedError +from cb_mcp.audit.identity import resolve_real_userid from cb_mcp.audit.middleware import AuditMiddleware, extract_keyspace +from cb_mcp.audit.record import OUTCOME_ERROR from cb_mcp.audit.sink import AuditSink @@ -205,6 +211,87 @@ def upsert_document_by_id( assert calls[0]["required_scope"] == "write" +@pytest.mark.asyncio +async def test_a_cancelled_call_is_still_recorded(tmp_path): + """A caller must not be able to erase a write record by cancelling. + + ``asyncio.CancelledError`` is a ``BaseException``, so an ``except + Exception`` around the tool let it escape with no record at all. The tool + may already have mutated the cluster by then — a client timeout, a dropped + HTTP connection or a shutdown mid-call would leave the write invisible, + and document-write ids are non-filterable precisely so they cannot be + suppressed. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + mutated = {"done": False} + + @mcp.tool + async def upsert_document_by_id( + ctx: Context, bucket_name: str, scope_name: str, collection_name: str + ) -> str: + """mutates, then is slow to answer""" + mutated["done"] = True + await asyncio.sleep(30) + return "never reached" + + async with Client(mcp) as client: + with contextlib.suppress(Exception, asyncio.CancelledError): + await asyncio.wait_for( + client.call_tool( + "upsert_document_by_id", + { + "bucket_name": "travel-sample", + "scope_name": "inventory", + "collection_name": "airline", + }, + ), + timeout=0.5, + ) + await asyncio.sleep(0.2) + + assert mutated["done"], "the probe never reached the mutation" + records = _records(audit) + writes = [r for r in records if r.get("tool_name") == "upsert_document_by_id"] + assert writes, f"the cancelled write left no record: {[r['name'] for r in records]}" + assert writes[0]["id"] == 61522, writes[0] + assert writes[0]["outcome"] == OUTCOME_ERROR + # Write ids are never filterable, so the record cannot be configured away + # either. + assert resolve_tool_call_event("upsert_document_by_id").filterable is False + + +@pytest.mark.asyncio +async def test_malformed_arguments_are_recorded_as_invalid_arguments(tmp_path): + """Bad input and a failed execution are different facts. + + FastMCP validates arguments against the tool's schema before the body runs, + so ``execution_error`` on one of those asserts an execution that never + began — on a record that also carries a keyspace and a required scope taken + from arguments the server refused. The PRD defines ``invalid_arguments`` + for exactly this; it was defined in the code and never emitted. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id( + ctx: Context, bucket_name: str, scope_name: str, collection_name: str + ) -> str: + """never reached""" + raise AssertionError("the tool body must not run") + + async with Client(mcp) as client: + with contextlib.suppress(Exception): + # scope_name and collection_name missing, bucket_name the wrong type + await client.call_tool("get_document_by_id", {"bucket_name": 123}) + + records = [r for r in _records(audit) if r.get("tool_name") == "get_document_by_id"] + assert records, "a rejected call left no record" + assert records[0]["outcome"] == OUTCOME_ERROR + assert records[0]["reason"] == "invalid_arguments", records[0] + + @pytest.mark.asyncio async def test_execution_error_outcome(tmp_path): audit = _build_logger(tmp_path) @@ -365,6 +452,51 @@ def delete_document_by_id(ctx: Context, bucket_name: str) -> str: assert skipped[0]["cid"] == calls[0]["cid"] +@pytest.mark.asyncio +async def test_confirmation_skipped_is_not_claimed_when_the_call_was_blocked(tmp_path): + """57491 asserts the tool executed. It must not fire when a gate stopped it. + + The confirmation wrapper sits outside the tool and the SQL++ gates sit + inside it, so a call can skip confirmation and then be refused anyway. + Emitting both left the trail saying a destructive tool ran unconfirmed + *and* was blocked — and 57491 carries ``outcome=success``, so a reviewer + counting unconfirmed executions would count one that never happened. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def run_sql_plus_plus_query(ctx: Context, bucket_name: str, query: str) -> str: + """skips confirmation, then the read-only gate refuses it""" + audit_state.record_confirmation(audit_state.CONFIRMATION_SKIPPED) + audit_state.record_statement_class("write") + audit_state.record_refusal( + event_id=ReadOnlyWriteBlockedError.audit_event.id, + event_name=ReadOnlyWriteBlockedError.audit_event.event_name, + outcome=ReadOnlyWriteBlockedError.audit_outcome, + reason=ReadOnlyWriteBlockedError.audit_reason, + statement_kind="data", + ) + raise ReadOnlyWriteBlockedError("not allowed in read-only mode") + + async with Client(mcp) as client: + with contextlib.suppress(ToolError): + await client.call_tool( + "run_sql_plus_plus_query", + {"bucket_name": "b", "query": "DELETE FROM airline"}, + ) + + records = _records(audit) + skipped = [r for r in records if r["id"] == AuditEvent.CONFIRMATION_SKIPPED.id] + blocked = [r for r in records if r["id"] == AuditEvent.WRITE_BLOCKED_READ_ONLY.id] + + assert blocked, "the refusal itself must still be recorded" + assert blocked[0]["outcome"] == "blocked" + assert skipped == [], ( + "57491 claims the tool executed without confirmation, but it was blocked" + ) + + @pytest.mark.asyncio async def test_sqlpp_write_statement_uses_the_query_write_id(tmp_path): audit = _build_logger(tmp_path) @@ -712,3 +844,87 @@ def get_document_by_id(ctx: Context, bucket_name: str) -> str: assert calls[0]["cid"] != calls[1]["cid"] for record in records: assert "sid" not in record + + +# --------------------------------------------------------------------------- +# identity on the records that carry it +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_every_record_carries_the_resolved_caller_identity(tmp_path): + """``real_userid`` is the field the whole subsystem exists for. + + The cluster sees one service account for every caller, so this is the only + place the actual caller is recorded. It was asserted on ``session + initialized`` and ``token rejected`` and nowhere else — replacing the + resolved identity with a hardcoded fake passed the entire suite, on every + tool-call and refusal record. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """a plain read""" + return "doc" + + @mcp.tool + def run_sql_plus_plus_query(ctx: Context, bucket_name: str, query: str) -> str: + """refused by the read-only gate""" + audit_state.record_statement_class("write") + audit_state.record_refusal( + event_id=ReadOnlyWriteBlockedError.audit_event.id, + event_name=ReadOnlyWriteBlockedError.audit_event.event_name, + outcome=ReadOnlyWriteBlockedError.audit_outcome, + reason=ReadOnlyWriteBlockedError.audit_reason, + ) + raise ReadOnlyWriteBlockedError("not allowed in read-only mode") + + async with Client(mcp) as client: + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + with contextlib.suppress(ToolError): + await client.call_tool( + "run_sql_plus_plus_query", + {"bucket_name": "b", "query": "UPDATE airline SET x = 1"}, + ) + + records = _records(audit) + # stdio has no authenticated caller, so the honest answer is the OS process + # owner in the ``local`` domain — resolved, not assumed. + expected = resolve_real_userid("stdio") + assert expected["domain"] == "local" + + carriers = [r for r in records if r["id"] in (61490, 57488)] + assert len(carriers) == 2, [r["name"] for r in records] + for record in carriers: + assert record["real_userid"] == expected, record + + +@pytest.mark.asyncio +async def test_an_oauth_caller_is_named_on_the_tool_call_record(tmp_path): + """The HTTP case: the record must name the token's subject, not the server.""" + audit = _build_logger(tmp_path) + mcp = FastMCP("audit-test") + mcp.add_middleware( + AuditMiddleware(transport="http", cb_userid="mcp_service", audit_logger=audit) + ) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """a plain read""" + return "doc" + + token = SimpleNamespace( + claims={"sub": "agent-svc-7"}, client_id="agent-svc-7", scopes=["read"] + ) + with patch("cb_mcp.audit.identity.get_access_token", return_value=token): + async with Client(mcp) as client: + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + + calls = [r for r in _records(audit) if r["id"] == 61490] + assert calls, "no tool-call record" + assert calls[0]["real_userid"] == {"domain": "oauth", "user": "agent-svc-7"} + # The cluster-side account is recorded separately and must not be mistaken + # for the caller. + assert calls[0]["cb_userid"] == "mcp_service" diff --git a/tests/unit/test_audit_record.py b/tests/unit/test_audit_record.py index f64ec1ab..40fed8be 100644 --- a/tests/unit/test_audit_record.py +++ b/tests/unit/test_audit_record.py @@ -237,3 +237,58 @@ def test_prd_sample_records_serialise(record, expected_subset): document = json.loads(record.to_json_line(SERVER)) for key, value in expected_subset.items(): assert document[key] == value + + +# --------------------------------------------------------------------------- +# one record is one line, whatever a caller puts in it +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("char", "name"), + [ + # The three that ``json.dumps`` leaves raw. Everything else + # ``str.splitlines`` splits on is below 0x20, which it already escapes. + ("\u2028", "LINE SEPARATOR"), + ("\u2029", "PARAGRAPH SEPARATOR"), + ("\x85", "NEXT LINE"), + ], +) +def test_a_caller_cannot_split_a_record_into_two_lines(char, name): + """JSON Lines means one record per line, and a caller must not break it. + + ``ensure_ascii=False`` leaves these as raw bytes, and Python's + ``str.splitlines`` — used by this repo's own readers and by many log + shippers — treats every one of them as a line terminator. A bucket or + document name carrying one would split its own record into unparseable + fragments, destroying the entry that recorded the call. Reachable without + any cluster access: the record is written even when the tool fails. + """ + record = AuditRecord( + id=61490, + name="document read", + description="d", + outcome=OUTCOME_SUCCESS, + payload={"ks": f"travel{char}sample.inventory.airline"}, + ) + line = record.to_json_line(ServerContext.detect()) + + assert line.endswith("\n") + assert len(line.splitlines()) == 1, f"{name} split the record" + # Escaped, not stripped: the recorded value still says what the caller sent. + assert json.loads(line)["ks"] == f"travel{char}sample.inventory.airline" + + +def test_escaping_leaves_ordinary_non_ascii_readable(): + """The escape must not undo ``ensure_ascii=False`` for normal text.""" + record = AuditRecord( + id=61490, + name="document read", + description="d", + outcome=OUTCOME_SUCCESS, + payload={"ks": "航空会社.inventory.航空"}, + ) + line = record.to_json_line(ServerContext.detect()) + + assert "航空会社" in line, "non-Latin names must stay readable in the file" + assert json.loads(line)["ks"] == "航空会社.inventory.航空" diff --git a/tests/unit/test_audit_sink.py b/tests/unit/test_audit_sink.py index 8ddf81b2..61ec8862 100644 --- a/tests/unit/test_audit_sink.py +++ b/tests/unit/test_audit_sink.py @@ -27,6 +27,7 @@ from __future__ import annotations +import functools import gzip import io import json @@ -625,12 +626,35 @@ def slow(lines): # --------------------------------------------------------------------------- +class _BrokenWrite(io.StringIO): + """Raises on write, optionally after accepting a few lines first.""" + + def __init__(self, fail_after: int = 0) -> None: + super().__init__() + self._fail_after = fail_after + self._accepted = 0 + + def write(self, text): + if self._accepted >= self._fail_after: + raise OSError("stream is gone") + self._accepted += 1 + return super().write(text) + + +class _BrokenFlush(io.StringIO): + """Buffers writes happily, then fails to flush, like a full disk.""" + + def flush(self): + raise OSError("No space left on device") + + def test_console_sink_writes_lines_to_its_stream(): stream = io.StringIO() sink = ConsoleAuditSink(stream) sink.start() sink.emit('{"a":1}\n') sink.emit('{"b":2}\n') + sink.close(timeout=5.0) assert stream.getvalue() == '{"a":1}\n{"b":2}\n' assert sink.stats == {"written": 2, "dropped": 0, "write_errors": 0} @@ -645,27 +669,93 @@ def test_console_sink_defaults_to_stderr_never_stdout(capsys): sink = ConsoleAuditSink() sink.start() sink.emit('{"audited":true}\n') + sink.close(timeout=5.0) captured = capsys.readouterr() assert captured.err == '{"audited":true}\n' assert captured.out == "" -def test_console_sink_counts_a_broken_stream_rather_than_raising(): - class Broken(io.StringIO): - def write(self, _text): - raise OSError("stream is gone") +def test_console_sink_never_blocks_its_caller_on_a_stalled_stream(): + """The blocker this sink's queue exists for. - sink = ConsoleAuditSink(Broken()) - sink.emit('{"x":1}\n') + ``console`` is the *default* sink, and under stdio its stderr is a pipe + owned by the MCP client. Written inline, a client that stops draining that + pipe blocks the audit write at the 64 KiB buffer — and the caller is the + asyncio event loop, so every in-flight tool call and the JSON-RPC reader + stall with it. Enabling auditing was enough to hang the server. - assert sink.stats["dropped"] == 1 - assert sink.stats["write_errors"] == 1 + The stall is a blocking ``write`` the test controls, rather than a real + pipe: the emitting runs on a worker thread and the stall is released in + teardown, so a regression *fails* here instead of hanging the suite — + which matters when the property under test is "does not block forever". + """ + release = threading.Event() + entered = threading.Event() + + class StalledStream(io.StringIO): + def write(self, text): + entered.set() + release.wait(timeout=30) + return super().write(text) + + sink = ConsoleAuditSink(StalledStream(), queue_size=16) + sink.start() + finished = threading.Event() + + def emit_many() -> None: + for index in range(2000): + sink.emit(json.dumps({"n": index}) + "\n") + finished.set() + + worker = threading.Thread(target=emit_many, daemon=True) + try: + worker.start() + assert entered.wait(timeout=5.0), "the writer never reached the stream" + assert finished.wait(timeout=5.0), ( + "emit() blocked on a stalled stream; the caller is the event loop" + ) + # The records that could not be written are dropped and *counted* — + # that is the whole bargain, and the counter is how an operator finds + # out. Silence would be the unacceptable outcome. + assert sink.stats["dropped"] > 0 + finally: + release.set() + sink.close(timeout=2.0) + + +@pytest.mark.parametrize( + ("stream_factory", "label"), + [ + (_BrokenWrite, "write fails"), + (_BrokenFlush, "flush fails"), + (functools.partial(_BrokenWrite, fail_after=2), "write fails mid-batch"), + ], +) +def test_console_sink_counts_every_lost_record(stream_factory, label): + """A buffered line that is never flushed never reached the console. + + Crediting only the lines not yet handed to ``write`` left the rest + unaccounted — ``written`` at zero and ``dropped`` at zero while records + vanished. ``dropped`` is documented as the only way an operator learns of + a gap, so it has to cover the whole batch. + """ + sink = ConsoleAuditSink(stream_factory()) + sink.start() + for index in range(5): + sink.emit(json.dumps({"n": index}) + "\n") + sink.close(timeout=5.0) + + stats = sink.stats + assert stats["write_errors"] >= 1, label + assert stats["written"] + stats["dropped"] == 5, f"{label}: {stats}" + assert stats["written"] == 0, f"{label}: {stats}" def test_console_sink_emits_whole_lines_under_concurrency(): stream = io.StringIO() sink = ConsoleAuditSink(stream) + sink.start() def worker(worker_id: int) -> None: for index in range(50): @@ -676,6 +766,7 @@ def worker(worker_id: int) -> None: thread.start() for thread in threads: thread.join() + sink.close(timeout=5.0) lines = stream.getvalue().splitlines() assert len(lines) == 300 @@ -867,3 +958,111 @@ def test_prd_retention_cases_end_to_end( survivors = _numbered(sink) assert survivors[-1] == records - 1, "the newest record was not retained" assert survivors == list(range(survivors[0], records)), survivors + + +def test_a_glob_metacharacter_in_the_path_does_not_disable_retention(tmp_path): + """``max_backups`` must bound the directory whatever the path looks like. + + The live filename becomes a glob pattern when backups are listed. Left + unescaped, a path like ``audit[1].log`` reads ``[1]`` as a character class, + matches nothing, and pruning silently stops — the directory grows without + limit while the setting claims to bound it, and the operator has no signal. + """ + line = json.dumps({"n": 0}) + "\n" + sink = AuditSink(tmp_path / "audit[1].log", max_bytes=len(line) * 2, max_backups=2) + sink.start() + for index in range(20): + sink.emit(json.dumps({"n": index}) + "\n") + _drain(sink) + + assert len(sink._existing_backups()) > 0, "the sink cannot see its own backups" + assert len(_backups(sink)) == 2, [p.name for p in _backups(sink)] + + +def test_a_failed_flush_is_counted_as_lost_not_written(tmp_path): + """``dropped`` is documented as the only way an operator learns of a gap. + + ``write`` only fills the stream's buffer; a full disk fails at ``flush``. + Counting a record as written when it was buffered meant the most likely + real failure reported a clean audit trail — ``written`` climbing, ``dropped`` + at zero — for records that never reached the disk. + """ + + class FailingFlush: + """Buffers writes happily, fails to flush, like ENOSPC.""" + + closed = False + + def __init__(self) -> None: + self.written: list[str] = [] + + def write(self, text: str) -> None: + self.written.append(text) + + def flush(self) -> None: + raise OSError("No space left on device") + + def close(self) -> None: + self.closed = True + + sink = AuditSink(tmp_path / "a.log", max_bytes=0, max_backups=0) + sink._stream.close() + sink._stream = FailingFlush() # type: ignore[assignment] + sink.start() + for index in range(5): + sink.emit(json.dumps({"n": index}) + "\n") + _settle(sink) + stats = dict(sink.stats) + sink.close(timeout=5.0) + + assert stats["written"] == 0, f"buffered records reported as written: {stats}" + assert stats["dropped"] == 5, stats + assert stats["write_errors"] >= 1, stats + + +def test_records_abandoned_at_shutdown_are_counted(tmp_path): + """A wedged destination must not make an audit gap invisible. + + ``close`` gives the writer a bounded time to drain, which a wedged disk or + an un-drained console pipe will exceed. Whatever is still queued is lost; + the counters have to say so, because ``dropped`` is the only signal an + operator has. + """ + release = threading.Event() + + class StalledStream(io.StringIO): + def write(self, text): + release.wait(timeout=30) + return super().write(text) + + sink = ConsoleAuditSink(StalledStream(), queue_size=256) + sink.start() + try: + for index in range(200): + sink.emit(json.dumps({"n": index}) + "\n") + sink.close(timeout=0.3) # the writer is stuck in write() + stats = sink.stats + finally: + release.set() + + assert stats["dropped"] > 0, f"abandoned records went uncounted: {stats}" + # The batch the writer is stuck inside is genuinely undecided — it may yet + # land — so the counters must not claim it either way. Everything still in + # the queue, which is the bulk of it, is counted. + assert stats["dropped"] >= 100, stats + assert stats["written"] + stats["dropped"] <= 200, ( + f"counters over-report what was handled: {stats}" + ) + + +def test_records_queued_before_start_are_not_silently_discarded(tmp_path): + """``emit`` before ``start`` used to be a no-op that lost records quietly.""" + stream = io.StringIO() + sink = ConsoleAuditSink(stream) + # Deliberately no start(). + sink.emit('{"early":1}\n') + sink.close(timeout=5.0) + + assert stream.getvalue() == '{"early":1}\n' + assert sink.stats["written"] == 1 + assert sink.stats["dropped"] == 0 diff --git a/tests/unit/test_audit_wiring.py b/tests/unit/test_audit_wiring.py index 73978635..59c95845 100644 --- a/tests/unit/test_audit_wiring.py +++ b/tests/unit/test_audit_wiring.py @@ -19,17 +19,21 @@ import asyncio import json +import logging +import signal from pathlib import Path from unittest.mock import MagicMock, patch import pytest from _all_specs import ALL_SPECS +from cb_mcp.audit import emitter +from cb_mcp.audit.catalog import AuditEvent from cb_mcp.audit.config import resolve_audit_config from cb_mcp.audit.emitter import get_audit_logger, init_audit, shutdown_audit from cb_mcp.audit.sink import process_scoped_path from cb_mcp.auth import CouchbaseJWTVerifier -from cb_mcp.core.app import build_app +from cb_mcp.core.app import _start_audit, build_app def _records(directory: Path) -> list[dict]: @@ -57,6 +61,22 @@ def _audit_config(tmp_path: Path, **overrides): return resolve_audit_config(**options) +def _build_unstarted_app(spec, audit_config, tmp_path): + """Call ``build_app`` without running its lifespan. + + The warning under test is emitted while the app is being built, not while + it runs, which is the point: an operator sees it at startup. + """ + with patch("cb_mcp.core.app.FastMCP", return_value=MagicMock()): + return build_app( + spec, + tools=list(spec.tools.all_tools)[:1], + settings={"transport": "stdio", "oauth_enabled": False}, + provider_factory=MagicMock(), + audit_config=audit_config, + ) + + def _run_lifespan(spec, audit_config, tmp_path): """Build the app for ``spec`` and take its lifespan through a clean cycle.""" captured: dict = {} @@ -223,6 +243,97 @@ async def test_an_accepted_token_emits_no_record(tmp_path): assert [r for r in _records(tmp_path) if r["name"] == "token rejected"] == [] +def test_the_startup_record_states_the_configuration_actually_in_force(tmp_path): + """The permanent record must not claim a file sink that failed to open. + + 57346 is written once and kept for the life of the audit trail. An earlier + version corrected the logger's own copy of the config but still wrote the + *requested* one into this record and onto ``AppContext``, so an auditor — + and ``get_server_configuration_status`` — were told records were going to + disk when the file sink had been dropped. + """ + blocked = tmp_path / "blocked" + blocked.mkdir() + config = _audit_config( + blocked, sinks="console,file", file=str(blocked / "audit.log") + ) + process_scoped_path(config.file).mkdir() + + try: + effective = _start_audit( + next(sp for sp in ALL_SPECS if sp.audit_package is not None), + config, + transport="stdio", + oauth_enabled=False, + read_only_mode=False, + registered_tool_names=["get_document_by_id"], + settings={}, + ) + finally: + shutdown_audit() + + # What _start_audit hands back is what every reporter must use. + assert effective.sinks == ("console",) + assert effective.process_file is None + assert config.sinks == ("console", "file"), "the requested config is unchanged" + + +def test_the_57346_record_and_app_context_both_state_what_is_in_force(tmp_path, capsys): + """The two places the snapshot is actually consumed. + + ``_start_audit`` returning the effective config is only half the fix: the + 57346 record is written once and kept forever, and ``AppContext`` is what + ``get_server_configuration_status`` reports. Asserting only the return + value left both consumers free to use the requested config instead — and + both mutations passed the whole suite, producing a permanent record that + claimed a file sink which had failed to open. + """ + blocked = tmp_path / "blocked" + blocked.mkdir() + config = _audit_config( + blocked, sinks="console,file", file=str(blocked / "audit.log") + ) + process_scoped_path(config.file).mkdir() + spec = next(sp for sp in ALL_SPECS if sp.audit_package is not None) + + captured: dict = {} + + def capture_app(*_args, **kwargs): + captured["lifespan"] = kwargs.get("lifespan") + return MagicMock() + + with patch("cb_mcp.core.app.FastMCP", side_effect=capture_app): + build_app( + spec, + tools=list(spec.tools.all_tools)[:1], + settings={"transport": "stdio", "oauth_enabled": False}, + provider_factory=MagicMock(), + audit_config=config, + ) + + async def run() -> None: + async with captured["lifespan"](MagicMock()) as app_context: + # What the status tool will report. + assert app_context.audit_config["sinks"] == ["console"] + assert app_context.audit_config["process_file"] is None + + try: + asyncio.run(run()) + finally: + shutdown_audit() + + # What the permanent record says. The file sink was dropped, so the record + # went to the surviving console sink — which is itself the point. + emitted = [ + json.loads(line) + for line in capsys.readouterr().err.splitlines() + if line.startswith("{") + ] + configuration = next(r for r in emitted if r["name"] == "server configuration") + assert configuration["audit_config"]["sinks"] == ["console"], configuration + assert configuration["audit_config"]["process_file"] is None, configuration + + def test_a_file_sink_that_cannot_be_opened_is_removed_from_the_snapshot(tmp_path): """The reported configuration must match what is actually being written. @@ -266,3 +377,126 @@ def test_auditing_reports_itself_off_when_every_sink_fails(tmp_path): assert audit.config.as_dict()["enabled"] is False finally: shutdown_audit() + + +def test_sigterm_records_a_clean_shutdown_when_nothing_else_handles_it( + tmp_path, monkeypatch +): + """``docker stop`` under stdio must not look like a crash in the trail. + + SIGTERM's default disposition terminates the process without running + ``atexit`` or unwinding the lifespan, so the most ordinary container + shutdown produced no ``server stopped`` record — while the catalogue says a + missing one means the shutdown was not clean. Every restart would have read + as an unclean one, which makes the signal useless for finding a real one. + """ + config = _audit_config(tmp_path, sinks="file") + killed: list[int] = [] + + audit = init_audit(config) + assert audit.active + try: + # SIG_DFL is the stdio case: nothing else will unwind anything. + monkeypatch.setattr(emitter, "_previous_sigterm", signal.SIG_DFL) + monkeypatch.setattr(emitter.signal, "signal", lambda *_a: None) + monkeypatch.setattr(emitter.os, "kill", lambda _pid, sig: killed.append(sig)) + emitter._handle_sigterm(signal.SIGTERM, None) + finally: + shutdown_audit() + + records = _records(tmp_path) + names = [r["name"] for r in records] + assert "server stopped" in names, names + stopped = next(r for r in records if r["name"] == "server stopped") + assert stopped["outcome"] == "success" + # Says *how* it stopped, without claiming a failure. + assert stopped["shutdown_signal"] == "SIGTERM" + assert "reason" not in stopped + # Still exits by the signal, so the status code does not pretend it was a + # normal exit. + assert killed == [signal.SIGTERM] + + +def test_sigterm_hands_over_without_closing_when_another_handler_owns_it( + tmp_path, monkeypatch +): + """Under http, uvicorn drains in-flight requests *after* its handler runs. + + Closing the audit trail before handing over left every call still running + unrecorded — the middleware reads the process-wide logger, finds it + inactive and emits nothing, not even a dropped count. A write cancelled by + a rolling restart would vanish, which is exactly the suppression that + catching ``BaseException`` around the tool exists to prevent. The lifespan + emits ``server stopped`` when uvicorn unwinds it, so handing over loses + nothing. + """ + config = _audit_config(tmp_path, sinks="file") + chained: list[int] = [] + + audit = init_audit(config) + assert audit.active + try: + monkeypatch.setattr( + emitter, "_previous_sigterm", lambda signum, _frame: chained.append(signum) + ) + emitter._handle_sigterm(signal.SIGTERM, None) + + # The whole point: auditing is still running, so records arriving + # during the drain are still written. + assert chained == [signal.SIGTERM] + assert get_audit_logger().active is True + get_audit_logger().emit_event(AuditEvent.SESSION_INITIALIZED, outcome="success") + finally: + shutdown_audit() + + names = [r["name"] for r in _records(tmp_path)] + assert "session initialized" in names, ( + f"a record arriving during the drain was lost: {names}" + ) + + +def test_the_sigterm_handler_is_installed_only_when_auditing_runs( + tmp_path, monkeypatch +): + """A server with auditing off must not touch the signal disposition.""" + monkeypatch.setattr(emitter, "_sigterm_installed", False) + monkeypatch.setattr(emitter, "_previous_sigterm", None) + original = signal.getsignal(signal.SIGTERM) + try: + init_audit(_audit_config(tmp_path, enabled=False)) + assert signal.getsignal(signal.SIGTERM) is original + assert emitter._sigterm_installed is False + + init_audit(_audit_config(tmp_path, sinks="file")) + assert signal.getsignal(signal.SIGTERM) is emitter._handle_sigterm + finally: + shutdown_audit() + signal.signal(signal.SIGTERM, original) + + +def test_enabling_auditing_on_an_unaudited_server_warns(tmp_path, caplog): + """Silence is indistinguishable from a broken sink. + + The Operational Insights server accepts every audit flag and reports it, + but records nothing. An operator who enabled auditing there would find an + empty directory, with the only signal being ``active: false`` buried in the + status tool. + """ + unaudited = next(sp for sp in ALL_SPECS if sp.audit_package is None) + config = _audit_config(tmp_path, sinks="file") + + with caplog.at_level(logging.WARNING): + _build_unstarted_app(unaudited, config, tmp_path) + + assert "declares no audit package" in caplog.text + assert unaudited.id in caplog.text + + +def test_an_audited_server_does_not_warn(tmp_path, caplog): + audited = next(sp for sp in ALL_SPECS if sp.audit_package is not None) + config = _audit_config(tmp_path, sinks="file") + + with caplog.at_level(logging.WARNING): + _build_unstarted_app(audited, config, tmp_path) + + assert "declares no audit package" not in caplog.text diff --git a/tests/unit/test_cli_options.py b/tests/unit/test_cli_options.py index 0e90084a..19cdbf73 100644 --- a/tests/unit/test_cli_options.py +++ b/tests/unit/test_cli_options.py @@ -21,7 +21,13 @@ import mcp_server from cb_mcp.auth import OAuthConfigError, resolve_oauth -from cb_mcp.utils.constants import LOGGER_ROOT, SCOPE_READ, SCOPE_WRITE +from cb_mcp.utils.cli_params import CliParams, CredentialProfile +from cb_mcp.utils.constants import ( + LOGGER_NAMESPACE, + LOGGER_ROOT, + SCOPE_READ, + SCOPE_WRITE, +) @pytest.fixture(autouse=True) @@ -340,3 +346,83 @@ def test_both_subcommands_accept_the_audit_flags(self): "--audit-log-disabled-events", ): assert flag in result.output, f"{flag} missing from {command} --help" + + +class TestAuditDiagnosticOrdering: + """Audit diagnostics must reach the operator's configured log sinks. + + ``README`` and ``DOCKER.md`` both promise "an error in the log" when the + file sink is selected without a path. Resolving the audit configuration + inside ``from_click`` emitted that error two lines before + ``cli.logging.apply()`` ran, so it went to ``logging.lastResort`` on raw + stderr while the per-level log files stayed empty — the documented + behaviour was simply not happening. + """ + + @staticmethod + def _params(**overrides) -> dict: + """A complete Click params mapping, as the real entrypoint receives it.""" + captured: dict = {} + + def capture(params, *, credentials): + captured["params"] = dict(params) + raise SystemExit(0) + + with patch.object(CliParams, "from_click", side_effect=capture): + CliRunner().invoke( + mcp_server.main, + [ + "operational", + "--connection-string", + "couchbase://localhost", + "--username", + "u", + "--password", + "p", + ], + env=dict(os.environ), + catch_exceptions=True, + ) + params = captured["params"] + params.update(overrides) + return params + + def test_resolution_is_deferred_until_first_access(self): + emitted: list[str] = [] + + class Capture(logging.Handler): + def emit(self, record): + emitted.append(record.getMessage()) + + params = self._params( + audit_log_enabled=True, + audit_log_sinks="file", + audit_log_file_path=None, + ) + handler = Capture() + audit_logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.config") + audit_logger.addHandler(handler) + try: + cli = CliParams.from_click( + params, + credentials=CredentialProfile(options=lambda f: f, settings_keys=()), + ) + # Nothing logged yet: this is the window in which the real startup + # path configures logging. + assert emitted == [], emitted + + config = cli.audit + finally: + audit_logger.removeHandler(handler) + + assert config.enabled is False + assert any("no audit file path is configured" in m for m in emitted), emitted + + def test_the_resolved_configuration_is_cached(self): + params = self._params(audit_log_enabled=True, audit_log_sinks="console") + cli = CliParams.from_click( + params, credentials=CredentialProfile(options=lambda f: f, settings_keys=()) + ) + # Resolution warns; repeating it per access would duplicate every + # diagnostic and re-derive the process-scoped path. + assert cli.audit is cli.audit diff --git a/tests/unit/test_server_configuration_status_tool.py b/tests/unit/test_server_configuration_status_tool.py index 74f053c9..aeadfa02 100644 --- a/tests/unit/test_server_configuration_status_tool.py +++ b/tests/unit/test_server_configuration_status_tool.py @@ -14,10 +14,44 @@ from fastmcp import Context +from cb_mcp.audit import emitter +from cb_mcp.audit.config import resolve_audit_config +from cb_mcp.audit.emitter import AuditLogger, shutdown_audit from cb_mcp.tools.status import get_server_configuration_status -def _make_ctx(settings=None, cluster_provider=None, logging_config=None) -> Context: +class _FakeSink: + """A sink that only has to report counters.""" + + def __init__(self, *, written: int, dropped: int, write_errors: int) -> None: + self._stats = { + "written": written, + "dropped": dropped, + "write_errors": write_errors, + } + + def start(self) -> None: + pass + + def emit(self, line: str) -> None: + pass + + def close(self, timeout: float = 5.0) -> None: + pass + + @property + def stats(self) -> dict[str, int]: + return dict(self._stats) + + +def init_audit_for_test(config, sink) -> None: + """Install an active logger without touching the filesystem.""" + emitter._active = AuditLogger(config, sink=sink) + + +def _make_ctx( + settings=None, cluster_provider=None, logging_config=None, audit_config=None +) -> Context: # SimpleNamespace duck-types the bits get_server_configuration_status # actually reads (request_context.lifespan_context.{settings, # cluster_provider, logging_config}); the cast tells pyright that's @@ -32,6 +66,7 @@ def _make_ctx(settings=None, cluster_provider=None, logging_config=None) -> Cont cluster_provider=cluster_provider, settings=settings if settings is not None else {}, logging_config=logging_config, + audit_config=audit_config, ) ) ), @@ -178,3 +213,75 @@ def test_logging_block_alongside_existing_configuration_keys(): assert "connections" in payload # logging is NOT inside configuration — it's a top-level peer. assert "logging" not in payload["configuration"] + + +# --------------------------------------------------------------------------- +# the audit block +# --------------------------------------------------------------------------- + + +def test_audit_block_reports_the_configuration_and_the_loss_counters(): + """The counters here are the only way an operator learns records were lost. + + README and DOCKER.md both document ``written`` / ``dropped`` / + ``write_errors`` as that signal, and the whole block was untested: + dropping ``audit_config`` from the lifespan context, or making + ``get_audit_config`` return ``None``, passed the entire suite while the + status tool silently stopped reporting auditing at all. + """ + config = resolve_audit_config( + enabled=True, + sinks="file", + file="/var/log/cb-mcp/audit.log", + rotation_max_size_mb=10, + rotation_interval="1d", + max_backups=7, + tool_args=None, + disabled_events=None, + ) + ctx = _make_ctx({}, audit_config=config.as_dict()) + + sink = _FakeSink(written=10427, dropped=3, write_errors=1) + try: + init_audit_for_test(config, sink) + payload = get_server_configuration_status(ctx) + finally: + shutdown_audit() + + audit = payload["audit"] + assert audit["enabled"] is True + assert audit["sinks"] == ["file"] + assert audit["rotation_interval"] == "1d" + assert audit["max_backups"] == 7 + assert audit["active"] is True + # The number that matters: a non-zero ``dropped`` is a gap in the trail. + assert audit["stats"] == {"written": 10427, "dropped": 3, "write_errors": 1} + + +def test_audit_block_says_inactive_when_nothing_is_being_written(): + config = resolve_audit_config( + enabled=True, + sinks="file", + file="/var/log/cb-mcp/audit.log", + rotation_max_size_mb=None, + rotation_interval="0", + max_backups=1, + tool_args=None, + disabled_events=None, + ) + ctx = _make_ctx({}, audit_config=config.as_dict()) + + payload = get_server_configuration_status(ctx) + audit = payload["audit"] + + # No logger installed: enabled by configuration, inactive in fact. An + # operator must be able to tell those apart. + assert audit["enabled"] is True + assert audit["active"] is False + + +def test_a_server_without_auditing_reports_a_null_audit_block(): + """``None`` is the right answer both for auditing off and for a host that + never adopted it: in neither case is anything being recorded.""" + payload = get_server_configuration_status(_make_ctx({})) + assert payload["audit"] is None From f5634055d78570bd26bc519007186dc3b863d132 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Fri, 9 Oct 2026 17:27:31 +0530 Subject: [PATCH 14/27] Update sync up --- src/cb_mcp/audit/classification.py | 3 +++ tests/unit/test_audit_classification.py | 1 + tests/unit/test_audit_integration_points.py | 7 ++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/cb_mcp/audit/classification.py b/src/cb_mcp/audit/classification.py index 6ff362f0..e40d7e19 100644 --- a/src/cb_mcp/audit/classification.py +++ b/src/cb_mcp/audit/classification.py @@ -61,6 +61,9 @@ # like their neighbours above. "get_cluster_tasks": ("cluster", "read"), "get_cluster_health_snapshot": ("cluster", "read"), + # The cluster's own event log via GET /events — a server-level read, + # like its neighbours above. + "get_cluster_system_events": ("cluster", "read"), # Reads bundled reference data rather than the cluster, so it touches # no keyspace and has no service of its own. Booked as a cluster read: # it is a server-level informational call, which is what that category diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py index 969a5d90..8c56faaa 100644 --- a/tests/unit/test_audit_classification.py +++ b/tests/unit/test_audit_classification.py @@ -93,6 +93,7 @@ def test_read_only_tools_are_classified_read(tool_name): ("test_cluster_connection", 61488), ("get_cluster_tasks", 61488), ("get_cluster_health_snapshot", 61488), + ("get_cluster_system_events", 61488), ("get_buckets_in_cluster", 61489), ("get_schema_for_collection", 61489), ("create_scope", 61521), diff --git a/tests/unit/test_audit_integration_points.py b/tests/unit/test_audit_integration_points.py index 1e390540..5901eea8 100644 --- a/tests/unit/test_audit_integration_points.py +++ b/tests/unit/test_audit_integration_points.py @@ -339,7 +339,12 @@ def test_unparseable_statement_still_runs_when_writes_are_allowed_and_auditing_o ) # Executed rather than raised, and booked against the never-filtered # query write id so a possible mutation is never dropped from the log. - assert result == [] + # Asserted on the outcome rather than the whole payload: what this test + # is about is that the call ran, not what the query tool's result looks + # like, and pinning the full shape made it a casualty of an unrelated + # change to that shape. + assert result["success"] is True + assert result["rows"] == [] assert audit_state.get_statement_class() == "write" finally: audit_state.reset(token) From 47aa7fa3ca8aa76ea0b06bd6e658eeb5756b8d8c Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Fri, 9 Oct 2026 17:54:34 +0530 Subject: [PATCH 15/27] Aikido Fix for auditability --- src/cb_mcp/audit/identity.py | 55 ++++++++++++++++----- tests/unit/test_audit_integration_points.py | 43 ++++++++++++++++ 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/cb_mcp/audit/identity.py b/src/cb_mcp/audit/identity.py index 8ebb43f3..792edf0e 100644 --- a/src/cb_mcp/audit/identity.py +++ b/src/cb_mcp/audit/identity.py @@ -9,7 +9,10 @@ * ``oauth`` — Streamable HTTP with OAuth active. The user is the bearer token's subject. -* ``local`` — stdio. The user is the OS process owner. +* ``local`` — stdio. The user is the OS process owner, resolved from the + effective uid rather than the environment: under stdio the client launches + the server and therefore controls its environment, so anything an + environment variable could set is not an identity. * ``anonymous`` — HTTP without OAuth. There is no caller authentication, so there is no identity to record. @@ -30,6 +33,11 @@ import logging import os +try: # pragma: no cover - import guard, exercised by platform not by test + import pwd +except ImportError: # pragma: no cover - Windows + pwd = None # type: ignore[assignment] + from fastmcp.server.dependencies import get_access_token from ..utils.constants import LOGGER_NAMESPACE, STREAMABLE_HTTP_TRANSPORT @@ -45,20 +53,41 @@ def _process_owner() -> str: - """Best-effort OS process owner. - - ``getpass.getuser`` consults the environment before the password database - and raises on a container with no matching passwd entry and no ``USER`` set, - so both are guarded. + """Best-effort OS process owner, resolved from the kernel where possible. + + The value lands in ``real_userid.user`` for every stdio record, which is + the access-decision identity an auditor reads. It must therefore come from + something the caller cannot set. + + ``getpass.getuser`` cannot be that source: it consults ``LOGNAME``, + ``USER``, ``LNAME`` and ``USERNAME`` *before* the password database, so a + caller who launches the server with ``USER=someone-else`` writes that name + into every record. On POSIX the effective uid is asked of the kernel and + mapped through the password database instead; ``getpass`` remains the + fallback on Windows, which has no ``pwd`` module and no such environment + precedence problem in the same form. + + A container with no matching passwd entry raises :class:`KeyError`, so the + numeric uid is the last resort before :data:`UNKNOWN_USER`. """ + if pwd is not None: # POSIX + try: + return pwd.getpwuid(os.geteuid()).pw_name + except Exception: + logger.debug( + "Could not map the effective uid to a passwd entry; " + "falling back to the numeric uid", + exc_info=True, + ) + else: # pragma: no cover - Windows has no pwd module + try: + return getpass.getuser() + except Exception: + logger.debug("getpass.getuser() failed", exc_info=True) + return UNKNOWN_USER + try: - return getpass.getuser() - except Exception: - logger.debug("getpass.getuser() failed; falling back to uid", exc_info=True) - try: - return str(os.getuid()) # type: ignore[attr-defined] - except AttributeError: # pragma: no cover - Windows has no getuid - return UNKNOWN_USER + return str(os.geteuid()) except Exception: # pragma: no cover - defensive return UNKNOWN_USER diff --git a/tests/unit/test_audit_integration_points.py b/tests/unit/test_audit_integration_points.py index 5901eea8..7cc9c2f0 100644 --- a/tests/unit/test_audit_integration_points.py +++ b/tests/unit/test_audit_integration_points.py @@ -20,6 +20,7 @@ - SQL++ statement class is recorded for audit when auditing is active - SQL++ classification is not computed when auditing is off (no added cost) - identity resolution across the three domains +- the local identity comes from the kernel, not the caller's environment """ from __future__ import annotations @@ -27,6 +28,8 @@ import asyncio import json import logging +import os +import pwd from types import SimpleNamespace from typing import cast from unittest.mock import MagicMock, patch @@ -449,6 +452,46 @@ def test_stdio_resolves_to_the_local_process_owner(): assert identity["user"] +def test_local_identity_ignores_the_caller_controlled_environment(): + """``real_userid`` must not be settable by whoever launches the server. + + Under stdio the client *is* the launcher — Claude Desktop, Cursor, a shell + script — so it controls the process environment. ``getpass.getuser()`` + reads ``LOGNAME``/``USER``/``LNAME``/``USERNAME`` before the password + database, which would let that launcher write any name it liked into the + access-decision field of every record. The uid comes from the kernel. + """ + real_name = pwd.getpwuid(os.geteuid()).pw_name + spoofed = { + "LOGNAME": "alice", + "USER": "alice", + "LNAME": "alice", + "USERNAME": "alice", + } + with ( + patch.dict(os.environ, spoofed), + patch("cb_mcp.audit.identity.get_access_token", return_value=None), + ): + identity = resolve_real_userid("stdio") + assert identity == {"domain": DOMAIN_LOCAL, "user": real_name} + assert identity["user"] != "alice" + + +def test_local_identity_falls_back_to_the_numeric_uid_without_a_passwd_entry(): + """Containers routinely run as a uid with no passwd entry. + + A number is a worse identity than a name but an honest one; failing the + call, or silently taking the environment's word for it, would both be + worse. + """ + with ( + patch("cb_mcp.audit.identity.pwd.getpwuid", side_effect=KeyError(1001)), + patch("cb_mcp.audit.identity.get_access_token", return_value=None), + ): + identity = resolve_real_userid("stdio") + assert identity == {"domain": DOMAIN_LOCAL, "user": str(os.geteuid())} + + def test_http_without_a_token_is_anonymous(): with patch("cb_mcp.audit.identity.get_access_token", return_value=None): identity = resolve_real_userid("http") From be2d758025e0affe19493b7060d370ad5fd40faa Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Fri, 9 Oct 2026 17:59:39 +0530 Subject: [PATCH 16/27] Review comments --- src/cb_mcp/audit/emitter.py | 20 ++++++++++++++------ tests/unit/test_audit_wiring.py | 28 +++++++++++++++++++++++++--- tests/unit/test_cli_options.py | 9 +++++++-- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/cb_mcp/audit/emitter.py b/src/cb_mcp/audit/emitter.py index c9ef5767..3b901818 100644 --- a/src/cb_mcp/audit/emitter.py +++ b/src/cb_mcp/audit/emitter.py @@ -273,7 +273,6 @@ def _shutdown_at_exit() -> None: #: The SIGTERM handler that was in place before auditing installed its own, so #: it can still run. ``None`` means nothing has been installed yet. _previous_sigterm: Any = None -_sigterm_installed = False #: How long to wait for the audit trail to close on a SIGTERM that nothing else #: is handling. Bounded because a shutdown must not hang; short because the @@ -349,12 +348,23 @@ def _install_sigterm_handler() -> None: ``signal.signal`` only works on the main thread of the main interpreter, so an embedding host that builds the app on a worker thread simply does not get this; auditing is unaffected otherwise. + + "Already installed" is read from the live signal disposition rather than + tracked in a module flag. The flag was a second copy of state the ``signal`` + module already owns, and the two could disagree: a host that installed its + own handler after ours would leave the flag saying "installed" while the + disposition said otherwise, and ours would never be restored. Reading the + disposition cannot drift — and it is what makes the guard below load-bearing + rather than an optimisation. """ - global _sigterm_installed, _previous_sigterm # noqa: PLW0603 - if _sigterm_installed: - return + global _previous_sigterm # noqa: PLW0603 try: previous = signal.getsignal(signal.SIGTERM) + if previous is _handle_sigterm: + # Ours is already in place. Falling through would record this very + # function as its own predecessor, and the next SIGTERM would + # recurse into it until the stack ran out. + return # Published before the handler is installed: a SIGTERM delivered # between the two would otherwise read ``None`` and take the default # path, skipping the handler that actually owns the shutdown. @@ -366,8 +376,6 @@ def _install_sigterm_handler() -> None: "will not record a 'server stopped' event.", exc_info=True, ) - return - _sigterm_installed = True __all__ = [ diff --git a/tests/unit/test_audit_wiring.py b/tests/unit/test_audit_wiring.py index 59c95845..1fa6892d 100644 --- a/tests/unit/test_audit_wiring.py +++ b/tests/unit/test_audit_wiring.py @@ -459,13 +459,12 @@ def test_the_sigterm_handler_is_installed_only_when_auditing_runs( tmp_path, monkeypatch ): """A server with auditing off must not touch the signal disposition.""" - monkeypatch.setattr(emitter, "_sigterm_installed", False) monkeypatch.setattr(emitter, "_previous_sigterm", None) original = signal.getsignal(signal.SIGTERM) + signal.signal(signal.SIGTERM, signal.SIG_DFL) try: init_audit(_audit_config(tmp_path, enabled=False)) - assert signal.getsignal(signal.SIGTERM) is original - assert emitter._sigterm_installed is False + assert signal.getsignal(signal.SIGTERM) is signal.SIG_DFL init_audit(_audit_config(tmp_path, sinks="file")) assert signal.getsignal(signal.SIGTERM) is emitter._handle_sigterm @@ -474,6 +473,29 @@ def test_the_sigterm_handler_is_installed_only_when_auditing_runs( signal.signal(signal.SIGTERM, original) +def test_installing_twice_does_not_chain_the_handler_to_itself(tmp_path): + """The install guard is correctness, not an optimisation. + + ``_handle_sigterm`` hands over to ``_previous_sigterm`` whenever it is + callable. If a second install captured the already-installed handler as its + own predecessor, the next SIGTERM would call itself until the stack ran + out — on the one path that exists to make shutdown *more* reliable. + """ + original = signal.getsignal(signal.SIGTERM) + signal.signal(signal.SIGTERM, signal.SIG_DFL) + try: + init_audit(_audit_config(tmp_path, sinks="file")) + shutdown_audit() + init_audit(_audit_config(tmp_path, sinks="file")) + + assert signal.getsignal(signal.SIGTERM) is emitter._handle_sigterm + assert emitter._previous_sigterm is not emitter._handle_sigterm + assert emitter._previous_sigterm is signal.SIG_DFL + finally: + shutdown_audit() + signal.signal(signal.SIGTERM, original) + + def test_enabling_auditing_on_an_unaudited_server_warns(tmp_path, caplog): """Silence is indistinguishable from a broken sink. diff --git a/tests/unit/test_cli_options.py b/tests/unit/test_cli_options.py index 19cdbf73..df06c683 100644 --- a/tests/unit/test_cli_options.py +++ b/tests/unit/test_cli_options.py @@ -424,5 +424,10 @@ def test_the_resolved_configuration_is_cached(self): params, credentials=CredentialProfile(options=lambda f: f, settings_keys=()) ) # Resolution warns; repeating it per access would duplicate every - # diagnostic and re-derive the process-scoped path. - assert cli.audit is cli.audit + # diagnostic and re-derive the process-scoped path. Two named locals + # rather than `cli.audit is cli.audit`: the latter reads as a comparison + # of identical expressions, which static analysis flags and a reader has + # to pause over. + first = cli.audit + second = cli.audit + assert first is second From f3f6a165db88dceed6d840cfb2d7c30b2505c085 Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 12 Oct 2026 07:45:18 +0000 Subject: [PATCH 17/27] DA-2038: classify the tools added by #303 and #308 create_query_index takes the index write id (61524) its deprecated predecessor create_index already used, so superseding the tool does not move the id a SIEM rule watches. The N1QL Admin REST tools are booked as performance rather than query: 'query read' means a caller ran a statement, and a reviewer reconstructing what was executed must not find listings of other callers' in-flight requests mixed into it. delete_active_query is the first performance write (61525), taking an id the catalogue already reserved. Without these entries the fail-closed path recorded all four against a write id flagged unclassified, which is why two classification tests were failing on the branch tip. --- src/cb_mcp/audit/classification.py | 23 +++++++++++++++++++++++ tests/unit/test_audit_classification.py | 7 +++++++ 2 files changed, 30 insertions(+) diff --git a/src/cb_mcp/audit/classification.py b/src/cb_mcp/audit/classification.py index e40d7e19..d66e81d2 100644 --- a/src/cb_mcp/audit/classification.py +++ b/src/cb_mcp/audit/classification.py @@ -100,6 +100,13 @@ "get_index_advisor_recommendations": ("index", "read"), "get_index_stats": ("index", "read"), "create_index": ("index", "write"), + # The replacement for the deprecated create_index (#303), creating + # scalar *and* vector GSI indexes through SQL++. It takes the same id: + # both create a GSI index, and an id that changed when a tool was + # superseded would silently break every SIEM rule watching for index + # creation. The retired `vector` slot (6) is deliberately not revived + # for it — see catalog.next_free_slot. + "create_query_index": ("index", "write"), "build_index": ("index", "write"), "drop_index": ("index", "write"), # -- performance ----------------------------------------------------- @@ -110,6 +117,22 @@ "get_queries_using_primary_index": ("performance", "read"), "get_queries_not_using_covering_index": ("performance", "read"), "get_queries_not_selective": ("performance", "read"), + # The N1QL Admin REST API (/admin/vitals, /admin/active_requests) added + # in #308. Booked as performance rather than query: `query read` means + # "a caller ran a statement", and a reviewer reconstructing what was + # executed must not find listings of other people's requests mixed into + # it. Vitals is the query service's own load picture — request rate, + # active and queued counts — which is what this category already means; + # get_cluster_metrics stays a cluster read because it covers node + # hardware rather than the N1QL layer. + "get_cluster_query_vitals": ("performance", "read"), + "get_active_queries": ("performance", "read"), + # The first performance *write*, taking the id (61525) the catalogue + # reserved when the category was allocated — a map entry, no new id and + # no SIEM-rule change. It is also the only tool in the server that + # terminates another caller's work, so it must be non-filterable, which + # the write class makes it by construction. + "delete_active_query": ("performance", "write"), # -- search (FTS) ------------------------------------------------------ # Its own category rather than folded into index: run_fts_query is a # query, so a reviewer filtering on `index read` should not get search diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py index 8c56faaa..0db6e5ca 100644 --- a/tests/unit/test_audit_classification.py +++ b/tests/unit/test_audit_classification.py @@ -102,9 +102,16 @@ def test_read_only_tools_are_classified_read(tool_name): ("run_sql_plus_plus_query", 61491), ("list_indexes", 61492), ("create_index", 61524), + # Superseding create_index must not move the id a SIEM rule watches. + ("create_query_index", 61524), ("drop_index", 61524), ("get_longest_running_queries", 61493), ("get_queries_not_selective", 61493), + ("get_cluster_query_vitals", 61493), + ("get_active_queries", 61493), + # The first tool to take the performance write id, and the only one in + # the server that cancels another caller's in-flight work. + ("delete_active_query", 61525), # Search keeps its own category rather than folding into index: a # reviewer filtering on `index read` must not get FTS traffic, and # `run_fts_query` is a query, not an index operation. From 7c643b2a1035594af7b22b6dc2a9c3fdcc995187 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Mon, 12 Oct 2026 13:32:49 +0530 Subject: [PATCH 18/27] Fix elicitation changes --- tests/unit/test_confirmation_tools.py | 183 ++++++++++++++++---------- 1 file changed, 113 insertions(+), 70 deletions(-) diff --git a/tests/unit/test_confirmation_tools.py b/tests/unit/test_confirmation_tools.py index 2e8df6da..7e20bfd8 100644 --- a/tests/unit/test_confirmation_tools.py +++ b/tests/unit/test_confirmation_tools.py @@ -10,19 +10,16 @@ - capability detection and fallback when elicitation is not advertised - fail-closed behavior for unexpected runtime errors - positional argument forwarding compatibility - - that the fakes below have not drifted from the real FastMCP signature """ import inspect from types import SimpleNamespace import pytest -from fastmcp import Context -from fastmcp.server.elicitation import ( - AcceptedElicitation, - CancelledElicitation, - DeclinedElicitation, -) +from fastmcp import Client, Context, FastMCP +from fastmcp.client.elicitation import ElicitResult +from fastmcp.exceptions import ToolError +from fastmcp.tools import FunctionTool from cb_mcp.tools.operational import TOOL_ANNOTATIONS, get_tools from cb_mcp.utils.config import parse_tool_names @@ -183,8 +180,12 @@ def __init__(self): session=FakeSession(supports_elicitation), ) - async def elicit(self, message, response_type): - return await elicit_callback(message, response_type) + async def elicit(self, **kwargs): + # Bind against FastMCP's real signature, so a keyword the + # real Context.elicit does not accept fails here exactly as + # it would in production (it once used ``schema=``). + inspect.signature(Context.elicit).bind(self, **kwargs) + return await elicit_callback(kwargs["message"], kwargs["response_type"]) return FakeContext() @@ -199,7 +200,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def decline_elicit(message, response_type): + async def decline_elicit(message, schema): return SimpleNamespace(action="decline") fake_ctx = self._make_context( @@ -224,7 +225,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def cancel_elicit(message, response_type): + async def cancel_elicit(message, schema): return SimpleNamespace(action="cancel") fake_ctx = self._make_context( @@ -249,7 +250,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def reject_confirm_elicit(message, response_type): + async def reject_confirm_elicit(message, schema): return SimpleNamespace( action="accept", data=SimpleNamespace(confirm=False), @@ -277,7 +278,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def accept_elicit(message, response_type): + async def accept_elicit(message, schema): return SimpleNamespace( action="accept", data=SimpleNamespace(confirm=True), @@ -303,7 +304,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def should_not_be_called_elicit(message, response_type): + async def should_not_be_called_elicit(message, schema): raise AssertionError("elicit should not be called without client support") fake_ctx = self._make_context( @@ -325,7 +326,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def transient_error_elicit(message, response_type): + async def transient_error_elicit(message, schema): raise RuntimeError("Transient elicitation failure") fake_ctx = self._make_context( @@ -351,7 +352,7 @@ def sample_tool( wrapped = wrap_with_confirmation(sample_tool) - async def accept_elicit(message, response_type): + async def accept_elicit(message, schema): return SimpleNamespace( action="accept", data=SimpleNamespace(confirm=True), @@ -385,7 +386,7 @@ def __init__(self): session=None, ) - async def elicit(self, message, response_type): + async def elicit(self, message, schema): raise AssertionError("elicit must not be called when session is None") result = await wrapped(ctx=FakeContext()) @@ -409,7 +410,7 @@ def __init__(self): session=SimpleNamespace(), # no check_client_capability ) - async def elicit(self, message, response_type): + async def elicit(self, message, schema): raise AssertionError( "elicit must not be called when capability check is unavailable" ) @@ -429,7 +430,7 @@ async def async_sample_tool(ctx: Context) -> bool: wrapped = wrap_with_confirmation(async_sample_tool) - async def accept_elicit(message, response_type): + async def accept_elicit(message, schema): return SimpleNamespace( action="accept", data=SimpleNamespace(confirm=True), @@ -445,62 +446,104 @@ async def accept_elicit(message, response_type): assert called is True -class TestElicitSignatureContract: - """The fakes in this file must not drift from the real FastMCP API. +class TestConfirmationPrompt: + """What the user is shown and asked to fill in when confirming a tool call. + + Coverage: + - The confirmation message names the tool and its key arguments. + - ``ConfirmationResult`` produces a valid JSON schema with a boolean + ``confirm`` field defaulting to true. + + Whether the prompt is actually sent is covered elsewhere: the fake + context in ``TestWrapWithConfirmation`` is checked against FastMCP's real + ``Context.elicit`` signature, and ``TestConfirmationEndToEnd`` runs the + round trip against a real FastMCP server and client. Clients that do not + advertise elicitation support are handled by capability detection in the + wrapper, not by anything in this class. + """ + + def test_confirmation_message_is_readable(self): + """Confirmation message should be human-readable and informative.""" + msg = _build_confirmation_message( + "upsert_document_by_id", + { + "bucket_name": "travel-sample", + "document_id": "hotel_001", + }, + ) + assert "upsert_document_by_id" in msg + assert "travel-sample" in msg + assert "hotel_001" in msg + # Message should not contain technical jargon + assert "{" not in msg or "{{" in msg # No raw JSON + + def test_confirmation_result_schema_is_valid(self): + """ConfirmationResult must generate valid JSON schema.""" + schema = ConfirmationResult.model_json_schema() + + # Schema must be valid JSON Schema + assert "type" in schema + assert "properties" in schema + assert "confirm" in schema["properties"] - This class exists because of a bug it would have caught. The production - call passed ``schema=ConfirmationResult`` while every ``Context.elicit`` - fake in this file declared ``elicit(self, message, schema)``. The tests - therefore agreed with the bug and disagreed with FastMCP, and stayed green - while the confirmation path raised + # The 'confirm' property should be boolean + confirm_prop = schema["properties"]["confirm"] + assert confirm_prop.get("type") == "boolean" + assert confirm_prop.get("default") is True - Context.elicit() got an unexpected keyword argument 'schema' - in every deployment whose client advertises elicitation. The parameter has - been ``response_type`` since at least fastmcp 3.2.0, the project's own - minimum pin, so the call never worked with any supported version. +class TestConfirmationEndToEnd: + """A real FastMCP server and client, in memory: no fakes on either side. - A hand-written fake can only ever prove the code agrees with the fake. - These tests bind against the installed library instead. + The fake-context tests above check the wrapper's decisions; these check + that the elicitation round trip actually works against FastMCP itself, + which is what the fakes once hid (``ctx.elicit(schema=...)`` raised + ``TypeError`` on every confirmed call). """ - def test_the_keywords_we_send_bind_to_the_real_signature(self): - """The guard: rename the parameter upstream and this fails, not prod.""" - signature = inspect.signature(Context.elicit) - signature.bind( - SimpleNamespace(), # self - message="Confirm execution of 'replace_document_by_id'?", - response_type=ConfirmationResult, - ) + @staticmethod + def _server(calls: list[str]) -> FastMCP: + def delete_thing(ctx: Context, document_id: str) -> str: + calls.append(document_id) + return f"deleted {document_id}" - def test_the_old_keyword_is_genuinely_gone(self): - """Pins the reason for the change, so nobody reverts it as cosmetic.""" - with pytest.raises(TypeError): - inspect.signature(Context.elicit).bind( - SimpleNamespace(), message="x", schema=ConfirmationResult - ) + mcp = FastMCP("confirmation-e2e") + mcp.add_tool(FunctionTool.from_function(wrap_with_confirmation(delete_thing))) + return mcp - def test_the_production_call_uses_the_real_keyword(self): - """Checks the module that actually calls FastMCP, not a fake of it. - - The binding tests above prove which keyword is correct; this proves the - production call site uses it. Together they close the gap that let the - bug ship: agreement between the code and a fake of the library is not - agreement with the library. - """ - real = list(inspect.signature(Context.elicit).parameters) - assert real[:3] == ["self", "message", "response_type"], real - - # The module is derived from a symbol this file already imports, so - # there is one import style for cb_mcp.utils.elicitation (CodeQL - # py/import-and-import-from) and the assertion is anchored to the - # function under test rather than to a separately imported name. - source = inspect.getsource(inspect.getmodule(wrap_with_confirmation)) - assert "response_type=ConfirmationResult" in source - assert "schema=Confirmation" not in source - - def test_the_result_types_expose_what_the_wrapper_reads(self): - """The wrapper branches on ``result.action`` and guards ``result.data``.""" - for cls in (AcceptedElicitation, DeclinedElicitation, CancelledElicitation): - assert "action" in cls.model_fields, cls.__name__ - assert "data" in AcceptedElicitation.model_fields + @pytest.mark.asyncio + async def test_confirmed_call_runs_the_tool(self): + calls: list[str] = [] + prompts: list[str] = [] + + async def confirm(message, response_type, params, context): + prompts.append(message) + return response_type(confirm=True) + + async with Client(self._server(calls), elicitation_handler=confirm) as client: + result = await client.call_tool("delete_thing", {"document_id": "doc-1"}) + + assert calls == ["doc-1"] + assert result.data == "deleted doc-1" + assert prompts and "delete_thing" in prompts[0] and "doc-1" in prompts[0] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "answer", + [ + pytest.param(lambda rt: rt(confirm=False), id="accept-confirm-false"), + pytest.param(lambda rt: ElicitResult(action="decline"), id="decline"), + pytest.param(lambda rt: ElicitResult(action="cancel"), id="cancel"), + ], + ) + async def test_unconfirmed_call_never_runs_the_tool(self, answer): + calls: list[str] = [] + + async def respond(message, response_type, params, context): + return answer(response_type) + + async with Client(self._server(calls), elicitation_handler=respond) as client: + with pytest.raises(ToolError, match="not confirmed"): + await client.call_tool("delete_thing", {"document_id": "doc-1"}) + + assert calls == [] From 018fa0f221c8fd536095ff15303e6ee68b0e83b6 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Mon, 12 Oct 2026 17:19:39 +0530 Subject: [PATCH 19/27] Cleanup --- src/cb_mcp/audit/catalog.py | 12 ------------ tests/unit/test_audit_config.py | 1 - 2 files changed, 13 deletions(-) diff --git a/src/cb_mcp/audit/catalog.py b/src/cb_mcp/audit/catalog.py index 0137f87e..e959fe00 100644 --- a/src/cb_mcp/audit/catalog.py +++ b/src/cb_mcp/audit/catalog.py @@ -15,18 +15,6 @@ :mod:`cb_mcp.audit.classification` — no new event ID, and no change to a SIEM rule. -Events defined in code are the source of truth for the server. ``descriptor.json`` -ships the same catalogue for operators and SIEM authors; -``tests/unit/test_audit_catalog.py`` asserts the two never drift. - -Two catalogue entries from the PRD draft are deliberately absent: - -* *tool invocation blocked (disabled)* — withheld and disabled tools are never - registered with FastMCP, so the model is never told they exist and there is - no refusal to record. Removed by product decision on 2026-08-25. -* *session terminated* — FastMCP 3.x exposes no session-end hook and a stdio - client simply closes the pipe, so the event could never be emitted reliably. - On hold by product decision. """ from __future__ import annotations diff --git a/tests/unit/test_audit_config.py b/tests/unit/test_audit_config.py index 9bf7362c..490fe63e 100644 --- a/tests/unit/test_audit_config.py +++ b/tests/unit/test_audit_config.py @@ -231,7 +231,6 @@ def test_tool_args_warns_only_when_auditing_is_active(tmp_path, caplog): ("30d", 30 * DAY), ("1w", WEEK), ("8w", 8 * WEEK), - # The PRD writes "1W" in one of its cases; case must not matter. ("1W", WEEK), (" 1d ", DAY), ("0", 0), From 74eacb7aa16fad5f8f46d1f19c7a5c4e39eeb4de Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Mon, 12 Oct 2026 18:12:02 +0530 Subject: [PATCH 20/27] Catergory classification --- src/cb_mcp/audit/classification.py | 2 +- src/cb_mcp/audit/descriptor.json | 4 ++-- tests/unit/test_audit_catalog.py | 16 +++------------- tests/unit/test_audit_classification.py | 8 ++++---- 4 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/cb_mcp/audit/classification.py b/src/cb_mcp/audit/classification.py index d66e81d2..8711ae2f 100644 --- a/src/cb_mcp/audit/classification.py +++ b/src/cb_mcp/audit/classification.py @@ -137,7 +137,7 @@ # Its own category rather than folded into index: run_fts_query is a # query, so a reviewer filtering on `index read` should not get search # traffic mixed in. FTS index management has since landed and takes the - # search *write* id (61528) that the catalogue already reserved — the + # search *write* id (61526) that the catalogue already reserved — the # extension point working as designed: a new tool is a map entry, not a # new id and not a SIEM rule change. "list_fts_indexes": ("search", "read"), diff --git a/src/cb_mcp/audit/descriptor.json b/src/cb_mcp/audit/descriptor.json index eb9bcc67..f3bb0f52 100644 --- a/src/cb_mcp/audit/descriptor.json +++ b/src/cb_mcp/audit/descriptor.json @@ -126,7 +126,7 @@ "filterable": true }, { - "id": 61496, + "id": 61494, "name": "search read", "description": "A search read operation was requested through a tool", "category": "tool_call", @@ -182,7 +182,7 @@ "filterable": false }, { - "id": 61528, + "id": 61526, "name": "search write", "description": "A search write operation was requested through a tool", "category": "tool_call", diff --git a/tests/unit/test_audit_catalog.py b/tests/unit/test_audit_catalog.py index 6478aebc..af80bb98 100644 --- a/tests/unit/test_audit_catalog.py +++ b/tests/unit/test_audit_catalog.py @@ -300,16 +300,6 @@ def test_retired_categories_are_gone_but_their_slots_are_recorded(): catalog.tool_call_event_id(name, "read") -def test_retired_ids_are_absent_from_the_catalogue_and_descriptor(): - """61494/61495 and 61526/61527 must not resolve to anything.""" - retired = {61494, 61495, 61526, 61527} - live = {event.id for event in all_tool_call_events()} - assert live & retired == set() - - shipped = json.loads(DESCRIPTOR_PATH.read_text(encoding="utf-8")) - assert {entry["id"] for entry in shipped["events"]} & retired == set() - - def test_retirement_did_not_move_the_surviving_ids(): """The point of the explicit slot table, asserted on the real retirement.""" assert tool_call_event_id("cluster", "read") == 61488 @@ -326,7 +316,7 @@ def test_search_ids_are_the_shipped_numbers(): """Pinned literally, like every other shipped id. Search took slot 8 rather than the reusable retired slot 6, so these are - 61496/61528 and not 61494/61526. + 61494/61526. """ - assert tool_call_event_id("search", "read") == 61496 - assert tool_call_event_id("search", "write") == 61528 + assert tool_call_event_id("search", "read") == 61494 + assert tool_call_event_id("search", "write") == 61526 diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py index 0db6e5ca..05e26922 100644 --- a/tests/unit/test_audit_classification.py +++ b/tests/unit/test_audit_classification.py @@ -115,10 +115,10 @@ def test_read_only_tools_are_classified_read(tool_name): # Search keeps its own category rather than folding into index: a # reviewer filtering on `index read` must not get FTS traffic, and # `run_fts_query` is a query, not an index operation. - ("list_fts_indexes", 61496), - ("run_fts_query", 61496), - ("upsert_fts_index", 61528), - ("drop_fts_index", 61528), + ("list_fts_indexes", 61494), + ("run_fts_query", 61494), + ("upsert_fts_index", 61526), + ("drop_fts_index", 61526), ], ) def test_tool_maps_to_the_expected_event_id(tool_name, expected_id): From 62c5936d0481a9f6e1df3b4bb5ff0f95fc202cfb Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Mon, 12 Oct 2026 19:02:08 +0530 Subject: [PATCH 21/27] DA-2038: audit Operational Insights and split the config category Operational Insights is now audited under its own Tier-2 block, 0x10000. It shares the category axis with the operational server but not the block: the two collide on five tool names -- get_server_configuration_status, create_index, list_indexes, get_collections_in_scope, get_schema_for_collection -- so booking its calls against the operational block would file them under ids that mean a different server. All 13 registered tools are classified; the roadmap's groupings map onto the shared axis rather than replacing it (Data Model & Schema Discovery and Metadata Introspection -> schema, Async Request API -> query, Query Monitoring -> performance). A package omits the slots it has no tools for rather than renumbering, so Operational Insights uses 1/3/4/7 and the rest keep their meaning. New category (MCP server status and configuration) takes slot 7 in both blocks: 61495/61527 and 65591/65623. That slot held the retired category. Reviving it is permitted only because never had a tool classified to it and so never emitted a record -- there can be no SIEM rule for the old meaning. next_free_slot still declines to offer retired slots, so this was an explicit decision rather than one inherited from a helper; a test now asserts the precondition. Moved into the category by product decision: get_server_configuration_status, discover_tool_input_values -> config get_cluster_query_vitals (from performance), get_index_stats (from index) -> cluster, which now means Cluster Setup & Health ALL_IDS and FILTERABLE_IDS span every package, so an operator sharing one CB_MCP_AUDIT_LOG_DISABLED_EVENTS across both servers does not have an id rejected as unknown. EVENT_NAMES_TO_IDS deliberately does not: a name like 'query read' exists in both blocks with different ids, and nothing resolves names at runtime. Each descriptor tool-call entry now names its service_package, for the same reason. The AI Data Plane block (0x13000) is recorded as a constant but left out of SERVICE_PACKAGE_BLOCKS: a block registered without a classification table would send every call down the fail-closed path. --- src/cb_mcp/audit/catalog.py | 149 +++++++++++++++--- src/cb_mcp/audit/classification.py | 91 ++++++++--- src/cb_mcp/audit/descriptor.json | 115 +++++++++++++- .../servers/operational_insights/spec.py | 7 + tests/unit/test_audit_catalog.py | 96 ++++++++++- tests/unit/test_audit_classification.py | 102 ++++++++++-- tests/unit/test_audit_wiring.py | 25 ++- 7 files changed, 510 insertions(+), 75 deletions(-) diff --git a/src/cb_mcp/audit/catalog.py b/src/cb_mcp/audit/catalog.py index e959fe00..42b90b57 100644 --- a/src/cb_mcp/audit/catalog.py +++ b/src/cb_mcp/audit/catalog.py @@ -15,6 +15,18 @@ :mod:`cb_mcp.audit.classification` — no new event ID, and no change to a SIEM rule. +Events defined in code are the source of truth for the server. ``descriptor.json`` +ships the same catalogue for operators and SIEM authors; +``tests/unit/test_audit_catalog.py`` asserts the two never drift. + +Two catalogue entries from the PRD draft are deliberately absent: + +* *tool invocation blocked (disabled)* — withheld and disabled tools are never + registered with FastMCP, so the model is never told they exist and there is + no refusal to record. Removed by product decision on 2026-08-25. +* *session terminated* — FastMCP 3.x exposes no session-end hook and a stdio + client simply closes the pipe, so the event could never be emitted reliably. + On hold by product decision. """ from __future__ import annotations @@ -32,21 +44,40 @@ #: Tier-2 block for the operational service package (kv, query, index, ...). OPERATIONAL_BLOCK = 0xF000 # 61440 -#: Service package this build audits by default, and the only one today. +#: Tier-2 block for the Operational Insights (analytics) service package. +OPERATIONAL_INSIGHTS_BLOCK = 0x10000 # 65536 + +#: Tier-2 block reserved for the AI Data Plane package. +#: +#: Claimed but not populated: no AI Data Plane tools exist in this repository, +#: so the package has no classification table and emits nothing. The number is +#: recorded here so the allocation is visible in one place and cannot be handed +#: to a different package later. +AI_DATA_PLANE_BLOCK = 0x13000 # 77824 + +#: Service package used when a caller names none. #: -#: The repository ships two servers. Only ``operational`` is audited: the -#: Operational Insights server's tool surface is new and still moving, and a -#: Tier-2 block, once records exist against it, can never be renumbered. Its -#: block is therefore left unallocated rather than spent early. +#: Not "the only audited package" any more — see +#: :data:`SERVICE_PACKAGE_BLOCKS`. It remains the default because the +#: operational server is the one whose tools the shared helpers are usually +#: asked about; every audited server passes its own package explicitly, from +#: ``ServerSpec.audit_package``. DEFAULT_SERVICE_PACKAGE = "operational" -#: ``service package -> Tier-2 block base``. Blocks are 0x1000 apart, so the -#: next package takes 0x10000. Adding one means an entry here, an entry in -#: :data:`CATEGORY_SLOTS`, a classification table in -#: :mod:`cb_mcp.audit.classification`, and ``audit_package`` on its -#: ``ServerSpec`` — and renumbers nothing that already exists. +#: ``service package -> Tier-2 block base``. Blocks are 0x1000 apart. Adding a +#: package means an entry here, an entry in :data:`CATEGORY_SLOTS`, a +#: classification table in :mod:`cb_mcp.audit.classification`, and +#: ``audit_package`` on its ``ServerSpec`` — and renumbers nothing that already +#: exists. +#: +#: The AI Data Plane block is deliberately **not** listed: a package with no +#: tools would otherwise look audited to :func:`tool_call_event`, and a block +#: present here but absent from :data:`CATEGORY_SLOTS` is a half-registered +#: package waiting to raise at runtime. Its number is reserved by the constant +#: above instead, which is a comment on paper rather than a promise in code. SERVICE_PACKAGE_BLOCKS: dict[str, int] = { "operational": OPERATIONAL_BLOCK, + "operational_insights": OPERATIONAL_INSIGHTS_BLOCK, } #: Relative offset of the first read event inside a Tier-2 block. @@ -71,6 +102,11 @@ #: #: Add a category by giving it the next free slot (:func:`next_free_slot`). #: Never change a number already in this table. +#: Every package instantiates the *same* axis, so a slot means the same +#: category in every block and an id decodes to package-by-block and +#: category-by-offset. A package omits the slots it has no tools for rather +#: than renumbering: Operational Insights has no kv or search surface, so those +#: slots simply stay empty in its block and keep their meaning. CATEGORY_SLOTS: dict[str, dict[str, int]] = { "operational": { "cluster": 0, @@ -79,14 +115,29 @@ "query": 3, "index": 4, "performance": 5, - # Slots 6 and 7 are retired (see RETIRED_SLOTS) and deliberately left - # unused, so search took the next never-allocated slot instead of - # reusing one. Reuse was permitted by the rule below — neither retired - # slot ever emitted a record — but declined: two dead numbers cost - # nothing in a 16-slot block, and a slot that has meant two different - # things is a trap for anyone reading old rules. + # Slot 6 is retired (see RETIRED_SLOTS) and deliberately left unused. + # + # Slot 7 held the retired `analytics` category and was revived for + # `config` on 2026-10-12 by product decision. Permitted by the rule + # below — `analytics` never had a tool classified to it, so the slot + # never emitted a record and no SIEM rule for the old meaning can + # exist. `next_free_slot` still declines to offer retired slots, so + # this reuse was chosen rather than inherited from a helper. + "config": 7, "search": 8, }, + # Operational Insights. Same axis, its own block: the two servers collide + # on tool names — `get_server_configuration_status`, `create_index`, + # `list_indexes`, `get_collections_in_scope`, `get_schema_for_collection` — + # so a shared table could not represent both, and booking one server's call + # against the other's block would put records under ids that mean a + # different server. + "operational_insights": { + "schema": 1, + "query": 3, + "index": 4, + "config": 7, + }, } #: Slots retired from use, kept here so their history is visible — ``category @@ -103,11 +154,14 @@ #: :func:`next_free_slot`'s reusable set. RETIRED_SLOTS: dict[str, dict[str, int]] = { "operational": { - # Retired before release: no tool was ever classified to either - # category, so neither slot ever emitted a record and both would be - # safe to reallocate. Left unallocated by choice when the search + # Retired before release: no tool was ever classified to it, so it + # never emitted a record. Left unallocated by choice when the search # category was added on 2026-09-29 — see the note in CATEGORY_SLOTS. "vector": 6, + # `analytics` held slot 7 and was retired on the same terms. The slot + # was then revived for `config` on 2026-10-12; the entry stays here so + # the history of the number remains visible, and so the invariant test + # can assert that a revived slot was one that never emitted. "analytics": 7, }, } @@ -336,6 +390,11 @@ def filterable(self) -> bool: "query": "query", "index": "index", "performance": "performance", + # Labels are shared across packages, so a slot reads the same wherever it + # appears. "config" covers the server's own status and configuration + # surface, which is MCP-level rather than cluster-level — the distinction + # that took it out of `cluster`. + "config": "config", "search": "search", } @@ -380,39 +439,79 @@ def build_descriptor() -> dict: } for event in AuditEvent ] + # Every package's Tier-2 events, not just the default one. The descriptor + # is what a SIEM author reads to know which ids this build can emit, and a + # deployment commonly runs both servers — shipping only one block's ids + # would make the other server's records look unknown. + # + # ``service_package`` is carried on each entry because the tool-call names + # repeat across blocks: "query read" is 61491 in operational and 65587 in + # operational_insights, and the name alone cannot tell them apart. entries += [ { "id": event.id, "name": event.event_name, "description": event.description, "category": "tool_call", + "service_package": package, "default_enabled": True, "filterable": event.filterable, } - for event in all_tool_call_events() + for package in SERVICE_PACKAGE_BLOCKS + for event in all_tool_call_events(package) ] entries.sort(key=lambda entry: entry["id"]) return { - "service_package": SERVICE_PACKAGE, + # Kept for readers that expect a single value, and meaning what it + # always did: the package a build audits by default. The authoritative + # list is `service_packages`, and every tool-call entry names its own. + "service_package": DEFAULT_SERVICE_PACKAGE, + "service_packages": sorted(SERVICE_PACKAGE_BLOCKS), "blocks": {"core": CORE_BLOCK, **SERVICE_PACKAGE_BLOCKS}, "events": entries, } -#: Every event name that may appear in a record, mapped to its numeric id. +#: Every event name that may appear in a record, mapped to its numeric id — +#: for the core block and the **default** package only. +#: +#: Deliberately not spanning every package: a tool-call name such as "query +#: read" exists in each Tier-2 block with a different id, so a name is only +#: unique within a package and a flat name map could not represent more than +#: one. Nothing resolves a name to an id at runtime — the operator-facing +#: filter takes ids, which are globally unique — so this stays a convenience +#: for the default package rather than becoming a package-keyed structure with +#: no caller. EVENT_NAMES_TO_IDS: dict[str, int] = { **{event.event_name: event.id for event in AuditEvent}, **{event.event_name: event.id for event in all_tool_call_events()}, } #: Ids of events an operator is permitted to suppress via configuration. +#: +#: Spans every registered package. A process runs one package, but an operator +#: writing CB_MCP_AUDIT_LOG_DISABLED_EVENTS should not have an id rejected as +#: unknown merely because this process happens to serve a different package — +#: the same configuration is routinely shared across both servers. FILTERABLE_IDS: frozenset[int] = frozenset( [event.id for event in AuditEvent if event.filterable] - + [event.id for event in all_tool_call_events() if event.filterable] + + [ + event.id + for package in SERVICE_PACKAGE_BLOCKS + for event in all_tool_call_events(package) + if event.filterable + ] ) -#: Every known event id. -ALL_IDS: frozenset[int] = frozenset(EVENT_NAMES_TO_IDS.values()) +#: Every known event id, across every registered package. +ALL_IDS: frozenset[int] = frozenset( + [event.id for event in AuditEvent] + + [ + event.id + for package in SERVICE_PACKAGE_BLOCKS + for event in all_tool_call_events(package) + ] +) __all__ = [ diff --git a/src/cb_mcp/audit/classification.py b/src/cb_mcp/audit/classification.py index 8711ae2f..f98c6d62 100644 --- a/src/cb_mcp/audit/classification.py +++ b/src/cb_mcp/audit/classification.py @@ -50,8 +50,18 @@ #: than alphabetically, so a reviewer can check a whole block in one pass. TOOL_CLASSIFICATION: dict[str, dict[str, tuple[str, str]]] = { "operational": { - # -- cluster / health --------------------------------------------- - "get_server_configuration_status": ("cluster", "read"), + # -- config (MCP server status and configuration) ------------------- + # The server's own status and configuration, not the cluster's. It + # reports what this process was started with — transport, read-only + # mode, the withheld and disabled tool sets — so an operator auditing + # "who inspected the server" must not have it mixed in with cluster + # health checks. + "get_server_configuration_status": ("config", "read"), + # Reads bundled reference data shipped with the server and touches no + # cluster at all, so it belongs with the server's own surface rather + # than with Cluster Setup & Health. + "discover_tool_input_values": ("config", "read"), + # -- cluster (Cluster Setup & Health) ------------------------------ "test_cluster_connection": ("cluster", "read"), "get_cluster_health_and_services": ("cluster", "read"), "get_cluster_diagnostics_report": ("cluster", "read"), @@ -64,11 +74,13 @@ # The cluster's own event log via GET /events — a server-level read, # like its neighbours above. "get_cluster_system_events": ("cluster", "read"), - # Reads bundled reference data rather than the cluster, so it touches - # no keyspace and has no service of its own. Booked as a cluster read: - # it is a server-level informational call, which is what that category - # already covers. - "discover_tool_input_values": ("cluster", "read"), + # Query-engine vitals (/admin/vitals) and GSI indexer stats. Both + # describe how a *service* in the cluster is holding up rather than + # what a caller queried or which indexes exist, which is what Cluster + # Setup & Health means; get_cluster_query_vitals moved here from + # performance and get_index_stats from index on 2026-10-12. + "get_cluster_query_vitals": ("cluster", "read"), + "get_index_stats": ("cluster", "read"), # -- schema / discovery -------------------------------------------- "get_buckets_in_cluster": ("schema", "read"), "get_scopes_in_bucket": ("schema", "read"), @@ -98,7 +110,6 @@ # -- index ----------------------------------------------------------- "list_indexes": ("index", "read"), "get_index_advisor_recommendations": ("index", "read"), - "get_index_stats": ("index", "read"), "create_index": ("index", "write"), # The replacement for the deprecated create_index (#303), creating # scalar *and* vector GSI indexes through SQL++. It takes the same id: @@ -117,15 +128,12 @@ "get_queries_using_primary_index": ("performance", "read"), "get_queries_not_using_covering_index": ("performance", "read"), "get_queries_not_selective": ("performance", "read"), - # The N1QL Admin REST API (/admin/vitals, /admin/active_requests) added - # in #308. Booked as performance rather than query: `query read` means - # "a caller ran a statement", and a reviewer reconstructing what was - # executed must not find listings of other people's requests mixed into - # it. Vitals is the query service's own load picture — request rate, - # active and queued counts — which is what this category already means; - # get_cluster_metrics stays a cluster read because it covers node - # hardware rather than the N1QL layer. - "get_cluster_query_vitals": ("performance", "read"), + # The N1QL Admin REST API's active-request surface (#308). Booked as + # performance rather than query: `query read` means "a caller ran a + # statement", and a reviewer reconstructing what was executed must not + # find listings of other callers' in-flight requests mixed into it. + # The vitals half of that API moved to cluster — it describes service + # health rather than the query workload. "get_active_queries": ("performance", "read"), # The first performance *write*, taking the id (61525) the catalogue # reserved when the category was allocated — a map entry, no new id and @@ -137,7 +145,7 @@ # Its own category rather than folded into index: run_fts_query is a # query, so a reviewer filtering on `index read` should not get search # traffic mixed in. FTS index management has since landed and takes the - # search *write* id (61526) that the catalogue already reserved — the + # search *write* id (61528) that the catalogue already reserved — the # extension point working as designed: a new tool is a map entry, not a # new id and not a SIEM rule change. "list_fts_indexes": ("search", "read"), @@ -149,6 +157,53 @@ "upsert_fts_index": ("search", "write"), "drop_fts_index": ("search", "write"), }, + # -- Operational Insights (analytics) ------------------------------------ + # + # Its own block (0x10000) on the *same* category axis, so a slot means the + # same thing in both and an id decodes to package-by-block and + # category-by-offset. The roadmap's own grouping maps onto that axis + # rather than replacing it: + # + # Data Model & Schema Discovery, Metadata Introspection -> schema + # Query and Index, Async Request API, Query Execution -> query / index + # Scope & Collection Management -> schema write + # Query Monitoring -> performance + # + # Only the tools this server actually registers are listed. The roadmap + # sheet also names 1.2 tools that do not exist yet, and spells the shipped + # ones `operational_insights_*` where the server registers them `oi_*`; + # classifying a name that is never registered would be dead weight that + # the parity test cannot check. + "operational_insights": { + # -- config (MCP server status and configuration) ------------------- + # Same tool name as the operational server's, which is exactly why + # this table is keyed by package: the two must resolve to different + # ids (61495 and 65591), and a flat name map could not express that. + "get_server_configuration_status": ("config", "read"), + # -- schema / discovery --------------------------------------------- + # Analytics names its containers database/scope/collection; they are + # the same discovery surface as the operational server's bucket/scope/ + # collection reads, so they take the same slot. + "oi_get_databases_in_cluster": ("schema", "read"), + "oi_get_scopes_in_database": ("schema", "read"), + "oi_get_collections_in_scope": ("schema", "read"), + "oi_get_schema_for_collection": ("schema", "read"), + # -- query (SQL++ over Analytics) ----------------------------------- + "oi_run_query_sync": ("query", "read"), + "oi_run_query_async": ("query", "read"), + "oi_explain_query": ("query", "read"), + # The async request lifecycle. Retrieving, discarding and cancelling + # all act on a handle for a query this caller submitted, so they stay + # with the query that produced it rather than forming a category of + # their own — "Async Request API" is a shape of the API, not a + # different resource. + "oi_get_async_query_results": ("query", "read"), + "oi_discard_async_query_results": ("query", "read"), + "oi_cancel_async_query": ("query", "read"), + # -- index ----------------------------------------------------------- + "oi_list_indexes": ("index", "read"), + "oi_create_index": ("index", "write"), + }, } #: The one tool whose read/write class depends on the statement it is given. diff --git a/src/cb_mcp/audit/descriptor.json b/src/cb_mcp/audit/descriptor.json index f3bb0f52..ea711642 100644 --- a/src/cb_mcp/audit/descriptor.json +++ b/src/cb_mcp/audit/descriptor.json @@ -1,8 +1,13 @@ { "service_package": "operational", + "service_packages": [ + "operational", + "operational_insights" + ], "blocks": { "core": 57344, - "operational": 61440 + "operational": 61440, + "operational_insights": 65536 }, "events": [ { @@ -82,6 +87,7 @@ "name": "cluster read", "description": "A cluster read operation was requested through a tool", "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": true }, @@ -90,6 +96,7 @@ "name": "schema read", "description": "A schema read operation was requested through a tool", "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": true }, @@ -98,6 +105,7 @@ "name": "document read", "description": "A document read operation was requested through a tool", "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": true }, @@ -106,6 +114,7 @@ "name": "query read", "description": "A query read operation was requested through a tool", "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": true }, @@ -114,6 +123,7 @@ "name": "index read", "description": "An index read operation was requested through a tool", "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": true }, @@ -122,14 +132,25 @@ "name": "performance read", "description": "A performance read operation was requested through a tool", "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": true }, { - "id": 61494, + "id": 61495, + "name": "config read", + "description": "A config read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": true + }, + { + "id": 61496, "name": "search read", "description": "A search read operation was requested through a tool", "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": true }, @@ -138,6 +159,7 @@ "name": "cluster write", "description": "A cluster write operation was requested through a tool", "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": false }, @@ -146,6 +168,7 @@ "name": "schema write", "description": "A schema write operation was requested through a tool", "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": false }, @@ -154,6 +177,7 @@ "name": "document write", "description": "A document write operation was requested through a tool", "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": false }, @@ -162,6 +186,7 @@ "name": "query write", "description": "A query write operation was requested through a tool", "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": false }, @@ -170,6 +195,7 @@ "name": "index write", "description": "An index write operation was requested through a tool", "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": false }, @@ -178,14 +204,97 @@ "name": "performance write", "description": "A performance write operation was requested through a tool", "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": false + }, + { + "id": 61527, + "name": "config write", + "description": "A config write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", "default_enabled": true, "filterable": false }, { - "id": 61526, + "id": 61528, "name": "search write", "description": "A search write operation was requested through a tool", "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": false + }, + { + "id": 65585, + "name": "schema read", + "description": "A schema read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": true + }, + { + "id": 65587, + "name": "query read", + "description": "A query read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": true + }, + { + "id": 65588, + "name": "index read", + "description": "An index read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": true + }, + { + "id": 65591, + "name": "config read", + "description": "A config read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": true + }, + { + "id": 65617, + "name": "schema write", + "description": "A schema write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": false + }, + { + "id": 65619, + "name": "query write", + "description": "A query write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": false + }, + { + "id": 65620, + "name": "index write", + "description": "An index write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": false + }, + { + "id": 65623, + "name": "config write", + "description": "A config write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", "default_enabled": true, "filterable": false } diff --git a/src/cb_mcp/servers/operational_insights/spec.py b/src/cb_mcp/servers/operational_insights/spec.py index 3664f744..d0a04234 100644 --- a/src/cb_mcp/servers/operational_insights/spec.py +++ b/src/cb_mcp/servers/operational_insights/spec.py @@ -23,6 +23,13 @@ fastmcp_name=FASTMCP_SERVER_NAME, logger_namespace=OPERATIONAL_INSIGHTS_LOGGER_NAMESPACE, display_name="Couchbase Operational Insights MCP Server", + # Audited under its own Tier-2 block (0x10000). It shares the category axis + # with the operational server but not the block: the two collide on tool + # names — get_server_configuration_status, create_index, list_indexes, + # get_collections_in_scope, get_schema_for_collection — and booking this + # server's calls against the operational block would file them under ids + # that mean a different server. + audit_package="operational_insights", tools=TOOL_SET, # Must differ from every other server's values (see ServerSpec.default_port # / default_log_file docstrings). diff --git a/tests/unit/test_audit_catalog.py b/tests/unit/test_audit_catalog.py index af80bb98..80e9d741 100644 --- a/tests/unit/test_audit_catalog.py +++ b/tests/unit/test_audit_catalog.py @@ -54,6 +54,10 @@ def test_category_order_is_the_wire_contract(): "query", "index", "performance", + # Slot 7, revived from the retired `analytics` category on 2026-10-12 + # by product decision. Safe only because `analytics` never had a tool + # classified to it and so never emitted a record; see RETIRED_SLOTS. + "config", "search", ) @@ -163,11 +167,16 @@ def test_shipped_descriptor_matches_the_code(): def test_descriptor_entries_are_well_formed(): shipped = json.loads(DESCRIPTOR_PATH.read_text(encoding="utf-8")) assert shipped["service_package"] == "operational" - assert shipped["blocks"] == {"core": 57344, "operational": 61440} + assert shipped["blocks"] == { + "core": 57344, + "operational": 61440, + "operational_insights": 65536, + } + assert shipped["service_packages"] == ["operational", "operational_insights"] ids = [entry["id"] for entry in shipped["events"]] assert ids == sorted(ids), "descriptor must be ordered by id" for entry in shipped["events"]: - assert set(entry) == { + required = { "id", "name", "description", @@ -175,6 +184,16 @@ def test_descriptor_entries_are_well_formed(): "default_enabled", "filterable", } + assert required <= set(entry), entry + # Tool-call names repeat across blocks -- "query read" is 61491 in + # operational and 65587 in operational_insights -- so a Tier-2 entry + # must name its package or a SIEM author cannot tell them apart. Core + # events are shared by every package and carry no such field. + if entry["category"] == "tool_call": + assert entry["service_package"] in {"operational", "operational_insights"} + assert set(entry) == required | {"service_package"} + else: + assert set(entry) == required assert entry["default_enabled"] is True assert entry["description"].strip() @@ -217,12 +236,43 @@ def test_slot_table_pins_the_shipped_numbers(): "query": 3, "index": 4, "performance": 5, + # Revived the retired `analytics` slot on 2026-10-12. Permitted + # because that category never emitted a record, and taken as an + # explicit decision -- next_free_slot still declines to offer it. + "config": 7, # Added 2026-09-29 with the FTS tools. Slot 8, not the reusable # retired 6, so no number has ever meant two things. "search": 8, } +def test_operational_insights_slots_match_the_shared_axis(): + """A slot must mean the same category in every block. + + That is what lets an id decode to package-by-block and category-by-offset. + A package omits the slots it has no tools for; it never renumbers. + """ + oi = catalog.CATEGORY_SLOTS["operational_insights"] + assert oi == {"schema": 1, "query": 3, "index": 4, "config": 7} + shared = catalog.CATEGORY_SLOTS["operational"] + for category, slot in oi.items(): + assert shared[category] == slot, category + + +def test_operational_insights_ids_sit_in_its_own_block(): + """Same offsets, different block -- and never the operational numbers.""" + base = catalog.SERVICE_PACKAGE_BLOCKS["operational_insights"] + assert base == 0x10000 + assert catalog.tool_call_event_id("query", "read", "operational_insights") == 65587 + assert catalog.tool_call_event_id("query", "write", "operational_insights") == 65619 + assert catalog.tool_call_event_id("config", "read", "operational_insights") == 65591 + # The collision this block exists to prevent: the same tool name in both + # servers must never resolve to the same id. + assert catalog.tool_call_event_id( + "config", "read", "operational" + ) != catalog.tool_call_event_id("config", "read", "operational_insights") + + def test_retiring_a_category_does_not_move_its_neighbours(monkeypatch): """The reason the slot table is explicit rather than positional. @@ -259,14 +309,16 @@ def test_next_free_slot_skips_retired_slots(monkeypatch): """ monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"vector": 6}}) monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) - assert catalog.next_free_slot() == 7 + # 7 is live (config) and 8 is live (search), so the next never-allocated + # slot is 9 -- the helper still refuses to offer the retired 6. + assert catalog.next_free_slot() == 9 def test_permanently_reserved_slots_are_never_reallocated(monkeypatch): """A slot that has emitted records must not be handed to a new category.""" monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"vector": 6}}) monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset({6})) - assert catalog.next_free_slot() == 7 + assert catalog.next_free_slot() == 9 def test_exhausted_block_is_an_explicit_error(monkeypatch): @@ -300,6 +352,36 @@ def test_retired_categories_are_gone_but_their_slots_are_recorded(): catalog.tool_call_event_id(name, "read") +def test_retired_ids_are_absent_from_the_catalogue_and_descriptor(): + """Slot 6 stays dead: 61494 and 61526 must not resolve to anything. + + Slot 7 (61495/61527) was retired with `analytics` and later revived for + `config`, which the rule permits only because `analytics` never emitted a + record. Slot 6 was not revived, so its pair must still resolve to nothing + -- and this test is what would catch it being handed out by accident. + """ + retired = {61494, 61526} + live = {event.id for event in all_tool_call_events()} + assert live & retired == set() + + shipped = json.loads(DESCRIPTOR_PATH.read_text(encoding="utf-8")) + assert {entry["id"] for entry in shipped["events"]} & retired == set() + + +def test_the_revived_slot_never_emitted_under_its_old_meaning(): + """The precondition for reusing slot 7, asserted rather than assumed. + + Reallocating a slot that had ever emitted a record would make an existing + SIEM rule silently match a new, unrelated event. `analytics` was retired + with no tool ever classified to it, so no record for 61495 can exist in + anyone's logs -- which is the only reason `config` may have the number. + """ + assert catalog.RETIRED_SLOTS["operational"]["analytics"] == 7 + assert catalog.CATEGORY_SLOTS["operational"]["config"] == 7 + # Permanently reserved slots are the ones that *did* emit; 7 is not one. + assert 7 not in catalog.PERMANENTLY_RESERVED_SLOTS + + def test_retirement_did_not_move_the_surviving_ids(): """The point of the explicit slot table, asserted on the real retirement.""" assert tool_call_event_id("cluster", "read") == 61488 @@ -316,7 +398,7 @@ def test_search_ids_are_the_shipped_numbers(): """Pinned literally, like every other shipped id. Search took slot 8 rather than the reusable retired slot 6, so these are - 61494/61526. + 61496/61528 and not 61494/61526. """ - assert tool_call_event_id("search", "read") == 61494 - assert tool_call_event_id("search", "write") == 61526 + assert tool_call_event_id("search", "read") == 61496 + assert tool_call_event_id("search", "write") == 61528 diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py index 05e26922..beda0e10 100644 --- a/tests/unit/test_audit_classification.py +++ b/tests/unit/test_audit_classification.py @@ -18,7 +18,7 @@ import pytest -from cb_mcp.audit.catalog import CATEGORIES +from cb_mcp.audit.catalog import CATEGORIES, SERVICE_PACKAGE_BLOCKS from cb_mcp.audit.classification import ( TOOL_CLASSIFICATION, classify_tool, @@ -28,10 +28,11 @@ unclassified_tool_names, ) from cb_mcp.tools.operational import ALL_TOOLS, READ_ONLY_TOOLS, WRITE_TOOLS +from cb_mcp.tools.operational_insights import ALL_TOOLS as OI_ALL_TOOLS +from cb_mcp.tools.operational_insights import WRITE_TOOLS as OI_WRITE_TOOLS -#: The audited package. Only ``operational`` has a Tier-2 block and a -#: classification table; the Operational Insights server declares no -#: ``audit_package`` and is deliberately not audited. +#: The operational package. Operational Insights has its own block and table +#: since 2026-10-12; constants for it are defined alongside these. PACKAGE = "operational" #: Main collapsed the four per-area tool groups into read-only and write when @@ -42,6 +43,11 @@ WRITE_TOOL_NAMES = {tool.__name__ for tool in WRITE_TOOLS} READ_ONLY_NAMES = {tool.__name__ for tool in READ_ONLY_TOOLS} +#: The Operational Insights server, audited under its own block since +#: 2026-10-12. +OI_REGISTERED = {tool.__name__ for tool in OI_ALL_TOOLS} +OI_WRITE_NAMES = {tool.__name__ for tool in OI_WRITE_TOOLS} + def test_every_registered_tool_is_classified(): assert unclassified_tool_names(REGISTERED, PACKAGE) == [] @@ -55,16 +61,71 @@ def test_classification_covers_the_registered_set_exactly(): assert set(TOOL_CLASSIFICATION[PACKAGE]) == REGISTERED -def test_only_the_operational_package_is_classified(): - """Operational Insights is deliberately unaudited. +def test_every_classified_package_has_a_block_and_vice_versa(): + """A half-registered package is the failure this guards. + + A table with no block raises at runtime the first time a tool is called; a + block with no table sends every tool down the fail-closed path, recording + real calls against a placeholder category. Both halves land together or + this fails. + """ + assert set(TOOL_CLASSIFICATION) == set(SERVICE_PACKAGE_BLOCKS) + assert set(TOOL_CLASSIFICATION) == {"operational", "operational_insights"} + + +def test_the_two_servers_resolve_their_shared_tool_names_differently(): + """The reason the table is keyed by package at all. - Its tools collide with operational's on five names, so classifying it - without its own Tier-2 block would book its records against operational - ids. Adding it is a table here plus a block in the catalogue — this test - is what will fail, informatively, on the day someone adds one without - the other. + Both servers register get_server_configuration_status. Booking one against + the other's block would file its records under ids that mean a different + server, and nothing in the record would reveal it. """ - assert set(TOOL_CLASSIFICATION) == {"operational"} + operational = resolve_tool_call_event( + "get_server_configuration_status", package="operational" + ) + insights = resolve_tool_call_event( + "get_server_configuration_status", package="operational_insights" + ) + assert operational.id == 61495 + assert insights.id == 65591 + # Same category and class, so the difference is purely the block. + assert operational.category == insights.category == "config" + assert operational.operation_class == insights.operation_class == "read" + + +def test_every_registered_oi_tool_is_classified(): + assert unclassified_tool_names(OI_REGISTERED, "operational_insights") == [] + + +def test_no_stale_oi_entries(): + assert ( + sorted(set(TOOL_CLASSIFICATION["operational_insights"]) - OI_REGISTERED) == [] + ) + + +@pytest.mark.parametrize( + ("tool_name", "expected_id"), + [ + ("get_server_configuration_status", 65591), # config read + ("oi_get_databases_in_cluster", 65585), # schema read + ("oi_get_schema_for_collection", 65585), + ("oi_run_query_sync", 65587), # query read + ("oi_cancel_async_query", 65587), + ("oi_list_indexes", 65588), # index read + ("oi_create_index", 65620), # index write + ], +) +def test_oi_tool_maps_to_the_expected_event_id(tool_name, expected_id): + """Every id is in the OI block, never the operational one.""" + event = resolve_tool_call_event(tool_name, package="operational_insights") + assert event.id == expected_id + assert 65536 <= event.id < 65536 + 4096 + + +@pytest.mark.parametrize("tool_name", sorted(OI_WRITE_NAMES)) +def test_oi_write_tools_are_classified_write(tool_name): + _, operation_class = classify_tool(tool_name, package="operational_insights") + assert operation_class == "write", tool_name @pytest.mark.parametrize("tool_name", sorted(WRITE_TOOL_NAMES)) @@ -94,6 +155,14 @@ def test_read_only_tools_are_classified_read(tool_name): ("get_cluster_tasks", 61488), ("get_cluster_health_snapshot", 61488), ("get_cluster_system_events", 61488), + # Moved into Cluster Setup & Health on 2026-10-12: both describe how a + # service is holding up rather than the query workload or the index + # inventory. + ("get_cluster_query_vitals", 61488), + ("get_index_stats", 61488), + # The server's own surface, split out of cluster on the same date. + ("get_server_configuration_status", 61495), + ("discover_tool_input_values", 61495), ("get_buckets_in_cluster", 61489), ("get_schema_for_collection", 61489), ("create_scope", 61521), @@ -107,7 +176,6 @@ def test_read_only_tools_are_classified_read(tool_name): ("drop_index", 61524), ("get_longest_running_queries", 61493), ("get_queries_not_selective", 61493), - ("get_cluster_query_vitals", 61493), ("get_active_queries", 61493), # The first tool to take the performance write id, and the only one in # the server that cancels another caller's in-flight work. @@ -115,10 +183,10 @@ def test_read_only_tools_are_classified_read(tool_name): # Search keeps its own category rather than folding into index: a # reviewer filtering on `index read` must not get FTS traffic, and # `run_fts_query` is a query, not an index operation. - ("list_fts_indexes", 61494), - ("run_fts_query", 61494), - ("upsert_fts_index", 61526), - ("drop_fts_index", 61526), + ("list_fts_indexes", 61496), + ("run_fts_query", 61496), + ("upsert_fts_index", 61528), + ("drop_fts_index", 61528), ], ) def test_tool_maps_to_the_expected_event_id(tool_name, expected_id): diff --git a/tests/unit/test_audit_wiring.py b/tests/unit/test_audit_wiring.py index 1fa6892d..af09b6c0 100644 --- a/tests/unit/test_audit_wiring.py +++ b/tests/unit/test_audit_wiring.py @@ -18,6 +18,7 @@ from __future__ import annotations import asyncio +import dataclasses import json import logging import signal @@ -499,12 +500,19 @@ def test_installing_twice_does_not_chain_the_handler_to_itself(tmp_path): def test_enabling_auditing_on_an_unaudited_server_warns(tmp_path, caplog): """Silence is indistinguishable from a broken sink. - The Operational Insights server accepts every audit flag and reports it, - but records nothing. An operator who enabled auditing there would find an - empty directory, with the only signal being ``active: false`` buried in the - status tool. + A server that declares no audit package accepts every audit flag and + reports it, but records nothing. An operator who enabled auditing there + would find an empty directory, with the only signal being ``active: false`` + buried in the status tool. + + Every server this repository ships is audited since Operational Insights + got its own block on 2026-10-12, so the spec is synthesised rather than + found. The warning is kept, and kept tested, because the next server added + starts out exactly like this one — and the day it does, an operator must be + told rather than left reading an empty file. """ - unaudited = next(sp for sp in ALL_SPECS if sp.audit_package is None) + audited = next(sp for sp in ALL_SPECS if sp.audit_package is not None) + unaudited = dataclasses.replace(audited, id="future-unaudited", audit_package=None) config = _audit_config(tmp_path, sinks="file") with caplog.at_level(logging.WARNING): @@ -514,6 +522,13 @@ def test_enabling_auditing_on_an_unaudited_server_warns(tmp_path, caplog): assert unaudited.id in caplog.text +def test_every_shipped_server_is_audited(): + """Records the state the test above has to synthesise around.""" + assert [sp.audit_package for sp in ALL_SPECS] == [ + sp.audit_package for sp in ALL_SPECS if sp.audit_package is not None + ] + + def test_an_audited_server_does_not_warn(tmp_path, caplog): audited = next(sp for sp in ALL_SPECS if sp.audit_package is not None) config = _audit_config(tmp_path, sinks="file") From a32cda53bfac0bf77436b7aa018bb9787049c4b1 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Mon, 12 Oct 2026 19:45:12 +0530 Subject: [PATCH 22/27] Fix with new space being used --- src/cb_mcp/audit/catalog.py | 17 +--- src/cb_mcp/audit/descriptor.json | 24 +++--- tests/unit/test_audit_catalog.py | 100 +++++++++++++----------- tests/unit/test_audit_classification.py | 11 +-- 4 files changed, 75 insertions(+), 77 deletions(-) diff --git a/src/cb_mcp/audit/catalog.py b/src/cb_mcp/audit/catalog.py index 42b90b57..d24113ad 100644 --- a/src/cb_mcp/audit/catalog.py +++ b/src/cb_mcp/audit/catalog.py @@ -115,16 +115,8 @@ "query": 3, "index": 4, "performance": 5, - # Slot 6 is retired (see RETIRED_SLOTS) and deliberately left unused. - # - # Slot 7 held the retired `analytics` category and was revived for - # `config` on 2026-10-12 by product decision. Permitted by the rule - # below — `analytics` never had a tool classified to it, so the slot - # never emitted a record and no SIEM rule for the old meaning can - # exist. `next_free_slot` still declines to offer retired slots, so - # this reuse was chosen rather than inherited from a helper. + "search": 6, "config": 7, - "search": 8, }, # Operational Insights. Same axis, its own block: the two servers collide # on tool names — `get_server_configuration_status`, `create_index`, @@ -154,14 +146,7 @@ #: :func:`next_free_slot`'s reusable set. RETIRED_SLOTS: dict[str, dict[str, int]] = { "operational": { - # Retired before release: no tool was ever classified to it, so it - # never emitted a record. Left unallocated by choice when the search - # category was added on 2026-09-29 — see the note in CATEGORY_SLOTS. "vector": 6, - # `analytics` held slot 7 and was retired on the same terms. The slot - # was then revived for `config` on 2026-10-12; the entry stays here so - # the history of the number remains visible, and so the invariant test - # can assert that a revived slot was one that never emitted. "analytics": 7, }, } diff --git a/src/cb_mcp/audit/descriptor.json b/src/cb_mcp/audit/descriptor.json index ea711642..eceaee18 100644 --- a/src/cb_mcp/audit/descriptor.json +++ b/src/cb_mcp/audit/descriptor.json @@ -137,18 +137,18 @@ "filterable": true }, { - "id": 61495, - "name": "config read", - "description": "A config read operation was requested through a tool", + "id": 61494, + "name": "search read", + "description": "A search read operation was requested through a tool", "category": "tool_call", "service_package": "operational", "default_enabled": true, "filterable": true }, { - "id": 61496, - "name": "search read", - "description": "A search read operation was requested through a tool", + "id": 61495, + "name": "config read", + "description": "A config read operation was requested through a tool", "category": "tool_call", "service_package": "operational", "default_enabled": true, @@ -209,18 +209,18 @@ "filterable": false }, { - "id": 61527, - "name": "config write", - "description": "A config write operation was requested through a tool", + "id": 61526, + "name": "search write", + "description": "A search write operation was requested through a tool", "category": "tool_call", "service_package": "operational", "default_enabled": true, "filterable": false }, { - "id": 61528, - "name": "search write", - "description": "A search write operation was requested through a tool", + "id": 61527, + "name": "config write", + "description": "A config write operation was requested through a tool", "category": "tool_call", "service_package": "operational", "default_enabled": true, diff --git a/tests/unit/test_audit_catalog.py b/tests/unit/test_audit_catalog.py index 80e9d741..c010e51b 100644 --- a/tests/unit/test_audit_catalog.py +++ b/tests/unit/test_audit_catalog.py @@ -54,11 +54,8 @@ def test_category_order_is_the_wire_contract(): "query", "index", "performance", - # Slot 7, revived from the retired `analytics` category on 2026-10-12 - # by product decision. Safe only because `analytics` never had a tool - # classified to it and so never emitted a record; see RETIRED_SLOTS. - "config", "search", + "config", ) @@ -236,13 +233,8 @@ def test_slot_table_pins_the_shipped_numbers(): "query": 3, "index": 4, "performance": 5, - # Revived the retired `analytics` slot on 2026-10-12. Permitted - # because that category never emitted a record, and taken as an - # explicit decision -- next_free_slot still declines to offer it. + "search": 6, "config": 7, - # Added 2026-09-29 with the FTS tools. Slot 8, not the reusable - # retired 6, so no number has ever meant two things. - "search": 8, } @@ -293,31 +285,24 @@ def test_retiring_a_category_does_not_move_its_neighbours(monkeypatch): def test_next_free_slot_takes_the_lowest_never_allocated(monkeypatch): monkeypatch.setattr(catalog, "RETIRED_SLOTS", {}) monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) - # 0-5 and 8 are allocated, so 6 is the lowest never-allocated slot. - assert catalog.next_free_slot() == 6 + # 0-7 are allocated, so 8 is the lowest never-allocated slot. + assert catalog.next_free_slot() == 8 def test_next_free_slot_skips_retired_slots(monkeypatch): """Retired slots are not offered, even when reuse would be safe. - - Reuse remains *permissible* for a slot that never emitted a record — the - rule in ``RETIRED_SLOTS`` still says so. It is simply not automatic. When - the search category was added the reusable slot 6 was declined in favour of - 8, on the grounds that a number which has meant two different things is a - trap for anyone reading an old rule. Handing retired slots back from a - helper would make the trap the default path. + default path, and the day a slot *has* emitted the default would be wrong. """ - monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"vector": 6}}) + monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"withdrawn": 9}}) monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) - # 7 is live (config) and 8 is live (search), so the next never-allocated - # slot is 9 -- the helper still refuses to offer the retired 6. - assert catalog.next_free_slot() == 9 + # 0-7 are live and 9 is retired, so the helper offers 8 and skips 9. + assert catalog.next_free_slot() == 8 def test_permanently_reserved_slots_are_never_reallocated(monkeypatch): """A slot that has emitted records must not be handed to a new category.""" - monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"vector": 6}}) - monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset({6})) + monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"withdrawn": 8}}) + monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset({8})) assert catalog.next_free_slot() == 9 @@ -352,34 +337,61 @@ def test_retired_categories_are_gone_but_their_slots_are_recorded(): catalog.tool_call_event_id(name, "read") -def test_retired_ids_are_absent_from_the_catalogue_and_descriptor(): - """Slot 6 stays dead: 61494 and 61526 must not resolve to anything. +def test_a_slot_that_ever_emitted_is_never_reallocated(): + """The one rule that outlives every revival. + + Both retired slots have now been revived -- 6 for `search`, 7 for + `config` -- so there is no longer a list of dead numbers to assert against. + What must still hold is the condition that made those revivals legitimate: + a slot may only be reused if no record was ever emitted against it. - Slot 7 (61495/61527) was retired with `analytics` and later revived for - `config`, which the rule permits only because `analytics` never emitted a - record. Slot 6 was not revived, so its pair must still resolve to nothing - -- and this test is what would catch it being handed out by accident. + PERMANENTLY_RESERVED_SLOTS is where a slot goes once it *has* emitted. It + is empty today because nothing has shipped. The day it is not, this test is + what stops one of its numbers being handed to a new category and making an + existing SIEM rule silently match an unrelated event. """ - retired = {61494, 61526} - live = {event.id for event in all_tool_call_events()} - assert live & retired == set() + for package, slots in catalog.CATEGORY_SLOTS.items(): + clash = set(slots.values()) & catalog.PERMANENTLY_RESERVED_SLOTS + assert clash == set(), f"{package} reallocated slots that have emitted: {clash}" shipped = json.loads(DESCRIPTOR_PATH.read_text(encoding="utf-8")) - assert {entry["id"] for entry in shipped["events"]} & retired == set() + dead_ids = { + base + catalog.READ_OFFSET + slot + delta + for base in catalog.SERVICE_PACKAGE_BLOCKS.values() + for slot in catalog.PERMANENTLY_RESERVED_SLOTS + for delta in (0, catalog.WRITE_DELTA) + } + assert {entry["id"] for entry in shipped["events"]} & dead_ids == set() + + +def test_every_revived_slot_was_one_that_never_emitted(): + """Checks the revivals themselves, not just the rule. + + A slot appearing in both RETIRED_SLOTS and CATEGORY_SLOTS is a revival. + Each one is only defensible because its old category never had a tool + classified to it, so no record for that id can exist in anyone's logs. + """ + for package, retired in catalog.RETIRED_SLOTS.items(): + live = catalog.CATEGORY_SLOTS.get(package, {}) + revived = set(retired.values()) & set(live.values()) + assert revived == {6, 7}, revived + assert revived & catalog.PERMANENTLY_RESERVED_SLOTS == set() def test_the_revived_slot_never_emitted_under_its_old_meaning(): - """The precondition for reusing slot 7, asserted rather than assumed. + """The precondition for reusing slots 6 and 7, asserted rather than assumed. Reallocating a slot that had ever emitted a record would make an existing SIEM rule silently match a new, unrelated event. `analytics` was retired with no tool ever classified to it, so no record for 61495 can exist in anyone's logs -- which is the only reason `config` may have the number. """ + assert catalog.RETIRED_SLOTS["operational"]["vector"] == 6 assert catalog.RETIRED_SLOTS["operational"]["analytics"] == 7 + assert catalog.CATEGORY_SLOTS["operational"]["search"] == 6 assert catalog.CATEGORY_SLOTS["operational"]["config"] == 7 - # Permanently reserved slots are the ones that *did* emit; 7 is not one. - assert 7 not in catalog.PERMANENTLY_RESERVED_SLOTS + # Permanently reserved slots are the ones that *did* emit; neither is one. + assert {6, 7} & catalog.PERMANENTLY_RESERVED_SLOTS == set() def test_retirement_did_not_move_the_surviving_ids(): @@ -390,15 +402,15 @@ def test_retirement_did_not_move_the_surviving_ids(): def test_a_new_category_takes_the_next_never_allocated_slot(): - """Slots 6 and 7 stay retired; the next category gets 9, after search's 8.""" - assert catalog.next_free_slot() == 9 + """Slots 0-7 are allocated, so the next category gets 8.""" + assert catalog.next_free_slot() == 8 def test_search_ids_are_the_shipped_numbers(): """Pinned literally, like every other shipped id. - Search took slot 8 rather than the reusable retired slot 6, so these are - 61496/61528 and not 61494/61526. + Search was moved into the revived slot 6 on 2026-10-12, from the slot 8 it + had held since 2026-09-29 without ever shipping. """ - assert tool_call_event_id("search", "read") == 61496 - assert tool_call_event_id("search", "write") == 61528 + assert tool_call_event_id("search", "read") == 61494 + assert tool_call_event_id("search", "write") == 61526 diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py index beda0e10..37a55859 100644 --- a/tests/unit/test_audit_classification.py +++ b/tests/unit/test_audit_classification.py @@ -182,11 +182,12 @@ def test_read_only_tools_are_classified_read(tool_name): ("delete_active_query", 61525), # Search keeps its own category rather than folding into index: a # reviewer filtering on `index read` must not get FTS traffic, and - # `run_fts_query` is a query, not an index operation. - ("list_fts_indexes", 61496), - ("run_fts_query", 61496), - ("upsert_fts_index", 61528), - ("drop_fts_index", 61528), + # `run_fts_query` is a query, not an index operation. Moved into the + # revived slot 6 on 2026-10-12 (was 61496/61528). + ("list_fts_indexes", 61494), + ("run_fts_query", 61494), + ("upsert_fts_index", 61526), + ("drop_fts_index", 61526), ], ) def test_tool_maps_to_the_expected_event_id(tool_name, expected_id): From 8ccafc3152fac8e90b968605b6a0a74f1c40f37c Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Mon, 12 Oct 2026 20:26:06 +0530 Subject: [PATCH 23/27] merge with main --- DOCKER.md | 76 +++++++++--------- README.md | 102 ++++++++++++------------ src/cb_mcp/audit/classification.py | 44 +++++----- tests/unit/test_audit_classification.py | 24 +++--- 4 files changed, 123 insertions(+), 123 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index bf990b07..1de1443f 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -21,7 +21,7 @@ Documentation: | `get_server_configuration_status` | Get the server status and configuration without connecting to the cluster — reports read-only mode, disabled/confirmation-required tools, OAuth settings, and the resolved logging configuration | | `test_cluster_connection` | Check the cluster credentials by connecting to the cluster | | `get_cluster_health_and_services` | Get cluster health status and list of all running services, optionally filtered to specific services via `service_types` | -| `get_cluster_diagnostics_report` | Get the SDK's cached connection diagnostics — whether connections were already broken and for how long, without any active network probing | +| `get_client_connectivity_report` | Get the SDK's cached connection diagnostics — whether connections were already broken and for how long, without any active network probing | | `get_cluster_metrics` | Get one or more cluster statistics over a historic time window via the Management REST API's stats-range endpoint. **Self-managed Couchbase Server 7.6+ only — not available on Capella.** | | `get_cluster_tasks` | Get the cluster tasks running right now — rebalance, compaction, XDCR, index build — via the Management REST API's tasks endpoint. Returns the raw task array; fields vary by task type. Requires the Read-Only Admin (`ro_admin`) role. **Self-managed Couchbase Server 7.6+ only — not available on Capella.** | | `get_cluster_health_snapshot` | Get a per-node health snapshot — service topology, membership, orchestrator and a cluster health rollup — merged from the Management REST API's `/pools/default`, `nodeServices` and `terseClusterInfo` endpoints. Isolates a symptom to a specific node/service and flags which nodes are safe to act on. Requires the Read-Only Admin (`ro_admin`) role. **Self-managed Couchbase Server 7.6+ only — not available on Capella.** | @@ -66,24 +66,24 @@ Documentation: ### Full-text search (FTS) tools -Requires Couchbase Server 7.6+ and the Search service. Vector search *querying* is not supported by these tools (see [Vector search tools](#vector-search-tools) below) — a vector field mapping in `upsert_fts_index` is unaffected by this and already works. +Requires Couchbase Server 7.6+ and the Search service. Vector search *querying* is not supported by these tools (see [Vector search tools](#vector-search-tools) below) — a vector field mapping in `upsert_search_index` is unaffected by this and already works. | Tool Name | Description | | --------- | ----------- | -| `list_fts_indexes` | List Search (FTS) indexes. With no filters, lists cluster-level (legacy) indexes; with `bucket_name`, lists scope-level (scoped) indexes across every scope in that bucket; with `bucket_name` and `scope_name`, lists scope-level indexes in that one scope. | -| `get_fts_index_definition` | Get the full definition of a single Search index (mappings, analyzers, plan params). Pass `bucket_name` and `scope_name` together for a scope-level index, or omit both for a cluster-level (legacy) index. | -| `run_fts_query` | Run an FTS query against a Search index, or fetch its execution plan. `query` is the raw FTS query JSON body, supporting any non-vector query type (match, match_phrase, term, conjuncts, disjuncts, geo, date/numeric range, query_string, ...). Pass `explain=true` to fetch the execution plan instead of results — this still executes the query (`limit` defaulting to 1) since the Search service only exposes the plan per matched hit, not as a separate dry-run call. | -| `upsert_fts_index` | Create or update a Search (FTS) index definition (mappings, analyzers, plan params) — including a vector field mapping (`type: "vector"`, with `dims`/`similarity`; note FTS's similarity values differ from GSI's). Works with both scope-level (scoped) and cluster-level (legacy) indexes. Pass `bucket_name` and `scope_name` together to target a scope-level index, or omit both for a cluster-level (legacy) index. Updating an existing index triggers a full rebuild — fetch the current definition with `get_fts_index_definition` first and pass its `uuid` back to avoid clobbering concurrent changes. **Disabled by default when `CB_MCP_READ_ONLY_MODE=true`.** | -| `drop_fts_index` | Drop a Search (FTS) index. Works with both scope-level (scoped) and cluster-level (legacy) indexes. Pass `bucket_name` and `scope_name` together for a scope-level index, or omit both for a cluster-level (legacy) index. This permanently removes the index and cannot be undone — confirm the exact name and location with `list_fts_indexes` first. **Disabled by default when `CB_MCP_READ_ONLY_MODE=true`.** | +| `list_search_indexes` | List Search (FTS) indexes. With no filters, lists cluster-level (legacy) indexes; with `bucket_name`, lists scope-level (scoped) indexes across every scope in that bucket; with `bucket_name` and `scope_name`, lists scope-level indexes in that one scope. | +| `get_search_index_definition` | Get the full definition of a single Search index (mappings, analyzers, plan params). Pass `bucket_name` and `scope_name` together for a scope-level index, or omit both for a cluster-level (legacy) index. | +| `run_search` | Run an FTS query against a Search index, or fetch its execution plan. `query` is the raw FTS query JSON body, supporting any non-vector query type (match, match_phrase, term, conjuncts, disjuncts, geo, date/numeric range, query_string, ...). Pass `explain=true` to fetch the execution plan instead of results — this still executes the query (`limit` defaulting to 1) since the Search service only exposes the plan per matched hit, not as a separate dry-run call. | +| `upsert_search_index` | Create or update a Search (FTS) index definition (mappings, analyzers, plan params) — including a vector field mapping (`type: "vector"`, with `dims`/`similarity`; note FTS's similarity values differ from GSI's). Works with both scope-level (scoped) and cluster-level (legacy) indexes. Pass `bucket_name` and `scope_name` together to target a scope-level index, or omit both for a cluster-level (legacy) index. Updating an existing index triggers a full rebuild — fetch the current definition with `get_search_index_definition` first and pass its `uuid` back to avoid clobbering concurrent changes. **Disabled by default when `CB_MCP_READ_ONLY_MODE=true`.** | +| `drop_search_index` | Drop a Search (FTS) index. Works with both scope-level (scoped) and cluster-level (legacy) indexes. Pass `bucket_name` and `scope_name` together for a scope-level index, or omit both for a cluster-level (legacy) index. This permanently removes the index and cannot be undone — confirm the exact name and location with `list_search_indexes` first. **Disabled by default when `CB_MCP_READ_ONLY_MODE=true`.** | ### Vector search tools -Both tools embed query text using the model configured via `EMBEDDING_*` environment variables (see the table below) — the caller passes plain text, never a raw vector. `run_vector_search` targets Couchbase Server 8.0+'s GSI vector indexes via SQL++; `run_search_vector_search` targets the Search service's vector search on Couchbase Server 7.6+. Available on both self-managed Couchbase Server and Capella. +Both tools embed query text using the model configured via `EMBEDDING_*` environment variables (see the table below) — the caller passes plain text, never a raw vector. `run_vector_query` targets Couchbase Server 8.0+'s GSI vector indexes via SQL++; `run_vector_search` targets the Search service's vector search on Couchbase Server 7.6+. Available on both self-managed Couchbase Server and Capella. | Tool Name | Description | | --------- | ----------- | -| `run_vector_search` | Embed a query and run a vector similarity search against a GSI vector index (Couchbase Server 8.0+), via SQL++'s `APPROX_VECTOR_DISTANCE()`. GSI selects the index automatically from the vector field referenced in the query — there is no `index_name` parameter. | -| `run_search_vector_search` | Run the Search service's vector search (Couchbase Server 7.6+) against a *named* Search index. `scalar_query` makes this a hybrid search (full-text and vector similarity both contribute to ranking); `prefilter` narrows the vector search's candidate pool before it runs (the Search-service equivalent of `run_vector_search`'s `where` prefilter). Both take the same raw FTS query JSON body `run_fts_query` accepts, and both are optional and combinable. | +| `run_vector_query` | Embed a query and run a vector similarity search against a GSI vector index (Couchbase Server 8.0+), via SQL++'s `APPROX_VECTOR_DISTANCE()`. GSI selects the index automatically from the vector field referenced in the query — there is no `index_name` parameter. | +| `run_vector_search` | Run the Search service's vector search (Couchbase Server 7.6+) against a *named* Search index. `scalar_query` makes this a hybrid search (full-text and vector similarity both contribute to ranking); `prefilter` narrows the vector search's candidate pool before it runs (the Search-service equivalent of `run_vector_query`'s `where` prefilter). Both take the same raw FTS query JSON body `run_search` accepts, and both are optional and combinable. | ### Query performance analysis tools @@ -114,30 +114,30 @@ append `operational-insights` to the container's command to select it instead of the default `operational` server (see [Configuration](#configuration) below). -Every tool name below is prefixed with `oi_` (`get_server_configuration_status` +Every tool name below is prefixed with `operational_insights_` (`get_server_configuration_status` excepted) so none can collide with the operational server's tool names, even if a single MCP client registers both servers at once. | Tool Name | Description | | --------- | ----------- | | `get_server_configuration_status` | Get this server's status and configuration without connecting to a cluster — read-only mode, disabled/confirmation-required tools, OAuth settings, and the resolved logging configuration. Shared with the operational server: the same tool, registered by both. | -| `oi_get_databases_in_cluster` | List all databases in the Operational Insights cluster. | -| `oi_get_scopes_in_database` | List all scopes in a database. | -| `oi_get_collections_in_scope` | List all collections (datasets) in a scope. | -| `oi_get_schema_for_collection` | Infer the JSON schema of a collection by sampling documents. | -| `oi_list_indexes` | List secondary indexes via the `System.Metadata.Index` catalog. | -| `oi_run_query_sync` | Run a SQL++ statement (SELECT, DML, or DDL) and return its result rows, up to `CB_MCP_MAX_QUERY_RESULT_SIZE` (a larger result reports `truncated: true`). Pass `copy_to_link`/`copy_to_bucket`/`copy_to_path` to export the rows to object storage instead. Enforces read-only mode server-side; there is no client-side SQL++ parser. | -| `oi_explain_query` | Generate the query plan for a SQL++ statement via EXPLAIN, without executing it. | -| `oi_create_index` | Create a secondary index via `CREATE INDEX`. **Disabled by default when `CB_MCP_READ_ONLY_MODE=true`.** | -| `oi_run_query_async` | Start a SQL++ statement without waiting for it to finish, returning a `query_handle` token. Accepts the same `copy_to_*` export arguments, and is preferred for a large export. Same read-only enforcement as `oi_run_query_sync`. | -| `oi_get_async_query_results` | Check whether an async query has finished and, if so, return its rows. | -| `oi_discard_async_query_results` | Free a finished async query's result buffers on the server. | -| `oi_cancel_async_query` | Stop an async query that is still running. Available in read-only mode: cancelling releases resources the caller allocated and does not modify stored data. | +| `operational_insights_get_databases_in_cluster` | List all databases in the Operational Insights cluster. | +| `operational_insights_get_scopes_in_database` | List all scopes in a database. | +| `operational_insights_get_collections_in_scope` | List all collections (datasets) in a scope. | +| `operational_insights_get_schema_for_collection` | Infer the JSON schema of a collection by sampling documents. | +| `operational_insights_list_indexes` | List secondary indexes via the `System.Metadata.Index` catalog. | +| `operational_insights_run_query_sync` | Run a SQL++ statement (SELECT, DML, or DDL) and return its result rows, up to `CB_MCP_MAX_QUERY_RESULT_SIZE` (a larger result reports `truncated: true`). Pass `copy_to_link`/`copy_to_bucket`/`copy_to_path` to export the rows to object storage instead. Enforces read-only mode server-side; there is no client-side SQL++ parser. | +| `operational_insights_explain_query` | Generate the query plan for a SQL++ statement via EXPLAIN, without executing it. | +| `operational_insights_create_index` | Create a secondary index via `CREATE INDEX`. **Disabled by default when `CB_MCP_READ_ONLY_MODE=true`.** | +| `operational_insights_run_query_async` | Start a SQL++ statement without waiting for it to finish, returning a `query_handle` token. Accepts the same `copy_to_*` export arguments, and is preferred for a large export. Same read-only enforcement as `operational_insights_run_query_sync`. | +| `operational_insights_get_async_query_results` | Check whether an async query has finished and, if so, return its rows. | +| `operational_insights_discard_async_query_results` | Free a finished async query's result buffers on the server. | +| `operational_insights_cancel_async_query` | Stop an async query that is still running. Available in read-only mode: cancelling releases resources the caller allocated and does not modify stored data. | The Server Async Request API tools form a start → poll → discard-or-cancel -flow: `oi_run_query_async` returns a `query_handle`, `oi_get_async_query_results` is -polled until ready, then `oi_discard_async_query_results` frees the results or -`oi_cancel_async_query` stops a still-running query. +flow: `operational_insights_run_query_async` returns a `query_handle`, `operational_insights_get_async_query_results` is +polled until ready, then `operational_insights_discard_async_query_results` frees the results or +`operational_insights_cancel_async_query` stops a still-running query. ## Usage @@ -177,14 +177,14 @@ Add the configuration specified below to the MCP configuration in your MCP clien To run the [Operational Insights server](#operational-insights-tools) instead, append `operational-insights` to `args` and use its own env vars -(`CB_OI_CONNECTION_STRING`/`CB_OI_USERNAME`/`CB_OI_PASSWORD`, see below) — +(`CB_OPERATIONAL_INSIGHTS_CONNECTION_STRING`/`CB_OPERATIONAL_INSIGHTS_USERNAME`/`CB_OPERATIONAL_INSIGHTS_PASSWORD`, see below) — with no trailing argument the container runs the default operational server: ```bash docker run --rm -i \ - -e CB_OI_CONNECTION_STRING=http://localhost:8095 \ - -e CB_OI_USERNAME=Administrator \ - -e CB_OI_PASSWORD=password \ + -e CB_OPERATIONAL_INSIGHTS_CONNECTION_STRING=http://localhost:8095 \ + -e CB_OPERATIONAL_INSIGHTS_USERNAME=Administrator \ + -e CB_OPERATIONAL_INSIGHTS_PASSWORD=password \ docker.io/couchbase/mcp-server:latest operational-insights ``` @@ -200,13 +200,13 @@ The detailed explanation for the environment variables can be found on the [GitH | `CB_CLIENT_CERT_PATH` | Path to the client certificate file for mTLS authentication | **Required if using mTLS (or Username and Password required)** | | `CB_CLIENT_KEY_PATH` | Path to the client key file for mTLS authentication | **Required if using mTLS (or Username and Password required)** | | `CB_CA_CERT_PATH` | Path to server root certificate for TLS if server is configured with a self-signed/untrusted certificate. | | -| `CB_OI_CONNECTION_STRING` | [Operational Insights server](#operational-insights-tools) endpoint URL (HTTP/HTTPS, not `couchbase://`). Ignored by the default `operational` server. | **Required for `operational-insights`** | -| `CB_OI_USERNAME` | Operational Insights username. Ignored by the default `operational` server. | **Required for `operational-insights`** | -| `CB_OI_PASSWORD` | Operational Insights password. Ignored by the default `operational` server. | **Required for `operational-insights`** | -| `CB_OI_CA_CERT_PATH` | Path to server root certificate (PEM) for the Operational Insights server, if self-signed/untrusted. Ignored by the default `operational` server. | | -| `CB_OI_CLIENT_CERT_PATH` | Path to the client certificate for Operational Insights mTLS authentication (PEM, or a PKCS#12 bundle with `CB_OI_CLIENT_KEY_PATH` unset). Requires an `https://` `CB_OI_CONNECTION_STRING`; overrides username/password when set. Ignored by the default `operational` server. | **Required if using mTLS (or Username and Password required)** | -| `CB_OI_CLIENT_KEY_PATH` | Path to the client certificate's private key (PEM) for Operational Insights mTLS. Leave unset for a PKCS#12 bundle. Ignored by the default `operational` server. | **Required if using mTLS (or Username and Password required)** | -| `CB_OI_CLIENT_CERT_PASSWORD` | Decryption password for an encrypted Operational Insights client key/PKCS#12 bundle. Ignored by the default `operational` server. | | +| `CB_OPERATIONAL_INSIGHTS_CONNECTION_STRING` | [Operational Insights server](#operational-insights-tools) endpoint URL (HTTP/HTTPS, not `couchbase://`). Ignored by the default `operational` server. | **Required for `operational-insights`** | +| `CB_OPERATIONAL_INSIGHTS_USERNAME` | Operational Insights username. Ignored by the default `operational` server. | **Required for `operational-insights`** | +| `CB_OPERATIONAL_INSIGHTS_PASSWORD` | Operational Insights password. Ignored by the default `operational` server. | **Required for `operational-insights`** | +| `CB_OPERATIONAL_INSIGHTS_CA_CERT_PATH` | Path to server root certificate (PEM) for the Operational Insights server, if self-signed/untrusted. Ignored by the default `operational` server. | | +| `CB_OPERATIONAL_INSIGHTS_CLIENT_CERT_PATH` | Path to the client certificate for Operational Insights mTLS authentication (PEM, or a PKCS#12 bundle with `CB_OPERATIONAL_INSIGHTS_CLIENT_KEY_PATH` unset). Requires an `https://` `CB_OPERATIONAL_INSIGHTS_CONNECTION_STRING`; overrides username/password when set. Ignored by the default `operational` server. | **Required if using mTLS (or Username and Password required)** | +| `CB_OPERATIONAL_INSIGHTS_CLIENT_KEY_PATH` | Path to the client certificate's private key (PEM) for Operational Insights mTLS. Leave unset for a PKCS#12 bundle. Ignored by the default `operational` server. | **Required if using mTLS (or Username and Password required)** | +| `CB_OPERATIONAL_INSIGHTS_CLIENT_CERT_PASSWORD` | Decryption password for an encrypted Operational Insights client key/PKCS#12 bundle. Ignored by the default `operational` server. | | | `CB_MCP_READ_ONLY_MODE` | Prevent all data modifications (KV, Query, index management, and FTS index management). When `true`, write tools are not loaded. | `true` | | `CB_MCP_TRANSPORT` | Transport mode (stdio/http/sse) | `stdio` | | `CB_MCP_HOST` | Server host (HTTP/SSE modes) | `127.0.0.1` | @@ -242,7 +242,7 @@ The detailed explanation for the environment variables can be found on the [GitH | `CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS` | Rotated backups retained per audit file, excluding the live file; they are gzipped. Bounds one file, not the directory: each container start creates a new file with its own backups | `10` | | `CB_MCP_AUDIT_LOG_TOOL_ARGS` | Record tool argument values. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | | `CB_MCP_AUDIT_LOG_DISABLED_EVENTS` | Audit events to suppress: comma-separated numeric event ids (e.g. `61490,61491`), or a file with one id per line. Only filterable events can be suppressed | None | -| `EMBEDDING_PROVIDER` | Embedding provider for `run_vector_search` / `run_search_vector_search`: one of `couchbase`, `openai`, `cohere`, `voyage`, `bedrock`. Unset disables both tools' embedding step until configured. | None | +| `EMBEDDING_PROVIDER` | Embedding provider for `run_vector_query` / `run_vector_search`: one of `couchbase`, `openai`, `cohere`, `voyage`, `bedrock`. Unset disables both tools' embedding step until configured. | None | | `EMBEDDING_MODEL` | Model name/ID for the configured embedding provider | None | | `EMBEDDING_API_KEY` | API key for the configured provider. Not used by `bedrock` (uses the AWS credential chain / `EMBEDDING_AWS_*` instead) | None | | `EMBEDDING_ENDPOINT` | Base URL override (an OpenAI-compatible local server, or a Couchbase Model Service deployment's own URL — **required** when `EMBEDDING_PROVIDER=couchbase`) | None | diff --git a/README.md b/README.md index 01f4b343..281ce8ea 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ the `couchbase-operational-insights` SDK. | `get_server_configuration_status` | Get the server status and configuration without connecting to the cluster — reports read-only mode, disabled/confirmation-required tools, OAuth settings, and the resolved logging configuration | | `test_cluster_connection` | Check the cluster credentials by connecting to the cluster | | `get_cluster_health_and_services` | Get cluster health status and list of all running services, optionally filtered to specific services via `service_types` | -| `get_cluster_diagnostics_report` | Get the SDK's cached connection diagnostics — whether connections were already broken and for how long, without any active network probing | +| `get_client_connectivity_report` | Get the SDK's cached connection diagnostics — whether connections were already broken and for how long, without any active network probing | | `get_cluster_metrics` | Get one or more cluster statistics over a historic time window via the Management REST API's stats-range endpoint. **Self-managed Couchbase Server 7.6+ only — not available on Capella.** | | `get_cluster_tasks` | Get the cluster tasks running right now — rebalance, compaction, XDCR, index build — via the Management REST API's tasks endpoint. Returns the raw task array; fields vary by task type. Requires the Read-Only Admin (`ro_admin`) role. **Self-managed Couchbase Server 7.6+ only — not available on Capella.** | | `get_cluster_health_snapshot` | Get a per-node health snapshot — service topology, membership, orchestrator and a cluster health rollup — merged from the Management REST API's `/pools/default`, `nodeServices` and `terseClusterInfo` endpoints. Isolates a symptom to a specific node/service and flags which nodes are safe to act on. Requires the Read-Only Admin (`ro_admin`) role. **Self-managed Couchbase Server 7.6+ only — not available on Capella.** | @@ -119,24 +119,24 @@ the `couchbase-operational-insights` SDK. ### Full-text search (FTS) tools -Requires Couchbase Server 7.6+ and the Search service. Vector search *querying* is not supported by these tools (see [Vector search tools](#vector-search-tools) below) — a vector field mapping in `upsert_fts_index` is unaffected by this and already works. +Requires Couchbase Server 7.6+ and the Search service. Vector search *querying* is not supported by these tools (see [Vector search tools](#vector-search-tools) below) — a vector field mapping in `upsert_search_index` is unaffected by this and already works. | Tool Name | Description | | --------- | ----------- | -| `list_fts_indexes` | List Search (FTS) indexes. With no filters, lists cluster-level (legacy) indexes; with `bucket_name`, lists scope-level (scoped) indexes across every scope in that bucket; with `bucket_name` and `scope_name`, lists scope-level indexes in that one scope. | -| `get_fts_index_definition` | Get the full definition of a single Search index (mappings, analyzers, plan params). Pass `bucket_name` and `scope_name` together for a scope-level index, or omit both for a cluster-level (legacy) index. | -| `run_fts_query` | Run an FTS query against a Search index, or fetch its execution plan. `query` is the raw FTS query JSON body, supporting any non-vector query type (match, match_phrase, term, conjuncts, disjuncts, geo, date/numeric range, query_string, ...). Pass `explain=true` to fetch the execution plan instead of results — this still executes the query (`limit` defaulting to 1) since the Search service only exposes the plan per matched hit, not as a separate dry-run call. | -| `upsert_fts_index` | Create or update a Search (FTS) index definition (mappings, analyzers, plan params) — including a vector field mapping (`type: "vector"`, with `dims`/`similarity`; note FTS's similarity values differ from GSI's). Works with both scope-level (scoped) and cluster-level (legacy) indexes. Pass `bucket_name` and `scope_name` together to target a scope-level index, or omit both for a cluster-level (legacy) index. Updating an existing index triggers a full rebuild — fetch the current definition with `get_fts_index_definition` first and pass its `uuid` back to avoid clobbering concurrent changes. **Disabled by default when `CB_MCP_READ_ONLY_MODE=true`.** | -| `drop_fts_index` | Drop a Search (FTS) index. Works with both scope-level (scoped) and cluster-level (legacy) indexes. Pass `bucket_name` and `scope_name` together for a scope-level index, or omit both for a cluster-level (legacy) index. This permanently removes the index and cannot be undone — confirm the exact name and location with `list_fts_indexes` first. **Disabled by default when `CB_MCP_READ_ONLY_MODE=true`.** | +| `list_search_indexes` | List Search (FTS) indexes. With no filters, lists cluster-level (legacy) indexes; with `bucket_name`, lists scope-level (scoped) indexes across every scope in that bucket; with `bucket_name` and `scope_name`, lists scope-level indexes in that one scope. | +| `get_search_index_definition` | Get the full definition of a single Search index (mappings, analyzers, plan params). Pass `bucket_name` and `scope_name` together for a scope-level index, or omit both for a cluster-level (legacy) index. | +| `run_search` | Run an FTS query against a Search index, or fetch its execution plan. `query` is the raw FTS query JSON body, supporting any non-vector query type (match, match_phrase, term, conjuncts, disjuncts, geo, date/numeric range, query_string, ...). Pass `explain=true` to fetch the execution plan instead of results — this still executes the query (`limit` defaulting to 1) since the Search service only exposes the plan per matched hit, not as a separate dry-run call. | +| `upsert_search_index` | Create or update a Search (FTS) index definition (mappings, analyzers, plan params) — including a vector field mapping (`type: "vector"`, with `dims`/`similarity`; note FTS's similarity values differ from GSI's). Works with both scope-level (scoped) and cluster-level (legacy) indexes. Pass `bucket_name` and `scope_name` together to target a scope-level index, or omit both for a cluster-level (legacy) index. Updating an existing index triggers a full rebuild — fetch the current definition with `get_search_index_definition` first and pass its `uuid` back to avoid clobbering concurrent changes. **Disabled by default when `CB_MCP_READ_ONLY_MODE=true`.** | +| `drop_search_index` | Drop a Search (FTS) index. Works with both scope-level (scoped) and cluster-level (legacy) indexes. Pass `bucket_name` and `scope_name` together for a scope-level index, or omit both for a cluster-level (legacy) index. This permanently removes the index and cannot be undone — confirm the exact name and location with `list_search_indexes` first. **Disabled by default when `CB_MCP_READ_ONLY_MODE=true`.** | ### Vector search tools -Both tools embed query text using the model configured via [Embedding Provider Configuration](#embedding-provider-configuration) — the caller passes plain text, never a raw vector. `run_vector_search` targets Couchbase Server 8.0+'s GSI vector indexes (Composite Vector Index / Hyperscale Vector Index) via SQL++; `run_search_vector_search` targets the Search service's vector search on Couchbase Server 7.6+. Available on both self-managed Couchbase Server and Capella. +Both tools embed query text using the model configured via [Embedding Provider Configuration](#embedding-provider-configuration) — the caller passes plain text, never a raw vector. `run_vector_query` targets Couchbase Server 8.0+'s GSI vector indexes (Composite Vector Index / Hyperscale Vector Index) via SQL++; `run_vector_search` targets the Search service's vector search on Couchbase Server 7.6+. Available on both self-managed Couchbase Server and Capella. | Tool Name | Description | | --------- | ----------- | -| `run_vector_search` | Embed a query and run a vector similarity search against a GSI vector index (Couchbase Server 8.0+), via SQL++'s `APPROX_VECTOR_DISTANCE()`. Unlike Search-service vector search, GSI selects the index automatically from the vector field referenced in the query — there is no `index_name` parameter. Returns full documents (or `select_fields`, if given); supports an optional raw SQL++ `where` prefilter and Hyperscale Vector Index tuning (`num_probes`/`rerank`/`top_n_scan`). | -| `run_search_vector_search` | Run the Search service's vector search (Couchbase Server 7.6+) against a *named* Search index. Two independent, combinable options each take the same raw FTS query JSON body `run_fts_query` accepts: `scalar_query` makes this a genuinely hybrid search (relevance-scored full-text matching and vector similarity both contribute to ranking), while `prefilter` narrows which documents the vector search is even allowed to consider as candidates before it runs — the Search-service equivalent of `run_vector_search`'s `where` prefilter. Returns index-stored fields per hit, same as `run_fts_query` (not full documents — follow up with `get_document_by_id` for those). | +| `run_vector_query` | Embed a query and run a vector similarity search against a GSI vector index (Couchbase Server 8.0+), via SQL++'s `APPROX_VECTOR_DISTANCE()`. Unlike Search-service vector search, GSI selects the index automatically from the vector field referenced in the query — there is no `index_name` parameter. Returns full documents (or `select_fields`, if given); supports an optional raw SQL++ `where` prefilter and Hyperscale Vector Index tuning (`num_probes`/`rerank`/`top_n_scan`). | +| `run_vector_search` | Run the Search service's vector search (Couchbase Server 7.6+) against a *named* Search index. Two independent, combinable options each take the same raw FTS query JSON body `run_search` accepts: `scalar_query` makes this a genuinely hybrid search (relevance-scored full-text matching and vector similarity both contribute to ranking), while `prefilter` narrows which documents the vector search is even allowed to consider as candidates before it runs — the Search-service equivalent of `run_vector_query`'s `where` prefilter. Returns index-stored fields per hit, same as `run_search` (not full documents — follow up with `get_document_by_id` for those). | ### Query performance analysis tools @@ -166,31 +166,31 @@ Registered by the separate `operational-insights` server (see [Operational Insights Server](#operational-insights-server) below), not the default `operational` one. -Every tool name below is prefixed with `oi_` (`get_server_configuration_status` +Every tool name below is prefixed with `operational_insights_` (`get_server_configuration_status` excepted) so none can collide with the operational server's tool names, even if a single MCP client registers both servers at once. | Tool Name | Description | | --------- | ----------- | | `get_server_configuration_status` | Get this server's status and configuration without connecting to a cluster — read-only mode, disabled/confirmation-required tools, OAuth settings, and the resolved logging configuration. Shared with the operational server: the same tool, registered by both. | -| `oi_get_databases_in_cluster` | List all databases in the Operational Insights cluster. | -| `oi_get_scopes_in_database` | List all scopes in a database. | -| `oi_get_collections_in_scope` | List all collections (datasets) in a scope. | -| `oi_get_schema_for_collection` | Infer the JSON schema of a collection by sampling documents. | -| `oi_list_indexes` | List secondary indexes via the `System.Metadata.Index` catalog (the SDK has no index manager). | -| `oi_run_query_sync` | Run a SQL++ statement (SELECT, DML, or DDL) and return its result rows, up to `CB_MCP_MAX_QUERY_RESULT_SIZE`; a larger result is cut short and reported with `truncated: true`. Pass `copy_to_link`, `copy_to_bucket` and `copy_to_path` to export the rows to object storage instead of returning them (see [Exporting large results](#exporting-large-results-operational-insights)). Enforces read-only mode server-side via `QueryOptions(readonly=True)` — there is no client-side SQL++ parser here. | -| `oi_explain_query` | Generate the query plan for a SQL++ statement via EXPLAIN, without executing it. | -| `oi_create_index` | Create a secondary index via `CREATE INDEX` (the SDK has no index manager). **Disabled by default when `CB_MCP_READ_ONLY_MODE=true`.** | -| `oi_run_query_async` | Start a SQL++ statement without waiting for it to finish, returning a `query_handle` token. Accepts the same `copy_to_*` export arguments as `oi_run_query_sync`, and is the better choice for a large export since it does not hold the request open for the upload. Same read-only enforcement as `oi_run_query_sync`. | -| `oi_get_async_query_results` | Check whether an async query has finished and, if so, return its rows (subject to the same `CB_MCP_MAX_QUERY_RESULT_SIZE` budget, reported via `truncated`). For an exporting query it reports completion and names the destination instead of returning rows. Doubles as the status check — call again later if not yet ready. | -| `oi_discard_async_query_results` | Free a finished async query's result buffers on the server. Normal cleanup step after `oi_get_async_query_results`. | -| `oi_cancel_async_query` | Stop an async query that is still running. Available in read-only mode: cancelling releases resources the caller allocated and does not modify stored data. A finished query cannot be cancelled — discard its results instead. | +| `operational_insights_get_databases_in_cluster` | List all databases in the Operational Insights cluster. | +| `operational_insights_get_scopes_in_database` | List all scopes in a database. | +| `operational_insights_get_collections_in_scope` | List all collections (datasets) in a scope. | +| `operational_insights_get_schema_for_collection` | Infer the JSON schema of a collection by sampling documents. | +| `operational_insights_list_indexes` | List secondary indexes via the `System.Metadata.Index` catalog (the SDK has no index manager). | +| `operational_insights_run_query_sync` | Run a SQL++ statement (SELECT, DML, or DDL) and return its result rows, up to `CB_MCP_MAX_QUERY_RESULT_SIZE`; a larger result is cut short and reported with `truncated: true`. Pass `copy_to_link`, `copy_to_bucket` and `copy_to_path` to export the rows to object storage instead of returning them (see [Exporting large results](#exporting-large-results-operational-insights)). Enforces read-only mode server-side via `QueryOptions(readonly=True)` — there is no client-side SQL++ parser here. | +| `operational_insights_explain_query` | Generate the query plan for a SQL++ statement via EXPLAIN, without executing it. | +| `operational_insights_create_index` | Create a secondary index via `CREATE INDEX` (the SDK has no index manager). **Disabled by default when `CB_MCP_READ_ONLY_MODE=true`.** | +| `operational_insights_run_query_async` | Start a SQL++ statement without waiting for it to finish, returning a `query_handle` token. Accepts the same `copy_to_*` export arguments as `operational_insights_run_query_sync`, and is the better choice for a large export since it does not hold the request open for the upload. Same read-only enforcement as `operational_insights_run_query_sync`. | +| `operational_insights_get_async_query_results` | Check whether an async query has finished and, if so, return its rows (subject to the same `CB_MCP_MAX_QUERY_RESULT_SIZE` budget, reported via `truncated`). For an exporting query it reports completion and names the destination instead of returning rows. Doubles as the status check — call again later if not yet ready. | +| `operational_insights_discard_async_query_results` | Free a finished async query's result buffers on the server. Normal cleanup step after `operational_insights_get_async_query_results`. | +| `operational_insights_cancel_async_query` | Stop an async query that is still running. Available in read-only mode: cancelling releases resources the caller allocated and does not modify stored data. A finished query cannot be cancelled — discard its results instead. | The Server Async Request API tools form a start → poll → discard-or-cancel -flow for long-running queries: `oi_run_query_async` returns a `query_handle`, -`oi_get_async_query_results` is polled until it reports readiness (and returns -the rows), then either `oi_discard_async_query_results` frees the results or, -for a query still running, `oi_cancel_async_query` stops it. +flow for long-running queries: `operational_insights_run_query_async` returns a `query_handle`, +`operational_insights_get_async_query_results` is polled until it reports readiness (and returns +the rows), then either `operational_insights_discard_async_query_results` frees the results or, +for a query still running, `operational_insights_cancel_async_query` stops it. ## Prerequisites @@ -306,7 +306,7 @@ The server can be configured using environment variables or command line argumen | `CB_MCP_PORT` | `--port` | Port for HTTP/SSE transport modes | `8000` | | `CB_MCP_DISABLED_TOOLS` | `--disabled-tools` | Tools to disable (see [Disabling Tools](#disabling-tools)) | None | | `CB_MCP_CONFIRMATION_REQUIRED_TOOLS` | `--confirmation-required-tools` | Tools that require explicit user confirmation before execution via MCP elicitation (see [Elicitation/Confirmation Required Tools](#elicitationconfirmation-for-tool-calls)) | None | -| `CB_MCP_MAX_QUERY_RESULT_SIZE` | `--max-query-result-size` | Maximum size **in bytes** of a query tool's result. Rows are streamed from the cluster and reading stops once the budget is reached; the response then reports `truncated: true` along with a `truncation` object explaining what happened. Applies to `run_sql_plus_plus_query` (operational) and `oi_run_query_sync` / `oi_get_async_query_results` (Operational Insights). Values above `1048576` (1 MB) or below `1024` (1 kB) are clamped with a startup warning | `15360` (15 kB) | +| `CB_MCP_MAX_QUERY_RESULT_SIZE` | `--max-query-result-size` | Maximum size **in bytes** of a query tool's result. Rows are streamed from the cluster and reading stops once the budget is reached; the response then reports `truncated: true` along with a `truncation` object explaining what happened. Applies to `run_sql_plus_plus_query` (operational) and `operational_insights_run_query_sync` / `operational_insights_get_async_query_results` (Operational Insights). Values above `1048576` (1 MB) or below `1024` (1 kB) are clamped with a startup warning | `15360` (15 kB) | | `CB_MCP_LOG_LEVEL` | `--log-level` | Logging level for the MCP server: `off`, `debug`, `info`, `warning`, `error` (see [Logging](#logging)) | `info` | | `CB_MCP_LOG_SINKS` | `--log-sinks` | Comma-separated log destinations: `stderr`, `file`, or both (see [Logging](#logging)) | `stderr` | | `CB_MCP_LOG_FILE` | `--log-file` | Base path for per-level log files (only used when the `file` sink is enabled) | `mcp_server.log` | @@ -336,7 +336,7 @@ The server can be configured using environment variables or command line argumen | `CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS` | `--audit-log-retention-max-backups` | Rotated backups retained **per audit file**, excluding the live file. Backups are gzipped. `0` keeps none. This bounds one file, not the directory — see [Auditing](#auditing) | `10` | | `CB_MCP_AUDIT_LOG_TOOL_ARGS` | `--audit-log-tool-args` | Record tool argument values in the audit log. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | | `CB_MCP_AUDIT_LOG_DISABLED_EVENTS` | `--audit-log-disabled-events` | Audit events to suppress: comma-separated numeric event ids (e.g. `61490,61491`), or a file with one id per line. Only filterable events can be suppressed | None | -| `EMBEDDING_PROVIDER` | `--embedding-provider` | Embedding provider for `run_vector_search` / `run_search_vector_search`: one of `couchbase`, `openai`, `cohere`, `voyage`, `bedrock`. Unset disables both tools' embedding step until configured — see [Embedding Provider Configuration](#embedding-provider-configuration) | None | +| `EMBEDDING_PROVIDER` | `--embedding-provider` | Embedding provider for `run_vector_query` / `run_vector_search`: one of `couchbase`, `openai`, `cohere`, `voyage`, `bedrock`. Unset disables both tools' embedding step until configured — see [Embedding Provider Configuration](#embedding-provider-configuration) | None | | `EMBEDDING_MODEL` | `--embedding-model` | Model name/ID for the configured embedding provider | None | | `EMBEDDING_API_KEY` | `--embedding-api-key` | API key for the configured provider. Not used by `bedrock` (uses the AWS credential chain / `EMBEDDING_AWS_*` instead) | None | | `EMBEDDING_ENDPOINT` | `--embedding-endpoint` | Base URL override (an OpenAI-compatible local server, or a Couchbase Model Service deployment's own URL — **required** when `EMBEDDING_PROVIDER=couchbase`) | None | @@ -348,7 +348,7 @@ The server can be configured using environment variables or command line argumen **`CB_MCP_READ_ONLY_MODE`** is the single switch controlling write operations: -- When `true` (default): All write operations (KV, Query, scope/collection management, index management, and FTS index management) are disabled. All write tools (KV: upsert, insert, replace, delete, sub-document mutate; scope/collection management: create_scope, create_collection, delete_scope, delete_collection; index management: create_index, create_query_index, build_index, drop_index; FTS index management: upsert_fts_index, drop_fts_index) are **not loaded** and will not be available to the LLM, and SQL++ queries that modify data or structure are blocked. +- When `true` (default): All write operations (KV, Query, scope/collection management, index management, and FTS index management) are disabled. All write tools (KV: upsert, insert, replace, delete, sub-document mutate; scope/collection management: create_scope, create_collection, delete_scope, delete_collection; index management: create_index, create_query_index, build_index, drop_index; FTS index management: upsert_search_index, drop_search_index) are **not loaded** and will not be available to the LLM, and SQL++ queries that modify data or structure are blocked. - When `false`: All write tools are loaded and SQL++ data/structure modification queries are allowed. This is the recommended safe default to prevent inadvertent data modifications by LLMs. @@ -358,7 +358,7 @@ This is the recommended safe default to prevent inadvertent data modifications b #### Embedding Provider Configuration -`EMBEDDING_PROVIDER` selects which model backend `run_vector_search` and `run_search_vector_search` embed query text with. It's entirely optional — the server starts fine with none of the `EMBEDDING_*` variables set, and both tools stay registered; calling either without a provider configured returns an error explaining what to set, rather than failing at startup or disappearing from tool discovery. Each provider needs a different subset of the `EMBEDDING_*` variables: +`EMBEDDING_PROVIDER` selects which model backend `run_vector_query` and `run_vector_search` embed query text with. It's entirely optional — the server starts fine with none of the `EMBEDDING_*` variables set, and both tools stay registered; calling either without a provider configured returns an error explaining what to set, rather than failing at startup or disappearing from tool discovery. Each provider needs a different subset of the `EMBEDDING_*` variables: | `EMBEDDING_PROVIDER` | Required | Optional | Notes | | --------------------- | -------- | -------- | ----- | @@ -410,7 +410,7 @@ Today this affects the eight tools that reach Couchbase through an admin REST en | `get_active_queries` | Query service REST (each query node's `/admin/active_requests`) | | `delete_active_query` | Query service REST (`DELETE /admin/active_requests/{id}`) | -Tools that go through the Couchbase SDK are unaffected and stay available on Capella — `get_cluster_health_and_services` (ping) and `get_cluster_diagnostics_report` (diagnostics) among them. +Tools that go through the Couchbase SDK are unaffected and stay available on Capella — `get_cluster_health_and_services` (ping) and `get_client_connectivity_report` (diagnostics) among them. #### Supported Formats @@ -767,9 +767,9 @@ uvx couchbase-mcp-server operational-insights uv run src/mcp_server.py operational-insights # or, via Docker: docker run --rm -i \ - -e CB_OI_CONNECTION_STRING=http://localhost:8095 \ - -e CB_OI_USERNAME=Administrator \ - -e CB_OI_PASSWORD=password \ + -e CB_OPERATIONAL_INSIGHTS_CONNECTION_STRING=http://localhost:8095 \ + -e CB_OPERATIONAL_INSIGHTS_USERNAME=Administrator \ + -e CB_OPERATIONAL_INSIGHTS_PASSWORD=password \ couchbase/mcp-server: operational-insights ``` @@ -780,13 +780,13 @@ common misconfiguration when pointing this server at a cluster. | CLI Argument | Environment Variable | Description | Default | | ------------- | --------------------- | ------------ | ------- | -| `--connection-string` | `CB_OI_CONNECTION_STRING` | Operational Insights endpoint URL (HTTP/HTTPS, not `couchbase://`) | None | -| `--username` | `CB_OI_USERNAME` | Operational Insights username | None | -| `--password` | `CB_OI_PASSWORD` | Operational Insights password | None | -| `--ca-cert-path` | `CB_OI_CA_CERT_PATH` | Path to server root certificate (PEM), for verifying a self-signed/untrusted server certificate | None | -| `--client-cert-path` | `CB_OI_CLIENT_CERT_PATH` | Path to the client certificate for mTLS authentication — a PEM cert (paired with `--client-key-path`) or a PKCS#12 bundle (`.p12`/`.pfx`, `--client-key-path` left unset). Requires an `https://` `--connection-string`; overrides `--username`/`--password` when set | None | -| `--client-key-path` | `CB_OI_CLIENT_KEY_PATH` | Path to the client certificate's private key (PEM). Leave unset when `--client-cert-path` is a PKCS#12 bundle | None | -| `--client-cert-password` | `CB_OI_CLIENT_CERT_PASSWORD` | Decryption password for an encrypted client key or PKCS#12 bundle | None | +| `--connection-string` | `CB_OPERATIONAL_INSIGHTS_CONNECTION_STRING` | Operational Insights endpoint URL (HTTP/HTTPS, not `couchbase://`) | None | +| `--username` | `CB_OPERATIONAL_INSIGHTS_USERNAME` | Operational Insights username | None | +| `--password` | `CB_OPERATIONAL_INSIGHTS_PASSWORD` | Operational Insights password | None | +| `--ca-cert-path` | `CB_OPERATIONAL_INSIGHTS_CA_CERT_PATH` | Path to server root certificate (PEM), for verifying a self-signed/untrusted server certificate | None | +| `--client-cert-path` | `CB_OPERATIONAL_INSIGHTS_CLIENT_CERT_PATH` | Path to the client certificate for mTLS authentication — a PEM cert (paired with `--client-key-path`) or a PKCS#12 bundle (`.p12`/`.pfx`, `--client-key-path` left unset). Requires an `https://` `--connection-string`; overrides `--username`/`--password` when set | None | +| `--client-key-path` | `CB_OPERATIONAL_INSIGHTS_CLIENT_KEY_PATH` | Path to the client certificate's private key (PEM). Leave unset when `--client-cert-path` is a PKCS#12 bundle | None | +| `--client-cert-password` | `CB_OPERATIONAL_INSIGHTS_CLIENT_CERT_PASSWORD` | Decryption password for an encrypted client key or PKCS#12 bundle | None | Every other flag (`--read-only-mode`, `--transport`, `--host`, `--port`, `--disabled-tools`, `--confirmation-required-tools`, `--log-*`, @@ -807,9 +807,9 @@ Example MCP client configuration: "command": "uvx", "args": ["couchbase-mcp-server", "operational-insights"], "env": { - "CB_OI_CONNECTION_STRING": "http://localhost:8095", - "CB_OI_USERNAME": "Administrator", - "CB_OI_PASSWORD": "password" + "CB_OPERATIONAL_INSIGHTS_CONNECTION_STRING": "http://localhost:8095", + "CB_OPERATIONAL_INSIGHTS_USERNAME": "Administrator", + "CB_OPERATIONAL_INSIGHTS_PASSWORD": "password" } } } @@ -824,8 +824,8 @@ operational server. A query whose result exceeds `CB_MCP_MAX_QUERY_RESULT_SIZE` comes back truncated, which is the right default for a tool whose output is read by an -LLM — but sometimes the full data is what you need. `oi_run_query_sync` and -`oi_run_query_async` can write the rows to external object storage instead of +LLM — but sometimes the full data is what you need. `operational_insights_run_query_sync` and +`operational_insights_run_query_async` can write the rows to external object storage instead of returning them, so the size of the result stops mattering. Pass all three destination arguments: @@ -859,10 +859,10 @@ Notes: - **All three of link, bucket and path are required.** A partial destination is rejected rather than quietly running the query and returning rows, which would write nothing and look like success. -- **`oi_run_query_sync` waits for the whole upload** before returning, so the +- **`operational_insights_run_query_sync` waits for the whole upload** before returning, so the call takes as long as the copy does. For a large export prefer - `oi_run_query_async`, which returns a `query_handle` as soon as the query is - submitted; `oi_get_async_query_results` then reports completion and names the + `operational_insights_run_query_async`, which returns a `query_handle` as soon as the query is + submitted; `operational_insights_get_async_query_results` then reports completion and names the destination rather than returning rows. - **Exports are blocked in read-only mode.** `COPY ... TO` writes data to external storage, so it requires `CB_MCP_READ_ONLY_MODE=false` (or a token @@ -1104,7 +1104,7 @@ We provide high-level MCP integration tests to verify that the server exposes th - `CB_PASSWORD` - Optional: `CB_MCP_TEST_BUCKET` (a bucket to probe during the tests) - Optional, for the [Operational Insights server](#operational-insights-server)'s - own tests: `CB_OI_CONNECTION_STRING` / `CB_OI_USERNAME` / `CB_OI_PASSWORD`. + own tests: `CB_OPERATIONAL_INSIGHTS_CONNECTION_STRING` / `CB_OPERATIONAL_INSIGHTS_USERNAME` / `CB_OPERATIONAL_INSIGHTS_PASSWORD`. Those tests skip automatically (not fail) when unset. 2. Run the tests: diff --git a/src/cb_mcp/audit/classification.py b/src/cb_mcp/audit/classification.py index f98c6d62..805a2c97 100644 --- a/src/cb_mcp/audit/classification.py +++ b/src/cb_mcp/audit/classification.py @@ -64,7 +64,7 @@ # -- cluster (Cluster Setup & Health) ------------------------------ "test_cluster_connection": ("cluster", "read"), "get_cluster_health_and_services": ("cluster", "read"), - "get_cluster_diagnostics_report": ("cluster", "read"), + "get_client_connectivity_report": ("cluster", "read"), "get_cluster_metrics": ("cluster", "read"), # Both read the cluster's own REST endpoints (/pools/default/tasks and # the node topology) and need only ro_admin, so they are cluster reads @@ -106,7 +106,7 @@ "explain_sql_plus_plus_query": ("query", "read"), # Embeds the query text, then runs a SELECT with APPROX_VECTOR_DISTANCE # against a GSI vector index: a Query-service read like its neighbours. - "run_vector_search": ("query", "read"), + "run_vector_query": ("query", "read"), # -- index ----------------------------------------------------------- "list_indexes": ("index", "read"), "get_index_advisor_recommendations": ("index", "read"), @@ -142,20 +142,20 @@ # the write class makes it by construction. "delete_active_query": ("performance", "write"), # -- search (FTS) ------------------------------------------------------ - # Its own category rather than folded into index: run_fts_query is a + # Its own category rather than folded into index: run_search is a # query, so a reviewer filtering on `index read` should not get search # traffic mixed in. FTS index management has since landed and takes the # search *write* id (61528) that the catalogue already reserved — the # extension point working as designed: a new tool is a map entry, not a # new id and not a SIEM rule change. - "list_fts_indexes": ("search", "read"), - "get_fts_index_definition": ("search", "read"), - "run_fts_query": ("search", "read"), + "list_search_indexes": ("search", "read"), + "get_search_index_definition": ("search", "read"), + "run_search": ("search", "read"), # Goes through the Search service, so it books against search rather - # than query even though run_vector_search is the GSI counterpart. - "run_search_vector_search": ("search", "read"), - "upsert_fts_index": ("search", "write"), - "drop_fts_index": ("search", "write"), + # than query even though run_vector_query is the GSI counterpart. + "run_vector_search": ("search", "read"), + "upsert_search_index": ("search", "write"), + "drop_search_index": ("search", "write"), }, # -- Operational Insights (analytics) ------------------------------------ # @@ -184,25 +184,25 @@ # Analytics names its containers database/scope/collection; they are # the same discovery surface as the operational server's bucket/scope/ # collection reads, so they take the same slot. - "oi_get_databases_in_cluster": ("schema", "read"), - "oi_get_scopes_in_database": ("schema", "read"), - "oi_get_collections_in_scope": ("schema", "read"), - "oi_get_schema_for_collection": ("schema", "read"), + "operational_insights_get_databases_in_cluster": ("schema", "read"), + "operational_insights_get_scopes_in_database": ("schema", "read"), + "operational_insights_get_collections_in_scope": ("schema", "read"), + "operational_insights_get_schema_for_collection": ("schema", "read"), # -- query (SQL++ over Analytics) ----------------------------------- - "oi_run_query_sync": ("query", "read"), - "oi_run_query_async": ("query", "read"), - "oi_explain_query": ("query", "read"), + "operational_insights_run_query_sync": ("query", "read"), + "operational_insights_run_query_async": ("query", "read"), + "operational_insights_explain_query": ("query", "read"), # The async request lifecycle. Retrieving, discarding and cancelling # all act on a handle for a query this caller submitted, so they stay # with the query that produced it rather than forming a category of # their own — "Async Request API" is a shape of the API, not a # different resource. - "oi_get_async_query_results": ("query", "read"), - "oi_discard_async_query_results": ("query", "read"), - "oi_cancel_async_query": ("query", "read"), + "operational_insights_get_async_query_results": ("query", "read"), + "operational_insights_discard_async_query_results": ("query", "read"), + "operational_insights_cancel_async_query": ("query", "read"), # -- index ----------------------------------------------------------- - "oi_list_indexes": ("index", "read"), - "oi_create_index": ("index", "write"), + "operational_insights_list_indexes": ("index", "read"), + "operational_insights_create_index": ("index", "write"), }, } diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py index 37a55859..aa0d6751 100644 --- a/tests/unit/test_audit_classification.py +++ b/tests/unit/test_audit_classification.py @@ -107,12 +107,12 @@ def test_no_stale_oi_entries(): ("tool_name", "expected_id"), [ ("get_server_configuration_status", 65591), # config read - ("oi_get_databases_in_cluster", 65585), # schema read - ("oi_get_schema_for_collection", 65585), - ("oi_run_query_sync", 65587), # query read - ("oi_cancel_async_query", 65587), - ("oi_list_indexes", 65588), # index read - ("oi_create_index", 65620), # index write + ("operational_insights_get_databases_in_cluster", 65585), # schema read + ("operational_insights_get_schema_for_collection", 65585), + ("operational_insights_run_query_sync", 65587), # query read + ("operational_insights_cancel_async_query", 65587), + ("operational_insights_list_indexes", 65588), # index read + ("operational_insights_create_index", 65620), # index write ], ) def test_oi_tool_maps_to_the_expected_event_id(tool_name, expected_id): @@ -150,7 +150,7 @@ def test_read_only_tools_are_classified_read(tool_name): ("upsert_document_by_id", 61522), ("mutate_subdocument", 61522), ("delete_document_by_id", 61522), - ("get_cluster_diagnostics_report", 61488), + ("get_client_connectivity_report", 61488), ("test_cluster_connection", 61488), ("get_cluster_tasks", 61488), ("get_cluster_health_snapshot", 61488), @@ -182,12 +182,12 @@ def test_read_only_tools_are_classified_read(tool_name): ("delete_active_query", 61525), # Search keeps its own category rather than folding into index: a # reviewer filtering on `index read` must not get FTS traffic, and - # `run_fts_query` is a query, not an index operation. Moved into the + # `run_search` is a query, not an index operation. Moved into the # revived slot 6 on 2026-10-12 (was 61496/61528). - ("list_fts_indexes", 61494), - ("run_fts_query", 61494), - ("upsert_fts_index", 61526), - ("drop_fts_index", 61526), + ("list_search_indexes", 61494), + ("run_search", 61494), + ("upsert_search_index", 61526), + ("drop_search_index", 61526), ], ) def test_tool_maps_to_the_expected_event_id(tool_name, expected_id): From ccb4cbffe66321617c06d3829d9e5dd28bf3d3eb Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Tue, 13 Oct 2026 11:23:49 +0530 Subject: [PATCH 24/27] Review fixes --- DOCKER.md | 2 +- README.md | 4 +- server.json | 4 +- src/cb_mcp/audit/catalog.py | 64 +++++++--- src/cb_mcp/audit/classification.py | 38 +++--- src/cb_mcp/audit/descriptor.json | 18 +++ src/cb_mcp/audit/emitter.py | 29 ++++- src/cb_mcp/audit/exceptions.py | 20 ++-- src/cb_mcp/audit/sink.py | 105 ++++++++++++++++- tests/unit/conftest.py | 52 +++++++++ tests/unit/test_audit_catalog.py | 123 +++++++++++--------- tests/unit/test_audit_classification.py | 46 ++++++-- tests/unit/test_audit_integration_points.py | 52 +++++++++ tests/unit/test_audit_middleware.py | 54 ++++++++- tests/unit/test_audit_sink.py | 70 ++++++++++- tests/unit/test_audit_wiring.py | 2 +- 16 files changed, 555 insertions(+), 128 deletions(-) create mode 100644 tests/unit/conftest.py diff --git a/DOCKER.md b/DOCKER.md index 1de1443f..27eb20e1 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -443,7 +443,7 @@ Records are JSON Lines, one immutable record per line, in the same field vocabul - **`CB_MCP_AUDIT_LOG_TOOL_ARGS`** — off by default, and warns at startup when enabled. There is no redaction in this release, so arguments are written verbatim, including full document bodies. - **Fail-open** — if the audit file cannot be written, the server keeps serving and records are dropped and counted. `get_server_configuration_status` reports the live `written` / `dropped` / `write_errors` counters. -Auditing applies to the **operational** server. The `operational-insights` server accepts the same variables and reports them, but records nothing. +Auditing applies to **both** servers. Each has its own Tier-2 event block, so an id names the server that emitted it: operational uses `0xF000` (61440), `operational-insights` `0x10000` (65536). The two share the core block `0xE000` (57344) for lifecycle, session, authorization and guardrail events. ```bash docker run --rm \ diff --git a/README.md b/README.md index 281ce8ea..a7424a5e 100644 --- a/README.md +++ b/README.md @@ -564,7 +564,7 @@ Records are **JSON Lines** — one immutable record per line — using the same - **Identity** — `real_userid` records the *caller*, in one of three domains: `oauth` (the bearer token's subject), `local` (the OS process owner, on stdio), or `anonymous` (HTTP with OAuth disabled). Enabling auditing on HTTP without OAuth warns at startup, because every record then resolves to `anonymous`. - **Correlation** — `cid` groups every record produced by one request, so an authorization denial, a guardrail refusal and the eventual tool result join without matching on timestamps. There is deliberately **no session identifier**: MCP is moving to a stateless model in which connection identity must not be read as session continuity. For SQL++, the same `cid` is sent as the query's `client_context_id`, which Couchbase Server records as `clientContextId` — an exact join between the two logs. -- **Events** — a fixed catalogue on numbered ID blocks: server lifecycle and configuration, session initialize, token rejected, scope check denied, read-only write blocked, confirmation declined or skipped, and one read/write pair per tool category (cluster, schema, document, query, index, performance, search). Adding a tool needs no new ID and no change to a SIEM rule. The full catalogue ships as `descriptor.json` inside the package. +- **Events** — a fixed catalogue on numbered ID blocks: server lifecycle and configuration, session initialize, token rejected, scope check denied, read-only write blocked, confirmation declined or skipped, and one read/write pair per tool category (cluster, schema, document, query, index, performance, search, config). Adding a tool needs no new ID and no change to a SIEM rule. The full catalogue ships as `descriptor.json` inside the package. - **Filtering** — `CB_MCP_AUDIT_LOG_DISABLED_EVENTS` turns *read* noise down, taking numeric event ids (the id is the wire contract; event names are prose and may be reworded, so they are not accepted). Write and security events are **not filterable**: an attempt to disable one is refused with a warning, so the record of who changed what cannot be switched off by configuration. - **Rotation** — two independent triggers, and either may be switched off with `0`. `CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB` rotates the live file when it grows past a size; `CB_MCP_AUDIT_LOG_ROTATION_INTERVAL` rotates it when it grows older than an age (`1d`, `30d`, `1w`, `8w`). Whichever falls due first rotates the file, and with both off the file simply keeps growing. The interval clock is anchored to the live file, not to startup, so a server that was down over the weekend rolls the stale file away rather than appending to it. - **Retention and compression** — `CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS` keeps that many rotated files beside the live one; `0` keeps none, truncating the live file at each rotation. Backups are named with the UTC rotation time (`audit...log.20261006T091422481503Z.gz`) and **gzipped**, which matters because audit retention is measured in months — JSON Lines compresses roughly ten to one. The live file is deliberately left uncompressed so `tail -f` and `grep` still work during an incident. @@ -573,7 +573,7 @@ Records are **JSON Lines** — one immutable record per line — using the same - **Fail-open** — if the audit file cannot be written, the server keeps serving and the records are dropped and counted rather than turning an audit outage into a server outage. `get_server_configuration_status` reports the live `written` / `dropped` / `write_errors` counters, and `dropped` is the only way to discover that records were lost. - **Tool arguments** — recorded only when `CB_MCP_AUDIT_LOG_TOOL_ARGS` is enabled, which warns at startup. There is no redaction in this release, so arguments are written verbatim, including the full document bodies passed to document-write tools. -Auditing currently applies to the **operational** server. The Operational Insights server accepts the same flags and reports them, but records nothing. +Auditing applies to **both** servers. Each has its own Tier-2 event block, so an id names the server that emitted it: operational uses `0xF000` (61440), Operational Insights `0x10000` (65536). The two share the core block `0xE000` (57344) for lifecycle, session, authorization and guardrail events. ```bash # Audit to the console (stderr) — no path needed diff --git a/server.json b/server.json index 2d42e6a5..86a6c98b 100644 --- a/server.json +++ b/server.json @@ -883,7 +883,7 @@ }, { "name": "CB_MCP_AUDIT_LOG_ENABLED", - "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false. This server does not emit audit records in this release: the setting is accepted and reported so one configuration can be shared with the operational server, which does.", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false. This server emits audit records under its own event block, 0x10000 (65536), so an event id names the server that produced it.", "isRequired": false, "format": "boolean", "isSecret": false @@ -1830,7 +1830,7 @@ }, { "name": "CB_MCP_AUDIT_LOG_ENABLED", - "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false. This server does not emit audit records in this release: the setting is accepted and reported so one configuration can be shared with the operational server, which does.", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false. This server emits audit records under its own event block, 0x10000 (65536), so an event id names the server that produced it.", "isRequired": false, "format": "boolean", "isSecret": false diff --git a/src/cb_mcp/audit/catalog.py b/src/cb_mcp/audit/catalog.py index d24113ad..f094c0e4 100644 --- a/src/cb_mcp/audit/catalog.py +++ b/src/cb_mcp/audit/catalog.py @@ -19,14 +19,13 @@ ships the same catalogue for operators and SIEM authors; ``tests/unit/test_audit_catalog.py`` asserts the two never drift. -Two catalogue entries from the PRD draft are deliberately absent: +Two catalogue entries named in the PRD are deliberately absent: * *tool invocation blocked (disabled)* — withheld and disabled tools are never registered with FastMCP, so the model is never told they exist and there is - no refusal to record. Removed by product decision on 2026-08-25. + no refusal to record. * *session terminated* — FastMCP 3.x exposes no session-end hook and a stdio - client simply closes the pipe, so the event could never be emitted reliably. - On hold by product decision. + client simply closes the pipe, so the event cannot be emitted reliably. """ from __future__ import annotations @@ -115,6 +114,9 @@ "query": 3, "index": 4, "performance": 5, + # The allocation is therefore contiguous across slots 0-7, with 8-15 + # free. The block has no retired numbers left in reserve: once a + # category has emitted, its number is dead permanently. "search": 6, "config": 7, }, @@ -125,6 +127,14 @@ # against the other's block would put records under ids that mean a # different server. "operational_insights": { + # `cluster` carries no Operational Insights tool today. It is allocated + # anyway because it is the fail-closed category: an unclassified tool + # resolves to (cluster, write), and a package without the slot made + # that resolution raise. The raise was swallowed by the middleware's + # "auditing must never break a call" guard, so the call succeeded and + # *nothing was recorded* -- silence in the one case fail-closed exists + # to make loud. See FAILSAFE_SLOT_REQUIRED below. + "cluster": 0, "schema": 1, "query": 3, "index": 4, @@ -132,6 +142,23 @@ }, } +#: The category every package must allocate, whatever its tool surface. +#: +#: Kept here rather than in :mod:`cb_mcp.audit.classification` because this is +#: the module that owns slot allocation, and the check below has to run where +#: the tables are defined. classification.FAILSAFE_CATEGORY must equal it; a +#: test asserts the two agree. +FAILSAFE_SLOT_REQUIRED = "cluster" + +for _package, _slots in CATEGORY_SLOTS.items(): + if FAILSAFE_SLOT_REQUIRED not in _slots: # pragma: no cover - import guard + raise RuntimeError( + f"Service package {_package!r} does not allocate the fail-closed " + f"category {FAILSAFE_SLOT_REQUIRED!r}. An unclassified tool in that " + "package would raise inside the audit middleware, which swallows the " + "error, and the call would go unrecorded entirely." + ) + #: Slots retired from use, kept here so their history is visible — ``category #: -> n``, using the number the category held. #: @@ -146,6 +173,11 @@ #: :func:`next_free_slot`'s reusable set. RETIRED_SLOTS: dict[str, dict[str, int]] = { "operational": { + # Neither ever had a tool classified to it, so neither number has + # appeared in a record. Both are reused in CATEGORY_SLOTS above, which + # that fact is what permits. The entries stay here because they record + # what each number has meant, and the invariant test reads them to + # check that a reused slot is one that never emitted. "vector": 6, "analytics": 7, }, @@ -189,14 +221,13 @@ def category_index(category: str, package: str = DEFAULT_SERVICE_PACKAGE) -> int def next_free_slot(package: str = DEFAULT_SERVICE_PACKAGE) -> int: """Return the slot a newly added category should take. - Returns the lowest **never-allocated** slot. Retired slots are deliberately - not offered, even the ones :data:`RETIRED_SLOTS` records as safe to reuse: - when the search category was added on 2026-09-29 the reusable slots 6 and 7 - were declined in favour of slot 8, on the grounds that two dead numbers cost - nothing in a 16-slot block while a slot that has meant two different things - is a trap for anyone reading an old rule. Reuse remains permissible, but it - is a decision someone must take deliberately rather than inherit from a - helper. + Returns the lowest **never-allocated** slot. Retired slots are never + offered, even the ones :data:`RETIRED_SLOTS` marks as safe to reuse. + + Reuse is permissible when the slot has never emitted a record, but it is + never automatic: it has to be chosen with that fact established, because + handing back a slot that *has* emitted would make an existing SIEM rule + silently match an unrelated event. Raises: ValueError: when the block's category template is exhausted. A new @@ -377,8 +408,7 @@ def filterable(self) -> bool: "performance": "performance", # Labels are shared across packages, so a slot reads the same wherever it # appears. "config" covers the server's own status and configuration - # surface, which is MCP-level rather than cluster-level — the distinction - # that took it out of `cluster`. + # surface, which is MCP-level rather than cluster-level. "config": "config", "search": "search", } @@ -447,9 +477,9 @@ def build_descriptor() -> dict: ] entries.sort(key=lambda entry: entry["id"]) return { - # Kept for readers that expect a single value, and meaning what it - # always did: the package a build audits by default. The authoritative - # list is `service_packages`, and every tool-call entry names its own. + # A single value, for readers that expect one: the package this build + # audits by default. The authoritative list is `service_packages`, and + # every tool-call entry names its own. "service_package": DEFAULT_SERVICE_PACKAGE, "service_packages": sorted(SERVICE_PACKAGE_BLOCKS), "blocks": {"core": CORE_BLOCK, **SERVICE_PACKAGE_BLOCKS}, diff --git a/src/cb_mcp/audit/classification.py b/src/cb_mcp/audit/classification.py index 805a2c97..f0bba7c7 100644 --- a/src/cb_mcp/audit/classification.py +++ b/src/cb_mcp/audit/classification.py @@ -41,10 +41,10 @@ #: and booking one server's tool against another's block would put records in a #: block whose ids mean something else. #: -#: Only ``operational`` is populated today; the Operational Insights server is -#: not audited (see :data:`cb_mcp.audit.catalog.DEFAULT_SERVICE_PACKAGE`). -#: Adding it later means a table here and a block in -#: :data:`~cb_mcp.audit.catalog.SERVICE_PACKAGE_BLOCKS`, and renumbers nothing. +#: Both shipped servers have a table. Adding a further package means a table +#: here and a block in :data:`~cb_mcp.audit.catalog.SERVICE_PACKAGE_BLOCKS`, +#: and renumbers nothing. Every package must allocate the fail-closed +#: category -- see :data:`~cb_mcp.audit.catalog.FAILSAFE_SLOT_REQUIRED`. #: #: Within a package, ordered by category to match the catalogue layout rather #: than alphabetically, so a reviewer can check a whole block in one pass. @@ -76,9 +76,8 @@ "get_cluster_system_events": ("cluster", "read"), # Query-engine vitals (/admin/vitals) and GSI indexer stats. Both # describe how a *service* in the cluster is holding up rather than - # what a caller queried or which indexes exist, which is what Cluster - # Setup & Health means; get_cluster_query_vitals moved here from - # performance and get_index_stats from index on 2026-10-12. + # what a caller queried or which indexes exist, which is what this + # category covers. "get_cluster_query_vitals": ("cluster", "read"), "get_index_stats": ("cluster", "read"), # -- schema / discovery -------------------------------------------- @@ -111,12 +110,11 @@ "list_indexes": ("index", "read"), "get_index_advisor_recommendations": ("index", "read"), "create_index": ("index", "write"), - # The replacement for the deprecated create_index (#303), creating - # scalar *and* vector GSI indexes through SQL++. It takes the same id: - # both create a GSI index, and an id that changed when a tool was - # superseded would silently break every SIEM rule watching for index - # creation. The retired `vector` slot (6) is deliberately not revived - # for it — see catalog.next_free_slot. + # The replacement for the deprecated create_index, creating scalar + # *and* vector GSI indexes through SQL++. It takes the same id as the + # tool it supersedes: both create a GSI index, and an id that moved + # would silently break every SIEM rule watching for index creation. + # Vector index creation is an index write, not a category of its own. "create_query_index": ("index", "write"), "build_index": ("index", "write"), "drop_index": ("index", "write"), @@ -128,12 +126,12 @@ "get_queries_using_primary_index": ("performance", "read"), "get_queries_not_using_covering_index": ("performance", "read"), "get_queries_not_selective": ("performance", "read"), - # The N1QL Admin REST API's active-request surface (#308). Booked as + # The N1QL Admin REST API's active-request surface. Booked as # performance rather than query: `query read` means "a caller ran a # statement", and a reviewer reconstructing what was executed must not # find listings of other callers' in-flight requests mixed into it. - # The vitals half of that API moved to cluster — it describes service - # health rather than the query workload. + # The vitals half of that API is a cluster read instead: it describes + # service health rather than the query workload. "get_active_queries": ("performance", "read"), # The first performance *write*, taking the id (61525) the catalogue # reserved when the category was allocated — a map entry, no new id and @@ -144,10 +142,10 @@ # -- search (FTS) ------------------------------------------------------ # Its own category rather than folded into index: run_search is a # query, so a reviewer filtering on `index read` should not get search - # traffic mixed in. FTS index management has since landed and takes the - # search *write* id (61528) that the catalogue already reserved — the - # extension point working as designed: a new tool is a map entry, not a - # new id and not a SIEM rule change. + # traffic mixed in. Index management for FTS takes the search *write* + # id (61526) the catalogue reserves — the extension point working as + # designed: a new tool is a map entry, not a new id and not a SIEM + # rule change. "list_search_indexes": ("search", "read"), "get_search_index_definition": ("search", "read"), "run_search": ("search", "read"), diff --git a/src/cb_mcp/audit/descriptor.json b/src/cb_mcp/audit/descriptor.json index eceaee18..c4a6d122 100644 --- a/src/cb_mcp/audit/descriptor.json +++ b/src/cb_mcp/audit/descriptor.json @@ -226,6 +226,15 @@ "default_enabled": true, "filterable": false }, + { + "id": 65584, + "name": "cluster read", + "description": "A cluster read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": true + }, { "id": 65585, "name": "schema read", @@ -262,6 +271,15 @@ "default_enabled": true, "filterable": true }, + { + "id": 65616, + "name": "cluster write", + "description": "A cluster write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": false + }, { "id": 65617, "name": "schema write", diff --git a/src/cb_mcp/audit/emitter.py b/src/cb_mcp/audit/emitter.py index 3b901818..2af92060 100644 --- a/src/cb_mcp/audit/emitter.py +++ b/src/cb_mcp/audit/emitter.py @@ -198,6 +198,12 @@ def init_audit(config: ResolvedAuditConfig) -> AuditLogger: """Build and install the audit logger for this process.""" global _active # noqa: PLW0603 + # One lifespan per process makes a second call unreachable today, but if it + # ever became reachable the old sink would keep running: its idle rotation + # would rename the live file out from under the new sink, whose records + # would then go to an unlinked inode and still be counted as written. + shutdown_audit() + if not config.enabled or not config.sinks: _active = AuditLogger(config, sink=None) return _active @@ -227,7 +233,28 @@ def init_audit(config: ResolvedAuditConfig) -> AuditLogger: return _active sink: AuditSinkProtocol = sinks[0] if len(sinks) == 1 else CompositeAuditSink(sinks) - sink.start() + try: + sink.start() + except Exception: + # Starting a writer thread can fail for reasons that have nothing to do + # with this server -- at a container's pids.max, Thread.start raises + # RuntimeError. Letting that propagate would abort startup because + # auditing could not start, which is the one outcome the design + # forbids. Close what did start so the console thread and the file + # handle do not leak, and run with auditing off. + logger.exception( + "Could not start the audit writer; the server will run with " + "auditing disabled. Audit records will NOT be written." + ) + try: + sink.close() + except Exception: + logger.debug( + "Audit sink cleanup failed after a failed start", exc_info=True + ) + _active = AuditLogger(replace(config, enabled=False), sink=None) + return _active + _active = AuditLogger(config, sink=sink) # Only once auditing is actually running: a server with auditing off has no # reason to touch the process's signal disposition. diff --git a/src/cb_mcp/audit/exceptions.py b/src/cb_mcp/audit/exceptions.py index 7c240b92..7e84062c 100644 --- a/src/cb_mcp/audit/exceptions.py +++ b/src/cb_mcp/audit/exceptions.py @@ -1,16 +1,14 @@ """Typed exceptions for auditable MCP-layer refusals. -Three semantically different refusals used to surface as a bare -``PermissionError`` carrying only a human-readable message, and a read-only -DML block surfaced as ``ValueError`` — the same type a malformed query raises. -An audit middleware that assigned ``outcome``/``reason`` by matching on those -message strings would silently mis-attribute records after the next copy edit, -and a mis-attributed audit record is worse than a missing one. - -Every class here **subclasses the exception type the call site raised before**, -so existing ``pytest.raises`` assertions and any client-visible behaviour stay -exactly as they were. The public message text is unchanged too; only the type -is narrowed. +Each refusal below needs an ``outcome`` and a ``reason`` on its audit record. +Deriving those by matching exception message strings would mis-attribute +records after any copy edit, and a mis-attributed audit record is worse than a +missing one, so each refusal carries its own type and its own metadata. + +Every class **subclasses the broader exception type its call site raises** +(``PermissionError``, or ``ValueError`` for a read-only DML block), so callers +catching the broad type are unaffected and the public message text is +unchanged; only the type is narrowed. Note that FastMCP wraps a tool's exception in ``ToolError`` before it reaches middleware (the original is preserved on ``__cause__``). Middleware therefore diff --git a/src/cb_mcp/audit/sink.py b/src/cb_mcp/audit/sink.py index f96ca50c..ec12f901 100644 --- a/src/cb_mcp/audit/sink.py +++ b/src/cb_mcp/audit/sink.py @@ -39,8 +39,7 @@ Runtime failures — disk full, permissions revoked, the file unmounted — are reported loudly to the operational log as errors and counted, and the server -keeps serving. That is a deliberate product decision: audit unavailability -should not become an outage. +keeps serving: audit unavailability must not become an outage. """ from __future__ import annotations @@ -90,6 +89,15 @@ _SENTINEL = object() + +class _WriterExitError(Exception): + """Raised inside the writer loop to end it deliberately. + + Distinct from an error so the loop's blanket handler can tell "stop now" + from "something broke"; only the former ends the thread. + """ + + #: Timestamp appended to a rotated file. Compact ISO 8601 basic format in UTC, #: to the microsecond: filename-safe on every platform and fixed-width. The #: sub-second part is not decoration — a small size cap rotates many times a @@ -121,6 +129,12 @@ def _host_token(host: str | None = None) -> str: return token[:64] or "unknown" +#: Filename used when CB_MCP_AUDIT_LOG_FILE_PATH names a directory rather +#: than a file, so a trailing slash writes inside the directory instead of +#: beside it. +DEFAULT_AUDIT_FILENAME = "audit.log" + + def process_scoped_path( path: str | os.PathLike[str], pid: int | None = None, @@ -137,6 +151,30 @@ def process_scoped_path( and a shared volume would otherwise give every container ``audit.1.log``. """ resolved = Path(path) + + # A path that names a directory rather than a file gets the default + # filename inside it. Two shapes reach here from an operator's + # CB_MCP_AUDIT_LOG_FILE_PATH, and without this guard each fails badly: + # + # "/" with_name() raises ValueError on a path with no name, + # and resolve_audit_config does not catch it, so the + # server would refuse to start -- contradicting this + # module's rule that a broken file sink degrades rather + # than aborts. + # "/var/log/cbmcp/" would yield /var/log/cbmcp.., a *sibling* + # of the directory the operator mounted and chmod'ed + # rather than a file inside it. Silent, and the likelier + # typo of the two. + # The trailing separator has to be read off the original string: Path() + # normalises it away, so Path("/var/log/cbmcp/").name is already "cbmcp" + # and nothing downstream can tell it from a filename. + raw = os.fspath(path) + looks_like_a_directory = raw.endswith((os.sep, "/")) or ( + os.altsep and raw.endswith(os.altsep) + ) + if looks_like_a_directory or resolved.name == "" or resolved.is_dir(): + resolved = resolved / DEFAULT_AUDIT_FILENAME + actual_pid = os.getpid() if pid is None else pid scope = f"{_host_token(host)}.{actual_pid}" if resolved.suffix: @@ -243,7 +281,22 @@ def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: # accounting for them. self._drain_remaining() - abandoned = self._queue.qsize() + # Drain rather than measure. qsize() only reads the queue, and on a + # timed-out join the writer is still alive -- which is the case this + # branch exists for. It would then pull these same records, fail to + # write them against a closed stream, and count them a second time: 500 + # lost records reported as dropped: 1000. Since `dropped` is the only + # way an operator learns records were lost, a doubled figure is exactly + # what they would act on. Taking the items out makes the count exact + # and leaves the writer nothing to re-count. + abandoned = 0 + while True: + try: + item = self._queue.get_nowait() + except queue.Empty: + break + if item is not _SENTINEL: + abandoned += 1 if abandoned: # The join timed out on a wedged destination. Those records are # gone; say so, because an audit gap nobody can see is the one @@ -284,6 +337,36 @@ def stats(self) -> dict[str, int]: # -- writer thread ---------------------------------------------------- def _run(self) -> None: + """Writer loop. Must not exit on an unexpected error. + + Every exit from this method is permanent: :meth:`start` returns early + when ``self._thread is not None``, so nothing restarts the writer. An + exception escaping ``_write_batch`` or ``_on_idle`` therefore stops all + audit delivery for the life of the process, silently, with no counter + moving. + + ``_write_batch`` guards the errors it expects (``OSError``, + ``ValueError``). The ones it cannot expect are what this wrapper is + for: ``sys.stderr`` is ``None`` under pythonw and under hosts that + detach stdio, so the console sink's ``stream.write`` raises + ``AttributeError`` -- and console is the default sink. + """ + while True: + try: + self._run_once() + except _WriterExitError: + return + except Exception: # the loop must outlive any error + # Already-dequeued records are gone; count them so the loss is + # visible rather than silent. + self._dropped += 1 + self._log_failure( + "audit writer hit an unexpected error; the record was " + "dropped and the writer is continuing" + ) + + def _run_once(self) -> None: + """One iteration of the writer loop. Raises :class:`_WriterExitError` to stop.""" while True: try: item = self._queue.get(timeout=_POLL_SECONDS) @@ -292,12 +375,12 @@ def _run(self) -> None: # which is what lets the thread finish when ``close`` could not # post the sentinel into a full queue. if self._closed.is_set(): - return + raise _WriterExitError from None self._on_idle() continue if item is _SENTINEL: self._drain_remaining() - return + raise _WriterExitError from None batch = [item] # Coalesce whatever else is already waiting into one write+flush. while len(batch) < _WRITE_BATCH: @@ -308,7 +391,7 @@ def _run(self) -> None: if nxt is _SENTINEL: self._write_batch(batch) self._drain_remaining() - return + raise _WriterExitError from None batch.append(nxt) self._write_batch(batch) @@ -414,6 +497,16 @@ def _write_batch(self, lines: list[str]) -> None: # Resolved per batch rather than held: pytest's capsys, and a host that # redirects output, both replace ``sys.stderr`` after construction. stream = self._stream if self._stream is not None else sys.stderr + if stream is None: + # sys.stderr is None under pythonw and under hosts that detach + # stdio. Without this the write raises AttributeError, which the + # except clause below does not cover; on the default sink that + # would take the writer thread down permanently. + self._dropped += len(lines) + self._log_failure( + f"no console stream available; dropped {len(lines)} audit record(s)" + ) + return buffered = 0 try: for line in lines: diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..b563fe2f --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,52 @@ +"""Shared fixtures for the unit suite. + +The audit subsystem keeps three pieces of process-global state, and a test that +installs any of them without restoring it changes the result of a *later, +unrelated* test. That is the worst kind of suite failure: the output names a +file that is not the one at fault, and the failure moves when tests are +reordered or run in isolation. + +The three: + +* the ``SIGTERM`` disposition, which :func:`cb_mcp.audit.emitter.init_audit` + replaces and ``shutdown_audit`` does not restore; +* ``emitter._previous_sigterm``, the handler the audit handler chains to, which + is what makes a server under uvicorn hand over instead of closing the trail; +* ``emitter._active``, the installed logger, which ``get_audit_logger`` returns + to every caller including tools asserting ``audit["active"] is False``. + +This module snapshots all three before each test and puts them back after, so +no test can depend on, or damage, another's leftovers. +""" + +from __future__ import annotations + +import contextlib +import signal + +import pytest + +from cb_mcp.audit import emitter + + +@pytest.fixture(autouse=True) +def _restore_audit_global_state(): + """Snapshot and restore the audit subsystem's process-global state. + + Autouse on purpose. Opting in per test is what produced the leak: the tests + that install this state are not the ones that notice it, so the fixture has + to cover the tests that would otherwise be the victims. + """ + previous_disposition = signal.getsignal(signal.SIGTERM) + previous_chain = emitter._previous_sigterm + previous_active = emitter._active + + yield + + emitter._active = previous_active + emitter._previous_sigterm = previous_chain + # Only settable from the main thread of the main interpreter, and a handler + # read back as None cannot be reinstalled. Neither is worth failing a + # passing test over. + with contextlib.suppress(TypeError, ValueError, OSError): + signal.signal(signal.SIGTERM, previous_disposition) diff --git a/tests/unit/test_audit_catalog.py b/tests/unit/test_audit_catalog.py index c010e51b..5d2e3fba 100644 --- a/tests/unit/test_audit_catalog.py +++ b/tests/unit/test_audit_catalog.py @@ -134,13 +134,16 @@ def test_ids_are_unique(): assert len(ids) == len(set(ids)) -def test_removed_and_deferred_events_are_absent(): - """Two PRD draft entries are deliberately not implemented. +def test_events_named_in_the_prd_but_not_emitted_are_absent(): + """Two ids the PRD names are deliberately not in the catalogue. - 57489 (tool invocation blocked - disabled) was removed by product decision: - withheld tools are never registered, so the model is never told they exist - and there is no refusal to record. 57361 (session terminated) is on hold - because FastMCP exposes no session-end hook. + 57489 (tool invocation blocked - disabled): withheld tools are never + registered, so the model is never told they exist and there is no refusal + to record. 57361 (session terminated): FastMCP exposes no session-end hook, + so the event cannot be emitted reliably. + + Both must stay absent rather than be defined and never fired — an id in the + catalogue is an id a SIEM rule can be written against. """ ids = {event.id for event in AuditEvent} assert 57489 not in ids @@ -233,6 +236,9 @@ def test_slot_table_pins_the_shipped_numbers(): "query": 3, "index": 4, "performance": 5, + # Both reuse retired slots, which is permitted only because neither + # retired category ever emitted a record. The allocation is contiguous + # across 0-7. "search": 6, "config": 7, } @@ -245,7 +251,9 @@ def test_operational_insights_slots_match_the_shared_axis(): A package omits the slots it has no tools for; it never renumbers. """ oi = catalog.CATEGORY_SLOTS["operational_insights"] - assert oi == {"schema": 1, "query": 3, "index": 4, "config": 7} + # `cluster` carries no OI tool; it is allocated because it is the + # fail-closed category and every package must be able to resolve it. + assert oi == {"cluster": 0, "schema": 1, "query": 3, "index": 4, "config": 7} shared = catalog.CATEGORY_SLOTS["operational"] for category, slot in oi.items(): assert shared[category] == slot, category @@ -291,12 +299,20 @@ def test_next_free_slot_takes_the_lowest_never_allocated(monkeypatch): def test_next_free_slot_skips_retired_slots(monkeypatch): """Retired slots are not offered, even when reuse would be safe. - default path, and the day a slot *has* emitted the default would be wrong. + + Reuse remains *permissible* for a slot that never emitted a record — the + rule in ``RETIRED_SLOTS`` says so, and both retired slots are in fact + reused. It is simply never *automatic*: reuse has to be chosen with the + evidence that the slot never emitted in front of you. Handing retired slots + back from a helper would make that the default path, and the day a slot + *has* emitted the default would be wrong. """ - monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"withdrawn": 9}}) + monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"withdrawn": 8}}) monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) - # 0-7 are live and 9 is retired, so the helper offers 8 and skips 9. - assert catalog.next_free_slot() == 8 + # 0-7 are live and 8 is retired, so the helper must skip past it to 9. + # Retiring a slot *above* the first free one would make this pass + # whether or not RETIRED_SLOTS were consulted at all. + assert catalog.next_free_slot() == 9 def test_permanently_reserved_slots_are_never_reallocated(monkeypatch): @@ -337,61 +353,55 @@ def test_retired_categories_are_gone_but_their_slots_are_recorded(): catalog.tool_call_event_id(name, "read") -def test_a_slot_that_ever_emitted_is_never_reallocated(): - """The one rule that outlives every revival. +def test_a_slot_that_ever_emitted_is_never_reallocated(monkeypatch): + """The rule that governs slot reuse, exercised rather than assumed. - Both retired slots have now been revived -- 6 for `search`, 7 for - `config` -- so there is no longer a list of dead numbers to assert against. - What must still hold is the condition that made those revivals legitimate: - a slot may only be reused if no record was ever emitted against it. + Both retired slots are reused -- 6 by `search`, 7 by `config` -- so there + is no list of dead numbers to assert against. What must hold is the + condition that permits the reuse: a slot may only be reused if no record + was ever emitted against it. - PERMANENTLY_RESERVED_SLOTS is where a slot goes once it *has* emitted. It - is empty today because nothing has shipped. The day it is not, this test is - what stops one of its numbers being handed to a new category and making an - existing SIEM rule silently match an unrelated event. + PERMANENTLY_RESERVED_SLOTS is where a slot goes once it *has* emitted, and + it is empty today because nothing has shipped. Asserting against it as it + stands is vacuously true and would pass even if the rule were deleted, so + this test populates it with a slot that is live and checks the violation is + actually caught. """ - for package, slots in catalog.CATEGORY_SLOTS.items(): - clash = set(slots.values()) & catalog.PERMANENTLY_RESERVED_SLOTS - assert clash == set(), f"{package} reallocated slots that have emitted: {clash}" + live = catalog.CATEGORY_SLOTS["operational"] + occupied = live["kv"] + + def reserved_slots_clash(reserved): + monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset(reserved)) + return { + package: set(slots.values()) & catalog.PERMANENTLY_RESERVED_SLOTS + for package, slots in catalog.CATEGORY_SLOTS.items() + } - shipped = json.loads(DESCRIPTOR_PATH.read_text(encoding="utf-8")) - dead_ids = { - base + catalog.READ_OFFSET + slot + delta - for base in catalog.SERVICE_PACKAGE_BLOCKS.values() - for slot in catalog.PERMANENTLY_RESERVED_SLOTS - for delta in (0, catalog.WRITE_DELTA) - } - assert {entry["id"] for entry in shipped["events"]} & dead_ids == set() + # The guard must fire when a slot that emitted is also live. + clashes = reserved_slots_clash({occupied}) + assert clashes["operational"] == {occupied} + # And must be quiet for a slot nobody has allocated. + free = catalog.next_free_slot() + assert all(not c for c in reserved_slots_clash({free}).values()) -def test_every_revived_slot_was_one_that_never_emitted(): - """Checks the revivals themselves, not just the rule. + # The real table, which must be clean. + monkeypatch.undo() + for package, slots in catalog.CATEGORY_SLOTS.items(): + assert set(slots.values()) & catalog.PERMANENTLY_RESERVED_SLOTS == set(), ( + package + ) - A slot appearing in both RETIRED_SLOTS and CATEGORY_SLOTS is a revival. - Each one is only defensible because its old category never had a tool - classified to it, so no record for that id can exist in anyone's logs. - """ + +def test_every_reused_slot_is_one_that_never_emitted(): + """Checks the reuses themselves: both must be absent from the emitted set.""" for package, retired in catalog.RETIRED_SLOTS.items(): live = catalog.CATEGORY_SLOTS.get(package, {}) - revived = set(retired.values()) & set(live.values()) - assert revived == {6, 7}, revived - assert revived & catalog.PERMANENTLY_RESERVED_SLOTS == set() - - -def test_the_revived_slot_never_emitted_under_its_old_meaning(): - """The precondition for reusing slots 6 and 7, asserted rather than assumed. - - Reallocating a slot that had ever emitted a record would make an existing - SIEM rule silently match a new, unrelated event. `analytics` was retired - with no tool ever classified to it, so no record for 61495 can exist in - anyone's logs -- which is the only reason `config` may have the number. - """ - assert catalog.RETIRED_SLOTS["operational"]["vector"] == 6 - assert catalog.RETIRED_SLOTS["operational"]["analytics"] == 7 + reused = set(retired.values()) & set(live.values()) + assert reused == {6, 7}, reused + assert reused & catalog.PERMANENTLY_RESERVED_SLOTS == set() assert catalog.CATEGORY_SLOTS["operational"]["search"] == 6 assert catalog.CATEGORY_SLOTS["operational"]["config"] == 7 - # Permanently reserved slots are the ones that *did* emit; neither is one. - assert {6, 7} & catalog.PERMANENTLY_RESERVED_SLOTS == set() def test_retirement_did_not_move_the_surviving_ids(): @@ -409,8 +419,7 @@ def test_a_new_category_takes_the_next_never_allocated_slot(): def test_search_ids_are_the_shipped_numbers(): """Pinned literally, like every other shipped id. - Search was moved into the revived slot 6 on 2026-10-12, from the slot 8 it - had held since 2026-09-29 without ever shipping. + Search occupies slot 6, one of the two reused from RETIRED_SLOTS. """ assert tool_call_event_id("search", "read") == 61494 assert tool_call_event_id("search", "write") == 61526 diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py index aa0d6751..f03202a2 100644 --- a/tests/unit/test_audit_classification.py +++ b/tests/unit/test_audit_classification.py @@ -18,8 +18,14 @@ import pytest -from cb_mcp.audit.catalog import CATEGORIES, SERVICE_PACKAGE_BLOCKS +from cb_mcp.audit.catalog import ( + CATEGORIES, + CATEGORY_SLOTS, + FAILSAFE_SLOT_REQUIRED, + SERVICE_PACKAGE_BLOCKS, +) from cb_mcp.audit.classification import ( + FAILSAFE_CATEGORY, TOOL_CLASSIFICATION, classify_tool, is_classified, @@ -32,7 +38,7 @@ from cb_mcp.tools.operational_insights import WRITE_TOOLS as OI_WRITE_TOOLS #: The operational package. Operational Insights has its own block and table -#: since 2026-10-12; constants for it are defined alongside these. +#: its own block and table; constants for it are defined alongside these. PACKAGE = "operational" #: Main collapsed the four per-area tool groups into read-only and write when @@ -43,8 +49,7 @@ WRITE_TOOL_NAMES = {tool.__name__ for tool in WRITE_TOOLS} READ_ONLY_NAMES = {tool.__name__ for tool in READ_ONLY_TOOLS} -#: The Operational Insights server, audited under its own block since -#: 2026-10-12. +#: The Operational Insights server, audited under its own block. OI_REGISTERED = {tool.__name__ for tool in OI_ALL_TOOLS} OI_WRITE_NAMES = {tool.__name__ for tool in OI_WRITE_TOOLS} @@ -155,9 +160,8 @@ def test_read_only_tools_are_classified_read(tool_name): ("get_cluster_tasks", 61488), ("get_cluster_health_snapshot", 61488), ("get_cluster_system_events", 61488), - # Moved into Cluster Setup & Health on 2026-10-12: both describe how a - # service is holding up rather than the query workload or the index - # inventory. + # Cluster Setup & Health: both describe how a service is holding up + # rather than the query workload or the index inventory. ("get_cluster_query_vitals", 61488), ("get_index_stats", 61488), # The server's own surface, split out of cluster on the same date. @@ -183,7 +187,7 @@ def test_read_only_tools_are_classified_read(tool_name): # Search keeps its own category rather than folding into index: a # reviewer filtering on `index read` must not get FTS traffic, and # `run_search` is a query, not an index operation. Moved into the - # revived slot 6 on 2026-10-12 (was 61496/61528). + # slot 6. ("list_search_indexes", 61494), ("run_search", 61494), ("upsert_search_index", 61526), @@ -215,6 +219,32 @@ def test_sqlpp_write_statement_moves_to_the_query_write_id(): assert write_event.filterable is False +@pytest.mark.parametrize("package", sorted(SERVICE_PACKAGE_BLOCKS)) +def test_fail_closed_resolves_in_every_package(package): + """The fail-closed path must produce a record, not an exception. + + An unclassified tool resolves to the failsafe category. If a package does + not allocate that category, the resolution raises -- and the audit + middleware swallows exceptions on purpose, so the call succeeds and + *nothing is recorded*. That is silence in the one case fail-closed exists + to make loud, and no counter moves: the operator sees dropped: 0. + + """ + event = resolve_tool_call_event("a_tool_nobody_classified", package=package) + assert event.operation_class == "write" + assert event.filterable is False + base = SERVICE_PACKAGE_BLOCKS[package] + assert base <= event.id < base + 4096 + assert is_classified("a_tool_nobody_classified", package) is False + + +def test_the_failsafe_category_is_allocated_everywhere(): + """Pins the invariant the import-time guard enforces.""" + assert FAILSAFE_CATEGORY == FAILSAFE_SLOT_REQUIRED + for package, slots in CATEGORY_SLOTS.items(): + assert FAILSAFE_CATEGORY in slots, package + + def test_unknown_tool_fails_closed_to_a_non_filterable_write(): assert is_classified("some_future_tool") is False event = resolve_tool_call_event("some_future_tool") diff --git a/tests/unit/test_audit_integration_points.py b/tests/unit/test_audit_integration_points.py index 7cc9c2f0..7c126e13 100644 --- a/tests/unit/test_audit_integration_points.py +++ b/tests/unit/test_audit_integration_points.py @@ -754,3 +754,55 @@ def query(self, statement, **kwargs): audit_state._AUDIT_STATE.reset(token) assert captured["kwargs"]["client_context_id"] == "callers-own-id" + + +# --------------------------------------------------------------------------- +# statement-level scope gate +# --------------------------------------------------------------------------- + + +def test_statement_scope_denial_records_a_refusal(): + """The only place a statement-level scope denial is decided. + + It is also the only refusal that passes an explicit ``required_scope`` into + the state channel, because the class depends on the statement rather than + on the tool. Without this test, deleting the ``record_refusal`` call leaves + the suite green while a policy denial is recorded as + ``query write / error / execution_error`` -- an execution failure, not a + refusal, and filterable rather than a security event. + """ + token = SimpleNamespace(scopes=["couchbase-mcp:read"], claims={}, client_id="c") + ctx = SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=SimpleNamespace( + cluster_provider=SimpleNamespace( + get_cluster=lambda _c: pytest.fail("must refuse before connecting") + ), + read_only_mode=False, + ) + ) + ) + state_token = audit_state.install() + try: + with ( + patch( + "cb_mcp.tools.operational.query.get_access_token", return_value=token + ), + patch( + "cb_mcp.tools.operational.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ), + pytest.raises(StatementScopeDeniedError), + ): + run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") + + refusal = audit_state.current()["refusal"] + finally: + audit_state.reset(state_token) + + assert refusal["event_id"] == StatementScopeDeniedError.audit_event.id + assert refusal["outcome"] == StatementScopeDeniedError.audit_outcome + assert refusal["reason"] == StatementScopeDeniedError.audit_reason + # The two fields only this gate contributes. + assert refusal["required_scope"] == "write" + assert refusal["statement_kind"] diff --git a/tests/unit/test_audit_middleware.py b/tests/unit/test_audit_middleware.py index efddc851..286b197d 100644 --- a/tests/unit/test_audit_middleware.py +++ b/tests/unit/test_audit_middleware.py @@ -571,7 +571,7 @@ async def test_withheld_or_unknown_tool_produces_no_record(tmp_path): Read-only mode and the disabled set both work by withholding the tool from registration, so a client is never told it exists and there is no refusal to - record — that was the product decision behind dropping event 57489. Emitting + record — which is why event 57489 does not exist. Emitting a tool-call record here would also assert an ``execution_error`` that never happened, because nothing ran. The enforced tool surface is captured once by the server-configuration record at startup instead. @@ -928,3 +928,55 @@ def get_document_by_id(ctx: Context, bucket_name: str) -> str: # The cluster-side account is recorded separately and must not be mistaken # for the caller. assert calls[0]["cb_userid"] == "mcp_service" + + +@pytest.mark.asyncio +async def test_scope_denial_record_carries_client_id_scopes_and_required_scope( + tmp_path, +): + """The three fields a SIEM rule reads off event 57377. + + The sibling test above runs under stdio, where both token helpers return + None and ``as_dict`` drops the fields, and it passes its own + ``required_scope``, which suppresses the ``setdefault`` under test. All + three ``setdefault`` lines in the middleware could be deleted and it would + still pass. This one patches the token helpers and passes no + ``required_scope``, so each field has to be supplied by the middleware. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def upsert_document_by_id(ctx: Context, bucket_name: str) -> str: + """denied before it runs""" + audit_state.record_refusal( + event_id=ScopeDeniedError.audit_event.id, + event_name=ScopeDeniedError.audit_event.event_name, + outcome=ScopeDeniedError.audit_outcome, + reason=ScopeDeniedError.audit_reason, + ) + raise ScopeDeniedError("missing scope") + + with ( + patch( + "cb_mcp.audit.middleware.client_id_of_current_token", + return_value="agent-svc-7", + ), + patch( + "cb_mcp.audit.middleware.scopes_of_current_token", + return_value=["couchbase-mcp:read"], + ), + ): + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("upsert_document_by_id", {"bucket_name": "b"}) + + denials = [ + r for r in _records(audit) if r["id"] == AuditEvent.SCOPE_CHECK_DENIED.id + ] + assert len(denials) == 1 + record = denials[0] + assert record["client_id"] == "agent-svc-7" + assert record["scopes"] == ["couchbase-mcp:read"] + # Derived from the tool's own class, not passed in: upsert is a write. + assert record["required_scope"] == "write" diff --git a/tests/unit/test_audit_sink.py b/tests/unit/test_audit_sink.py index 61ec8862..c5abe26a 100644 --- a/tests/unit/test_audit_sink.py +++ b/tests/unit/test_audit_sink.py @@ -33,6 +33,7 @@ import json import os import re +import sys import threading import time from pathlib import Path @@ -1056,7 +1057,7 @@ def write(self, text): def test_records_queued_before_start_are_not_silently_discarded(tmp_path): - """``emit`` before ``start`` used to be a no-op that lost records quietly.""" + """``emit`` before ``start`` must not lose records quietly.""" stream = io.StringIO() sink = ConsoleAuditSink(stream) # Deliberately no start(). @@ -1066,3 +1067,70 @@ def test_records_queued_before_start_are_not_silently_discarded(tmp_path): assert stream.getvalue() == '{"early":1}\n' assert sink.stats["written"] == 1 assert sink.stats["dropped"] == 0 + + +class TestPathShapesAndWriterSurvival: + """Failure modes an operator cannot see from the outside. + + Each would otherwise present as a server that refuses to start, records + written beside the mounted directory rather than inside it, a writer thread + that dies for good, or a loss figure reported at twice its real size. + """ + + @pytest.mark.parametrize( + "given", + ["/", "/var/log/cbmcp/", "logs/", "."], + ) + def test_a_directory_shaped_path_resolves_inside_the_directory(self, given): + """A trailing slash must not produce a *sibling* of the directory. + + Without the guard, /var/log/cbmcp/ resolves to + /var/log/cbmcp.. -- next to the volume the operator mounted + and chmod'ed rather than in it, with no warning. "/" raises instead, + and nothing catches it, so a path typo would stop the server starting. + """ + resolved = process_scoped_path(given, pid=7, host="h") + assert resolved.name.startswith("audit.") + assert resolved.suffix == ".log" + assert resolved.parent == Path(given.rstrip("/") or "/") + + def test_a_file_shaped_path_is_left_alone(self): + assert process_scoped_path("/var/log/cbmcp/audit.log", pid=7, host="h") == Path( + "/var/log/cbmcp/audit.h.7.log" + ) + + def test_the_writer_survives_an_unexpected_error(self): + """Every exit from the writer loop is permanent: start() will not restart it. + + sys.stderr is None under pythonw and under hosts that detach stdio, so + the console sink -- the default -- raised AttributeError, which was not + in its except clause. The thread died and all audit delivery stopped + for the process lifetime, silently. + """ + sink = ConsoleAuditSink() + sink.start() + real = sys.stderr + sys.stderr = None + try: + for _ in range(3): + sink.emit('{"id": 1}') + _settle(sink) + assert sink._thread is not None and sink._thread.is_alive() + assert sink.stats["dropped"] == 3 + finally: + sys.stderr = real + sink.close() + + def test_abandoned_records_are_counted_once(self): + """dropped is the only signal that records were lost; 2x is actionable. + + close() must drain the backlog rather than measure it with qsize(). + On a timed-out join the writer is still running, so records left in the + queue would be pulled by it, fail against the closed stream, and be + counted a second time. + """ + sink = ConsoleAuditSink() + for _ in range(5): + sink.emit('{"id": 1}') # queued before start, so never written + sink.close() + assert sink.stats["dropped"] + sink.stats["written"] == 5 diff --git a/tests/unit/test_audit_wiring.py b/tests/unit/test_audit_wiring.py index af09b6c0..c8e3bc4e 100644 --- a/tests/unit/test_audit_wiring.py +++ b/tests/unit/test_audit_wiring.py @@ -506,7 +506,7 @@ def test_enabling_auditing_on_an_unaudited_server_warns(tmp_path, caplog): buried in the status tool. Every server this repository ships is audited since Operational Insights - got its own block on 2026-10-12, so the spec is synthesised rather than + has its own block, so the spec is synthesised rather than found. The warning is kept, and kept tested, because the next server added starts out exactly like this one — and the day it does, an operator must be told rather than left reading an empty file. From 8b25914d6afe649188328aaad46584179c4f212d Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Wed, 14 Oct 2026 13:39:38 +0530 Subject: [PATCH 25/27] Fixed Review COmments --- server.json | 128 ++++++++++++++++++ src/cb_mcp/audit/catalog.py | 61 ++++----- src/cb_mcp/audit/classification.py | 56 ++++++-- src/cb_mcp/audit/middleware.py | 8 +- src/cb_mcp/audit/sink.py | 19 ++- src/cb_mcp/servers/operational/spec.py | 2 - .../tools/operational_insights/query.py | 40 ++++++ tests/unit/test_audit_catalog.py | 70 +++++----- tests/unit/test_audit_classification.py | 89 ++++++++++-- tests/unit/test_audit_middleware.py | 24 +++- 10 files changed, 388 insertions(+), 109 deletions(-) diff --git a/server.json b/server.json index 86a6c98b..9c4fe46a 100644 --- a/server.json +++ b/server.json @@ -681,6 +681,70 @@ "isRequired": false, "format": "string", "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-enabled", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-sinks", + "description": "Comma-separated audit destinations: console, file, or both. 'console' writes to stderr, never stdout, because stdout carries the JSON-RPC protocol under the stdio transport. Default is console.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-file-path", + "description": "Path to the audit log file, required when 'file' is in CB_MCP_AUDIT_LOG_SINKS. Each server process writes its own file with the host and process id inserted before the extension (audit.log becomes audit.myhost.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with the file sink dropped and an error recorded.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-rotation-max-size-mb", + "description": "Maximum size in MB the live audit file may reach before it rotates. 0 turns size-based rotation off. Default is 10 MB.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-rotation-interval", + "description": "Age the live audit file may reach before it rotates, as where the unit is 'd' (24 hours) or 'w' (7 days) - for example 1d, 30d, 1w, 8w. 0 turns interval-based rotation off. Size and interval are independent: whichever falls due first rotates the file. Default is 1d.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-retention-max-backups", + "description": "Number of rotated audit files retained, excluding the live file. Rotated files are gzipped and named with the UTC rotation time. Set to 0 to keep no backups: the live file is truncated at each rotation. Default is 10.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-tool-args", + "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-disabled-events", + "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "isRequired": false, + "format": "boolean", + "isSecret": false } ] }, @@ -1266,6 +1330,70 @@ "isRequired": false, "format": "string", "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-enabled", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false. This server emits audit records under its own event block, 0x10000 (65536), so an event id names the server that produced it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-sinks", + "description": "Comma-separated audit destinations: console, file, or both. 'console' writes to stderr, never stdout, because stdout carries the JSON-RPC protocol under the stdio transport. Default is console.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-file-path", + "description": "Path to the audit log file, required when 'file' is in CB_MCP_AUDIT_LOG_SINKS. Each server process writes its own file with the host and process id inserted before the extension (audit.log becomes audit.myhost.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with the file sink dropped and an error recorded.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-rotation-max-size-mb", + "description": "Maximum size in MB the live audit file may reach before it rotates. 0 turns size-based rotation off. Default is 10 MB.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-rotation-interval", + "description": "Age the live audit file may reach before it rotates, as where the unit is 'd' (24 hours) or 'w' (7 days) - for example 1d, 30d, 1w, 8w. 0 turns interval-based rotation off. Size and interval are independent: whichever falls due first rotates the file. Default is 1d.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-retention-max-backups", + "description": "Number of rotated audit files retained, excluding the live file. Rotated files are gzipped and named with the UTC rotation time. Set to 0 to keep no backups: the live file is truncated at each rotation. Default is 10.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-tool-args", + "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-disabled-events", + "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "isRequired": false, + "format": "boolean", + "isSecret": false } ] }, diff --git a/src/cb_mcp/audit/catalog.py b/src/cb_mcp/audit/catalog.py index f094c0e4..8b0dd63b 100644 --- a/src/cb_mcp/audit/catalog.py +++ b/src/cb_mcp/audit/catalog.py @@ -114,9 +114,9 @@ "query": 3, "index": 4, "performance": 5, - # The allocation is therefore contiguous across slots 0-7, with 8-15 - # free. The block has no retired numbers left in reserve: once a - # category has emitted, its number is dead permanently. + # Contiguous across 0-7, with 8-15 free for new categories. A number + # here is a wire contract: once a category has emitted records, its + # slot can never mean anything else. "search": 6, "config": 7, }, @@ -159,35 +159,25 @@ "error, and the call would go unrecorded entirely." ) -#: Slots retired from use, kept here so their history is visible — ``category -#: -> n``, using the number the category held. +#: Slots withdrawn from a released catalogue — ``package -> {category -> n}``, +#: under the number the category held. #: -#: A retired slot may be reallocated to a new category **only** when no audit -#: record was ever emitted against it. That holds for a category retired -#: because it never had any tools classified to it: with no tools there were no -#: records, so no SIEM rule for the old meaning can exist and reuse is safe. +#: Empty, because nothing has been withdrawn from a released catalogue. It +#: exists so the first withdrawal has somewhere to go, and so +#: :func:`next_free_slot` has something to consult. #: -#: A slot that has ever emitted a record must never be reused, even after the -#: category is withdrawn — reallocating it would make an old rule silently -#: match a new, unrelated event. Record such a slot here and leave it out of -#: :func:`next_free_slot`'s reusable set. -RETIRED_SLOTS: dict[str, dict[str, int]] = { - "operational": { - # Neither ever had a tool classified to it, so neither number has - # appeared in a record. Both are reused in CATEGORY_SLOTS above, which - # that fact is what permits. The entries stay here because they record - # what each number has meant, and the invariant test reads them to - # check that a reused slot is one that never emitted. - "vector": 6, - "analytics": 7, - }, -} +#: A withdrawn slot may be given to a new category **only** when no record was +#: ever emitted against it. A slot that has emitted must never be reused: +#: reallocating it would make an existing SIEM rule silently match a new, +#: unrelated event. Record such a slot in :data:`PERMANENTLY_RESERVED_SLOTS` +#: as well, which keeps it out of :func:`next_free_slot` for good. +RETIRED_SLOTS: dict[str, dict[str, int]] = {} #: Retired slots that must never be reallocated because records exist for them. PERMANENTLY_RESERVED_SLOTS: frozenset[int] = frozenset() -def categories_for(package: str = DEFAULT_SERVICE_PACKAGE) -> tuple[str, ...]: +def categories_for(package: str) -> tuple[str, ...]: """Category axis for ``package``'s Tier-2 block, ordered by slot. Derived from :data:`CATEGORY_SLOTS` so the two can never disagree. @@ -200,14 +190,14 @@ def categories_for(package: str = DEFAULT_SERVICE_PACKAGE) -> tuple[str, ...]: #: Category axis of the default package, kept as a module constant because #: it is the axis every descriptor and every test refers to today. -CATEGORIES: tuple[str, ...] = categories_for() +CATEGORIES: tuple[str, ...] = categories_for(DEFAULT_SERVICE_PACKAGE) #: Name of the service package this build audits. Recorded once at startup #: rather than repeated on every record. SERVICE_PACKAGE = DEFAULT_SERVICE_PACKAGE -def category_index(category: str, package: str = DEFAULT_SERVICE_PACKAGE) -> int: +def category_index(category: str, package: str) -> int: """Return the slot ``n`` for ``category`` within ``package``'s block.""" try: return CATEGORY_SLOTS[package][category] @@ -218,7 +208,7 @@ def category_index(category: str, package: str = DEFAULT_SERVICE_PACKAGE) -> int ) from exc -def next_free_slot(package: str = DEFAULT_SERVICE_PACKAGE) -> int: +def next_free_slot(package: str) -> int: """Return the slot a newly added category should take. Returns the lowest **never-allocated** slot. Retired slots are never @@ -250,9 +240,7 @@ def next_free_slot(package: str = DEFAULT_SERVICE_PACKAGE) -> int: ) -def tool_call_event_id( - category: str, operation_class: str, package: str = DEFAULT_SERVICE_PACKAGE -) -> int: +def tool_call_event_id(category: str, operation_class: str, package: str) -> int: """Resolve the Tier-2 event ID for a tool call. Args: @@ -414,9 +402,7 @@ def filterable(self) -> bool: } -def tool_call_event( - category: str, operation_class: str, package: str = DEFAULT_SERVICE_PACKAGE -) -> ToolCallEvent: +def tool_call_event(category: str, operation_class: str, package: str) -> ToolCallEvent: """Build the :class:`ToolCallEvent` for a category and operation class.""" label = _CATEGORY_LABELS[category] verb = "read" if operation_class == "read" else "write" @@ -431,7 +417,7 @@ def tool_call_event( def all_tool_call_events( - package: str = DEFAULT_SERVICE_PACKAGE, + package: str, ) -> list[ToolCallEvent]: """Every Tier-2 tool-call entry for ``package``, for descriptor generation.""" return [ @@ -499,7 +485,10 @@ def build_descriptor() -> dict: #: no caller. EVENT_NAMES_TO_IDS: dict[str, int] = { **{event.event_name: event.id for event in AuditEvent}, - **{event.event_name: event.id for event in all_tool_call_events()}, + **{ + event.event_name: event.id + for event in all_tool_call_events(DEFAULT_SERVICE_PACKAGE) + }, } #: Ids of events an operator is permitted to suppress via configuration. diff --git a/src/cb_mcp/audit/classification.py b/src/cb_mcp/audit/classification.py index f0bba7c7..616669dd 100644 --- a/src/cb_mcp/audit/classification.py +++ b/src/cb_mcp/audit/classification.py @@ -30,7 +30,7 @@ from collections.abc import Iterable -from .catalog import DEFAULT_SERVICE_PACKAGE, ToolCallEvent, tool_call_event +from .catalog import ToolCallEvent, tool_call_event #: ``service package -> {tool_name -> (category, operation_class)}``. #: @@ -187,8 +187,13 @@ "operational_insights_get_collections_in_scope": ("schema", "read"), "operational_insights_get_schema_for_collection": ("schema", "read"), # -- query (SQL++ over Analytics) ----------------------------------- - "operational_insights_run_query_sync": ("query", "read"), - "operational_insights_run_query_async": ("query", "read"), + # Both accept SELECT, DML and DDL, so they are statement-classified + # (see STATEMENT_CLASSIFIED_TOOLS). The entry here is the fallback used + # when no class was recorded, and it is `write` deliberately: a read id + # is filterable, so guessing read would let a mutation be configured + # out of the audit log. + "operational_insights_run_query_sync": ("query", "write"), + "operational_insights_run_query_async": ("query", "write"), "operational_insights_explain_query": ("query", "read"), # The async request lifecycle. Retrieving, discarding and cancelling # all act on a handle for a query this caller submitted, so they stay @@ -204,8 +209,34 @@ }, } -#: The one tool whose read/write class depends on the statement it is given. -STATEMENT_CLASSIFIED_TOOL = "run_sql_plus_plus_query" +#: Tools whose read/write class depends on the statement they are given, as +#: ``package -> {tool names}``. +#: +#: Each of these accepts SELECT, DML and DDL through one entry point, so a +#: fixed class would be wrong half the time -- and wrong in the dangerous +#: direction: read ids are filterable, so a mutation booked as a read could be +#: suppressed from the audit log by configuration. The tool records its own +#: class into :mod:`cb_mcp.audit.state` at invocation and the middleware reads +#: it back. +#: +#: A tool listed here that fails to record a class falls back to its table +#: entry below, which is why those entries are ``write``: the failure mode is +#: a filterable read, so the static answer has to be the non-filterable one. +STATEMENT_CLASSIFIED_TOOLS: dict[str, frozenset[str]] = { + "operational": frozenset({"run_sql_plus_plus_query"}), + "operational_insights": frozenset( + { + "operational_insights_run_query_sync", + "operational_insights_run_query_async", + } + ), +} + + +def is_statement_classified(tool_name: str, package: str) -> bool: + """True when ``tool_name`` reports its own read/write class per invocation.""" + return tool_name in STATEMENT_CLASSIFIED_TOOLS.get(package, frozenset()) + #: Fail-closed operation class for a tool with no entry. Write IDs are never #: filterable, so an unknown tool is always recorded. @@ -218,13 +249,13 @@ def classification_for( - package: str = DEFAULT_SERVICE_PACKAGE, + package: str, ) -> dict[str, tuple[str, str]]: """Return ``package``'s classification table, or an empty one if unaudited.""" return TOOL_CLASSIFICATION.get(package, {}) -def is_classified(tool_name: str, package: str = DEFAULT_SERVICE_PACKAGE) -> bool: +def is_classified(tool_name: str, package: str) -> bool: """True when ``tool_name`` has an explicit entry in ``package``'s table.""" return tool_name in classification_for(package) @@ -232,7 +263,7 @@ def is_classified(tool_name: str, package: str = DEFAULT_SERVICE_PACKAGE) -> boo def classify_tool( tool_name: str, *, - package: str = DEFAULT_SERVICE_PACKAGE, + package: str, operation_class_override: str | None = None, ) -> tuple[str, str]: """Return ``(category, operation_class)`` for ``tool_name``. @@ -256,7 +287,7 @@ def classify_tool( def resolve_tool_call_event( tool_name: str, *, - package: str = DEFAULT_SERVICE_PACKAGE, + package: str, operation_class_override: str | None = None, ) -> ToolCallEvent: """Resolve the catalogue entry for a tool invocation in ``package``.""" @@ -277,9 +308,7 @@ def required_scope_for(operation_class: str) -> str: return "write" if operation_class == "write" else "read" -def unclassified_tool_names( - tool_names: Iterable[str], package: str = DEFAULT_SERVICE_PACKAGE -) -> list[str]: +def unclassified_tool_names(tool_names: Iterable[str], package: str) -> list[str]: """Return the subset of ``tool_names`` with no entry in ``package``'s table.""" table = classification_for(package) return sorted(name for name in tool_names if name not in table) @@ -289,7 +318,8 @@ def unclassified_tool_names( "FAILSAFE_CATEGORY", "classification_for", "FAILSAFE_OPERATION_CLASS", - "STATEMENT_CLASSIFIED_TOOL", + "STATEMENT_CLASSIFIED_TOOLS", + "is_statement_classified", "TOOL_CLASSIFICATION", "classify_tool", "is_classified", diff --git a/src/cb_mcp/audit/middleware.py b/src/cb_mcp/audit/middleware.py index fd49825e..7d1119ab 100644 --- a/src/cb_mcp/audit/middleware.py +++ b/src/cb_mcp/audit/middleware.py @@ -51,11 +51,11 @@ from ..utils.constants import LOGGER_NAMESPACE from . import state -from .catalog import DEFAULT_SERVICE_PACKAGE, AuditEvent +from .catalog import AuditEvent from .classification import ( - STATEMENT_CLASSIFIED_TOOL, classify_tool, is_classified, + is_statement_classified, required_scope_for, resolve_tool_call_event, ) @@ -137,7 +137,7 @@ def __init__( *, transport: str, cb_userid: str | None = None, - service_package: str = DEFAULT_SERVICE_PACKAGE, + service_package: str, audit_logger: AuditLogger | None = None, ) -> None: """ @@ -393,7 +393,7 @@ def _emit_tool_call( """Emit the Tier-2 record for a call that actually reached the tool.""" override = ( state.get_statement_class() - if tool_name == STATEMENT_CLASSIFIED_TOOL + if is_statement_classified(tool_name, self._service_package) else None ) event = resolve_tool_call_event( diff --git a/src/cb_mcp/audit/sink.py b/src/cb_mcp/audit/sink.py index ec12f901..4329bb34 100644 --- a/src/cb_mcp/audit/sink.py +++ b/src/cb_mcp/audit/sink.py @@ -622,13 +622,22 @@ def _next_rotation_deadline(self, *, from_existing_file: bool = False) -> float: """When the interval trigger next falls due, or ``inf`` when it is off. On open the clock is anchored to the file's **last modification time** - rather than to now, so a restart does not hand the live file a fresh - interval it has not earned. A file last written longer ago than the - interval is therefore already due and rolls over on the first record — - which is what an operator asking for daily files expects after the - server was down for a week. Once running, each rotation sets the next + rather than to now, so a process does not hand the live file a fresh + interval it has not earned. Once running, each rotation sets the next deadline from the clock, so a busy file cannot push its own deadline forward by being written to. + + Reaching that anchoring at all requires a file to be there already, and + the live filename carries the process id — ``audit...log``. + So the only way a new process finds one is pid reuse: the operating + system hands out a pid an earlier server held, and that server's file + is still on disk. Uncommon, but not rare over a container's lifetime. + + The anchoring is what makes that case safe. The earlier file is older + than the interval in any realistic case, so it is already due and + rotates on the first record — the previous process's records go to a + timestamped backup and this process starts a clean file, rather than + the two interleaving in one file with nothing to tell them apart. """ if self._interval <= 0: return float("inf") diff --git a/src/cb_mcp/servers/operational/spec.py b/src/cb_mcp/servers/operational/spec.py index deec93cf..7e052321 100644 --- a/src/cb_mcp/servers/operational/spec.py +++ b/src/cb_mcp/servers/operational/spec.py @@ -85,7 +85,5 @@ def _configure_couchbase_sdk_logging(logger_root: str, level: int) -> None: "embedding_aws_access_key_id", "embedding_aws_secret_access_key", ), - # The only audited server today. Its Tier-2 block (0xF000) and category - # slots are shipped and frozen; see cb_mcp.audit.catalog. audit_package="operational", ) diff --git a/src/cb_mcp/tools/operational_insights/query.py b/src/cb_mcp/tools/operational_insights/query.py index 37513ec2..3fd602b5 100644 --- a/src/cb_mcp/tools/operational_insights/query.py +++ b/src/cb_mcp/tools/operational_insights/query.py @@ -39,6 +39,8 @@ from fastmcp import Context from fastmcp.server.dependencies import get_access_token +from ...audit import state as audit_state +from ...audit.emitter import get_audit_logger from ...servers.operational_insights.constants import ( OPERATIONAL_INSIGHTS_LOGGER_NAMESPACE, ) @@ -79,6 +81,32 @@ def _strip_leading_comments(statement: str) -> str: pos = match.end() +#: Statement forms that only read. Anything else is treated as a write. +#: +#: Deliberately a short allow-list rather than a parser. The Analytics dialect +#: is not the one lark_sqlpp parses, and a misparse here would be worse than no +#: parse at all: the fallback has to be the non-filterable class, so an +#: unrecognised statement is recorded as a write. The cost is that a SELECT +#: written behind a CTE is audited as a write -- noise in the log, not a gap in +#: it. +_READ_ONLY_STATEMENT = re.compile(r"^(SELECT|EXPLAIN)\b", re.IGNORECASE) + + +def _audit_statement_class(statement: str) -> str: + """Classify a statement as ``"read"`` or ``"write"`` for the audit record. + + These tools accept SELECT, DML and DDL through one entry point, so the + class cannot come from the tool name. Read ids are filterable, which is why + anything not provably a read is recorded as a write: a mutation booked as a + filterable read could be configured out of the audit log entirely. + """ + return ( + "read" + if _READ_ONLY_STATEMENT.match(_strip_leading_comments(statement)) + else "write" + ) + + def _is_copy_to_statement(statement: str) -> bool: """True for a ``COPY ... TO`` statement. @@ -304,6 +332,12 @@ def operational_insights_run_query_sync( lacks_write_scope = token is not None and SCOPE_WRITE not in (token.scopes or []) enforce_readonly = read_only_mode or lacks_write_scope + # The audit category depends on the statement, not on the tool name, so it + # has to be reported from here. Without this the record falls back to the + # table entry in cb_mcp.audit.classification. + if get_audit_logger().active: + audit_state.record_statement_class(_audit_statement_class(statement)) + try: statement_to_run, destination = _resolve_copy_to( statement, @@ -513,6 +547,12 @@ def operational_insights_run_query_async( lacks_write_scope = token is not None and SCOPE_WRITE not in (token.scopes or []) enforce_readonly = read_only_mode or lacks_write_scope + # The audit category depends on the statement, not on the tool name, so it + # has to be reported from here. Without this the record falls back to the + # table entry in cb_mcp.audit.classification. + if get_audit_logger().active: + audit_state.record_statement_class(_audit_statement_class(statement)) + try: statement_to_run, destination = _resolve_copy_to( statement, diff --git a/tests/unit/test_audit_catalog.py b/tests/unit/test_audit_catalog.py index 5d2e3fba..8c5a23c4 100644 --- a/tests/unit/test_audit_catalog.py +++ b/tests/unit/test_audit_catalog.py @@ -95,27 +95,27 @@ def test_core_event_ids_are_the_prd_values(event, expected_id, expected_name): ], ) def test_tool_call_ids_are_the_prd_values(category, read_id, write_id): - assert tool_call_event_id(category, "read") == read_id - assert tool_call_event_id(category, "write") == write_id + assert tool_call_event_id(category, "read", "operational") == read_id + assert tool_call_event_id(category, "write", "operational") == write_id assert write_id - read_id == WRITE_DELTA def test_kv_events_use_the_document_label(): # The PRD names the KV events "document read"/"document write" even though # the category axis calls the category "kv". - assert tool_call_event("kv", "read").event_name == "document read" - assert tool_call_event("kv", "write").event_name == "document write" + assert tool_call_event("kv", "read", "operational").event_name == "document read" + assert tool_call_event("kv", "write", "operational").event_name == "document write" def test_unknown_category_and_class_are_rejected(): with pytest.raises(ValueError, match="Unknown audit category"): - tool_call_event_id("nonsense", "read") + tool_call_event_id("nonsense", "read", "operational") with pytest.raises(ValueError, match="must be 'read' or 'write'"): - tool_call_event_id("kv", "sideways") + tool_call_event_id("kv", "sideways", "operational") def test_writes_are_never_filterable_and_reads_always_are(): - for event in all_tool_call_events(): + for event in all_tool_call_events("operational"): assert event.filterable is (event.operation_class == "read") @@ -129,7 +129,7 @@ def test_security_and_lifecycle_events_are_not_filterable(): def test_ids_are_unique(): ids = [event.id for event in AuditEvent] + [ - event.id for event in all_tool_call_events() + event.id for event in all_tool_call_events("operational") ] assert len(ids) == len(set(ids)) @@ -286,15 +286,15 @@ def test_retiring_a_category_does_not_move_its_neighbours(monkeypatch): if name != "index" } monkeypatch.setattr(catalog, "CATEGORY_SLOTS", {"operational": trimmed}) - assert catalog.tool_call_event_id("performance", "read") == 61493 - assert catalog.tool_call_event_id("query", "read") == 61491 + assert catalog.tool_call_event_id("performance", "read", "operational") == 61493 + assert catalog.tool_call_event_id("query", "read", "operational") == 61491 def test_next_free_slot_takes_the_lowest_never_allocated(monkeypatch): monkeypatch.setattr(catalog, "RETIRED_SLOTS", {}) monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) # 0-7 are allocated, so 8 is the lowest never-allocated slot. - assert catalog.next_free_slot() == 8 + assert catalog.next_free_slot("operational") == 8 def test_next_free_slot_skips_retired_slots(monkeypatch): @@ -312,14 +312,14 @@ def test_next_free_slot_skips_retired_slots(monkeypatch): # 0-7 are live and 8 is retired, so the helper must skip past it to 9. # Retiring a slot *above* the first free one would make this pass # whether or not RETIRED_SLOTS were consulted at all. - assert catalog.next_free_slot() == 9 + assert catalog.next_free_slot("operational") == 9 def test_permanently_reserved_slots_are_never_reallocated(monkeypatch): """A slot that has emitted records must not be handed to a new category.""" monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"withdrawn": 8}}) monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset({8})) - assert catalog.next_free_slot() == 9 + assert catalog.next_free_slot("operational") == 9 def test_exhausted_block_is_an_explicit_error(monkeypatch): @@ -331,26 +331,26 @@ def test_exhausted_block_is_an_explicit_error(monkeypatch): monkeypatch.setattr(catalog, "RETIRED_SLOTS", {}) monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) with pytest.raises(ValueError, match="request its own block"): - catalog.next_free_slot() + catalog.next_free_slot("operational") -def test_retired_categories_are_gone_but_their_slots_are_recorded(): - """vector and analytics were retired before release. +def test_nothing_is_retired_in_a_catalogue_that_has_not_shipped(): + """No category has been withdrawn from a released catalogue. - Neither ever had a tool classified to it, so neither slot ever emitted a - record — which is precisely the condition that makes a slot safe to hand to - a future category. The numbers stay recorded so the history is visible and - nobody wonders why the live table starts skipping at 6. + The table exists for the first withdrawal, not to carry development + history: a slot that never reached a release never meant anything to an + operator, so recording it as retired would only make a reader wonder which + numbers they must avoid. """ - assert "vector" not in catalog.CATEGORY_SLOTS["operational"] - assert "analytics" not in catalog.CATEGORY_SLOTS["operational"] - assert catalog.RETIRED_SLOTS == {"operational": {"vector": 6, "analytics": 7}} - # Never emitted a record, so never permanently reserved. - assert not catalog.PERMANENTLY_RESERVED_SLOTS + assert catalog.RETIRED_SLOTS == {} + assert frozenset() == catalog.PERMANENTLY_RESERVED_SLOTS - for name in ("vector", "analytics"): - with pytest.raises(ValueError, match="Unknown audit category"): - catalog.tool_call_event_id(name, "read") + +def test_every_slot_in_the_block_is_allocated_contiguously(): + """0-7 are in use and 8-15 are free, with no gaps to explain.""" + slots = sorted(catalog.CATEGORY_SLOTS["operational"].values()) + assert slots == list(range(8)) + assert catalog.next_free_slot("operational") == 8 def test_a_slot_that_ever_emitted_is_never_reallocated(monkeypatch): @@ -382,7 +382,7 @@ def reserved_slots_clash(reserved): assert clashes["operational"] == {occupied} # And must be quiet for a slot nobody has allocated. - free = catalog.next_free_slot() + free = catalog.next_free_slot("operational") assert all(not c for c in reserved_slots_clash({free}).values()) # The real table, which must be clean. @@ -406,14 +406,14 @@ def test_every_reused_slot_is_one_that_never_emitted(): def test_retirement_did_not_move_the_surviving_ids(): """The point of the explicit slot table, asserted on the real retirement.""" - assert tool_call_event_id("cluster", "read") == 61488 - assert tool_call_event_id("performance", "read") == 61493 - assert tool_call_event_id("performance", "write") == 61525 + assert tool_call_event_id("cluster", "read", "operational") == 61488 + assert tool_call_event_id("performance", "read", "operational") == 61493 + assert tool_call_event_id("performance", "write", "operational") == 61525 def test_a_new_category_takes_the_next_never_allocated_slot(): """Slots 0-7 are allocated, so the next category gets 8.""" - assert catalog.next_free_slot() == 8 + assert catalog.next_free_slot("operational") == 8 def test_search_ids_are_the_shipped_numbers(): @@ -421,5 +421,5 @@ def test_search_ids_are_the_shipped_numbers(): Search occupies slot 6, one of the two reused from RETIRED_SLOTS. """ - assert tool_call_event_id("search", "read") == 61494 - assert tool_call_event_id("search", "write") == 61526 + assert tool_call_event_id("search", "read", "operational") == 61494 + assert tool_call_event_id("search", "write", "operational") == 61526 diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py index f03202a2..e17d9d28 100644 --- a/tests/unit/test_audit_classification.py +++ b/tests/unit/test_audit_classification.py @@ -29,6 +29,7 @@ TOOL_CLASSIFICATION, classify_tool, is_classified, + is_statement_classified, required_scope_for, resolve_tool_call_event, unclassified_tool_names, @@ -36,6 +37,7 @@ from cb_mcp.tools.operational import ALL_TOOLS, READ_ONLY_TOOLS, WRITE_TOOLS from cb_mcp.tools.operational_insights import ALL_TOOLS as OI_ALL_TOOLS from cb_mcp.tools.operational_insights import WRITE_TOOLS as OI_WRITE_TOOLS +from cb_mcp.tools.operational_insights.query import _audit_statement_class #: The operational package. Operational Insights has its own block and table #: its own block and table; constants for it are defined alongside these. @@ -114,7 +116,9 @@ def test_no_stale_oi_entries(): ("get_server_configuration_status", 65591), # config read ("operational_insights_get_databases_in_cluster", 65585), # schema read ("operational_insights_get_schema_for_collection", 65585), - ("operational_insights_run_query_sync", 65587), # query read + # Statement-classified: 65619 is the non-filterable fallback used + # when the statement reported no class. + ("operational_insights_run_query_sync", 65619), ("operational_insights_cancel_async_query", 65587), ("operational_insights_list_indexes", 65588), # index read ("operational_insights_create_index", 65620), # index write @@ -136,14 +140,14 @@ def test_oi_write_tools_are_classified_write(tool_name): @pytest.mark.parametrize("tool_name", sorted(WRITE_TOOL_NAMES)) def test_repository_write_tools_are_classified_write(tool_name): """The audit class must agree with the server's own categorisation.""" - _, operation_class = classify_tool(tool_name) + _, operation_class = classify_tool(tool_name, package=PACKAGE) assert operation_class == "write", tool_name - assert resolve_tool_call_event(tool_name).filterable is False + assert resolve_tool_call_event(tool_name, package=PACKAGE).filterable is False @pytest.mark.parametrize("tool_name", sorted(READ_ONLY_NAMES)) def test_read_only_tools_are_classified_read(tool_name): - _, operation_class = classify_tool(tool_name) + _, operation_class = classify_tool(tool_name, package=PACKAGE) assert operation_class == "read", tool_name @@ -204,13 +208,13 @@ class against the server's own read/write split, so a tool filed under the wire contract a SIEM rule is written against; only an explicit expectation holds it still. """ - assert resolve_tool_call_event(tool_name).id == expected_id + assert resolve_tool_call_event(tool_name, package=PACKAGE).id == expected_id def test_sqlpp_write_statement_moves_to_the_query_write_id(): - read_event = resolve_tool_call_event("run_sql_plus_plus_query") + read_event = resolve_tool_call_event("run_sql_plus_plus_query", package=PACKAGE) write_event = resolve_tool_call_event( - "run_sql_plus_plus_query", operation_class_override="write" + "run_sql_plus_plus_query", package=PACKAGE, operation_class_override="write" ) assert read_event.id == 61491 assert write_event.id == 61523 @@ -246,15 +250,15 @@ def test_the_failsafe_category_is_allocated_everywhere(): def test_unknown_tool_fails_closed_to_a_non_filterable_write(): - assert is_classified("some_future_tool") is False - event = resolve_tool_call_event("some_future_tool") + assert is_classified("some_future_tool", PACKAGE) is False + event = resolve_tool_call_event("some_future_tool", package=PACKAGE) assert event.operation_class == "write" assert event.filterable is False def test_unclassified_tool_names_reports_only_the_gaps(): names = ["get_document_by_id", "brand_new_a", "brand_new_b"] - assert unclassified_tool_names(names) == ["brand_new_a", "brand_new_b"] + assert unclassified_tool_names(names, PACKAGE) == ["brand_new_a", "brand_new_b"] @pytest.mark.parametrize( @@ -269,3 +273,68 @@ def test_every_classification_uses_a_known_category_and_class(): for tool_name, (category, operation_class) in TOOL_CLASSIFICATION[PACKAGE].items(): assert category in CATEGORIES, tool_name assert operation_class in ("read", "write"), tool_name + + +class TestStatementClassifiedTools: + """Tools that accept SELECT, DML and DDL through one entry point. + + Their class cannot come from the tool name. Read ids are filterable, so a + mutation recorded as a read could be configured out of the audit log -- + which is why the static fallback for each is write. + """ + + def test_the_operational_insights_query_tools_are_statement_classified(self): + for tool_name in ( + "operational_insights_run_query_sync", + "operational_insights_run_query_async", + ): + assert is_statement_classified(tool_name, "operational_insights"), tool_name + + def test_sqlpp_is_statement_classified_on_the_operational_server(self): + assert is_statement_classified("run_sql_plus_plus_query", PACKAGE) + + def test_a_statement_classified_tool_is_not_shared_across_packages(self): + """The name only means something inside its own package.""" + assert not is_statement_classified( + "run_sql_plus_plus_query", "operational_insights" + ) + assert not is_statement_classified( + "operational_insights_run_query_sync", PACKAGE + ) + + @pytest.mark.parametrize( + "tool_name", + [ + "operational_insights_run_query_sync", + "operational_insights_run_query_async", + ], + ) + def test_the_fallback_class_is_the_non_filterable_one(self, tool_name): + """If no class was recorded, the record must not be filterable. + + The failure mode of the state channel is silence, so the static answer + has to be the one that cannot hide a mutation. + """ + event = resolve_tool_call_event(tool_name, package="operational_insights") + assert event.operation_class == "write" + assert event.filterable is False + + @pytest.mark.parametrize( + ("statement", "expected"), + [ + ("SELECT 1", "read"), + (" -- a comment\nSELECT 1", "read"), + ("EXPLAIN SELECT 1", "read"), + ("INSERT INTO x VALUES (1)", "write"), + ("DELETE FROM x WHERE a = 1", "write"), + ("CREATE INDEX i ON d(a)", "write"), + ("COPY d TO link AT 'p'", "write"), + # Not provably a read, so it is recorded as a write: noise in the + # log rather than a mutation that could be filtered out of it. + ("WITH a AS (SELECT 1) SELECT * FROM a", "write"), + ], + ) + def test_statements_that_are_not_provably_reads_are_writes( + self, statement, expected + ): + assert _audit_statement_class(statement) == expected diff --git a/tests/unit/test_audit_middleware.py b/tests/unit/test_audit_middleware.py index 286b197d..f32e9fce 100644 --- a/tests/unit/test_audit_middleware.py +++ b/tests/unit/test_audit_middleware.py @@ -95,7 +95,10 @@ def _server(audit: AuditLogger, transport: str = "stdio") -> FastMCP: mcp = FastMCP("audit-test") mcp.add_middleware( AuditMiddleware( - transport=transport, cb_userid="mcp_service", audit_logger=audit + transport=transport, + cb_userid="mcp_service", + audit_logger=audit, + service_package="operational", ) ) return mcp @@ -258,7 +261,12 @@ async def upsert_document_by_id( assert writes[0]["outcome"] == OUTCOME_ERROR # Write ids are never filterable, so the record cannot be configured away # either. - assert resolve_tool_call_event("upsert_document_by_id").filterable is False + assert ( + resolve_tool_call_event( + "upsert_document_by_id", package="operational" + ).filterable + is False + ) @pytest.mark.asyncio @@ -778,7 +786,10 @@ async def test_correlation_survives_without_on_message(tmp_path): """ audit = _build_logger(tmp_path) middleware = AuditMiddleware( - transport="stdio", cb_userid="mcp_service", audit_logger=audit + transport="stdio", + cb_userid="mcp_service", + audit_logger=audit, + service_package="operational", ) @dataclass @@ -907,7 +918,12 @@ async def test_an_oauth_caller_is_named_on_the_tool_call_record(tmp_path): audit = _build_logger(tmp_path) mcp = FastMCP("audit-test") mcp.add_middleware( - AuditMiddleware(transport="http", cb_userid="mcp_service", audit_logger=audit) + AuditMiddleware( + transport="http", + cb_userid="mcp_service", + audit_logger=audit, + service_package="operational", + ) ) @mcp.tool From e72ff3ab1292f00df78a4ddd833c122ff9dbe1f7 Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Wed, 14 Oct 2026 15:17:19 +0530 Subject: [PATCH 26/27] Cleanup --- src/cb_mcp/audit/catalog.py | 47 ++++------------- tests/unit/test_audit_catalog.py | 87 +++----------------------------- 2 files changed, 17 insertions(+), 117 deletions(-) diff --git a/src/cb_mcp/audit/catalog.py b/src/cb_mcp/audit/catalog.py index 8b0dd63b..1ed09fad 100644 --- a/src/cb_mcp/audit/catalog.py +++ b/src/cb_mcp/audit/catalog.py @@ -60,8 +60,8 @@ #: :data:`SERVICE_PACKAGE_BLOCKS`. It remains the default because the #: operational server is the one whose tools the shared helpers are usually #: asked about; every audited server passes its own package explicitly, from -#: ``ServerSpec.audit_package``. -DEFAULT_SERVICE_PACKAGE = "operational" +#: ``ServerSpec.audit_package``, and every record carries that one. +SERVICE_PACKAGE = "operational" #: ``service package -> Tier-2 block base``. Blocks are 0x1000 apart. Adding a #: package means an entry here, an entry in :data:`CATEGORY_SLOTS`, a @@ -169,13 +169,11 @@ #: A withdrawn slot may be given to a new category **only** when no record was #: ever emitted against it. A slot that has emitted must never be reused: #: reallocating it would make an existing SIEM rule silently match a new, -#: unrelated event. Record such a slot in :data:`PERMANENTLY_RESERVED_SLOTS` -#: as well, which keeps it out of :func:`next_free_slot` for good. +#: unrelated event. :func:`next_free_slot` never offers a retired slot, so the +#: reuse has to be written into :data:`CATEGORY_SLOTS` by hand, with that fact +#: established. RETIRED_SLOTS: dict[str, dict[str, int]] = {} -#: Retired slots that must never be reallocated because records exist for them. -PERMANENTLY_RESERVED_SLOTS: frozenset[int] = frozenset() - def categories_for(package: str) -> tuple[str, ...]: """Category axis for ``package``'s Tier-2 block, ordered by slot. @@ -190,11 +188,7 @@ def categories_for(package: str) -> tuple[str, ...]: #: Category axis of the default package, kept as a module constant because #: it is the axis every descriptor and every test refers to today. -CATEGORIES: tuple[str, ...] = categories_for(DEFAULT_SERVICE_PACKAGE) - -#: Name of the service package this build audits. Recorded once at startup -#: rather than repeated on every record. -SERVICE_PACKAGE = DEFAULT_SERVICE_PACKAGE +CATEGORIES: tuple[str, ...] = categories_for(SERVICE_PACKAGE) def category_index(category: str, package: str) -> int: @@ -224,10 +218,8 @@ def next_free_slot(package: str) -> int: service package should then take a fresh 0x1000 block rather than crowding this one. """ - taken = ( - set(CATEGORY_SLOTS[package].values()) - | set(RETIRED_SLOTS.get(package, {}).values()) - | PERMANENTLY_RESERVED_SLOTS + taken = set(CATEGORY_SLOTS[package].values()) | set( + RETIRED_SLOTS.get(package, {}).values() ) for slot in range(MAX_SLOT + 1): if slot not in taken: @@ -466,31 +458,13 @@ def build_descriptor() -> dict: # A single value, for readers that expect one: the package this build # audits by default. The authoritative list is `service_packages`, and # every tool-call entry names its own. - "service_package": DEFAULT_SERVICE_PACKAGE, + "service_package": SERVICE_PACKAGE, "service_packages": sorted(SERVICE_PACKAGE_BLOCKS), "blocks": {"core": CORE_BLOCK, **SERVICE_PACKAGE_BLOCKS}, "events": entries, } -#: Every event name that may appear in a record, mapped to its numeric id — -#: for the core block and the **default** package only. -#: -#: Deliberately not spanning every package: a tool-call name such as "query -#: read" exists in each Tier-2 block with a different id, so a name is only -#: unique within a package and a flat name map could not represent more than -#: one. Nothing resolves a name to an id at runtime — the operator-facing -#: filter takes ids, which are globally unique — so this stays a convenience -#: for the default package rather than becoming a package-keyed structure with -#: no caller. -EVENT_NAMES_TO_IDS: dict[str, int] = { - **{event.event_name: event.id for event in AuditEvent}, - **{ - event.event_name: event.id - for event in all_tool_call_events(DEFAULT_SERVICE_PACKAGE) - }, -} - #: Ids of events an operator is permitted to suppress via configuration. #: #: Spans every registered package. A process runs one package, but an operator @@ -522,14 +496,11 @@ def build_descriptor() -> dict: "ALL_IDS", "CATEGORIES", "CATEGORY_SLOTS", - "DEFAULT_SERVICE_PACKAGE", "SERVICE_PACKAGE_BLOCKS", "categories_for", "MAX_SLOT", - "PERMANENTLY_RESERVED_SLOTS", "RETIRED_SLOTS", "CORE_BLOCK", - "EVENT_NAMES_TO_IDS", "FILTERABLE_IDS", "OPERATIONAL_BLOCK", "READ_OFFSET", diff --git a/tests/unit/test_audit_catalog.py b/tests/unit/test_audit_catalog.py index 8c5a23c4..7ba04bf8 100644 --- a/tests/unit/test_audit_catalog.py +++ b/tests/unit/test_audit_catalog.py @@ -292,7 +292,6 @@ def test_retiring_a_category_does_not_move_its_neighbours(monkeypatch): def test_next_free_slot_takes_the_lowest_never_allocated(monkeypatch): monkeypatch.setattr(catalog, "RETIRED_SLOTS", {}) - monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) # 0-7 are allocated, so 8 is the lowest never-allocated slot. assert catalog.next_free_slot("operational") == 8 @@ -300,28 +299,19 @@ def test_next_free_slot_takes_the_lowest_never_allocated(monkeypatch): def test_next_free_slot_skips_retired_slots(monkeypatch): """Retired slots are not offered, even when reuse would be safe. - Reuse remains *permissible* for a slot that never emitted a record — the - rule in ``RETIRED_SLOTS`` says so, and both retired slots are in fact - reused. It is simply never *automatic*: reuse has to be chosen with the - evidence that the slot never emitted in front of you. Handing retired slots - back from a helper would make that the default path, and the day a slot - *has* emitted the default would be wrong. + Reuse remains *permissible* for a slot that never emitted a record. It is + simply never *automatic*: reuse has to be chosen with the evidence that the + slot never emitted in front of you, and written into ``CATEGORY_SLOTS`` by + hand. Handing retired slots back from a helper would make that the default + path, and the day a slot *has* emitted the default would be wrong. """ monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"withdrawn": 8}}) - monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) # 0-7 are live and 8 is retired, so the helper must skip past it to 9. # Retiring a slot *above* the first free one would make this pass # whether or not RETIRED_SLOTS were consulted at all. assert catalog.next_free_slot("operational") == 9 -def test_permanently_reserved_slots_are_never_reallocated(monkeypatch): - """A slot that has emitted records must not be handed to a new category.""" - monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"withdrawn": 8}}) - monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset({8})) - assert catalog.next_free_slot("operational") == 9 - - def test_exhausted_block_is_an_explicit_error(monkeypatch): monkeypatch.setattr( catalog, @@ -329,7 +319,6 @@ def test_exhausted_block_is_an_explicit_error(monkeypatch): {"operational": {f"c{n}": n for n in range(catalog.MAX_SLOT + 1)}}, ) monkeypatch.setattr(catalog, "RETIRED_SLOTS", {}) - monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset()) with pytest.raises(ValueError, match="request its own block"): catalog.next_free_slot("operational") @@ -343,7 +332,6 @@ def test_nothing_is_retired_in_a_catalogue_that_has_not_shipped(): numbers they must avoid. """ assert catalog.RETIRED_SLOTS == {} - assert frozenset() == catalog.PERMANENTLY_RESERVED_SLOTS def test_every_slot_in_the_block_is_allocated_contiguously(): @@ -353,73 +341,14 @@ def test_every_slot_in_the_block_is_allocated_contiguously(): assert catalog.next_free_slot("operational") == 8 -def test_a_slot_that_ever_emitted_is_never_reallocated(monkeypatch): - """The rule that governs slot reuse, exercised rather than assumed. - - Both retired slots are reused -- 6 by `search`, 7 by `config` -- so there - is no list of dead numbers to assert against. What must hold is the - condition that permits the reuse: a slot may only be reused if no record - was ever emitted against it. - - PERMANENTLY_RESERVED_SLOTS is where a slot goes once it *has* emitted, and - it is empty today because nothing has shipped. Asserting against it as it - stands is vacuously true and would pass even if the rule were deleted, so - this test populates it with a slot that is live and checks the violation is - actually caught. - """ - live = catalog.CATEGORY_SLOTS["operational"] - occupied = live["kv"] - - def reserved_slots_clash(reserved): - monkeypatch.setattr(catalog, "PERMANENTLY_RESERVED_SLOTS", frozenset(reserved)) - return { - package: set(slots.values()) & catalog.PERMANENTLY_RESERVED_SLOTS - for package, slots in catalog.CATEGORY_SLOTS.items() - } - - # The guard must fire when a slot that emitted is also live. - clashes = reserved_slots_clash({occupied}) - assert clashes["operational"] == {occupied} - - # And must be quiet for a slot nobody has allocated. - free = catalog.next_free_slot("operational") - assert all(not c for c in reserved_slots_clash({free}).values()) - - # The real table, which must be clean. - monkeypatch.undo() - for package, slots in catalog.CATEGORY_SLOTS.items(): - assert set(slots.values()) & catalog.PERMANENTLY_RESERVED_SLOTS == set(), ( - package - ) - - -def test_every_reused_slot_is_one_that_never_emitted(): - """Checks the reuses themselves: both must be absent from the emitted set.""" - for package, retired in catalog.RETIRED_SLOTS.items(): - live = catalog.CATEGORY_SLOTS.get(package, {}) - reused = set(retired.values()) & set(live.values()) - assert reused == {6, 7}, reused - assert reused & catalog.PERMANENTLY_RESERVED_SLOTS == set() - assert catalog.CATEGORY_SLOTS["operational"]["search"] == 6 - assert catalog.CATEGORY_SLOTS["operational"]["config"] == 7 - - -def test_retirement_did_not_move_the_surviving_ids(): - """The point of the explicit slot table, asserted on the real retirement.""" +def test_slot_table_ids_are_the_shipped_numbers(): + """The point of the explicit slot table: every id pinned literally.""" assert tool_call_event_id("cluster", "read", "operational") == 61488 assert tool_call_event_id("performance", "read", "operational") == 61493 assert tool_call_event_id("performance", "write", "operational") == 61525 -def test_a_new_category_takes_the_next_never_allocated_slot(): - """Slots 0-7 are allocated, so the next category gets 8.""" - assert catalog.next_free_slot("operational") == 8 - - def test_search_ids_are_the_shipped_numbers(): - """Pinned literally, like every other shipped id. - - Search occupies slot 6, one of the two reused from RETIRED_SLOTS. - """ + """Pinned literally, like every other shipped id.""" assert tool_call_event_id("search", "read", "operational") == 61494 assert tool_call_event_id("search", "write", "operational") == 61526 From f60500c0b80f6ec099f2b645ccbee0b025bcff6f Mon Sep 17 00:00:00 2001 From: AayushTyagi1 Date: Wed, 14 Oct 2026 16:04:21 +0530 Subject: [PATCH 27/27] Remove operation as default package --- src/cb_mcp/audit/__init__.py | 3 +-- src/cb_mcp/audit/catalog.py | 35 ++++--------------------- src/cb_mcp/audit/descriptor.json | 1 - src/cb_mcp/audit/emitter.py | 5 ++-- tests/unit/test_audit_catalog.py | 20 ++++++-------- tests/unit/test_audit_classification.py | 4 +-- 6 files changed, 18 insertions(+), 50 deletions(-) diff --git a/src/cb_mcp/audit/__init__.py b/src/cb_mcp/audit/__init__.py index c78e150b..6fcb1c10 100644 --- a/src/cb_mcp/audit/__init__.py +++ b/src/cb_mcp/audit/__init__.py @@ -29,7 +29,7 @@ class of decisions — scope denials, read-only blocks, declined or skipped * :mod:`~cb_mcp.audit.state` — per-request refusal channel """ -from .catalog import SERVICE_PACKAGE, AuditEvent, build_descriptor +from .catalog import AuditEvent, build_descriptor from .classification import ( TOOL_CLASSIFICATION, classify_tool, @@ -72,7 +72,6 @@ class of decisions — scope denials, read-only blocks, declined or skipped "OUTCOME_DENIED", "OUTCOME_ERROR", "OUTCOME_SUCCESS", - "SERVICE_PACKAGE", "TOOL_CLASSIFICATION", "AuditEvent", "AuditLogger", diff --git a/src/cb_mcp/audit/catalog.py b/src/cb_mcp/audit/catalog.py index 1ed09fad..c5739797 100644 --- a/src/cb_mcp/audit/catalog.py +++ b/src/cb_mcp/audit/catalog.py @@ -46,23 +46,6 @@ #: Tier-2 block for the Operational Insights (analytics) service package. OPERATIONAL_INSIGHTS_BLOCK = 0x10000 # 65536 -#: Tier-2 block reserved for the AI Data Plane package. -#: -#: Claimed but not populated: no AI Data Plane tools exist in this repository, -#: so the package has no classification table and emits nothing. The number is -#: recorded here so the allocation is visible in one place and cannot be handed -#: to a different package later. -AI_DATA_PLANE_BLOCK = 0x13000 # 77824 - -#: Service package used when a caller names none. -#: -#: Not "the only audited package" any more — see -#: :data:`SERVICE_PACKAGE_BLOCKS`. It remains the default because the -#: operational server is the one whose tools the shared helpers are usually -#: asked about; every audited server passes its own package explicitly, from -#: ``ServerSpec.audit_package``, and every record carries that one. -SERVICE_PACKAGE = "operational" - #: ``service package -> Tier-2 block base``. Blocks are 0x1000 apart. Adding a #: package means an entry here, an entry in :data:`CATEGORY_SLOTS`, a #: classification table in :mod:`cb_mcp.audit.classification`, and @@ -186,11 +169,6 @@ def categories_for(package: str) -> tuple[str, ...]: ) -#: Category axis of the default package, kept as a module constant because -#: it is the axis every descriptor and every test refers to today. -CATEGORIES: tuple[str, ...] = categories_for(SERVICE_PACKAGE) - - def category_index(category: str, package: str) -> int: """Return the slot ``n`` for ``category`` within ``package``'s block.""" try: @@ -236,7 +214,7 @@ def tool_call_event_id(category: str, operation_class: str, package: str) -> int """Resolve the Tier-2 event ID for a tool call. Args: - category: One of :data:`CATEGORIES`. + category: A category allocated in ``package``'s block. operation_class: ``"read"`` or ``"write"``. """ if operation_class not in ("read", "write"): @@ -378,7 +356,7 @@ def filterable(self) -> bool: #: Human-readable names for the tool-call categories, used to build the -#: ``name`` field. Keys must cover :data:`CATEGORIES`. +#: ``name`` field. Keys must cover every category in :data:`CATEGORY_SLOTS`. _CATEGORY_LABELS: dict[str, str] = { "cluster": "cluster", "schema": "schema", @@ -455,10 +433,9 @@ def build_descriptor() -> dict: ] entries.sort(key=lambda entry: entry["id"]) return { - # A single value, for readers that expect one: the package this build - # audits by default. The authoritative list is `service_packages`, and - # every tool-call entry names its own. - "service_package": SERVICE_PACKAGE, + # No single `service_package`: the descriptor covers every registered + # package, and naming one at the top of the document would be wrong + # for every server running another. Each tool-call entry names its own. "service_packages": sorted(SERVICE_PACKAGE_BLOCKS), "blocks": {"core": CORE_BLOCK, **SERVICE_PACKAGE_BLOCKS}, "events": entries, @@ -494,7 +471,6 @@ def build_descriptor() -> dict: __all__ = [ "ALL_IDS", - "CATEGORIES", "CATEGORY_SLOTS", "SERVICE_PACKAGE_BLOCKS", "categories_for", @@ -504,7 +480,6 @@ def build_descriptor() -> dict: "FILTERABLE_IDS", "OPERATIONAL_BLOCK", "READ_OFFSET", - "SERVICE_PACKAGE", "WRITE_DELTA", "AuditEvent", "ToolCallEvent", diff --git a/src/cb_mcp/audit/descriptor.json b/src/cb_mcp/audit/descriptor.json index c4a6d122..effb1bf6 100644 --- a/src/cb_mcp/audit/descriptor.json +++ b/src/cb_mcp/audit/descriptor.json @@ -1,5 +1,4 @@ { - "service_package": "operational", "service_packages": [ "operational", "operational_insights" diff --git a/src/cb_mcp/audit/emitter.py b/src/cb_mcp/audit/emitter.py index 2af92060..4757dda2 100644 --- a/src/cb_mcp/audit/emitter.py +++ b/src/cb_mcp/audit/emitter.py @@ -22,7 +22,7 @@ from typing import Any from ..utils.constants import LOGGER_NAMESPACE -from .catalog import SERVICE_PACKAGE, AuditEvent, ToolCallEvent +from .catalog import AuditEvent, ToolCallEvent from .config import SINK_FILE, ResolvedAuditConfig from .record import OUTCOME_SUCCESS, AuditRecord, ServerContext from .sink import ( @@ -261,13 +261,12 @@ def init_audit(config: ResolvedAuditConfig) -> AuditLogger: _install_sigterm_handler() logger.info( - "Audit logging enabled. sinks=%s, service_package=%s, file=%s, " + "Audit logging enabled. sinks=%s, file=%s, " "rotates=%s, max_backups=%d, backups_compressed=yes, tool_args=%s, " "disabled_events=%s. Each server process writes its own file; the " "configured path has the host and process id inserted so concurrent " "stdio servers cannot corrupt one another's records.", ",".join(config.sinks), - SERVICE_PACKAGE, file_sink.path if file_sink is not None else "none", _describe_rotation(config), config.max_backups, diff --git a/tests/unit/test_audit_catalog.py b/tests/unit/test_audit_catalog.py index 7ba04bf8..10ff8cc2 100644 --- a/tests/unit/test_audit_catalog.py +++ b/tests/unit/test_audit_catalog.py @@ -22,7 +22,6 @@ from cb_mcp.audit import catalog from cb_mcp.audit.catalog import ( - CATEGORIES, CORE_BLOCK, OPERATIONAL_BLOCK, READ_OFFSET, @@ -30,6 +29,7 @@ AuditEvent, all_tool_call_events, build_descriptor, + categories_for, tool_call_event, tool_call_event_id, ) @@ -47,7 +47,7 @@ def test_block_bases_match_the_prd(): def test_category_order_is_the_wire_contract(): # The list index is the ``n`` in base+48+n, so reordering silently # renumbers every tool-call event. - assert CATEGORIES == ( + assert categories_for("operational") == ( "cluster", "schema", "kv", @@ -166,7 +166,6 @@ def test_shipped_descriptor_matches_the_code(): def test_descriptor_entries_are_well_formed(): shipped = json.loads(DESCRIPTOR_PATH.read_text(encoding="utf-8")) - assert shipped["service_package"] == "operational" assert shipped["blocks"] == { "core": 57344, "operational": 61440, @@ -204,16 +203,13 @@ def test_descriptor_entries_are_well_formed(): def test_category_slots_and_categories_cannot_disagree(): - """``CATEGORIES`` is derived from the slot table, ordered by slot.""" - assert ( - tuple( - name - for name, _ in sorted( - catalog.CATEGORY_SLOTS["operational"].items(), key=lambda kv: kv[1] - ) + """``categories_for`` is derived from the slot table, ordered by slot.""" + assert tuple( + name + for name, _ in sorted( + catalog.CATEGORY_SLOTS["operational"].items(), key=lambda kv: kv[1] ) - == catalog.CATEGORIES - ) + ) == catalog.categories_for("operational") def test_slot_numbers_are_unique_and_within_the_template(): diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py index e17d9d28..b8575dda 100644 --- a/tests/unit/test_audit_classification.py +++ b/tests/unit/test_audit_classification.py @@ -19,10 +19,10 @@ import pytest from cb_mcp.audit.catalog import ( - CATEGORIES, CATEGORY_SLOTS, FAILSAFE_SLOT_REQUIRED, SERVICE_PACKAGE_BLOCKS, + categories_for, ) from cb_mcp.audit.classification import ( FAILSAFE_CATEGORY, @@ -271,7 +271,7 @@ def test_required_scope_is_derived_from_the_operation_class(operation_class, exp def test_every_classification_uses_a_known_category_and_class(): for tool_name, (category, operation_class) in TOOL_CLASSIFICATION[PACKAGE].items(): - assert category in CATEGORIES, tool_name + assert category in categories_for(PACKAGE), tool_name assert operation_class in ("read", "write"), tool_name