DEV Community

Laurels Ozy Echichinwo
Laurels Ozy Echichinwo

Posted on

Least privilege, checked by a compiler

TL;DR: In Staal, code can't send a message, write a file, call an API or move money unless it holds a capability for that action. Capabilities come only from the host app, can only be narrowed, are checked before every action, and are never handed to a model. Below: how that works, real compiler errors, a real run's log, and a benchmark against plain Python.

Nine seconds

On April 24, 2026, an AI coding agent deleted a company's production database in nine seconds.

It was doing routine maintenance in staging for PocketOS, which builds software for car rental businesses. It hit a credential mismatch, searched the filesystem, and found an API token in an unrelated file. The token had been created to manage custom domains, but it was root-scoped: it could perform any operation the hosting provider's API offered. One call later, the production volume was gone, along with every backup stored inside it.

The model made a bad call, but that isn't the interesting part. The task needed to fix a configuration in staging. The token could delete production. Nothing between the agent and the API knew what the task was, so nothing could say that's too much power for this.

It's a pattern. In July, researchers showed that an instruction left in a public GitHub issue could steer an agentic workflow into leaking private repository data. In Gravitee's survey of 750 organisations this year, excessive permissions was the most consistently reported failure: agents with more access than their task needs, often through shared service accounts and inherited credentials.

Models will keep being wrong, and keep being talked into things. What decides the damage is how much the agent was able to do.

The payment networks already know the answer

Where agents touch money, the industry has stopped handing them general-purpose credentials. Stripe's agentic commerce tokens are scoped to one merchant, one session and one amount range, and usable once. Mastercard's Agent Pay makes an agent present a signed record of what it was asked to do, and the network stops a transaction that goes beyond it.

That's capability security, an idea that's decades old: instead of asking who are you and what may you do?, hand out unforgeable tokens that each carry one bounded power. The payment networks are building it into their rails one network at a time. Staal builds it into the language, for every action an agent's code takes.

Authority as a value

A capability is part of a function's type, and the type says how far it reaches:

`fn remind(patients: List<Patient, 50>, texts: cap sms.send(uses: 50, only: ["234*"])) -> Int
fn archive(report: Report, files: cap fs.write(only: ["/reports/*"])) -> Receipt
fn open_doors(lock: cap building.lock.open(uses: 2, only: ["north-door", "loading-bay"])) -> Done
fn refund(r: Refund, pay: cap payments.refund(max: 500 USD, each: 50 USD)) -> Receipt`
Enter fullscreen mode Exit fullscreen mode

The first can send at most 50 texts, only to Nigerian numbers. The second can write only under /reports. The third can open the north door and the loading bay, at most twice in all. The fourth can refund at most 50 USD at a time and 500 USD in total.

Limit What it bounds
only which targets an action may touch: exact text, or a pattern with one *
uses how many actions may be attempted, failed ones included
each the most money one action may move
max the most money moved in total over the run

The names after cap aren't built in. sms.send, building.lock.open and payments.refund are paths chosen by whoever deploys the program, and the rules treat them all alike. You can read a signature like these in code review and know the most the function can ever do before you read its body.

Only the app creates authority

Nothing inside a Staal program can create a capability: no function, no library, no model. They come from the host app that starts the run, which is ordinary Python or Node.js and the only place that knows which customer this is and what they're entitled to:

python

import staal

lib = staal.Library()
TIER_LIMIT = {"gold": 500, "standard": 50}  # USD per refund, decided by your app

def handle(customer, order, complaint, model):
    limit = min(TIER_LIMIT[customer["tier"]], order["total"])
    return lib.run("refund.staal", {
        "entry": "main",
        "inputs": {"complaint": complaint},
        "grants": {"pay": {"cap": "payments.refund",
                            "each": {"amount": str(limit), "currency": "USD"},
                            "only": [order["payment_id"]]}},
        "model": model,
    })

Enter fullscreen mode Exit fullscreen mode

The app decides; Staal enforces, and so does the code's own signature. If the program declares each: 500 USD and the app grants 800 by mistake, the run gets 500. A grant can narrow what the code accepts, never widen it.

The config is validated before anything runs: a missing grant or an unknown key means nothing starts. And capabilities, receipts and outcomes are never accepted as inputs at any depth, so neither input data nor a model can forge one.

That's the answer to the token in the unrelated file. A token found lying around is only text. Acting with it still needs a capability for that API, and only the app can grant one.

Narrowed, never widened

A capability can be passed on, and every time it's passed on it can only shrink:

text

type Patient { name: Text, phone: Text }

fn remind(patients: List<Patient, 50>, texts: cap sms.send(uses: 50, only: ["234*"])) -> Int {
    let sent = 0
    for p in patients {
        let r = try notify(p, texts)
        if r.ok {
            set sent = sent + 1
        }
    }
    return sent
}

fn notify(p: Patient, texts: cap sms.send(uses: 1, only: ["234*"])) -> Receipt {
    return texts.send(p.phone, "Hello " + p.name + ", see you tomorrow.")
}
Enter fullscreen mode Exit fullscreen mode

Each call to notify gets a child capability with one use. Everything the child does counts against its parent, and the parent's parent, up to the grant. That's delegation in a multi-agent system, checked by a compiler.

Going the other way doesn't compile:

text

widen.staal:4:21: error[E003]: in call to 'blast': capability limited to 1 uses cannot be widened to 100 uses
Enter fullscreen mode Exit fullscreen mode

And neither does acting without one:

text

