Skip to content

feat(audit): record ai tool executions on every execution path#523

Open
hamza-56 wants to merge 3 commits into
mainfrom
feat/audit-tool-executions
Open

feat(audit): record ai tool executions on every execution path#523
hamza-56 wants to merge 3 commits into
mainfrom
feat/audit-tool-executions

Conversation

@hamza-56

Copy link
Copy Markdown
Contributor

Closes: AI tool executions are not audited on any execution path

What

Every AI tool execution, on every execution path (the FastMCP server on /ai/mcp, the chat LangChain loop, and the RAG LangGraph agent), now leaves an append-only audit record with actor, origin, arguments, model identity, and outcome, closing the epic #435 gap for AI actions.

Changes

  • feat(core): audited_tool_handler wrapper records a fail-closed tool.invoked event before the handler runs (a failed write refuses the call, per ADR-0002 / NIST AU-5) plus a tool.completed or tool.failed outcome event; both share a generated invocation id on target so a crash mid-execution is detectable
  • feat(core): new tool.invoked / tool.completed / tool.failed event types on the AUDIT_EVENTS hook, and ai_audit_context to stamp the surface (chat / rag) and driving model onto tool executions
  • feat(core): the Tool hook dataclass audit-wraps its handler at construction, covering the FastMCP server and the chat tool registry with one seam; wrapping preserves name, docstring, and generated input schema (regression-tested)
  • feat(core): the chat provider loop records model provenance (provider, configured model, provider-reported model version) with source chat
  • feat(rag): the RAG agent tools (which bypass Tool.handler) wrap explicitly; the agent runner stamps source rag with best-effort model identity
  • feat(mcp): ToolCallAuditMiddleware backstop records protocol-level failures that never reach a handler (unknown tool, input validation) without double-recording handler failures; the audit context middleware stamps source mcp for the /ai mount
  • test(core): completeness test (ADR-0002 §6) asserts every registered server tool and RAG tool is audited and pins the set of modules allowed to construct executable tools, so new execution paths cannot appear unaudited
  • docs: plugin guide section on audited tool executions; CLAUDE.md audit directory entry

How to Test

  1. make services.up && make migrations && make backend.up.dev
  2. Call any tool on the MCP endpoint, e.g. with a FastMCP client against http://localhost:7727/ai/mcp: call_tool("get_course_generation_prompt_tool", {"course_params": {"course_name": "Algebra", "course_description": "Linear equations"}})
  3. make db-shell, then SELECT category, action, source, outcome, tool_name, target_id, tool_args FROM audit_events ORDER BY id; shows the tool.invoked / tool.completed pair with source=mcp, a shared target_id, and captured request IP / request id
  4. Call an unknown tool name: a single tool.failed row appears (protocol-level backstop)
  5. Call canvas_authenticate with a bogus URL and token: the auth payload is stored as "[REDACTED]" and the failed execution records tool.invoked + tool.failed
  6. Tool calls made through chat carry source=chat plus model_provider / model_name / model_version (covered by tests/llm/test_provider_tool_audit.py)
  7. make test.backend.pytest && make mypy && make lint.backend all pass (1439 tests)

Notes

No migration: the audit_events AI-provenance columns shipped with #492. Fail-closed semantics mean an audit-database outage refuses AI tool calls instead of running them unrecorded; this is the deliberate ADR-0002 trade-off. Argument-validation refusals inside the chat tool conversion layer (before a handler is reached) are not yet recorded; auxiliary LLM calls without tools (title generation, scope classifier, Slack synthesis) are generation-level provenance and remain for a follow-up issue.

This pull request (code and description) was written with the assistance of an LLM (Claude).

