attenu-guard x Haystack
Enforced authority attenuation for deepset Haystack's Agent and AgentTool.
Tested against haystack-ai 3.1.0 (Apache-2.0, requires Python >= 3.10).
A Haystack sub-agent keeps its own tool list. Nothing in the framework relates it to the agent that delegated to it, so a sub-agent can hold permissions its caller never had. This adapter adds the missing relation: a child's authority is the meet of what it asks for and what its parent holds, and every tool call is checked against it before the tool body runs.
What it hooks
| Hook point | API used |
|---|---|
| Child creation | The AgentTool call itself (haystack/tools/agent_tool.py:46). Haystack's delegation primitive is a ComponentTool wrapping a whole Agent, so the delegation moment is a tool invocation. A ToolPolicy(delegates_to=..., grant=...) mints the child with parent.delegate(...) after the check passes and before the sub-agent's first step. |
| Tool invocation | Tool.invoke / Tool.invoke_async (haystack/tools/tool.py:283, :305) via guard_tools(...). These are the only paths from the Agent's run loop to a tool body (components/agents/tool_calling.py:219, :256), so a denial provably precedes the body. The guarded object is a subclass of the tool's own class, so isinstance(tool, ComponentTool) — which _get_func_params keys off — and inputs_from_state / outputs_to_state / outputs_to_string all keep working. |
| Tool invocation (alt) | AttenuationStrategy, a ConfirmationStrategy registered through Haystack's own ConfirmationHook at the before_tool hook point (hooks/human_in_the_loop/hooks.py:19, run at agent.py:1003, before _run_tool at agent.py:1014). A ToolExecutionDecision(execute=False) makes Haystack drop the call and answer the model with an error tool-result. Use it instead of the Tool.invoke gate for a given tool, never as well — each is a full check, and metered calls would be counted twice. |
Both tool hooks go through one authorize_tool_call(...), so they cannot disagree.
Nothing is monkeypatched: the adapter subclasses Tool and implements the
ConfirmationStrategy protocol, which is how Haystack itself builds ComponentTool,
AgentTool and BlockingConfirmationStrategy.
Who counts as the parent is held in a contextvars.ContextVar, which is correct under
Haystack's own concurrency: a turn's tool calls each run under their own
copy_context() (tool_calling.py:213) or their own task (:251), so three AgentTool
calls in one model turn become three siblings of the same parent, never a chain.
Run it
pip install 'attenu-guard[haystack]'
python examples/integrations/haystack/demo.py # offline, no API key
pytest tests/integrations/test_haystack.py
What you'll see
A coordinator (crm.*, mail.send, 100 000 rows, egress any) delegates through an
AgentTool to a researcher (crm.read, 5 000 rows, egress none, ttl 900) whose model has
been poisoned. Then: crm_query(4200) runs; crm_export(...) is denied before its body
(ops.exported_to stays None); a 90 000-row read is denied on the ceiling even though the
scope is granted; a delegation asking for more is met down; three delegations in one turn
land as siblings; revoking the sub-agent by name refuses the next delegation to it; and the
hash-chained audit log verifies and carries the deny with reason scope_not_granted.
A denial raises AuthorityDeniedTool, a subclass of Haystack's own ToolInvocationError.
That hands the outcome to the Agent's existing switch: with
raise_on_tool_invocation_failure=False (Haystack's default) the model is shown an error
tool-result saying it lacks the authority and the run continues; with True the run aborts.
The body never runs either way. on_deny="raise" raises AuthorityDenied instead — a stop
Haystack does not catch, whatever the Agent is configured to do.
Trust boundary
The Guard lives in your process, next to the tool it is protecting; the check is a
function call, and no network is involved. What is enforced is structural, not persuasive:
the sub-agent is not told to avoid crm_export, it is never given the authority for it, so
a prompt injection has nothing to escalate.
The adapter is only as good as the two things you write: the Authority you issue and the
ToolPolicy map that says what each tool consumes. attenu-guard deliberately does not
decide those for you. A tool with no policy, and any call made outside
with authority(guard): ..., are denied and recorded — the fail-closed default.
Denials are recorded in a hash-chained log that anyone can verify offline, without attenu-guard's help; the log is tamper-evident, so an alteration is detectable after the fact rather than impossible.
Two limits worth knowing. A guarded tool is bound to a live Guard, so it refuses
to_dict(): serialize your pipeline with the unguarded tools and apply guard_tools(...)
after from_dict(). And the before_tool hook sees pending tool calls, not the sub-agent an
AgentTool is about to start, so it cannot mint a child Guard — guard AgentTools with
guard_tools(...) even when leaf tools go through the strategy.