nocap.staal:2:12: error[E002]: 'files.write' acts on the world but 'files' is not a capability in scope

Enter fullscreen mode Exit fullscreen mode

Both come from staalc check, before anything runs. Widening covers every limit: a higher max, each or uses, a dropped limit, or an only pattern that reaches outside the original. The pattern check is deliberately conservative.

Checked before every action

The compiler proves the program never asks for more than it holds. The runtime makes sure it never uses more either.

Every capability lives in a broker, and a program only ever holds a handle. When the program acts, the broker checks the action against that capability and every capability it was narrowed from, before the service is called. Here's the reminder program above, granted two uses for this run, over four patients. The third has a UK number:

json

{"entry": "remind",
 "inputs": {"patients": [
   {"name": "Ada",   "phone": "2348012345678"},
   {"name": "Tunde", "phone": "2348098765432"},
   {"name": "Mary",  "phone": "447700900123"},
   {"name": "Chidi", "phone": "2348055555555"}]},
 "grants": {"texts": {"cap": "sms.send", "uses": 2}}}
Enter fullscreen mode Exit fullscreen mode

The run returns 2. Its log, condensed:

action_performed  sms.send 2348012345678
action_performed  sms.send 2348098765432
action_refused    'sms.send' may only act on ["234*"]; this action targets "447700900123"
action_refused    'sms.send' has used all 2 of its allowed uses
Enter fullscreen mode Exit fullscreen mode

The UK number hit the limit the code declared. The fourth patient hit the limit the app granted. Neither request reached the SMS service.

Three details that matter:

  1. Uses count attempts: A use is spent when the broker approves the action, so a failed request still costs one and a loop can't retry its way past a limit.
  2. Limits fail closed: A limit that can't determine the amount or target refuses the action.
  3. Money is charged even on failure when a request may have moved it, so failures can't be used to spend past max.

Models propose, programs act

A model never holds a capability. propose keeps the instruction (which must be trusted) apart from the data (which may be anything), and the answer comes back as Proposed, which can't reach an action until validate checks it against T. This gets both rules wrong:

fn handle(ticket: Text, pay: cap payments.refund(each: 50 USD)) -> Text budget(calls: 1, tokens: 500) {
    let d = propose(Decision, instruct: ticket, data: [pay], tokens: 500)
    return "ok"
}
Enter fullscreen mode Exit fullscreen mode
model.staal:3:41: error[E006]: untrusted value used as a model instruction; pass it in `data: [...]` instead, or mark its source @trusted
model.staal:3:56: error[E101]: a capability cannot be sent to a model as data
Enter fullscreen mode Exit fullscreen mode

Everything that comes back from the world is untrusted by type: HTTP bodies, tool results, model output (even after validation). So an injected instruction can change what the model asks for, but the proposal still has to pass validation, and the action still has to pass the broker, which holds a limit the model can't see or reach.

Keys out of reach

Real services need keys, URLs and HTTP access. None of those belong to the program that wants to act. They belong to a library that declares a capability:

capability termii.sms {
    needs http: cap net.http.post
    needs key: Secret
    needs base: Text
    needs sender: Text

    canonical(phone: Text) -> Text {
        return number(phone)
    }

    action send(to: Text @target, text: Text) -> Text {
        ...   // one request through `http`
    }
}
Enter fullscreen mode Exit fullscreen mode

The host grants the library its key (by environment variable name, so it never lands in a config, journal or log), its URL, and an HTTP capability narrowed to the one endpoint. The program asks for cap termii.sms.send(uses: 50) and holds the right to send texts, nothing else. only is matched against the library's canonical form, so +234 801 234 5678 and 2348012345678 are the same number to the broker.

Secrets never become text. A Secret can't be printed, interpolated or encoded, even inside the library. When a benchmark edit tried to log request headers:

error[E101]: `json` cannot encode a Header: secrets never become text
Why it holds as code changes
Enter fullscreen mode Exit fullscreen mode

Any careful team can build least privilege into an agent on the day it ships. The hard part is keeping it. So I built the same support agent twice, in Python alone and in Python with Staal, with the same limits, permissions, retries and crash recovery. Both passed all 47 scenarios: prompt injection, malformed model output, permission escalation, failing services, and the process killed before and after every external call. Python took 312 lines; Staal, 159.

Then I made six changes to each, the kind teams make months later. In Python, "one refund per job" lived in a policy object, and the refactor built a fresh one per step, silently resetting its counter. In Staal, the refactor passed the capability to a helper, the broker counted the use against the original grant, and the second refund was refused. The limit was never in the code that changed.

In ordinary code, least privilege is a convention every future change has to respect. In Staal, it's a property the compiler and the broker keep, whoever edits the code next.

Try it

Your app stays in Python or Node.js. Only the part that acts is Staal:

bash

pip install staal
npm install @staal/node
Enter fullscreen mode Exit fullscreen mode

Packages are built and tested for Linux (x86_64, arm64) and macOS (Apple Silicon, Intel). Apache-2.0 with the LLVM exception.

Repo and quickstart: github.com/laurells/staal
Benchmark: github.com/laurells/staal/bench/agent
Runtime spec: github.com/laurells/staal/docs/RUNTIME.md

If you're building agents that touch money, messages or records, I'd like to hear where this would and wouldn't fit. Comments are open.

Sources: Giskard on the PocketOS deletion · Curity: 5 agentic AI security incidents · Gravitee: State of AI Agent Security 2026 · ATXP: agent payment protocols compared

Top comments (0)