Skip to content

Commit 803abed

Browse files
VladUZHclaude
andcommitted
fix: govern acall in the sync LlamaIndex wrapper — async agents bypassed the gate
govern_llamaindex_tool replaced only `tool.call` and returned. LlamaIndex agents invoke `acall` for async execution, so a tool wrapped by the sync governor ran with no evaluate, no approval and no audit trace on that path. Unlike the denylist work in 1476fcd/54b2899, this one was a live bypass rather than a latent one — nothing upstream guarded it, and no warning was emitted. The async sibling (govern_llamaindex_tool_async) already wrapped both methods; only the sync wrapper was incomplete. The client here is synchronous, so its blocking HTTP calls run via anyio.to_thread.run_sync rather than stalling the caller's event loop. anyio is already a hard dependency. Tools that do not expose `acall` are unaffected — getattr guards the wrap. 4 new tests: acall denied does not execute and does reach the policy engine; acall allowed executes; sync call still governed; a tool without acall still works. Mutation-checked — removing the wrap fails 2 of them. 204 tests pass, ruff clean. Still unpublished at 0.2.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 54b2899 commit 803abed

2 files changed

Lines changed: 139 additions & 1 deletion

File tree

src/sidclaw/middleware/llamaindex.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@
2121
"""
2222
from __future__ import annotations
2323

24+
import functools
2425
from typing import Any
2526

27+
import anyio.to_thread
28+
2629
from .._client import AsyncSidClaw, SidClaw
2730
from .._types import DataClassification
2831
from ._base import (
@@ -59,7 +62,9 @@ def govern_llamaindex_tool(
5962
data_classification: Data classification level (default: "internal").
6063
6164
Returns:
62-
The same tool with its ``call`` method wrapped.
65+
The same tool with both ``call`` and ``acall`` wrapped. ``acall`` is
66+
governed too because LlamaIndex agents use it for async execution;
67+
wrapping only ``call`` left that path ungoverned.
6368
"""
6469
integration = target_integration or tool.metadata.name
6570
original_call = tool.call
@@ -86,6 +91,45 @@ def governed_call(*args: Any, **kwargs: Any) -> Any:
8691
raise
8792

8893
tool.call = governed_call
94+
95+
# LlamaIndex agents invoke `acall` for async execution. This wrapper used to
96+
# replace only `call`, so an async agent bypassed governance entirely — the
97+
# tool ran with no evaluate, no approval, and no audit trace. The client here
98+
# is synchronous, so its blocking HTTP calls run in a worker thread rather
99+
# than stalling the event loop.
100+
original_acall = getattr(tool, "acall", None)
101+
if original_acall is not None:
102+
103+
async def governed_acall(*args: Any, **kwargs: Any) -> Any:
104+
decision = await anyio.to_thread.run_sync(
105+
functools.partial(
106+
evaluate_governance_sync,
107+
client,
108+
tool.metadata.name,
109+
target_integration=integration,
110+
resource_scope=resource_scope,
111+
data_classification=data_classification,
112+
context={
113+
"input": args[0] if args else kwargs,
114+
"tool_description": tool.metadata.description,
115+
},
116+
)
117+
)
118+
119+
try:
120+
result = await original_acall(*args, **kwargs)
121+
await anyio.to_thread.run_sync(
122+
functools.partial(record_outcome_sync, client, decision.trace_id)
123+
)
124+
return result
125+
except Exception as e:
126+
await anyio.to_thread.run_sync(
127+
functools.partial(record_outcome_sync, client, decision.trace_id, e)
128+
)
129+
raise
130+
131+
tool.acall = governed_acall
132+
89133
return tool
90134

91135

tests/test_llamaindex_middleware.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,97 @@ async def test_wraps_all_tools(self, async_client: AsyncSidClaw, mock_api: respx
320320
async def test_empty_list(self, async_client: AsyncSidClaw, mock_api: respx.MockRouter):
321321
governed = govern_llamaindex_tools_async(async_client, [])
322322
assert governed == []
323+
324+
325+
class TestSyncWrapperGovernsAcall:
326+
"""`govern_llamaindex_tool` wrapped only `call`, leaving `acall` ungoverned.
327+
328+
LlamaIndex agents invoke `acall` for async execution, so a tool governed by
329+
the sync wrapper ran with no evaluate, no approval and no audit trace on
330+
that path — a real bypass, not a latent one.
331+
"""
332+
333+
class _Meta:
334+
name = "search_docs"
335+
description = "search"
336+
337+
class _Tool:
338+
def __init__(self):
339+
self.metadata = TestSyncWrapperGovernsAcall._Meta()
340+
self.sync_ran = False
341+
self.async_ran = False
342+
343+
def call(self, *a, **k):
344+
self.sync_ran = True
345+
return "sync-result"
346+
347+
async def acall(self, *a, **k):
348+
self.async_ran = True
349+
return "async-result"
350+
351+
def _client(self, decision: str):
352+
from unittest.mock import MagicMock
353+
354+
from sidclaw._types import EvaluateResponse
355+
356+
c = MagicMock()
357+
c.evaluate.return_value = EvaluateResponse(
358+
decision=decision,
359+
trace_id="t-1",
360+
approval_request_id=None,
361+
reason="r",
362+
policy_rule_id=None,
363+
)
364+
c.record_outcome.return_value = None
365+
return c
366+
367+
async def test_acall_is_governed_and_denied(self):
368+
from sidclaw._errors import ActionDeniedError
369+
from sidclaw.middleware.llamaindex import govern_llamaindex_tool
370+
371+
tool = self._Tool()
372+
client = self._client("deny")
373+
governed = govern_llamaindex_tool(client, tool)
374+
375+
with pytest.raises(ActionDeniedError):
376+
await governed.acall("query")
377+
378+
assert tool.async_ran is False, "acall executed despite a deny decision"
379+
assert client.evaluate.called, "acall did not reach the policy engine at all"
380+
381+
async def test_acall_is_governed_and_allowed(self):
382+
from sidclaw.middleware.llamaindex import govern_llamaindex_tool
383+
384+
tool = self._Tool()
385+
client = self._client("allow")
386+
governed = govern_llamaindex_tool(client, tool)
387+
388+
assert await governed.acall("query") == "async-result"
389+
assert tool.async_ran is True
390+
assert client.evaluate.called
391+
392+
def test_sync_call_still_governed(self):
393+
from sidclaw._errors import ActionDeniedError
394+
from sidclaw.middleware.llamaindex import govern_llamaindex_tool
395+
396+
tool = self._Tool()
397+
client = self._client("deny")
398+
governed = govern_llamaindex_tool(client, tool)
399+
400+
with pytest.raises(ActionDeniedError):
401+
governed.call("query")
402+
assert tool.sync_ran is False
403+
404+
def test_tool_without_acall_still_works(self):
405+
"""Not every LlamaIndex tool exposes acall — must not crash."""
406+
from sidclaw.middleware.llamaindex import govern_llamaindex_tool
407+
408+
class NoAcall:
409+
metadata = TestSyncWrapperGovernsAcall._Meta()
410+
411+
def call(self, *a, **k):
412+
return "ok"
413+
414+
client = self._client("allow")
415+
governed = govern_llamaindex_tool(client, NoAcall())
416+
assert governed.call("q") == "ok"

0 commit comments

Comments
 (0)