Guides / Rules for tool calls
Rules for tool calls
A list of permitted tools says which tools an agent may use. It cannot say what the agent may use them for. Rules look at the arguments of each call and answer one of three things: allow, deny, or ask a person first.
Why a tool list is not enough
Take one tool, create_issue, on a GitHub tool server. With tool lists alone you have
two choices.
Tool lists only
Put create_issue in allow_tools and the agent may open an issue in
any repository its token can reach, including a public one that belongs to someone else.
Leave it out and the agent cannot file an issue in your own repository either.
With a rule
Allow the tool, and add one rule: deny the call when repo is not under
acme/. The agent files issues where you meant it to, and nowhere else.
The only difference between the good call and the bad one was an argument.
Rules are part of the same signed MCP policy as the tool lists. If you have not written one yet, start with Company rules, which covers keys, signing and installing. This page covers what goes inside.
Three answers
reason, so write it as the next step.When several rules match one call, deny beats ask beats allow, wherever each is written. A narrow prohibition survives any broader grant, before or after it. Within one effect, the first matching rule in the file is the one reported.
allow_toolsanddeny_toolsdecide whether the tool may be used at all. A tool they refuse never reaches the rules.- The server's rules test the arguments. A deny here is final.
- The arguments are checked against the tool's pinned schema. See Tool servers.
- If no rule objected, the call is allowed.
- If a rule said ask, the call is held. A person approves it, or the optional local scorer judges it routine.
- A deny from any step refuses the call and tells the agent why.
- The tool runs, and its result is checked against the output schema if it declared one.
- Exactly one decision is recorded per call, whatever it was.
Writing a rule
Rules live in the rules list of a server entry, inside a scope. Here is a whole
policy with two of them.
{
"name": "example-corp",
"default_scope": "eng",
"scopes": [
{ "name": "eng",
"servers": [
{ "server": "github",
"allow_tools": ["list_*", "get_*", "create_issue", "merge_*"],
"rules": [
{ "id": "only-acme",
"tools": ["create_issue"],
"when": [ { "arg": "/repo", "op": "not_glob", "value": "acme/*" } ],
"effect": "deny",
"reason": "issues go in acme repositories" },
{ "id": "merge-needs-a-person",
"tools": ["merge_*"],
"effect": "ask",
"reason": "merging changes what ships" }
] }
] }
]
}
| Field | Required | Meaning |
|---|---|---|
id | no | A short name for the rule, shown in decisions and audit entries. Must be unique within the server's rules. Without one the rule is called rule[0], rule[1] and so on. |
tools | yes | Tool-name patterns the rule applies to. * stands for any run of characters. Names are the server's own (create_issue), not the prefixed name the agent sees. |
when | no | Conditions that must all hold. Leave it out and the rule applies to every call of the matched tools. |
when[].arg | yes | Which argument to test, as a JSON Pointer. See below. |
when[].op | yes | The operator. See the table below. |
when[].value | depends | What to compare against, typed per operator. exists and absent take none. |
effect | yes | allow, deny or ask. |
reason | no | What the agent or person is told. Without one, kosa8 writes a sentence naming the tool, the rule and the operator. Argument values are never quoted in it. |
Six worked examples
Only in one place. Because deny outranks allow, "only in acme" cannot be an
allow beside a broad deny. It is a deny on everything else, written with a not_
operator. This also stops a call that leaves repo out and lets the server pick a
default.
{ "id": "only-acme", "tools": ["create_issue"], "effect": "deny",
"when": [ { "arg": "/repo", "op": "not_glob", "value": "acme/*" } ],
"reason": "issues go in acme repositories" }
Read-only SQL. Deny a query that does not begin with SELECT. The
value is an RE2 regular expression, which matches anywhere unless you anchor it.
{ "id": "select-only", "tools": ["query"], "effect": "deny",
"when": [ { "arg": "/sql", "op": "not_matches", "value": "(?i)^\\s*select\\b" } ],
"reason": "this connection is for reading; use the migration tool to change data" }
A pattern is a tripwire, not a SQL parser: a statement can start with SELECT and
still do something else after a semicolon. Give the server a read-only database account as well.
Ask before anything that merges. No conditions, so it applies to every call of the matched tools.
{ "id": "merge-needs-a-person", "tools": ["merge_*"], "effect": "ask",
"reason": "merging changes what ships" }
Cap a number. The value is a JSON number, unquoted.
{ "id": "limit-cap", "tools": ["search_*", "list_*"], "effect": "deny",
"when": [ { "arg": "/limit", "op": "gt", "value": 1000 } ],
"reason": "ask for at most 1000 rows at a time" }
Keep out of a directory.
{ "id": "no-etc", "tools": ["read_file", "write_file"], "effect": "deny",
"when": [ { "arg": "/path", "op": "prefix", "value": "/etc/" } ],
"reason": "system configuration is off limits" }
The rule tests the string the agent sent, not where it leads. A relative path, or one that
reaches /etc through .. or a symlink, does not start with
/etc/. Where that matters, turn the rule round: deny every path that does
not start with the one directory you mean to allow, and add a second rule that denies any
path that contains ...
Require an argument. Deny the call when it is missing.
{ "id": "branch-required", "tools": ["create_pull_request"], "effect": "deny",
"when": [ { "arg": "/base", "op": "absent" } ],
"reason": "name the base branch explicitly" }
Every operator
There are 23. Comparison is typed: the string "1" never equals the number
1, and numbers compare by exact value, so 1, 1.0 and
1e0 are equal and very large integers do not lose precision.
| Operator | Value | Holds when the argument… | If it is missing |
|---|---|---|---|
exists | none | is present. An explicit null is present. | false |
absent | none | is not present. | true |
eq | string, number, boolean or null | is a single value of the same type and equal. | false |
ne | string, number, boolean or null | is a single value that eq would reject. | true |
in | non-empty array of single values | equals at least one entry. | false |
not_in | non-empty array of single values | equals no entry. | true |
prefix | string | is a string starting with the value. | false |
suffix | string | is a string ending with the value. | false |
contains | string | is a string containing the value. | false |
glob | string; * is any run of characters | is a string matching the whole pattern. Case-sensitive. | false |
matches | RE2 regular expression | is a string the expression matches anywhere. Anchor with ^ and $ for the whole value. | false |
not_prefix | string | is a string that does not start with the value. | true |
not_suffix | string | is a string that does not end with the value. | true |
not_contains | string | is a string that does not contain the value. | true |
not_glob | string pattern | is a string that does not match the pattern. | true |
not_matches | RE2 regular expression | is a string the expression does not match. | true |
lt | number | is a number less than the value. | false |
le | number | is a number less than or equal to the value. | false |
gt | number | is a number greater than the value. | false |
ge | number | is a number greater than or equal to the value. | false |
len_le | non-negative integer | has length at most the value: characters of a string, elements of an array, keys of an object. | false |
len_ge | non-negative integer | has length at least the value, counted the same way. | false |
type | one of string, number, integer, boolean, array, object, null | has that JSON type. integer is any number with no fractional part (1.0 counts), and every integer is also a number. | false |
Regular expressions use RE2 syntax, which has no backreferences or lookaround and runs in time
proportional to the input. A rule that needs "present and different" says so with two
conditions: exists, then ne.
Naming an argument
arg is a JSON Pointer (RFC 6901): a path of names separated by /,
starting from the arguments object.
| Pointer | Refers to, in {"repo":"acme/api","files":[{"path":"a.go"}],"a/b":1,"m~n":2} |
|---|---|
/repo | "acme/api" |
/files/0/path | "a.go". Array elements are numbered from 0. |
/files | The whole array. Use len_le to cap how many files one call may touch. |
/a~1b | 1. Inside a name, ~1 stands for a literal /. |
/m~0n | 2. ~0 stands for a literal ~. |
/files/3/path | Nothing. The argument counts as missing. |
| the empty string | The whole arguments object. |
A pointer that walks into something with no members, such as a name looked up inside a string,
finds nothing, exactly like a key that is not there. An array index has no leading zeros:
/files/01 is not element 1.
What if the agent sends the wrong type?
Suppose a rule denies /amount gt 1000, and the agent sends
"amount": "5000", a string. A string cannot be compared with a number, so the
condition is neither true nor false. kosa8 calls this indeterminate.
Treating "could not tell" as "false" is the classic policy bug: the agent would get past a limit by adding quotation marks. So an indeterminate condition takes whichever side makes the outcome stricter.
| Rule effect | Condition clearly true | Condition clearly false | Condition indeterminate |
|---|---|---|---|
deny | rule fires | rule does not fire | fires the call is denied |
ask | rule fires | rule does not fire | fires the call is held |
allow | rule fires | rule does not fire | does not fire the allow is not earned |
When a rule fires this way, the reason says so after the rule's own text, so nobody goes looking for a large amount that was never there:
kosa8: amounts over 1000 need finance [argument "/amount" could not be evaluated by "gt": it is a string, and this operator needs a number, so the rule was applied rather than risk letting the call through; send that argument in the type the tool's schema declares]
These make a condition indeterminate:
- A value of a type the operator cannot test: a number given to
prefix, a string given togt, an array or object given toeq, a number given tolen_le. - A string longer than 64 KiB given to
glob,matchesor theirnot_forms. - Arguments larger than 1 MiB, arguments that are not valid JSON or not valid UTF-8, arguments that are not an object, and arguments that repeat a key. Two programs can read a repeated key two ways, so no answer about it is trusted.
One certain "false" still settles a rule: if any condition definitely fails, the rule does not fire, whatever its other conditions did.
Try rules before enforcing them
A new rule can break someone's work. Log mode decides and records exactly as enforcement would, and then lets the call through. Set it at the top of the policy:
{
"name": "example-corp",
"rules_mode": "log",
"default_scope": "eng",
"scopes": [ … ]
}
The audit entry for a call that a rule would have stopped is marked allowed, and its detail says what enforcement would have done and why:
call allowed for reviewer (scope eng): log mode: would deny — issues go in acme repositories
A rule that would have asked reads log mode: would ask — …, and the call is not
held. Run in log mode for a while, read the entries
(kosa8 mcp-policy events --format table), then remove the line or set
"rules_mode": "enforce" and sign again.
allow_tools, deny_tools, pinned tool definitions and schema checks
still refuse in log mode. They are not new, so there is nothing to try out.
Rules for kosa8's own tools
kosa8's own tools (sandbox_create, container_run and the rest) are
governed by naming a server called kosa8. The lists and rules work exactly as they do
for a third-party server.
{ "server": "kosa8",
"deny_tools": ["sandbox_delete", "snapshot_rm"],
"rules": [
{ "id": "approved-images",
"tools": ["container_run", "sandbox_run"],
"when": [ { "arg": "/image", "op": "not_in",
"value": ["alpine", "node:22-alpine", "python:3.13-slim"] } ],
"effect": "deny",
"reason": "run one of the approved images" },
{ "id": "open-network-needs-a-person",
"tools": ["sandbox_create"],
"when": [ { "arg": "/egress", "op": "eq", "value": "any" } ],
"effect": "ask",
"reason": "an unrestricted network is reviewed first" }
] }
Two things differ from a third-party server.
- Silence means "leave them alone". A scope that does not list
kosa8does not govern these tools at all, and nothing extra is recorded for them. Policies written before this existed said nothing about kosa8 itself, and reading that silence as "deny" would have taken the runtime away from everyone they govern. Third-party servers are the opposite: one that is not listed is denied. - They are refused when called, not hidden. A denied tool of kosa8's own still appears in the tool list; calling it returns the refusal.
Once a scope does list kosa8, the usual tool-list behaviour applies: with an
allow_tools list, any tool not on it is denied.
The ask flow
MCP has no dependable way to put a question to a person in the middle of a call, so the gateway holds the decision itself.
- The agent calls a tool that an
askrule matches. - The gateway refuses for now, and returns an id that names this exact call.
- A person lists what is waiting and reads the arguments the agent sent.
- They approve that id, at a terminal.
- The agent repeats the call with the same arguments.
- It runs, and the approval is used up.
- The same call a third time asks the question again.
What the agent is told, word for word, with the rule's reason first:
kosa8: deploys are reviewed — this call needs a person's approval and you cannot give it. Ask a person to
review it with `kosa8 mcp asks` and approve it with `kosa8 mcp approve 3f2a9c0d1e4b5a67`, then make exactly
the same call again
What the person does:
$ kosa8 mcp asks 3f2a9c0d1e4b5a67 pending echo/echo by agt_1a2b3c4d5e6f why: deploys are reviewed args: { … the arguments exactly as the agent sent them … } until 15:04:05 $ kosa8 mcp approve 3f2a9c0d1e4b5a67 approved echo/echo (held call 3f2a9c0d1e4b5a67) the agent can now make the same call again; the approval is good for one use, for ten minutes
To say no, use kosa8 mcp refuse ID. The agent's next attempt is answered
kosa8: a person refused this call (held call ID): …, and keeps being answered that way
until the hour the question was given runs out, so asking again does not raise a fresh question.
What an approval is bound to
| Property | Value |
|---|---|
| Bound to | The agent's verified identity, the server, the tool, and the SHA-256 digest of the arguments. Change any one and it is a different question with a different id. |
| Uses | One. Running the call spends it. Two identical calls racing for one approval cannot both win. |
| Approval lifetime | Ten minutes from the moment it is given. |
| How long a question waits | One hour. After that the agent must make the call again to ask afresh. |
| Who may decide | Someone at a terminal. Through a pipe the command answers `kosa8 mcp approve` must be run by a person at a terminal. |
| Arguments shown | In full, with terminal control sequences removed, up to 64 KiB. Beyond that only their digest is shown. |
The agent identity in that list is the one from Agent
identity. A gateway with no identity shows as (unidentified agent), and an
approval given to one unidentified gateway fits any other.
The optional local scorer
People asked too often stop reading and approve by reflex, and then the control has stopped working while still looking present. A policy can let a small model on your own machine answer the routine questions, so the ones that reach a person deserve attention.
"classifier": {
"scorer": "local-model",
"endpoint": "http://127.0.0.1:7434",
"model": "qwen",
"threshold": 0.95,
"timeout_ms": 2000
}
| Field | Meaning | Checked when the policy is signed |
|---|---|---|
scorer | The kind of scorer. | Must be local-model, the only kind there is. An unknown kind is refused rather than ignored. |
endpoint | The base URL of an OpenAI-compatible model server. | Must be http:// on localhost or a loopback address. Tool arguments must not leave the machine. |
model | The model name to ask for, as the local model server knows it. | Required. A classifier with no model is refused. |
threshold | How confident an "allow" must be to be acted on. | Above 0.5 and below 1. There is no default: how sure is sure enough is the decision this field records. |
timeout_ms | The longest one question may take. Optional; 0 means two seconds. | Between 0 and 30000. |
What it can and cannot do
- It only ever answers an ask. It is consulted for a call a rule said to ask about, when no person has answered yet. It is never shown a call the policy denies, and it has no answer that means deny.
- The best it can do is skip a question. A confident "allow" lets the call run, recorded as
auto-allowed by … at confidence 0.97 (threshold 0.95), in place of asking a person. Anything else leaves the question with a person, exactly as if there were no scorer. - It fails closed. An error, a timeout, an answer that is neither of the two, a missing probability, or a confidence under the threshold all mean "ask a person".
The veto that runs first
Before the model is asked anything, a fixed set of checks reads the call. If any fires, the question goes straight to a person and the model never sees the arguments. These checks are code, not a model, so they cannot be argued with.
| Class | What trips it |
|---|---|
destructive | Verbs that make data or a running thing go away, in the tool name or the arguments, and command shapes such as rm -rf, --force, --hard, kill -9, chmod 777 or piping into a shell. |
credential | Values in the published shape of a secret (API keys, GitHub and Slack tokens, AWS key IDs, private keys, JWTs), long random-looking strings, and argument names such as password or api_key. |
sensitive-path | Paths into the system or credential stores, .env files, and .. as a path element. |
external-destination | Anything naming somewhere other than this machine: a URL, a hostname, an IP address, an email address. |
prompt-injection | Text addressed to a reviewer rather than a tool, chat-template control tokens, and hidden or direction-changing characters. |
size | Arguments too large, too deeply nested, or holding too many strings to inspect. |
malformed | Arguments that are not valid JSON, or that repeat a key. |
The veto errs towards asking. Any commit hash looks like a token to it, so those calls reach a person.
What it needs
The scorer reads the model's answer as a probability, which requires the model server to return
token log-probabilities. kosa8 model serve NAME --backend llamay passes them through
from llamay. kosa8's own inference engine has none to give, so through it the
scorer never auto-allows and every ask goes to a person. See Local
models.
Sign it and install it
Rules take effect like any other change to the MCP policy: sign the file with your organisation's key and install the signed copy. The key, the pinning step and the reasons are in Company rules.
$ kosa8 mcp-policy sign key.txt policy.json > signed.json $ kosa8 mcp-policy install signed.json
Signing validates every rule first, so a typo does not become a deny that silently never fires:
refusing to sign: scope "eng", server "github": only-acme: condition 0 ("/repo" "globb"): unknown operator;
see the operator table in the package documentation
A running gateway reads the policy once, when it starts, so restart
kosa8 mcp serve after installing.
Try a rule before an agent meets it. kosa8 mcp-policy check SERVER TOOL answers for
the tool lists; add --args with the call's arguments as JSON and the server's rules are
applied to them, exactly as the gateway would:
$ kosa8 mcp-policy check github create_issue --args '{"repo":"evil/web"}' DENIED: github/create_issue for scope eng effect with these arguments: deny issues go in acme repositories rule: eng/github rules "only-acme" $ kosa8 mcp-policy check github create_issue --args '{"repo":"acme/web"}' allowed: github/create_issue for scope eng effect with these arguments: allow $ kosa8 mcp-policy check github create_issue --args '{}' # no repo at all DENIED: github/create_issue for scope eng effect with these arguments: deny $ kosa8 mcp-policy check github merge_pull_request --args '{"pr":41}' ASK: github/merge_pull_request for scope eng effect with these arguments: ask merging changes what ships
It exits non-zero for a deny or an ask, so a policy change can be tested in CI before it is signed.
| Limit | Value |
|---|---|
| Rules per server entry | 512 |
| Conditions per rule | 16 |
| Tool patterns per rule | 64 |
| A pattern or regular expression | 512 bytes |
A value | 4096 bytes; a list holds at most 256 entries |
A rule id | 128 bytes |
What this does not do
- Rules see only the arguments the agent chose to send. A deny on
/pathdoes nothing about a server that also accepts/file. The schema check helps only if the tool's schema forbids extra arguments. - Rules test text, not meaning. They do not resolve paths, parse SQL, or follow a URL. A pattern can be satisfied by a call that still does harm.
- An approval is a local file. It is decided by a command your user runs. Requiring a terminal stops an agent approving its own call through a pipe, and nothing more determined. An agent that can run commands as you, outside a sandbox, is beyond what a local file can prevent; signed approvals from named people are the control for that.
- The scorer can be talked round. It reads text an attacker may have chosen. Its confidence is not calibrated, and a threshold has to be tuned against your own team's calls, in log mode, before it is trusted. Where a wrong "allow" is unaffordable, write a deny.
- The veto is a tripwire. It recognises the shapes listed above and nothing else. A destructive action phrased politely passes it.
- Deleting the policy file lifts its rules on kosa8's own tools. With a signer pinned and
mcp-policy.jsonremoved, every third-party server is denied, which is noticed, but kosa8's own tools go back to ungoverned. A policy that is present and fails verification denies both. - Nothing reads tool results for hidden instructions. Rules are about what goes in.
Built-in rules for an agent's own tool calls
Everything above is about tools served through kosa8's MCP gateway. An agent's own tools —
Claude Code's Bash, Write and Edit, Codex's
shell, Gemini's run_shell_command — get a fixed set of rules instead. They
are checked before each call runs when the kit has a pre-run hook (the claude kit
does), and against every call the model asks for in the audit log. A shell command is parsed, not
pattern-matched: cd repo && git push -f, bash -c 'git push -f',
sudo -u ci git push -f, git -C repo push -f and
find . -exec git push -f \; are all the same force push.
| answer | rules |
|---|---|
| refused | K8-D001 a download run as code (curl … | sh,
sh -c "$(curl …)") · K8-D002 a write to the agent's own settings or hooks, or
kosa8's · K8-D003 credential files sent off the machine |
| asks you | K8-A001 force push or remote delete · A002 cloud or cluster destroy ·
A003 publishing a package or image · A004 PR merge, repo delete, API DELETE · A005 reading
credentials or the whole environment · A006 destructive SQL · A007 git reset --hard,
git clean -fdx, deleting .git or the workspace · A008 code piped
into an interpreter from something the rules cannot read · a command that does not parse |
| runs, marked | K8-F001 network tools · F002 package installs · F003 git push · F004 sudo · F005 a program named by a variable · F006 inline interpreter code |
How often they catch it
The rules are measured against a hand-labelled set of tool calls
(pkg/rules/testdata/corpus.jsonl): 75 that a person should see before they run, and
89 of the ordinary work an agent does all day. The risky set deliberately includes evasions.
Today:
- 66 of 75 risky calls stopped (88.0%) — refused or held for you.
- 1 of 89 ordinary calls stopped (1.1%):
env | grep -c PATH, which reads the whole environment to count one line.
The nine it misses, and why: a make target and a script file (what they run is not
in the call); an alias and a program held in a variable (G=git; $G push -f); code inside
a Python or Node one-liner that shells out or writes a file; a download saved to a file and then
run as a second command; and credentials tarred and piped to an upload. A rule reads the call it is
given — it cannot see inside a file or a language it does not parse. That is why kosa8 does not rely
on the rules alone: the sandbox still bounds what any call can reach, every call the model asked for
is recorded whether or not a rule caught it, and a rewind undoes what happened inside it.
The test fails if either rate gets worse; go test ./pkg/rules -run Corpus -v lists
every miss.
Where to go next
Agent identity
Different rules for different agents, and approvals that belong to one of them.
Tool servers (MCP)
Running third-party servers in sandboxes, with pinned definitions.
Company rules
Keys, signing, installing, and what tampering does.
Agent troubleshooting
Every refusal message, what it means and what to do.