okay-guard Open the studio

The policy language

A policy says what an AI agent may do with its services: what goes ahead, what waits for a person, and what is never done. okay-guard checks every call against it before the call reaches the service.

The language is small. It has declarations of what can be known, and rules about what is allowed. It is typed: a misspelt argument or a comparison of a text with a number is an error in the editor, before anything runs. Every decision is explained in the journal, and a decision by a rule is a theorem with a certificate anyone can check again.

All the examples below are real files, checked by okay-guard with their tests.

A first policy

An agent handles refunds. It may look at charges and make small refunds. A person decides on a big refund. It never deletes a customer.

first.policypolicy refunds

type User

call list_charges()
call create_refund(charge: Text, amount: Int)
call delete_customer(customer: Text)

allow list_charges

allow create_refund
  when amount <= 20000

ask create_refund
  when amount > 20000 and amount <= 100000
  because "more than 200 €: you decide"

deny delete_customer
  because "nothing is deleted from here"

In the studio the policy is in Rules. On your machine it is a file, refunds.policy, with its tests in refunds.tests: okay-guard check refunds.policy runs them.

How a call is decided

For each call, okay-guard takes the rules whose target matches the call and checks their conditions. Then:

  1. If a deny applies, the call is denied.
  2. Otherwise, if an ask applies, a person is asked.
  3. Otherwise, if an allow applies, the call is allowed.
  4. Otherwise the policy's default decides: default allow, default ask or default deny. A policy with no default denies.

The order of the rules in the text does not matter. A deny always wins, and an ask wins over an allow. So a broad allow and a narrow ask go together: allow github.* lets the agent use GitHub, and ask github.create_pull_request still stops a pull request for a person.

Targets: a tool, a service, every call

A rule's target is one tool (create_refund), every tool of a server (github.*), or every call (*). A tool written with its server's name, github.get_file, is that server's tool only: one policy can hold rules for several servers.

servers.policypolicy servers

type User

call github.get_file(repo: Text, path: Text)
call github.create_pull_request(repo: Text, title: Text)
call github.delete_repo(repo: Text)
call fs.read(path: Text)
call fs.write(path: Text)

-- GitHub for our organisation; a pull request for a person; a repository never deleted
allow github.*
  when repo starts "my-org/"

ask github.create_pull_request
  because "a pull request: you decide"

deny github.delete_repo
  because "never"

allow fs.read
allow fs.write
  when path starts "/workspace/" and not (path ends ".env")

Deny everything, or allow everything

With no default, a policy denies what no rule allows: list what may be done. For the other way round, say default allow and list what may not. A deny still wins and an ask still asks.

A tool the policy never declares, such as a server's new tool, is decided by the rules for every call (*, with tool its name) and by the default. Its arguments are not read, so a condition on them does not hold: declare the tool with call to decide on its arguments.

everything.policypolicy everything

type User

call read_file(path: Text)
call delete_file(path: Text)

-- what no rule decides is allowed; a deny still wins, an ask still asks
default allow

ask read_file
  when path starts "/secrets/"
  because "a secret: you decide"

deny * when tool starts "delete_"
  because "nothing is deleted"

REST APIs

An agent can call a REST API through okay-guard: add it in Tools by its OpenAPI. Its operations become the agent's tools, named by their operationId, and the policy decides each call before it is made.

petstore.policy-- an agent at a REST API: Swagger's pet store, added in the studio by its OpenAPI (Servers to try: petstore);
-- its operations are the agent's tools, named by the server: petstore.findPetsByStatus, petstore.addPet, …
policy petstore

type User

call petstore.addPet(name: Text)
call petstore.deletePet(petId: Int)

-- reading goes ahead: no rule for it
default allow

ask petstore.addPet
  because "a new pet: you decide"

deny petstore.deletePet
  because "no pet is deleted from here"

Writing rules

Types

typewhat it is
Intwhole numbers: amounts in cents, counts
Decexact decimals, for money: 500.00
Texttext: "eur"
Booltrue or false; a condition by itself: dry_run, not force
User, Accountan entity type the policy declares; its values are names
type Item = { sku: Text, qty: Int }a record, for an argument that is a JSON object
List[Item]a list, for an argument that is a JSON array

An optional argument is declared with ?: coupon?: Text.

Conditions

conditionwhat it says
amount <= 20000a comparison: = != < <= > >=
a and b, a or b, not acombined
currency in ["eur", "usd"]one of a list
path starts "/workspace/"texts: starts, ends, contains
amount + fee <= 100sums; a product by a number only
has(coupon)an optional argument is there
shipping.countrya record's field
count(items) <= 20a list's length
every i in items: (i.qty <= 10)each element of a list; some for one of them

A missing argument makes the condition that uses it fail, so a missing argument can deny or ask but never allow.

shop.policypolicy shop

type User
type Item = { sku: Text, qty: Int, price: Dec, note?: Text }
type Address = { country: Text, zip?: Text }

call shop.order(items: List[Item], shipping: Address, coupon?: Text, dry_run: Bool)

allow shop.order
  when dry_run

allow shop.order
  when count(items) <= 20
   and every i in items: (i.qty <= 10 and i.price <= 500.00)
   and shipping.country in ["DE", "PL"]
   and (not has(coupon) or coupon starts "VIP")

Facts and state

A state is a value okay-guard keeps between calls, per minute, hour or day. A rule changes it with then, only when the call goes ahead. A count rule decides nothing: it only changes state for every call that goes ahead.

