fix: write-ahead audit entry so calls stop when the audit log stops accepting writes (SEC-03)

This commit is contained in:
Latte committed 2026-10-14 17:11:53 +02:00
1 parent 5b622e64e2
commit 43eebd3089
8 files changed
+134 -15

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
"""The audit log is fail closed after startup too (SEC-03) and names the client (SEC-12)."""
from __future__ import annotations
import json
from typing import Any
import pytest
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp.audit import get_audit_logger
from tests.test_server_helpers import API, connected, mock_user
REPO_ARGS = {"owner": "octo", "repo": "demo"}
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger reads the process-wide settings."""
class FailingFile:
"""Stands in for the open audit file and fails every write like a full disk."""
def __init__(self) -> None:
self.attempts = 0
def write(self, data: str) -> int:
self.attempts += 1
raise OSError(28, "No space left on device")
def flush(self) -> None: # pragma: no cover - never reached
raise OSError(28, "No space left on device")
def close(self) -> None:
return None
def payload_of(result: Any) -> dict[str, Any]:
decoded: dict[str, Any] = json.loads(result.content[0].text)
return decoded
async def test_calls_stop_when_the_audit_log_stops_accepting_writes(
httpx_mock: HTTPXMock,
) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"name": "demo"}, is_reusable=True)
async with connected() as client:
first = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert not first.is_error
sent_before = len(httpx_mock.get_requests())
audit = get_audit_logger()
healthy_file = audit._log_file
audit._log_file = FailingFile() # type: ignore[assignment]
second = await client.call_tool("gitea_repo_get", REPO_ARGS)
mutation = await client.call_tool(
"gitea_issue_create_issue", {**REPO_ARGS, "body": {"title": "x"}}
)
assert second.is_error and mutation.is_error
assert payload_of(second)["error"]["kind"] == "audit_unavailable"
assert payload_of(mutation)["error"]["kind"] == "audit_unavailable"
# Gitea was not contacted at all while the log was broken.
assert len(httpx_mock.get_requests()) == sent_before
audit._log_file = healthy_file
resumed = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert not resumed.is_error
async def test_the_refusal_does_not_leak_the_failure_reason() -> None:
async with connected() as client:
get_audit_logger()._log_file = FailingFile() # type: ignore[assignment]
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
text = result.content[0].text # type: ignore[union-attr]
assert "No space left" not in text and "OSError" not in text
+3
View File
@@ -476,6 +476,9 @@ async def test_audit_write_failure_after_execution_does_not_break_the_call(
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
class BrokenAudit:
def log_tool_call_started(self, *args: Any, **kwargs: Any) -> str:
return "started"
def log_tool_invocation(self, *args: Any, **kwargs: Any) -> str:
raise OSError("disk full: " + GITEA_TOKEN)
+4 -2
View File
@@ -341,8 +341,10 @@ async def test_real_stdio_subprocess_serves_the_catalog(tmp_path: Path) -> None:
payload = json.loads(result.content[0].text) # type: ignore[union-attr]
assert "error" in payload and GITEA_TOKEN not in json.dumps(payload)
audit = (tmp_path / "state" / "audit.log").read_text(encoding="utf-8").splitlines()
assert len(audit) == 1
assert GITEA_TOKEN not in audit[0]
events = [json.loads(line)["event_type"] for line in audit]
# A write-ahead entry, then the one tool_invocation entry with the result.
assert events == ["tool_call_started", "tool_invocation"]
assert all(GITEA_TOKEN not in line for line in audit)
# serve command