Policy and approvals
Decide which tools may run, which never may, and which need a person to say yes first.
A policy is three lists of tool patterns: allow, deny and require_approval. It applies to every tool call made with your account, whoever makes it: your code running an operation, your LLM through a toolkit, or a hosted agent calling its own tools.
import noclick
noclick.Policy.set(
deny=["*.delete_*"],
require_approval=["gmail.send_email_message", "stripe.*"],
)Patterns
A pattern matches dotted tool names: an integration and an operation (gmail.send_email_message), with * as a wildcard (gmail.*, *.delete_*), or a capability (search, fetch, code). An agent's shell commands count as code.
- Deny wins. A tool that matches
denynever runs. - Allow narrows. An empty
allowallows everything the other lists don't stop. A non-empty one allows only what matches it. - Approval holds. A tool that matches
require_approvalruns only after a person approves that exact call.
Each list holds up to 200 patterns. Policy.set replaces the whole policy, so pass every rule you want to keep, or change one rule at a time with add and remove.
A policy is about tools across your account. To make one connected account ask its owner before it acts, wherever it's used (the app's workflows included), require approval on the connection itself.
wake_me is the tool every agent has to wake itself later, so deny=["wake_me"] keeps an agent from setting its own wake-ups.
The tools agents use on each other have names of their own. The agents capability's are agents.spawn, agents.message, agents.ask, agents.status, agents.list and agents.cancel (agents.* for all of them; see Coordinators), and tell_parent is the one every spawned child has to report to its parent. So require_approval=["agents.spawn"] makes a person approve each new child.
Rules with conditions
A pattern judges a tool whatever it's asked to do. A noclick.Rule also looks at the call: when is a CEL expression over args (the call's final arguments, bound ones included) and user (the user it's made for: id, email, name, metadata), and the rule applies only when it's true. approver names who approves a held call: a Human or an email.
import noclick
noclick.Policy.set(
deny=[noclick.Rule("gmail.send_email_message", when='args.to.exists(t, t.endsWith("@rival.com"))')],
require_approval=[
noclick.Rule("stripe.create_refund", when="args.amount > 10000", approver="finance@acme.com"),
noclick.Rule("quickbooks.create_bill", when="args.total_amount > 2500"),
{"tool": "calendar.*", "when": 'user.metadata.plan != "pro"', "approver": noclick.Human()},
"gmail.*",
],
)- A rule goes in any of the three lists, beside plain patterns; a dict with
tool,whenandapproverworks too.getandlistread a rule back as aRule. Awhenthat isn't CEL, or reads anything butargsanduser, raisesnoclick.InvalidRequestwhen you save it. - A rule without a
whenapplies to every call it matches, like a pattern. - A call a
whencan't judge (an argument the call doesn't have, values that don't compare) meets adenyorrequire_approvalrule and misses anallowone, so a condition never opens what it was written to close. Writehas(args.amount) && args.amount > 10000to say exactly what a missing argument means. Numbers compare within their own type:double(args.amount) > 99.5for an amount that may have a fraction. - Only a
require_approvalrule has anapprover.noclick.Human()there is the user the call is made for,user.humana particular user, and an email anyone. The approver is emailed the approval, and only they answer it: not your account's key, not the console. Without one, the owner of what the call acts in approves.
evaluate with arguments= judges one call, and each verdict's rule is the rule that decided it, whole:
[verdict] = noclick.Policy.evaluate("stripe.create_refund", arguments={"amount": 25000}, user="user_42")
print(verdict.verdict, verdict.rule) # approval Rule(tool='stripe.create_refund', when='args.amount > 10000', ...)Without arguments, a rule with a when never matches.
Change one rule
Policy.add puts rules in one list and Policy.remove takes the rules for some tools out of every list, each in one change on the server that leaves the other rules alone, however many callers change the policy at once. So the code that owns a rule can keep it, without reading and rewriting the whole policy:
noclick.Policy.add("quickbooks.create_bill")
noclick.Policy.add("slack.*", to="deny", user="user_42")
noclick.Policy.remove("slack.*", user="user_42")to is "require_approval" (the default), "deny" or "allow". A rule for a tool that list already has replaces it, in place, so adding the same rule on every deploy changes nothing and a new threshold replaces the old one:
noclick.Policy.add(noclick.Rule("quickbooks.create_bill", when="args.total_amount > 2500", approver="finance@acme.com"))remove takes the tools as written ("gmail.*" removes the gmail.* rule, not gmail.send_email_message's), and a tool with no rule changes nothing. Both follow as_user like set, a list still holds up to 200 patterns (noclick.InvalidRequest past that), and only a key acting for your whole account changes a policy (noclick.PermissionDenied otherwise).
A user's own policy
A user's policy narrows the account's. It can deny more or require more approvals, never less:
noclick.Policy.set(allow=["search", "gmail.*"], user="user_42")
noclick.Policy.get(user="user_42")
noclick.Policy.list() # the account's policy and every user'sLike every call, set, get and evaluate follow as_user: inside it they address that user's policy, so a bare Policy.set there never replaces the account's. Pass user=None for the account's policy.
Check a policy without running anything
evaluate says how the policy judges each tool, and which rule decided. Pass a draft to try rules before you save them:
for verdict in noclick.Policy.evaluate(
["gmail.send_email_message", "gmail.fetch_emails_from_inbox"],
draft={"layer": "account", "deny": ["gmail.*"]},
):
print(verdict.tool, verdict.verdict, verdict.matched_pattern)Approvals in your own code
When a call you make directly is held, it raises noclick.ApprovalRequired. The approval request is already on its way to the approver. Wait for it, then repeat the identical call with its approval_id:
gmail = noclick.Connection.from_name("gmail")
args = {"to": ["team@acme.com"], "subject": "Q3 numbers", "body": "..."}
try:
gmail.run("send_email_message", args)
except noclick.ApprovalRequired as pending:
if pending.operation.wait():
gmail.run("send_email_message", args, approval_id=pending.approval_id)The approver has 24 hours to answer. A granted approval covers one call: the same tool with the same arguments, once. A rejected approval makes the retry fail with noclick.PolicyDenied.
pending.operation is there whoever approves, including when your server acts for a user and your account's owner approves. It's None only when a user's session, or a key pinned to that user, makes a call someone else approves, since their credential can't read your account's approvals: retry the call with approval_id later instead, which raises noclick.ApprovalRequired again until it's approved. pending.approve_url is set only when the approver is the one the call is made for (see Who approves).
Approvals for LLMs and agents
A toolkit never raises: a held call comes back to the model as an error result, and the approval's details go to your on_approval callback instead. A hosted agent is told only that the call waits for approval. Neither ever sees the approval link, so a model can't approve its own call.
Once the person approves, a toolkit model's retry of the identical call goes through. A hosted agent's held call resumes its conversation on its own: once the person decides, the conversation takes a turn with what came of it (see When a call waits for approval).
- Approved, NoClick runs the exact call that was held (an operation's, with the arguments and the connection it was held with, once) and the turn reads its result; the agent is told not to make the call again.
- Declined, the turn says so, with the approver's note, and the call never runs.
- A held shell command or other tool that isn't an operation the agent makes again itself, as the turn tells it.
The reply goes out wherever the conversation lives (a chat, or on an email thread, the same thread, since an inbox agent's reply tool is bound to the thread's latest message), so nothing waits on the agent being messaged again. A channel's conversation isn't resumed: the channel starts its turns. agent.triggers() lists this as kind approval while your policy may hold one of the agent's tools. An approver answering from your own UI passes the note with request.approve(note=...) or request.decline(note=...).
What the approver sees
The approval page and its email say what is being approved, in words a person can judge: the operation's name as the service labels it ("Stripe: Create Refund", with the tool beside it), its arguments as a table, the user the call is made for, and, for an agent's call, the agent, a link to the conversation in your console and the agent's reason: what it wrote in its turn before making the call (a coding harness's words reach NoClick only when its turn ends, so its calls carry none). The email carries the same summary, so a person on their phone can decide without opening the page. Each argument shows up to 500 characters, and up to 30 of them.
request.request["details"] holds the same (Action, User, Agent, Reason, Conversation and Arguments), and the request's approval names the label, agent, conversation and reason on their own.
Who approves
A rule's approver approves the calls its rule holds. Otherwise the owner of what a call acts in approves it:
- A call on a user's own connection is approved by that user, through
user.human. - A call on one of your account's connections, an operation that runs on NoClick's key with no connection, and a capability (
search,fetch,code) are approved by your account's owner. They spend your account's money, not anything of the user's. - A call on a connection shared from another scope (invite-only beta) is approved by that connection's owner.
Who is talking to an agent doesn't change this. When a customer asks your account's support agent for a refund and it calls stripe.create_refund on your account's Stripe, your account's owner approves it, never the customer.
Every scope's rules are still judged: your account's, those of the user the call is made for, and those of the connection's owner. The strictest decides. So a user's own require_approval rule holds your account agent's call in that user's conversation, and your account's owner approves it.
The approver gets an email with a link to the approval page, and pending approvals addressed to you appear in the developer console. The approve link comes back in noclick.ApprovalRequired only when the approver is the one the call is made for: your account's owner for your own calls, the user for calls on their own connection.
A user with no email address can't be emailed, so:
- When a toolkit or hosted agent call on their connection is held, your account's owner approves instead. If nobody who could approve has an email address, the call fails with
noclick.NotAvailable. - When your own direct call on their connection is held, nothing is emailed. Pass the error's
approve_urlon to the user yourself.