kosa8 docs

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

allowThe call goes ahead. Mostly useful to name a rule in the audit log, because a call no rule matches goes ahead anyway once the tool lists have admitted it.
denyThe call is refused. The agent is told the rule's reason, so write it as the next step.
askThe call is permitted in principle and someone should look at this one first. It is held until a person approves that exact call.

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.

How one tool call is decided A call passes the tool lists, then the argument rules, then the schema check. A deny from any of these refuses the call with a reason. If the outcome is allow, the tool runs at once. If it is ask, the call is held for a person, or for the optional local scorer, and runs only when approved. Every outcome is written to the audit log. tool lists is it listed? rules by argument schema the pinned shape allow nothing objected ask a person decides deny refused, with the reason, from any step tool runs result is checked audit log one entry per call: who asked, which rule decided, and a digest of the arguments
Rules never widen. They are consulted only for a tool the tool lists already admit.
  1. allow_tools and deny_tools decide whether the tool may be used at all. A tool they refuse never reaches the rules.
  2. The server's rules test the arguments. A deny here is final.
  3. The arguments are checked against the tool's pinned schema. See Tool servers.
  4. If no rule objected, the call is allowed.
  5. If a rule said ask, the call is held. A person approves it, or the optional local scorer judges it routine.
  6. A deny from any step refuses the call and tells the agent why.
  7. The tool runs, and its result is checked against the output schema if it declared one.
  8. 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" }
          ] }
      ] }
  ]
}
FieldRequiredMeaning
idnoA 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.
toolsyesTool-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.
whennoConditions that must all hold. Leave it out and the rule applies to every call of the matched tools.
when[].argyesWhich argument to test, as a JSON Pointer. See below.
when[].opyesThe operator. See the table below.
when[].valuedependsWhat to compare against, typed per operator. exists and absent take none.
effectyesallow, deny or ask.
reasonnoWhat 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.

OperatorValueHolds when the argument…If it is missing
existsnoneis present. An explicit null is present.false
absentnoneis not present.true
eqstring, number, boolean or nullis a single value of the same type and equal.false
nestring, number, boolean or nullis a single value that eq would reject.true
innon-empty array of single valuesequals at least one entry.false
not_innon-empty array of single valuesequals no entry.true
prefixstringis a string starting with the value.false
suffixstringis a string ending with the value.false
containsstringis a string containing the value.false
globstring; * is any run of charactersis a string matching the whole pattern. Case-sensitive.false
matchesRE2 regular expressionis a string the expression matches anywhere. Anchor with ^ and $ for the whole value.false
not_prefixstringis a string that does not start with the value.true
not_suffixstringis a string that does not end with the value.true
not_containsstringis a string that does not contain the value.true
not_globstring patternis a string that does not match the pattern.true
not_matchesRE2 regular expressionis a string the expression does not match.true
ltnumberis a number less than the value.false
lenumberis a number less than or equal to the value.false
gtnumberis a number greater than the value.false
genumberis a number greater than or equal to the value.false
len_lenon-negative integerhas length at most the value: characters of a string, elements of an array, keys of an object.false
len_genon-negative integerhas length at least the value, counted the same way.false
typeone of string, number, integer, boolean, array, object, nullhas 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.

PointerRefers 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.
/filesThe whole array. Use len_le to cap how many files one call may touch.
/a~1b1. Inside a name, ~1 stands for a literal /.
/m~0n2. ~0 stands for a literal ~.
/files/3/pathNothing. The argument counts as missing.
the empty stringThe 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 effectCondition clearly trueCondition clearly falseCondition indeterminate
denyrule firesrule does not firefires the call is denied
askrule firesrule does not firefires the call is held
allowrule firesrule does not firedoes 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:

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.

Log mode softens rules only

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.

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.

A held call, approved by a person A sequence between an agent, the kosa8 gateway and a person. The agent calls a tool. The gateway refuses and returns an id for the held call. The person runs kosa8 mcp asks to read the call, then kosa8 mcp approve with the id. The agent makes exactly the same call again and it runs once. A further identical call is held again. the agent the gateway a person calls echo_echo held: id 3f2a9c0d1e4b5a67 kosa8 mcp asks kosa8 mcp approve 3f2a9c0d1e4b5a67 exactly the same call again runs once; the approval is spent a further identical call is held again
The approval is for one call, not for the tool.
  1. The agent calls a tool that an ask rule matches.
  2. The gateway refuses for now, and returns an id that names this exact call.
  3. A person lists what is waiting and reads the arguments the agent sent.
  4. They approve that id, at a terminal.
  5. The agent repeats the call with the same arguments.
  6. It runs, and the approval is used up.
  7. 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

PropertyValue
Bound toThe 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.
UsesOne. Running the call spends it. Two identical calls racing for one approval cannot both win.
Approval lifetimeTen minutes from the moment it is given.
How long a question waitsOne hour. After that the agent must make the call again to ask afresh.
Who may decideSomeone at a terminal. Through a pipe the command answers `kosa8 mcp approve` must be run by a person at a terminal.
Arguments shownIn 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
}
FieldMeaningChecked when the policy is signed
scorerThe kind of scorer.Must be local-model, the only kind there is. An unknown kind is refused rather than ignored.
endpointThe base URL of an OpenAI-compatible model server.Must be http:// on localhost or a loopback address. Tool arguments must not leave the machine.
modelThe model name to ask for, as the local model server knows it.Required. A classifier with no model is refused.
thresholdHow 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_msThe longest one question may take. Optional; 0 means two seconds.Between 0 and 30000.

What it can and cannot do

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.

ClassWhat trips it
destructiveVerbs 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.
credentialValues 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-pathPaths into the system or credential stores, .env files, and .. as a path element.
external-destinationAnything naming somewhere other than this machine: a URL, a hostname, an IP address, an email address.
prompt-injectionText addressed to a reviewer rather than a tool, chat-template control tokens, and hidden or direction-changing characters.
sizeArguments too large, too deeply nested, or holding too many strings to inspect.
malformedArguments 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.

LimitValue
Rules per server entry512
Conditions per rule16
Tool patterns per rule64
A pattern or regular expression512 bytes
A value4096 bytes; a list holds at most 256 entries
A rule id128 bytes

What this does not do

  • Rules see only the arguments the agent chose to send. A deny on /path does 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.json removed, 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.

answerrules
refusedK8-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 youK8-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, markedK8-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:

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