@hamza-56 hamza-56 self-assigned this Jul 12, 2026
@hamza-56 hamza-56 force-pushed the feat/audit-tool-executions branch from 6fa85f0 to 351d59f Compare July 12, 2026 22:00
Tool executions were completely opaque: no record of who invoked which
tool, when, with which inputs, from which model, or with what outcome
(issue #501, part of epic #435). Every execution path now records a
fail-closed tool.invoked event before the handler runs plus a
tool.completed / tool.failed outcome event, paired by a shared
invocation id, per ADR-0002:

- Tool (MCP_TOOLS hook) audit-wraps its handler at construction,
  covering the FastMCP server and the chat tool registry at once
- the chat provider loop stamps source=chat and model identity
  (provider, configured model, provider-reported version)
- the RAG agent tools wrap explicitly and the agent runner stamps
  source=rag with best-effort model identity
- a FastMCP middleware backstop records protocol-level failures that
  never reach a handler; the audit context middleware stamps
  source=mcp for the /ai mount
- a completeness test pins the set of modules allowed to construct
  executable tools so new paths cannot appear unaudited

Tool args are redacted before persistence (auth payloads and
secret-named keys are replaced wholesale). No schema change: the
audit_events AI-provenance columns shipped with #492.
@hamza-56 hamza-56 force-pushed the feat/audit-tool-executions branch from 351d59f to ac55801 Compare July 12, 2026 22:14
@hamza-56 hamza-56 requested review from abdulrafey1, regisb and zaira-bibi and removed request for abdulrafey1, regisb and zaira-bibi July 12, 2026 22:26
@hamza-56

Copy link
Copy Markdown
Contributor Author

@claude-review

@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @hamza-56's task in 4m 17s —— View job


Review: audit AI tool executions on every execution path

  • Gather context (read diff + key files)
  • Security review
  • Correctness & logic
  • Performance & async patterns
  • Maintainability & design
  • Test coverage
  • Documentation

Reviewed all 28 files against origin/main. This is a well-architected PR: the single audited_tool_handler seam, the fail-closed tool.invoked-before-execution ordering, the shared invocation target id for crash detection, and the completeness guard test (test_capture_completeness.py) that pins the set of tool-construction sites are all thoughtful and align cleanly with the stated ADR-0002 / NIST AU-5 goals. CI is green (1439 tests). 👏

A few points worth addressing before merge — none block the core design.

Findings

🟠 Medium — error_detail is stored unredacted (sparkth/core/audit/execution.py:94, sparkth/mcp/audit.py:50)
tool_args is redacted via redact() in AuditEvent.from_event, but error_detail = f"{type(exc).__name__}: {exc}" is persisted verbatim (models.py:154). Pydantic v2 ValidationError echoes input_value=... for failing fields, and httpx errors can carry tokenized URLs — so a secret that redaction keeps out of tool_args can re-enter the store (and the canonical bytes) through the exception message. Suggest scrubbing error_detail through the same secret filter, or recording only the exception type plus a scrubbed/bounded message. Inline comment left.

🟠 Medium — anti-double-record guarantee lacks a direct test (tests/mcp/test_server_audit.py:40)
test_handler_level_failure_is_not_double_recorded triggers a protocol-level (pre-handler) failure — per its own line-46 comment handler_recorded() is never True, so it verifies the middleware records once, not that it suppresses a duplicate. The subtle path — a wrapped handler that raises at runtime sets _execution_recorded=True, and the middleware must observe that (via contextvar propagation back through FastMCP) and stay silent — has no direct test. Suggest a server tool whose handler raises at runtime, asserting exactly one tool.invoked + one tool.failed. Inline comment left.

🟡 Low — two committed transactions per tool call (sparkth/core/audit/execution.py:78-100)
Each execution does two independent record_event_now calls, each opening a fresh session_scope() and committing. In the chat loop (up to max_tool_executions=50) and RAG agent loops this doubles commit/round-trip count per tool. This is an inherent consequence of the fail-closed + append-both-endpoints design (the invoked write must commit before the handler runs), so it's acceptable — flagging only for awareness.

🟡 Low / nit — stray contextvar set outside the middleware (sparkth/core/audit/execution.py:81)
_execution_recorded.set(True) has no token/reset and is only meaningful under tool_execution_recording() (the MCP middleware). On the chat/RAG paths nothing reads or resets it, so it lingers on the request-scoped context. Harmless, but a one-line note that it's a no-op outside the middleware backstop would save a future reader the trace.

Things done well

  • Schema identity preservedfunctools.wraps sets __wrapped__, and typing.get_type_hints unwraps it to resolve the original module globals, so generated input schemas stay byte-identical post-wrap. Nicely regression-tested (test_execution.py:175).
  • Idempotent wrapping guard (__audit_wrapped__) lets seams wrap defensively.
  • Redaction correctly runs post-_jsonable — Pydantic dumps and container recursion feed the key-based redactor, with MAX_DEPTH bounding both walks; secret nesting inside dumped models is covered (test_pydantic_args_are_dumped_and_redacted).
  • Contextvar model/source propagation is the right call over threading provider objects through every handler, and works across child tasks (context is copied at task creation, inside the ai_audit_context block).
  • Completeness source-scan test is a strong guard against new unaudited execution paths.

Previous Review Comments

No prior review threads on this PR.


Overall: solid, mergeable work. Recommend addressing the error_detail redaction gap (Medium/security) and adding the runtime-handler-failure test before merge; the two Low items are optional.
· feat/audit-tool-executions

Comment thread sparkth/core/audit/execution.py Outdated
tool=call,
model=model,
target=target,
error_detail=f"{type(exc).__name__}: {exc}",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium (security / defense-in-depth): error_detail is stored unredacted while tool_args is redacted.

tool.args goes through redact() in AuditEvent.from_event (models.py:116), but error_detail is persisted verbatim (models.py:154). The detail here is f"{type(exc).__name__}: {exc}", and several common exception types echo their input back in the message — Pydantic v2 ValidationError in particular renders input_value=... for each failing field. So a handler that raises while validating a credentials payload (or an httpx error carrying a tokenized URL) can leak the very secret that the auth/token redaction was designed to keep out of the store — and out of the canonical bytes.

Same path applies to the middleware backstop (sparkth/mcp/audit.py:50).

Consider scrubbing error_detail through the same secret filter (or capturing only type(exc).__name__ plus a length-bounded, secret-scrubbed message). At minimum, document that error_detail is not redacted so callers don't assume the whole row is safe.

@hamza-56 hamza-56 Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 634b06f. Added scrub_error_detail in redaction.py, used by both the wrapper and the backstop: ValidationErrors are re-rendered without their inputs, secret-keyed assignments (token=..., 'api_key': ...) are masked with the same SECRET_KEYS set, and the result is capped at 1000 chars. Tests in test_redaction.py plus test_handler_error_detail_is_scrubbed end to end.

This comment was written with the assistance of an LLM (Claude).

Comment on lines +40 to +51
async def test_handler_level_failure_is_not_double_recorded(audit_events: AuditEventsFetcher) -> None:
"""When the wrapped handler already recorded the failure, the middleware
backstop must stay silent: one invocation, one failure record."""
async with Client(mcp) as client:
with pytest.raises(ToolError):
# Invalid input shape passes the protocol layer but fails handler-side
# validation before the wrapped handler runs, so this is protocol-level.
await client.call_tool("get_course_generation_prompt_tool", {"course_params": "not-a-mapping"})

rows = await audit_events()
failures = [row for row in rows if row.action == "failed"]
assert len(failures) == 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium (test coverage): the real "handler raised → middleware stays silent" path isn't exercised.

As the comment on line 46 notes, this test triggers a protocol-level (pre-handler) validation failure — handler_recorded() is never set to True here, so it actually verifies the middleware records once, not that the middleware suppresses a duplicate.

The subtle part of the design — a wrapped handler that raises during execution sets _execution_recorded=True, and the middleware must then observe that and stay silent — depends on the contextvar mutation propagating from the handler back up through FastMCP's on_call_tool. That end-to-end path has no direct test. The success path (test_direct_server_tool_call_is_audited) exercises propagation for a returning handler, but not for a raising one.

Suggest registering a server tool whose handler raises at runtime (e.g. via a throwaway FastMCP instance + ToolCallAuditMiddleware), calling it, and asserting exactly one tool.invoked + one tool.failed (i.e. the backstop did not add a second failure). That pins the anti-double-record guarantee against future FastMCP dispatch changes.

@hamza-56 hamza-56 Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 634b06f: test_runtime_handler_failure_is_recorded_exactly_once spins up a throwaway FastMCP server with the middleware, calls a tool that raises at runtime, and asserts exactly one tool.invoked + one tool.failed, i.e. the backstop stayed silent.

This comment was written with the assistance of an LLM (Claude).

Comment thread sparkth/mcp/audit.py
Comment on lines +34 to +41
return await call_next(context)
# NOTE: Kept broad intentionally: any protocol-layer error
# (unknown tool, validation, handler crash) must be considered
# for the backstop record, and is always re-raised.
except Exception as exc:
if not handler_recorded():
await self._record_protocol_failure(context.message, exc)
raise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When call_next fails because the wrapper's tool.invoked write raised, handler_recorded() is False, so this issues another record_event_now against the same (down) audit DB. That write also raises, replacing the original exception and leaving no record, which is the opposite of the module docstring's "every tools/call … leaves a record either way." Consider catching the backstop write's own failure (log it, preserve/chain the original exc) so an audit-store outage doesn't swap the surfaced error and lose the trail.

@hamza-56 hamza-56 Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 634b06f. The backstop write is now wrapped in try/except SQLAlchemyError: we log it and re-raise the original error untouched. Also toned down the docstring, since when the audit DB itself is down there is genuinely nothing we can save. Test: test_backstop_write_failure_does_not_replace_the_original_error.

This comment was written with the assistance of an LLM (Claude).

Comment thread sparkth/llm/providers.py
Comment on lines +229 to +230
with ai_audit_context(source=AuditSource.CHAT, model=self._audit_model_info(response)):
tool_result = await self._execute_tool(tool_name, tool_args, tools)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caught by Claude:

This PR installs ai_audit_context around tool execution to make capture fail-closed, but the wrapper's refusal exception is absorbed before it can fail the request:

  • Chat: _execute_tool's broad except Exception at providers.py:324-326 catches the wrapper's SQLAlchemyError and returns it as a string; tool_func at plugins/chat/tools.py:186 only catches (ValidationError, ValueError, TypeError, RuntimeError), so the DB error flows straight to that broad handler.
  • RAG: create_agent(...) at agent.py:65 runs tools through LangGraph's ToolNode (default handle_tool_errors=True), which converts the exception into an error ToolMessage; run_agentic_rag_retrieval only re-raises ValidationError/GraphRecursionError/LangChainException.

Net: when the tool.invoked write fails, the call is refused (side effect prevented) but there's no audit record and no surfaced failure — it looks like an ordinary tool error and chat/RAG continue. That defeats "record on every execution path" for exactly these two seams. Suggest letting audit-infra exceptions propagate as a distinct hard failure past both seams, or documenting chat/RAG capture as best-effort.

@hamza-56 hamza-56 Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the chat half in 634b06f. The wrapper now raises a dedicated AuditCaptureError when the invoked/completed write fails, and _execute_tool re-raises it before its broad except, so an audit outage fails the request instead of becoming an error string for the model. Test: test_audit_capture_outage_is_a_hard_failure_not_a_tool_error_string.

The RAG half turns out not to apply on our pinned versions: langgraph 1.0.7's default handler only converts ToolInvocationError into a ToolMessage and re-raises everything else (handle_tool_errors=True was the old create_react_agent default). So the refusal already propagates there; documented it in the docstring.

This comment was written with the assistance of an LLM (Claude).

Comment thread sparkth/core/audit/execution.py Outdated
Comment on lines +87 to +89
except Exception as exc:
await record_event_now(
ToolFailedAuditEvent(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this write is before the raise, so if it itself raises (e.g. if the DB is down), then the callers/logs will see an audit-infra error instead of the actual failure.

Perhaps guard the audit write as well:

except Exception as exc:
    try:
        await record_event_now(ToolFailedAuditEvent(..., error_detail=f"{type(exc).__name__}: {exc}"))
    except SQLAlchemyError:
        logger.exception("audit tool.failed write failed for %s", handler.__name__)
    raise  

@hamza-56 hamza-56 Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 634b06f, pretty much as you suggested: the tool.failed write is guarded, logged, and the handler's own error re-raised. Since the invocation is already committed at that point, an invoked event with no outcome is exactly the crash signal, so we lose nothing. Test: test_failed_write_failure_surfaces_the_handler_error.

This comment was written with the assistance of an LLM (Claude).

)
_execution_recorded.set(True)
try:
result = await handler(*args, **kwargs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await handler(*args, **kwargs) assumes a coroutine, but Tool.__post_init__ (lib/mcp/hooks.py:34-35) wraps every Tool.handler and both FastMCP and chat's sync_tool_func (plugins/chat/tools.py:190) support sync handlers. A sync handler would write tool.invoked, then hit TypeError: object … can't be used in 'await', recorded as tool.failed on every call. Latent today (all canvas/openedx handlers are async), but the wrapper narrows the accepted contract without a guard — consider inspect.iscoroutinefunction (fail loudly at wrap time) or supporting sync handlers.

Caught by Claude.

@hamza-56 hamza-56 Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 634b06f, went with the fail-loudly option: TypeError at wrap time if the handler isn't a coroutine function. Supporting sync handlers would just move the problem (chat's sync_tool_func assumes a coroutine too). The plugins guide now says handlers must be async. Test: test_sync_handler_is_rejected_at_wrap_time.

This comment was written with the assistance of an LLM (Claude).


@functools.wraps(handler)
async def audited(*args: Any, **kwargs: Any) -> Any:
call = AuditToolCall(name=handler.__name__, args=_named_jsonable_args(handler, args, kwargs))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if two different handlers have the same __name__ , but in different modules? Will they be tracked under the same name as well?

@hamza-56 hamza-56 Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, they'd show up under the same tool_name; the row only records handler.__name__. In practice tool names are already the identity everywhere (the server refuses duplicate registrations and the LLM calls tools by name), so a second same-named tool can't actually be exposed. If you'd like provenance anyway, adding handler.__module__ as a column is cheap; happy to do it as a follow-up.

This comment was written with the assistance of an LLM (Claude).

Comment thread sparkth/mcp/audit.py Outdated
audited handler wrapper (:func:`sparkth.lib.audit.audited_tool_handler`); this
middleware covers the calls that never reach a handler (unknown tool names
and input-validation rejections), so every ``tools/call`` on ``/ai/mcp``
leaves a record either way (ADR-0002 seam table row three).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the point of tracking calls to tools that do not exist. This adds extra complexity without any visible gain. The way I see it, this is causing us to record things that did not happen. But I might be missing some context?

And if we do want to keep track of non-existing tool calls, then we should keep this middleware but get rid of the audited_tool_handler decorator. The decorator forces developers to wrap all tools, while the middleware automatically captures all calls. Isn't that a duplicated feature?

@hamza-56 hamza-56 Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A rejected tools/call is still a request that happened; we record it with outcome=failure, not as something that succeeded. Failed attempts (unknown tool, bad input, someone probing tool names) are exactly what an audit trail should have on record, same reason we record failed logins.

They also aren't duplicates, because they cover different ground: chat and RAG run tools in-process and never touch the middleware, so the wrapper is the only capture there; and calls that die before dispatch never reach a wrapped handler, so the middleware is the only capture for those. Developers don't wrap anything by hand either, Tool.__post_init__ does it automatically.

If we decide pre-dispatch failures aren't worth recording, the middleware (and the contextvar with it) can go, but that's a scope decision on the trail. Happy to discuss.

This comment was written with the assistance of an LLM (Claude).

# Scoped per protocol-level call by tool_execution_recording(); the FastMCP
# middleware uses it to record failures that never reached a wrapped handler
# without ever double-recording ones that did.
_execution_recorded: ContextVar[bool] = ContextVar("audit_tool_execution_recorded", default=False)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This global context variable is a red flag to me. I believe that we need it because of the ToolCallAuditMiddleware, which tracks calls to tools that don't exist. If we get rid of ToolCallAuditMiddleware then we can get rid of this variable and the tool_execution_recording context manager, right?

@hamza-56 hamza-56 Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right: it only exists so the wrapper and the middleware don't double-record on the MCP surface, so it lives and dies with the middleware. If the other thread lands on dropping the middleware, this and tool_execution_recording go with it. While we keep it, a contextvar is the async-safe way to share that one bit without threading state through FastMCP.

This comment was written with the assistance of an LLM (Claude).

Comment thread sparkth/core/audit/middleware.py Outdated
def _source(scope: Scope) -> AuditSource:
"""MCP for the FastMCP mount (mounted at ``/ai`` in main.py), REST otherwise."""
path: str = scope.get("path", "")
if path == "/ai" or path.startswith("/ai/"):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "/ai" is a hard-coded string that should be replaced by a variable. Unless each router configures its own AuditSource?

@hamza-56 hamza-56 Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2982a4e: extracted MCP_MOUNT_PATH into core/config.py, used by both the mount in main.py and the middleware. Kept it a plain constant since we only have one non-REST mount today; easy to promote to per-router config if that changes.

This comment was written with the assistance of an LLM (Claude).

_execution_recorded.reset(token)


def audited_tool_handler(handler: AsyncToolHandler) -> AsyncToolHandler:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a problem with the name of this decorator. If we decide to keep it, and not the middleware, then we should probably rename to something like "audited_tool".

In general "handler"/"manager"/etc. suffixes don't add meaningful to a name and should be either removed or replaced by something more specific. (There are exceptions though)

@hamza-56 hamza-56 Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, audited_tool is better. Holding the rename until the middleware question (other thread) settles, then I'll do it in one pass since it touches the lib surface and the docs.

This comment was written with the assistance of an LLM (Claude).

hamza-56 added 2 commits July 14, 2026 16:18
Review findings on PR #523: an audit-store outage could mask the real
error on every path, or be silently absorbed. Invoked/completed write
failures now surface as a distinct AuditCaptureError so the chat
loop's broad tool-error handling can no longer turn a fail-closed
refusal into a model-visible string; a failing tool.failed write (and
the MCP backstop write) is logged and the original exception re-raised,
since the committed invocation with no outcome event is already the
abnormal-termination signal. error_detail is scrubbed before
persistence (validation errors re-rendered without inputs, secret-keyed
assignments masked, length bounded), and sync handlers are rejected at
wrap time instead of failing on await at every call.
The "/ai" literal was duplicated between the app assembly and the
audit context middleware's source detection (PR #523 review).
@hamza-56 hamza-56 requested review from regisb and zaira-bibi July 15, 2026 22:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AI tool executions are not audited on any execution path

3 participants