Policy

Policy: which tools may run, which need a person's approval.

noclick.Policy.set(deny=["*.delete_*"], require_approval=["gmail.send_email_message"])
noclick.Policy.set(allow=["search", "gmail.*"], user="user_42")   # narrows the account policy

Patterns match dotted tool names (gmail.*, *.delete_*, search). Deny wins; an empty allow allows everything the other rules don't stop. Like every call, get, set and evaluate follow as_user: inside it they address that user's policy, and user=None addresses the account's. A rule can also be a noclick.Rule with a condition on the call's arguments and a person who approves it.

class Policy

The account's policy, or one user's (user_id). Each list holds patterns, and Rules where a rule has a condition or an approver.

Attributes
allowlist[str | Rule]
denylist[str | Rule]
require_approvallist[str | Rule]
user_idstr | None
rawdict[str, Any]

The response JSON exactly as the API sent it.

listclassmethodasync

noclick.Policy.list(*, client=None) -> list[Policy]

List the account's policy and every user policy.

Parameters
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
list[Policy]

The account's policy first (its user_id is None), then each user that has a policy of its own, with your id for that user as its user_id.

Usage
for policy in noclick.Policy.list():
    print(policy.user_id or "account", policy.deny, policy.require_approval)

evaluateclassmethodasync

noclick.Policy.evaluate(
    tools, *, arguments=None, user=..., draft=None, client=None
) -> list[PolicyVerdict]

Judge each of tools (dotted or wire names, at most 500) against the policy for the account, or user= (inside as_user, that user; user=None for the account). A draft ({"layer": "account"|"user", "allow", "deny", "require_approval"}) is judged in place of its layer's saved rules. Nothing runs and no approval is requested. arguments judges one tool's call with those arguments, so rules with a when apply; without them a rule with a when never matches.

Parameters
toolsstr | Sequence[str]required
argumentsdict[str, Any] | Nonedefault None
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
draftdict[str, Any] | Nonedefault None
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
list[PolicyVerdict]

One verdict per tool, in order: allow, deny or approval, with the matched_pattern and layer that decided it, and the whole rule. matched_pattern is None when no rule matched, or when an allow list left the tool out.

Usage

See how a user's calls would be judged:

for verdict in noclick.Policy.evaluate(["gmail.send_email_message", "search"], user="user_42"):
    print(verdict.tool, verdict.verdict, verdict.matched_pattern)

Try rules before you save them:

verdicts = noclick.Policy.evaluate(
    ["gmail.send_email_message", "gmail.fetch_emails_from_inbox"],
    draft={"layer": "account", "deny": ["gmail.*"]},
)

See which rule a particular call meets:

