dendrux
v0.4.0Get started →

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:

ParameterScopeEvent emitted
deny=["tool_name"]Per-agent list of tools that cannot execute, even if the LLM calls thempolicy.denied
before_tool=hookA function that decides each call from its arguments and context: allow, deny, require approval or substitute a result. See Tool policy hookpolicy.denied, policy.substituted, approval.requested, policy.hook_error
deny_skills=["skill_name"]Per-agent list of skills removed from every runNames listed in skill.catalog_resolved
MCP server auth / errorsNetwork-layer failures reaching an MCP servermcp.error

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: 0

Four things to pull out of that trace:

  1. The tool function never ran. DELETE_CALLED is empty. No DB row landed in tool_calls (that table only records actual executions, not blocked attempts).
  2. A policy.denied event landed on the timeline. seq=2, same correlation_id as the LLM's tool-call id, so a reader can join the denial to the call that triggered it.
  3. 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.
  4. 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:

  1. Deny runs before deanonymization. The deny_set check is phase 0 of the tool-dispatch stage. Phase 0.5 is guardrail_engine.deanonymize(tc.params) which only runs on the non_denied bucket. 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.
  2. There is no tool.completed event and no tool_calls row. The comment in the source makes it explicit: "denied tools are not executions." Persisting a fake execution would confuse readers who query the tool_calls table expecting one row per side effect.
  3. The synthetic result is a normal tool-result message. It flows through the same on_message_appended path 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 of deny. A skill listed there is removed from the run before the model sees anything: it is not in the catalogue, and use_skill, read_skill_resource and run_skill_script cannot reach it. The names are listed in the denied field of the skill.catalog_resolved event at run start. To gate what an allowed skill may do, name the skill tools in deny or require_approval. See Skills.
  • MCP connections happen in _emit_init_events during agent setup. An auth or discovery failure emits mcp.error. A best_effort source 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_id inherits the parent's.
  • A child started with the same tenant_id as its parent is fine.
  • A child started with a different tenant_id raises ValueError before 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":

APIWith tenant_id=Omitted
agent.cancel_run(run_id, cascade=True)Root must be in the tenant; the cascade only reaches descendants in the same tenant.Unscoped (trusted single-tenant use).
agent.request_pause(run_id, cascade=True)Same.Unscoped.
agent.submit_note(run_id, text)Run must be in the tenant, else RunNotFoundError.Unscoped.
RunStore.get_subtree(run_id)Root and every descendant must be in the tenant; others are excluded with everything below them.Unscoped.
RunStore.get_notes(run_id)Run must be in the tenant, else dendrux.store.RunNotFoundError.Unscoped.

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.

  1. 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.
  2. Denial is not an error. status=error is 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.denied is 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.error events on run_events.
  • Sits alongside the other governance layers: guardrails, approval, budget. Nothing about access control fights any of them.