kisenon
Agent-Safe Change Control

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" }
FieldLegal values
risk classadditive, mutating, destructive, unbounded, rewrite
actionauto_promote, require_human, block
on_conflictsignore, 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_modeEvery class defaults to
selfauto_promote
humanrequire_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 DEFINER functions.

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 diff as risk_report,
  • per-action in keon sandbox log,
  • as max_risk_class on the sandbox wire.

Classes, lowest to highest severity:

additive  <  mutating  <  rewrite  <  unbounded  <  destructive

An 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-less DELETE reads as destructive whether 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.