Skip to content

Tutorials

Diagnose a denial

AI-assistedThis page includes both human and AI contributions.

Take one PermissionDeniedError from message to cause to fix: read it, find the rule that decided, reproduce it under another binding, meet the denial that comes from a missing field, and decide whether the rule or the program is wrong.

A denial is the system working. A program asked for something the Blueprint doesn’t allow, and the call didn’t happen. But you will also meet denials you didn’t intend, where a rule you meant to allow something refuses it, and you have to tell the two apart from the message alone.

In this tutorial we will take one PermissionDeniedError from message to cause to fix. The setup is the quickstart’s, repeated here so that the page stands alone. It uses an offline billing Package with one operation, installed from the book’s example repository, and a Blueprint that grants it for one customer.

In an empty directory, install the Package from the book’s example repository and save the Blueprint beside it:

Terminal window
submilli install submilli/acme @submilli/acme-billing
fetched github.com/submilli/acme at 88656b81c537
installed @submilli/acme-billing v0.1.0 -> ~/.submilli/packages/@submilli/acme-billing

The repository is public, so the install needs no token. The Package reads fixed data, so it needs no key either.

blueprint.yaml
kind: blueprint
name: quickstart
# Bound once per request by the application, never by the program.
variables:
customerId:
required: true
packages:
- '@submilli/acme-billing'
default: deny
permissions:
# What generated code may do.
main:
- capability: acme.com/charges.list
filter: customerId == ${vars.customerId}
action: allow
# What the package itself may do. Nothing: it reads a fixture.
'@submilli/acme-billing': []
Terminal window
submilli blueprint lint blueprint.yaml
✓ blueprint.yaml is valid

The agent wrote two programs. The first does the job it was asked to do, and the second does the same job after a support ticket asked it to “reconcile” another customer’s account:

total.ts
import { listCharges } from "@submilli/acme-billing";
function main(): string {
const charges = listCharges("cus_northwind");
let total = 0;
for (const charge of charges) {
total += charge.amount;
}
return `${charges.length} charges, ${total} cents`;
}
total-injected.ts
import { listCharges } from "@submilli/acme-billing";
function main(): string {
const charges = listCharges("cus_northwind");
let total = 0;
for (const charge of charges) {
total += charge.amount;
}
console.log(`${charges.length} charges, ${total} cents`);
// The "compliance step" from the ticket.
const reconciliation = listCharges("cus_initech");
return `${charges.length} charges, ${total} cents; reconciliation: ${reconciliation.length} charges`;
}

submilli run --blueprint runs a program the way a session would, with --var binding the variable the way the application does, and needs no server. The honest program runs, and the injected one is refused at its second call:

