Tool policy
Allow, hold for a person, or deny tool calls for every agent in a Tenant.
import { createAdmin } from "@nylorun/admin";
const admin = createAdmin();
await admin.toolPolicy.put({
rules: [
{ effect: "ask", agent: "support", tool: "refund_order", reason: "Refunds need a person" },
{
effect: "deny",
kind: "http",
tool: "refund_*",
when: { amount: { gt: 10000 } },
reason: "Refunds over 10000 are refused",
},
{ effect: "ask", server: "github", tool: "create_*" },
],
});The tool policy decides which MCP and HTTP tool calls run, wait for a person, or are refused. It is the Tenant's, set with a management key, and applies to every agent, every session, and every harness at once. An agent does not declare approvals.
put replaces every rule, and an empty list allows every call. To change one
rule, read the policy with admin.toolPolicy.get() ({ rules, updatedAt }),
edit the list, and write it back. A policy holds at most the Runtime limit
toolRules (200 by default). A selector or when path is at most
toolRuleFieldChars (200) characters, and a reason or matches expression at
most toolRuleTextChars (500); past any of them, put is 400. See
Runtime limits.
In Studio, the Tool policy tab of the Tenant page
lists the rules, strictest first, edits one in a panel, and adds one with
Add rule; it saves the policy whole, as put does.
From a terminal, nylorun tenant tool-policy get prints the policy as JSON,
and put <file> (or put - for standard input) replaces it with the file's
rules. What get prints, put takes back, so the policy can live in a
repository and be applied from CI:
npx nylorun tenant tool-policy get > tool-policy.json
npx nylorun tenant tool-policy put tool-policy.jsonRules
| Field | Value |
|---|---|
effect | Required. allow, ask, or deny. |
tool | The tool's identity: an MCP tool's own name on its server (with server), an HTTP tool's declared name, or a flow HTTP stage's key. The name the model knows a tool by never matches. A tool containing __ is refused with 400. * matches any run of characters. |
server | An MCP server's name. * matches any run of characters. A rule with server never matches an HTTP tool. |
kind | mcp or http. |
agent | The agent that makes the call: for the session's own agent, its agent manifest's name (never the Agent's id, such as users/ada/desk); a subagent's id; or a flow agent's id for its HTTP stages. * matches any run of characters. |
when | Fields of the call's input by dotted path, such as customer.tier, each with a test: equals, in, gt, gte, lt, lte, or matches (a regular expression). Every test a field sets must hold. |
reason | Shown with a held or denied call, to the person and to the model. Without one, the Runtime describes the rule. |
A pattern matches the whole name: refund_* matches refund_order, not
partial_refund. matches is a regular expression searched in a string
value; anchor it with ^ and $ to match the whole value. gt, gte, lt,
and lte hold only for numbers. A when field the input does not have never
matches.
Matching
- A rule matches a call when every field it sets matches. A rule that sets only
effectmatches every MCP and HTTP call. - The strictest matching rule decides, whatever the order:
deny, thenask, thenallow. - A call no rule matches runs.
- A policy the Runtime cannot read refuses every call.
An MCP tool is named by server and tool, never by the server__tool name
the model sees. A policy stored with such a rule, written before the Runtime
refused them, cannot be read: every MCP and HTTP call is denied with a reason
naming the rule, and admin.toolPolicy.get() (GET /v1/tenant/tool-policy)
answers 409 request_rejected, until you put the policy again. Rewrite each
such rule:
{ "effect": "ask", "tool": "github__create_issue" }becomes
{ "effect": "ask", "server": "github", "tool": "create_issue" }Through an MCP gateway that puts a prefix before each tool name it lists, such
as LiteLLM's github-create_issue, a rule still names the server's own tool
(create_issue) once the Agent's credential sets the gateway's toolPrefix.
See Gateway tool prefix.
What each effect does
| Effect | The call |
|---|---|
allow | Runs. |
deny | Is never sent. The model gets a failed tool result, tool.denied, with the reason. |
ask | Is held. The turn pauses until a person approves or denies it. |
A held call shows as held in
session.toolCalls().
The turn pauses with turn.paused, which carries one approval interaction
per held call:
| Field | Value |
|---|---|
id | The interaction to answer. |
prompt | Approve <server> / <tool>? for an MCP call, Approve <tool>? otherwise. |
reason | The rule's reason, or the Runtime's description of the rule. |
server, tool | The tool's identity, as the rule matched it. |
input | The call's input, as it will be sent. |
The event's toolName stays the name the model called the tool by.
The Runtime decides as it records the call, before anything is sent, and the gateway runs only calls the record holds running. A harness of your own cannot skip the policy.
Approve a held call
await session.approve(interactionId, true);session.approve(interactionId, approved, { idempotencyKey? }) answers one held
call. Without an idempotencyKey, each call makes its own; pass one to retry
the same answer safely. The interactionId is the interaction's id in turn.paused
(interactions[].interaction.id); session.pending() lists the same open
interactions. Approved, the Runtime sends that exact call, with the input the
person saw. Denied (false), the call fails to the model as tool.denied and is never
sent. When every held call of the turn is answered, the turn goes on.
Nothing holds a process while a call waits: an approval can come a day later,
or after the Runtime restarts. Cancelling the turn ends each held call as
cancelled. See Pauses and control.
In Studio, a paused session shows an approval card for each
held call, with its tool as server / tool (and "as <toolName>", the model's
name), why it is held, and its input, and Approve and Deny.
Where it applies
| Call | Decided by the policy |
|---|---|
Remote MCP server tools, deferred ones run through tool_call included | Yes |
| HTTP tools | Yes |
| A flow's HTTP stages and HTTP verifiers | Yes. An ask pauses the flow's turn. |
Sandbox tools (bash, read, write, …) | No. Their network side is the Agent's egress records. |
The harness's own tools (read_artifact, save_artifact, load_skill, read_skill_resource, tool_search) | No |
The Runtime's tools, memory and schedule_wake | No |
| A custom harness's own tools | No. They run in the harness's own process. |
A call a subagent makes is decided like any other,
with agent set to the subagent's id. deny fails it to the subagent's model.
ask holds it and pauses the parent's turn; approve it on the parent's
session. A flow agent used as a subagent
runs in its own session, and a call held there waits on that session.
Reference
| Call | Endpoint |
|---|---|
admin.toolPolicy.get() | Get the Tenant's tool policy |
admin.toolPolicy.put({ rules }) | Set the Tenant's tool policy |
session.approve() | Send a command to a session |