[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', ...)

getclassmethodasync

noclick.Policy.get(user=..., *, client=None) -> Policy

Read the account policy, or with user= (a User or your id for one, and inside as_user that user) that user's own rules; user=None reads the account's.

Parameters
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Policy

The allow, deny and require_approval lists, empty where nothing is set. A user's policy holds only its own rules, not the account's.

Usage
policy = noclick.Policy.get()
print(policy.allow, policy.deny, policy.require_approval)

ana = noclick.Policy.get(user="user_42")

setclassmethodasync

noclick.Policy.set(
    *, allow=(), deny=(), require_approval=(), user=..., client=None
) -> Policy

Replace the account policy (or user='s, and inside as_user that user's; user=None is the account's) with these rules: patterns, and noclick.Rules (or dicts) with a condition or an approver.

Parameters
allowSequence[Any]default ()
denySequence[Any]default ()
require_approvalSequence[Any]default ()
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Policy

The saved policy, its patterns lowercased and deduplicated. A list you left out is now empty.

Usage

Pass every rule you want to keep, since the call replaces the whole policy:

noclick.Policy.set(deny=["*.delete_*"], require_approval=["gmail.send_email_message", "stripe.*"])

A user's policy narrows the account's:

noclick.Policy.set(allow=["search", "gmail.*"], user="user_42")

addclassmethodasync

noclick.Policy.add(*rules, to='require_approval', user=..., client=None) -> Policy

Add rules to one list of the account's policy (or user='s; inside as_user that user's), leaving every other rule as it is, in one change on the server. A rule for a tool that list already has replaces it, so code that owns a rule can add it on every run. to is "require_approval", "deny" or "allow".

Parameters
*rulesAny
tostrdefault 'require_approval'
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Policy

The saved policy, with the rest of its rules.

Usage

Own one rule from the code that needs it, safe to run on every deploy:

noclick.Policy.add("quickbooks.create_bill")

Deny an operation for one user:

noclick.Policy.add("slack.*", to="deny", user="user_42")

Hold only large bills, for finance to approve:

noclick.Policy.add(
    noclick.Rule("quickbooks.create_bill", when="args.total_amount > 2500", approver="finance@acme.com"),
)

removeclassmethodasync

noclick.Policy.remove(*tools, user=..., client=None) -> Policy

Remove the rules for these tools, as written ("stripe.create_refund", "gmail.*"), from every list of the account's policy (or user='s; inside as_user that user's), leaving the rest. A tool with no rule changes nothing.

Parameters
*toolsstr
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Policy

The saved policy, with the rules that are left.

Usage
noclick.Policy.remove("quickbooks.create_bill")

PolicyVerdict

class PolicyVerdict

What a policy decides for one tool (allow, deny or approval), and which rule (matched_pattern in layer account/user) decided it.

Attributes
toolstr
verdictstr
matched_patternstr | None
layerstr | None
rawdict[str, Any]

The response JSON exactly as the API sent it.

rulestr | Rule | None

The rule that decided, whole: a pattern, or a Rule with its condition and approver (None when nothing matched).

Rule

class Rule

A policy rule that says more than a pattern: tool is the pattern ("stripe.create_refund", "gmail.*"), when a CEL expression the call must satisfy for the rule to apply, over args (the call's final arguments, bound ones included) and user (the user it's made for: id, email, name, metadata), and approver who approves it in require_approval: a Human (user.human, noclick.Human(email=...), or noclick.Human() for the user the call is made for) or an email; it's emailed the approval, and only it may answer. Without an approver the owner of what the call acts in approves, as for a pattern. A rule goes in any of the three lists; a dict with the same keys works too.

message, on a deny rule, is what the call's refusal says in your words, so the agent can tell the user what to do: "Web search comes with Pro: https://acme.com/upgrade". It may use {{user.id}} and the other user fields, as pause_spending does, filled in for whom the call is for; a field they lack leaves the refusal its usual words.

A call when can't judge (an argument it lacks, values that don't compare) meets a deny or require_approval rule and misses an allow one, so a condition never opens what it was written to close; has(args.amount) && ... is precise. Numbers compare within their own type: write double(args.amount) > 99.5 for an amount that may have a fraction.

Usage

Refunds over $100 wait for finance; smaller ones run:

noclick.Policy.add(
    noclick.Rule("stripe.create_refund", when="args.amount > 10000", approver="finance@acme.com"),
    to="require_approval",
)

A plan feature, refused with what to do about it:

noclick.Policy.add(
    noclick.Rule("search", when='user.metadata.plan == "free"',
                 message="Web search comes with Pro: https://acme.com/upgrade?user={{user.id}}"),
    to="deny",
)

Never email a competitor, and let pro users' agents book without asking:

noclick.Policy.set(
    deny=[noclick.Rule("gmail.send_email_message", when='args.to.exists(t, t.endsWith("@rival.com"))')],
    require_approval=[noclick.Rule("calendar.*", when='user.metadata.plan != "pro"')],
)
Attributes
toolstr
whenstr | None
approverAny
messagestr | None

What a deny rule's refusal says, for the user.