Deny-listing tools and skills at the agent level, how the runtime enforces it before execution, and why a synthetic tool result beats an exception.
Access control
Access control in dendrux is the set of rules that decide whether a tool call is even allowed to execute. If a denied tool is attempted, the runtime short-circuits: it never calls the function, it emits a policy.denied governance event, and it hands a synthetic failure back to the model so the loop can continue. The tool's side effects never happen.
Four knobs cover the common cases:
The rest of this page focuses on deny, which is the most commonly used. For rules that depend on the call's arguments or on something outside the agent, use the tool policy hook; a static deny is always applied first and the hook cannot undo it. The others share the same shape: decide at the gate, emit a typed event, keep going. The three tools that skills add (use_skill, read_skill_resource, run_skill_script) are ordinary names for deny; see Skills.
A real deny in action
An admin agent has two tools: refund and delete_user. delete_user is in the deny list. The user asks the agent to delete user 42:
import asyncio
from dendrux import Agent, tool
from dendrux.llm.anthropic import AnthropicProvider
@tool(target="server")
async def refund(order_id: int) -> str:
"""Issue a refund for an order."""
return f"Refunded order {order_id}"
@tool(target="server")
async def delete_user(user_id: int) -> str:
"""Permanently delete a user."""
return f"Deleted user {user_id}"
async def main() -> None:
async with Agent(
name="Admin",
provider=AnthropicProvider(model="claude-haiku-4-5"),
prompt="You are an admin agent.",
tools=[refund, delete_user],
deny=["delete_user"], # delete_user can never execute
database_url="sqlite+aiosqlite:///admin.db",
) as agent:
result = await agent.run("Delete user 42 using the delete_user tool.")
print(result.status.value) # success (deny is a gate, not a run-killer)
print(result.answer) # "...delete_user has been denied by policy..."
asyncio.run(main())Captured from a live run of that script:
status: success
delete_user actually called? False
final answer preview: "I'm unable to delete user 42. The delete_user action has been denied
by policy, which means I don't have permission to permanently delete..."
run_events:
[lifecycle] seq=0 run.started
[lifecycle] seq=1 llm.completed (model emits delete_user(user_id=42))
[GOV] seq=2 policy.denied data={"tool_name": "delete_user", "call_id": "...", "reason": "denied_by_policy"}
[lifecycle] seq=3 llm.completed (model sees the denial, picks a different action)
[lifecycle] seq=4 run.completed
tool_calls table:
rows: 0Four things to pull out of that trace:
- The tool function never ran.
DELETE_CALLEDis empty. No DB row landed intool_calls(that table only records actual executions, not blocked attempts). - A
policy.deniedevent landed on the timeline.seq=2, samecorrelation_idas the LLM's tool-call id, so a reader can join the denial to the call that triggered it. - The model was told. A synthetic tool result with
{"denied": "Tool 'delete_user' is denied by policy."}was appended to history. The next LLM turn saw it and produced a sensible user-facing answer. - The run still succeeded.
status=success. Deny is not a run-killer; it is a gate that the loop routes around.
What "deny" actually does inside the loop
From dendrux/loops/react.py, the relevant branch of the tool-dispatch phase:
if tc.name in deny_set:
deny_msg = f"Tool '{tc.name}' is denied by policy."
deny_result = ToolResult(
name=tc.name,
call_id=tc.id,
payload=json.dumps({"denied": deny_msg}),
success=False,
error=deny_msg,
)
# No _record_tool / _notify_tool — denied tools are not executions.
# Only policy.denied event + synthetic message for the model.
await _record_governance(recorder, "policy.denied", iteration, event_data, correlation_id=tc.id)
await _notify_governance(notifier, "policy.denied", iteration, event_data, correlation_id=tc.id, warnings=warnings)
result_msg = strategy.format_tool_result(deny_result)
history.append(result_msg)
await _record_message(recorder, result_msg, iteration)
await _notify_message(notifier, result_msg, iteration, warnings)
all_results.append((tc, deny_result))
else:
non_denied.append(tc)Three observations about the exact code:
- Deny runs before deanonymization. The
deny_setcheck is phase 0 of the tool-dispatch stage. Phase 0.5 isguardrail_engine.deanonymize(tc.params)which only runs on thenon_deniedbucket. Denied tool calls never touch their params. This also means the LLM's placeholder values are never unwrapped for a denied call, which is the right behavior. - There is no
tool.completedevent and notool_callsrow. The comment in the source makes it explicit: "denied tools are not executions." Persisting a fake execution would confuse readers who query thetool_callstable expecting one row per side effect. - The synthetic result is a normal tool-result message. It flows through the same
on_message_appendedpath as any other tool result, so the model sees it in the right place in history. The LLM has no way to tell a denied call apart from a failed tool call, which means the existing prompt guidance for handling tool failures applies without change.
Construction-time validation
The deny list is not just enforced at runtime; Agent validates it at construction:
# From dendrux/agent.py
if self._deny:
unknown = self._deny - known_tools
if unknown:
raise ValueError(
f"Agent '{self.name}' has deny={sorted(self._deny)} but "
f"{sorted(unknown)} are not in tools."
)If you misspell a tool name in deny, the agent refuses to construct. This prevents a class of silent-deny bugs where a typo leaves a sensitive tool wide open because the policy name never matched.
deny also conflicts with itself against require_approval: a tool that is denied cannot also be in the approval gate, and the constructor raises on overlap.
Skills and MCP: the other gates
Skills and MCP connections enter the governance pipeline at different points, but they follow the same principle: fail at the boundary with a typed event, keep the run moving when it can.
deny_skills=[...]is the skill analogue ofdeny. A skill listed there is removed from the run before the model sees anything: it is not in the catalogue, anduse_skill,read_skill_resourceandrun_skill_scriptcannot reach it. The names are listed in thedeniedfield of theskill.catalog_resolvedevent at run start. To gate what an allowed skill may do, name the skill tools indenyorrequire_approval. See Skills.- MCP connections happen in
_emit_init_eventsduring agent setup. An auth or discovery failure emitsmcp.error. Abest_effortsource is skipped and the run continues without its tools; a strict source failure terminates the run. See MCP.
All three layers share run_events as the audit surface. Querying WHERE event_type IN ('policy.denied', 'skill.catalog_resolved', 'skill.activation_failed', 'mcp.error') on a run returns the full access-control story in one query, in order.
Tenants and delegated runs
A run can carry a tenant_id (agent.run(..., tenant_id="acme")) so one database can serve many customers. Since 0.3.0 a delegated child run always belongs to its parent's tenant:
- A child started without a
tenant_idinherits the parent's. - A child started with the same
tenant_idas its parent is fine. - A child started with a different
tenant_idraisesValueErrorbefore the child run is created. The message names the parent run and its tenant. Nothing is written, so a tool that tries to move work across tenants cannot do it by accident.
The rule applies to run(), stream() and retry() when they are called from inside a tool that is itself running under a parent run. A root run (no parent) keeps whatever tenant_id you gave it, including None.
Before 0.3.0 a child run with no tenant_id was stored untenanted, so tenant-scoped reads of the parent's subtree missed it. If you list child runs by tenant, those older rows still have a NULL tenant.
The run-control and read APIs take a tenant_id= argument that makes a call behave as "this run, in this tenant, or not found":
tenant_id=None is a real value meaning "untenanted runs only", which is different from leaving the argument out. A before_tool hook sees the run's tenant as ToolCallContext.tenant_id.
Passing tenant_id is how your route handlers keep one customer from touching another's runs. Dendrux does not decide who the caller is; derive the tenant from the authenticated request and pass it in.
Why a synthetic result instead of an exception
The most obvious alternative is to raise a Python exception when a denied tool is called. The loop could catch it, mark the run failed, and stop.
That design is wrong for two reasons.
- The LLM has useful work left to do. A model that gets a "denied" tool result can apologize, ask for a different approach, suggest an escalation path, or try a non-destructive alternative. All of that is lost if the runtime kills the run on the first denied attempt. The user's request still deserves a response.
- Denial is not an error.
status=erroris reserved for things that actually broke: a guardrail-blocked secret, an unrecoverable tool failure, a provider outage. A policy denial is a normal branch of the decision tree, and should look like one in the event log.policy.deniedis its own event type so dashboards can aggregate access-control activity without mixing it with failure alerts.
The synthetic tool result is the cheapest way to give the model enough information to keep going. The success=False, error="denied" shape matches what it would see from a real tool failure, and most prompts already handle that case.
Where this fits
- Declared on
Agent(deny=[...], deny_skills=[...], before_tool=...). - Enforced in
dendrux.loops.react.ReActLoop(phase 0 of tool dispatch) and when the run's skill set is resolved. - Emits
policy.denied,policy.substituted,policy.hook_error,skill.catalog_resolved,skill.activation_failed,mcp.errorevents onrun_events. - Sits alongside the other governance layers: guardrails, approval, budget. Nothing about access control fights any of them.