The state is kept where the studio is: in your browser's storage on the site, in the workspace's state/ on your machine (state/<agent>.json; state/outside.json for an agent on stdio). A page reloaded or the guard started again keeps the day's sums and the minute's counts; a window that has passed starts again from the initial value. In the studio, Rules → Its state shows each value and its window; Reset empties it.

budget.policypolicy budget

type User

state refunded(user: User): Int per day = 0
state calls(user: User): Int per minute = 0

call create_refund(charge: Text, amount: Int)
call list_charges()

deny *
  when calls(caller) >= 30
  because "too many calls: wait a minute"

allow list_charges

allow create_refund
  when amount > 0 and refunded(caller) + amount <= 20000
  then refunded(caller) += amount

ask create_refund
  when amount > 0 and amount <= 100000 and refunded(caller) + amount > 20000
  because "over 200 € refunded today: you decide"

count * then calls(caller) += 1

A fact is something the policy needs that is not in the call. from mcp makes okay-guard ask a tool of the agent's server itself, before it decides; at is the path to the value in the answer. A fact that cannot be found is unknown, and an unknown fact never allows a call.

facts.policypolicy facts

type User

fact available(u: User): Int from mcp payments.get_balance() at "available"

call payments.get_balance()
call payments.create_refund(charge: Text, amount: Int)

allow payments.get_balance

deny payments.create_refund
  when amount > available(caller)
  because "more than the balance"

allow payments.create_refund
  when amount > 0

Webhooks

A webhook is an address okay-guard sends a message to when something happens: a Slack channel, your security system, your own service.

alerts.policypolicy alerts

type User

-- where the alerts go: an address, or the variable that holds it (Slack's address is a secret)
webhook security = env "SECURITY_WEBHOOK"
webhook ops = env "OPS_WEBHOOK"

call create_refund(charge: Text, amount: Int)
call delete_customer(customer: Text)

default allow

ask create_refund
  when amount > 20000
  because "more than 200 €: you decide"

-- on the rule itself: whenever it decides
deny delete_customer
  because "nothing is deleted from here"
  notify security

-- by criteria: an outcome, a target, a condition
notify ops on refused create_refund
notify ops on timed out create_refund
notify security on ask create_refund
  when amount > 100000

Each webhook gets the call's record from the journal as JSON, with a line of text on top.

Tests

A policy's tests are a file of their own, beside it. Each test is a call and the decision it must get; given sets facts and state.

first.teststests for refunds

test "a small refund goes ahead"
  when agent calls create_refund(charge: "ch_1", amount: 4200)
  expect allow

test "a big one waits for a person"
  when agent calls create_refund(charge: "ch_2", amount: 30000)
  expect ask "more than 200 €: you decide"

test "a huge one is denied: no rule allows it"
  when agent calls create_refund(charge: "ch_3", amount: 500000)
  expect deny

test "nothing is deleted"
  when agent calls delete_customer(customer: "cus_9")
  expect deny "nothing is deleted from here"

In the studio, Tests makes them of your agent's calls and runs them at every change of the policy. Replay the journal decides every recorded call again by the policy as it is now. On your machine:

okay-guard check refunds.policy                         # the tests; exit 1 if one fails
okay-guard journal replay --policy refunds.policy       # the journal decided again; exit 1 if anything changes

Errors

A policy that does not compile is not used, and the error says where:

8:8: an unknown name amout
11:15: Text > Int: different sorts

Reference

policy    ::= "policy" name decl*
decl      ::= type | record | fact | state | call | rule | count | default | webhook | notify
type      ::= "type" Name
record    ::= "type" Name "=" "{" field ("," field)* "}"
fact      ::= "fact" name "(" params ")" (":" sort)? ("from" source)?
source    ::= name | "mcp" [server "."] tool "(" args ")" "at" text
state     ::= "state" name "(" params ")" ":" sort ("per" ("minute" | "hour" | "day"))? "=" literal
call      ::= "call" [server "."] tool "(" params ")"
param     ::= name "?"? ":" sort
rule      ::= ("allow" | "ask" | "deny") target ("when" cond)? ("then" update ("," update)*)? ("because" text)?
              ("notify" name ("," name)*)?
count     ::= "count" target ("when" cond)? "then" update ("," update)*
default   ::= "default" ("allow" | "ask" | "deny")
webhook   ::= "webhook" name "=" (text | "env" text)
notify    ::= "notify" name ("," name)* "on" outcome target ("when" cond)?
target    ::= tool | server "." tool | server ".*" | "*"
cond      ::= cond "and" cond | cond "or" cond | "not" cond | "(" cond ")" | atom
atom      ::= expr cmp expr | expr "in" "[" exprs "]" | expr ("starts" | "ends" | "contains") expr
            | "has" "(" path ")" | ("every" | "some") name "in" path ":" cond | name "(" exprs ")" | path
expr      ::= expr ("+" | "-") expr | expr "*" literal | "(" expr ")" | literal | path | name "(" exprs ")"
update    ::= name "(" exprs ")" ("=" | "+=" | "-=") expr
sort      ::= "Int" | "Dec" | "Text" | "Bool" | Name | "List" "[" sort "]"

tests     ::= "tests" "for" name test*
test      ::= "test" text ("given" given ("," given)*)? "when" name "calls" tool "(" args ")"
              "expect" ("allow" | "ask" | "deny") text?

Names of types start with a capital letter; names of facts, states and arguments with a small one. The variables of a rule are caller, tool and the call's arguments. A tool whose name is not a plain word is written in quotes: call "list-files"(path: Text). -- starts a comment.

The language is okay-proof's okay-policy: its full specification, the logic underneath and the kernel that checks every decision are there.