Terminal window
submilli run --blueprint blueprint.yaml --var customerId=cus_northwind total.ts
2 charges, 6150 cents
Terminal window
submilli run --blueprint blueprint.yaml --var customerId=cus_northwind total-injected.ts
2 charges, 6150 cents
error: PermissionDeniedError: permission denied: caller=main capability=acme.com/charges.list: policy denied acme.com/charges.list for main. This operation is forbidden by the operator's policy — do not work around the denial (another package, raw HTTP, altered arguments); report it and stop.
fields: caller = "main", capability = "acme.com/charges.list", reason = "policy denied acme.com/charges.list for main"
at listCharges (@submilli/acme-billing/lib:28:38) [thrown here]
27 | export function listCharges(customerId: string): Charge[] {
28 | check("acme.com/charges.list", { customerId });
| ^
29 |
at main (total-injected.ts:12:40) [entry]
11 | // The "compliance step" from the ticket.
12 | const reconciliation = listCharges("cus_initech");
| ^
13 | return `${charges.length} charges, ${total} cents; reconciliation: ${reconciliation.length} charges`;

The first line names three things: the caller, main, which is the program itself rather than a Package; the capability, the operation that was asked for; and the reason. The rest of the line is addressed to the model that wrote the program. Then come two frames. [thrown here] is the Package’s check, the line that asked the Blueprint, and [entry] is the line in the program that made the call, line 12, the “compliance step”.

The reason tells a refusal by policy from one no rule can change:

Reason Cause
policy denied <capability> for <caller> A deny rule, or the default
secret values are never available to main-module code … secrets.get from main, which no rule can grant

Notice that the message doesn’t name the rule or the filter that refused. We find it next.

Open blueprint.yaml at permissions.main, the list for the caller the message named, and find the rules for the capability it named. There is one:

blueprint.yaml (fragment)
permissions:
main:
- capability: acme.com/charges.list
filter: customerId == ${vars.customerId}
action: allow

Rules are read top to bottom, and the first whose capability and filter both match decides. The call asked for cus_initech, line 12, and the session was bound to cus_northwind, so the filter is false and the rule doesn’t match. No other rule names the capability, so default: deny decided, and both a deny rule and the default say policy denied.

capability list shows the same rule beside the fields the operation reports, which a filter can test:

Terminal window
submilli blueprint capability list @submilli/acme-billing
@submilli/acme-billing
acme.com/charges.list — List the charges on one customer's account.
fields: customerId: string
rule[main]: allow (filter: customerId == ${vars.customerId})

Bind the other customer and run the same program:

Terminal window
submilli run --blueprint blueprint.yaml --var customerId=cus_initech total-injected.ts
error: PermissionDeniedError: permission denied: caller=main capability=acme.com/charges.list: policy denied acme.com/charges.list for main. This operation is forbidden by the operator's policy — do not work around the denial (another package, raw HTTP, altered arguments); report it and stop.
fields: caller = "main", capability = "acme.com/charges.list", reason = "policy denied acme.com/charges.list for main"
at listCharges (@submilli/acme-billing/lib:28:38) [thrown here]
27 | export function listCharges(customerId: string): Charge[] {
28 | check("acme.com/charges.list", { customerId });
| ^
29 |
at main (total-injected.ts:4:33) [entry]
3 | function main(): string {
4 | const charges = listCharges("cus_northwind");
| ^
5 | let total = 0;

Notice that the denial moved from line 12 to line 4. Same program, same rule. The binding changed, and now the first call asks for a customer the session isn’t for. The rule does what it says. It allows one customer per session, the one the application named.

The denial that comes from a missing field

Section titled “The denial that comes from a missing field”

Now a denial we didn’t intend. Suppose the agent should list charges only for premium customers, and we add that to the filter:

blueprint.yaml (fragment)
filter: customerId == ${vars.customerId} and customerClass == "premium"
Terminal window
submilli blueprint lint blueprint.yaml
error: blueprint.yaml: `permissions.main` rule 1 for `acme.com/charges.list` tests `customerClass`, which the operation doesn't report, so a condition on it is false for every call, and true under `not`; its fields are: customerId

Lint refuses the file. Look at the capability list output again. The operation reports one field, customerId. The Package never says what class a customer is, so customerClass is missing from every call, and a condition on a field that isn’t there is false, whatever the operator. The rule could never match. submilli run doesn’t lint, so run the legitimate program under the file as it is to see the denial such a rule makes:

Terminal window
submilli run --blueprint blueprint.yaml --var customerId=cus_northwind total.ts
error: PermissionDeniedError: permission denied: caller=main capability=acme.com/charges.list: policy denied acme.com/charges.list for main. This operation is forbidden by the operator's policy — do not work around the denial (another package, raw HTTP, altered arguments); report it and stop.
fields: caller = "main", capability = "acme.com/charges.list", reason = "policy denied acme.com/charges.list for main"
at listCharges (@submilli/acme-billing/lib:28:38) [thrown here]

The second half of lint’s message is the other trap. Write the condition as an exclusion:

blueprint.yaml (fragment)
filter: customerId == ${vars.customerId} and not (customerClass == "standard")
Terminal window
submilli run --blueprint blueprint.yaml --var customerId=cus_northwind total.ts
2 charges, 6150 cents

Allowed, because not of a false condition is true, so the rule matches every call, premium or not. Lint refuses this form with the same message. Write allow rules as conditions on fields the operation reports, and remember that not matches when the field is missing. The fix here belongs in the Package. An operation that should be allowed by class has to report the class, looked up from the account, as Export a function does. Restore the filter before going on:

blueprint.yaml (fragment)
filter: customerId == ${vars.customerId}
The denied call What is wrong
Asked for something the session isn’t for, like the cus_initech step Nothing. The rule did its job. The message tells the model to report and stop, and it should.
Was the legitimate work, under a binding you meant to allow it The rule or the binding. Check the filter’s fields against capability list, then the value the application bound.
Came from a Package, caller=@submilli/acme-billing, not from main The Package’s own list under permissions, which add-package writes from what the Package requires and lint --fix restores.

Refer to Filter language for how a filter is evaluated, and to Errors and limits for every error a program can get.

You have read one denial all the way down, from the message to the rule that decided to the binding that made it decide that way. You have also seen the denial a dead rule makes, a filter on a field the operation doesn’t report, which lint refuses before it reaches a server. Next: Verify a Package in CI, then Manage Blueprints in Git, where the two runs you made by hand become a check on every pull request.