Guardrails
Budgets, policy tiers, scoped authority, risk lint, and blast-radius checks — what stops an agent before it hurts you.
Five guardrails sit around an AI agent sandbox. Two are hard limits the agent physically cannot exceed (LEASH, SCOPE), one turns data into a verdict (POLICY), and two produce data for it to weigh (LINT, BLAST). None puts an LLM in the trust path.
LEASH — per-sandbox budgets
Blocks: an agent that runs away — burning statements, compute, or wall-clock without bound.
Set a budget when you create or run the sandbox:
keon sandbox create \
--budget-statements 500 \
--budget-compute-seconds 120 \
--budget-wall-seconds 900
# or the same three flags on: keon sandbox run …On breach the sandbox self-terminates: status becomes discarded and
budget_breached names which limit blew — statements, compute_seconds, or
wall_clock. The CLI exits with error code sandbox_budget_breached. A warning
fires at 80% of any budget.
On the wire, budgets are a budgets object, one entry per budget:
"budgets": {
"statements": { "limit": 500, "used": 218, "warned": false },
"compute_seconds": { "limit": 120, "used": 41, "warned": false },
"wall_clock": { "used": 63 }
}An absent limit means unlimited — there is no sentinel value to
misread.
Configure defaults and caps (owner/admin):
# Per-project defaults double as the create-time cap.
curl -X PATCH .../v1/projects/{id} \
-d '{"sandbox_budget_defaults": {"statements": 500, "compute_seconds": 120}}'The default is the cap: asking at create time for more than the project
default returns 422 sandbox_budget_exceeds_cap. Per-project defaults are
configurable — there is no fixed number to quote here.
Two invariants:
- Frozen at create. Budgets are fixed when the sandbox is created; changing the project defaults later never re-leashes a live sandbox.
- Agent keys tighten only. An agent-capability key may request tighter budgets at create; it can never set the project defaults.
POLICY — promote-policy tiers
Blocks: a promote whose changes are riskier than the project allows — even one an agent (or a human) tries to wave through.
POLICY is a per-project overlay mapping a risk class to an action,
layered over the base promote_mode.
Configure (CLI):
keon projects promote-policy <projectId> # show effective policy
keon projects promote-policy <projectId> \
--set destructive=block \
--set unbounded=require_human \
--on-conflicts require_human # merge-write
keon projects promote-policy <projectId> --clear # drop the overlay--set <class=action> is repeatable and merges into the existing overlay.
Configure (API): GET / PUT / DELETE /v1/projects/{projectId}/promote-policy:
{ "version": 1,
"rules": { "destructive": "block", "unbounded": "require_human" },
"on_conflicts": "require_human" }| Field | Legal values |
|---|---|
| risk class | additive, mutating, destructive, unbounded, rewrite |
| action | auto_promote, require_human, block |
on_conflicts | ignore, require_human, block |
ignore is legal only for on_conflicts, never as a class action.
Base posture comes from promote_mode, and the overlay overrides per class:
promote_mode | Every class defaults to |
|---|---|
self | auto_promote |
human | require_human |
Each effective rule reports where it came from — source: overlay,
promote_mode, or default.
What the agent sees when blocked: a blocked promote returns
409 promote_blocked_by_policy with the decision details attached.
Key rule: a human Approve can never override a policy block. The policy
is re-evaluated at approve time, so block holds against the click. Policy
validation is fail-closed / strict — an unparseable policy denies, it never
degrades to open.
SCOPE — scoped authority (lanes)
Blocks: any read or write outside the schemas and tables you declared — the agent's lane.
Configure (create-time only):
keon sandbox create \
--schemas app,analytics \
--tables app.users,app.orders
# omit both flags → unscoped (full-branch authority)--tables without --schemas is rejected client-side as invalid_lane.
Inside the fork the agent physically cannot write outside the lane — an
out-of-lane write fails in-fork with SQLSTATE 42501 (insufficient privilege)
or 3F000 (invalid schema name). Out-of-lane reads are denied too. And
promote refuses any out-of-lane change with 409 sandbox_out_of_lane.
On the wire the sandbox carries a scope object (absent = unscoped):
"scope": { "v": 1, "schemas": ["app", "analytics"], "tables": ["app.users", "app.orders"] }keon sandbox diff gains a lane object listing any violations[], each with
a reason — out_of_lane_schema, out_of_lane_relation, or
unresolvable_targets.
Two limits:
- Immutable after create. Widening a lane means discard + recreate — there is no widen-in-place.
- Direct targets only. A lane does not govern transitive side effects
reached through triggers or
SECURITY DEFINERfunctions.
LINT — static risk classification
Produces: a static risk class for every statement in the replay set — no execution, no LLM, no row counts. Purely textual.
There is no new command. LINT surfaces in three places you already read:
- inside
keon sandbox diffasrisk_report, - per-action in
keon sandbox log, - as
max_risk_classon the sandbox wire.
Classes, lowest to highest severity:
additive < mutating < rewrite < unbounded < destructiveAn empty replay set classifies as none.
Report shape:
{ "statements": [
{ "seq": 4, "risk_class": "unbounded",
"reasons": ["where_trivially_true: DELETE guarded by WHERE 1=1 matches every row"] }
],
"rollup": { "max_class": "unbounded", "counts": { "additive": 2, "unbounded": 1 } } }Each reason is <snake_case_code>: <sentence>. For example, WHERE true or
WHERE 1=1 classifies the statement as unbounded with reason
where_trivially_true.
Two things to remember:
- LINT is data, not a decision. It classifies; POLICY enforces. LINT never blocks anything on its own.
- It fails up. Under uncertainty LINT assigns the worst plausible class.
Because detection is textual, actual row counts are not an input — a
WHERE-lessDELETEreads asdestructivewhether the table holds one row or a billion.
BLAST — blast-radius dry-run
Produces: measured evidence of what a promote would actually do — real lock levels, real lock-hold durations, real row counts, and any divergence since the fork.
keon sandbox dry-run <id>It replays the sandbox's exact statement set against two throwaway forks of
the parent, measures the real cost, detects divergence, then destroys both. It
never touches main, and the agent never receives a fork credential.
BLAST is advisory — it never blocks a promote; conflicts are surfaced as
data.
Full field reference, conflict semantics, and the endpoint list live on Sandboxes & promote → Blast-radius dry-run.
How they fit together
- LINT and BLAST produce data — one static, one measured.
- POLICY turns that data into a decision, fail-closed, unoverridable by a
human click when it says
block. - LEASH and SCOPE are hard limits the agent cannot exceed at all — enforced in the fork, frozen at create.
See Common pitfalls for the ways these five are routinely misread.
Sandboxes & promote
Point an AI coding agent at your production database safely — captured, observed, promoted through a gate, and reversible.
Masked forks
Give an agent (or a human) prod's shape and none of its PII — masking policies, the built-in function library, and how masked branches and sandboxes stay sealed until the mask commits.