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.
Three knobs cover the common cases:
The rest of this page focuses on deny, which is the most commonly used. The other two share the same shape: decide at the gate, emit a typed event, keep going.
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. Captured from a live run:
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 will not be registered when the agent boots. The runner emitsskill.deniedon startup. See Skills.- MCP connections happen in
_emit_init_eventsduring agent setup. A connection that fails auth or discovery emitsmcp.error, with the error payload indata. The run continues without the server's tools. See MCP.
All three layers share run_events as the audit surface. Querying WHERE event_type IN ('policy.denied', 'skill.denied', 'mcp.error') on a run returns the full access-control story in one query, in order.
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=[...]). - Enforced in
dendrux.loops.react.ReActLoop(phase 0 of tool dispatch) and during skill registration. - Emits
policy.denied,skill.denied,mcp.errorevents onrun_events. - Sits alongside the other governance layers: guardrails, approval, budget. Nothing about access control fights any of them.