Skip to main content

Gate a Dangerous Tool with Guardrails

A guardrail answers a question IAM cannot: not "may this caller reach this endpoint?" but "may this specific action, with these arguments run on its own — or must a human sign off?". It classifies every gated tool call into an action class — A (always execute), B (execute only if a guard passes), C (human sign-off), D (forbidden) — using deterministic JSON Logic expressions with no LLM anywhere in the evaluation path.

You will build one guardrail over a budget-update tool and watch all three of its outcomes:

  1. Create a project and the tool the guardrail gates.
  2. Write a guardrail: class B below a threshold, class C at or above it, with a guard on the amount.
  3. Dry-run all three decisions before attaching anything.
  4. Attach the guardrail to the tool and drive it from an orchestration tool node.
  5. See a passing class B execute autonomously, a class C park the run for sign-off, and a failing guard tripwire into a routable hard stop.
  6. Read the governance trail the platform wrote — approvals, exceptions, and the audit log.
  7. Raise the floor for the whole project with a second guardrail, and see stricter-wins composition tighten a call that used to be autonomous.

Everything here is deterministic — no AI provider is required. The gate is driven from an orchestration tool node, which is evaluated exactly like an agent tool call but with no model in the loop.

Prerequisites

  • SOAT running locally. Follow the Quick Start guide to bring the stack up with Docker Compose.
  • New to SOAT? Read Key Concepts to understand projects, tools, and runs first.
  • CLI installed and configured, or SDK set up. See CLI or SDK.
  • For production hardening (secrets, env vars), see Configuration.
  • Server is at http://localhost:5047.
export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin

Admin is the built-in superuser role. See Users for authentication details.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

Step 2 — Create a project

Every resource lives inside a project. The project is also the broadest guardrail attach scope — you will use it in Step 12.

PROJECT_ID=$(soat create-project --name "Guardrails Demo" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"

Step 3 — Create the tool the guardrail gates

The gate sits at the tool-execution boundary, so the guardrail needs a Tool to govern. Create a read-only SOAT tool named update-budget so the tutorial needs no external services — in a real system this would be the tool that actually moves money.

BUDGET_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "update-budget" \
--type "soat" \
--description "Stand-in for the sensitive action the guardrail gates" \
--actions '["get-project"]' \
--preset-parameters '{"project_id": "'"$PROJECT_ID"'"}' | jq -r '.id')
echo "BUDGET_TOOL_ID: $BUDGET_TOOL_ID"

Step 4 — Write the guardrail

A guardrail document has three parts:

  • class — a literal (A/B/C/D) or a single JSON Logic expression returning one. Here: B below 500, C at or above.
  • guard — a single JSON Logic expression that must be truthy for a class-B call to execute autonomously. Here: the amount must be under 200.
  • default_class — applied when the class expression returns anything invalid. It defaults to C, so anything nobody classified needs a human. That fail-closed default is the whole point: a misconfigured document never grants autonomy.

Between them, the three amounts 150, 900, and 450 exercise every outcome the guardrail can produce.

GUARDRAIL_ID=$(soat create-guardrail \
--project-id "$PROJECT_ID" \
--name "Budget Update Guardrail" \
--description "Autonomous under 200, gated under 500, sign-off above" \
--document '{
"default_class": "C",
"class": { "if": [{ "<": [{ "var": "args.amount" }, 500] }, "B", "C"] },
"guard": { "<": [{ "var": "args.amount" }, 200] }
}' | jq -r '.id')
echo "GUARDRAIL_ID: $GUARDRAIL_ID"

The document is validated on write: every var must resolve to the args.*, context.*, or soat.* namespaces, and an out-of-catalog soat.* key is rejected with 400 rather than silently reading null at runtime.


Step 5 — Dry-run every decision before attaching

Attaching a default_class: C guardrail to a busy tool without rehearsing it floods the approvals queue. Dry-run evaluation runs the real pipeline — the class expression, the guard, live soat.* resolution — against arguments you supply and returns the exact evaluation record a real call would produce. Nothing executes, no approval is filed, no activity is written.

soat evaluate-guardrail --guardrail-id "$GUARDRAIL_ID" --tool-id "$BUDGET_TOOL_ID" \
--args '{"amount": 150}' | jq '{class, decision, guard_result, context_snapshot}'

Expected output — under both thresholds, so it runs on its own:

{
"class": "B",
"decision": "execute",
"guard_result": true,
"context_snapshot": {
"args.amount": 150
}
}

Now the other two amounts:

soat evaluate-guardrail --guardrail-id "$GUARDRAIL_ID" --tool-id "$BUDGET_TOOL_ID" \
--args '{"amount": 900}' | jq '{class, decision, guard_result}'
soat evaluate-guardrail --guardrail-id "$GUARDRAIL_ID" --tool-id "$BUDGET_TOOL_ID" \
--args '{"amount": 450}' | jq '{class, decision, guard_result}'

900 classifies C (decision: "route_to_approval", guard_result: null — the guard is not consulted for a class-C call). 450 classifies B but fails the < 200 guard, so decision: "tripwire".

warning

JSON Logic coerces an absent var to a zero-ish value, so { "<": [{ "var": "args.amount" }, 500] } is true when amount is missing entirely — a call with no amount takes the permissive branch. When a missing argument must not reach it, test presence explicitly: { "and": [{ "var": "args.amount" }, { "<": [{ "var": "args.amount" }, 500] }] }. See Missing keys and comparisons.


Step 6 — Attach the guardrail to the tool

A guardrail governs nothing until it is attached. Attaching at the tool scope means this tool carries its own gate wherever it is used — binding it to a new agent can never silently escape classification. guardrail_ids is a list, so several guardrails can compose on one tool.

soat update-tool --tool-id "$BUDGET_TOOL_ID" \
--guardrail-ids "$GUARDRAIL_ID" | jq '{id, name, guardrail_ids}'

Attach is cheap, detach is gated: adding an id needs only tools:UpdateTool, because it can only tighten the outcome. Removing one additionally requires guardrails:DetachGuardrail — see Step 13.


Step 7 — Drive the tool from an orchestration

An orchestration tool node is gated at dispatch exactly like an agent tool call, minus the model — which makes it the deterministic way to watch a guardrail work. With no agent in scope it composes the project + tool scopes only.

The apply node feeds the run's amount into the tool call, and that value becomes the guardrail's args.amount. Three edges cover the outcomes: the unlabeled edge is the success path, and the blocked / tripwire edges catch a guardrail refusal — a refusal is a routable outcome, not a run failure, so an unlabeled success edge does not auto-follow it.

ORCH_NODES='[
{"id":"apply","type":"tool","tool_id":"'"$BUDGET_TOOL_ID"'","operation_id":"get-project","input_mapping":{"amount":{"var":"input.amount"}}},
{"id":"done","type":"transform","expression":"Budget updated.","state_mapping":{"state.outcome":{"var":"output.result"}}},
{"id":"halted","type":"transform","expression":"Stopped by a guardrail.","state_mapping":{"state.outcome":{"var":"output.result"}}}
]'

ORCH_EDGES='[
{"from":"apply","to":"done"},
{"from":"apply","to":"halted","condition":"blocked"},
{"from":"apply","to":"halted","condition":"tripwire"}
]'

ORCHESTRATION_ID=$(soat create-orchestration \
--project-id "$PROJECT_ID" \
--name "Budget Update Pipeline" \
--description "Applies a budget change through the gated tool" \
--nodes "$ORCH_NODES" \
--edges "$ORCH_EDGES" | jq -r '.id')
echo "ORCHESTRATION_ID: $ORCHESTRATION_ID"

Step 8 — Class B with a passing guard: the call just runs

Start a run with amount: 150. The guardrail classifies B, the guard passes, and the tool dispatches with no human involved. This is the case that matters most in production — a guardrail's job is to stay out of the way until it shouldn't.

RUN1=$(soat start-orchestration-run --orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":150}' --wait true)
RUN1_ID=$(printf '%s\n' "$RUN1" | jq -r '.id')
printf '%s\n' "$RUN1" | jq '{status, required_action}'
soat get-orchestration-run --run-id "$RUN1_ID" | jq '{outcome: .state.outcome}'

Expected output:

{ "status": "succeeded", "required_action": null }
{ "outcome": "Budget updated." }

Step 9 — Class C: the run parks for sign-off

Now amount: 900. The guardrail classifies C, so the tool is not dispatched: the run parks as awaiting_input and files an approval item carrying the frozen arguments.

RUN2=$(soat start-orchestration-run --orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":900}' --wait true)
RUN2_ID=$(printf '%s\n' "$RUN2" | jq -r '.id')
APPROVAL_ID=$(printf '%s\n' "$RUN2" | jq -r '.required_action.approval_id')
printf '%s\n' "$RUN2" | jq '{status, required_action}'

Expected output:

{
"status": "awaiting_input",
"required_action": {
"type": "approval",
"node_id": "apply",
"prompt": "Approval required for tool call.",
"context": { "amount": 900 },
"approval_id": "apr_...",
"expires_at": "..."
}
}

The item records exactly which guardrail — and which version of it — sent the call here:

soat get-approval --approval-id "$APPROVAL_ID" \
| jq '{status, origin, run_id, node_id, proposed_action, policy_version}'
{
"status": "pending",
"origin": "node",
"run_id": "orch_run_...",
"node_id": "apply",
"proposed_action": { "tool_id": "tool_...", "arguments": { "amount": 900 } },
"policy_version": "guard_...@1"
}

Approve it, and the node re-dispatches the tool with the frozen arguments:

soat approve-approval --approval-id "$APPROVAL_ID" | jq '{status, resolved_by}'
soat get-orchestration-run --run-id "$RUN2_ID" | jq '{status, outcome: .state.outcome}'
{ "status": "succeeded", "outcome": "Budget updated." }

On approval the tool is re-dispatched with the frozen (or edited) arguments and the guardrail is not re-evaluated — the human decision is final for that call. Rejection or expiry means the tool never runs at all, and only a matching rejected / expired edge follows.


Step 10 — A failing guard: the tripwire

amount: 450 classifies B — but fails the < 200 guard. By default a failing class-B guard is a tripwire: it aborts the action outright rather than quietly downgrading it. That is what stops a runaway loop with a hard, non-LLM decision.

RUN3=$(soat start-orchestration-run --orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":450}' --wait true)
RUN3_ID=$(printf '%s\n' "$RUN3" | jq -r '.id')
soat get-orchestration-run --run-id "$RUN3_ID" \
| jq '{status, outcome: .state.outcome, refusal: .artifacts.apply}'

Expected output — the run succeeds down the tripwire edge; the refusal is data, not a crash:

{
"status": "succeeded",
"outcome": "Stopped by a guardrail.",
"refusal": {
"status": "tripwire",
"reason": "A guardrail tripwire fired: a class-B guard failed and the action was aborted."
}
}

A tripwire also files an exception so the abort lands in a triage queue instead of a log line:

soat list-exceptions --project-id "$PROJECT_ID" --kind guardrail_tripwire \
| jq '.data[0] | {kind, severity, status, title, occurrence_count}'
{
"kind": "guardrail_tripwire",
"severity": "warning",
"status": "open",
"title": "Guardrail tripwire aborted update-budget",
"occurrence_count": 1
}
tip

Add "escalate": true to the document to soften this: a failing guard then routes to the approvals queue for a human decision instead of aborting. escalate is per-guardrail, and a tripwire from another applying guardrail still wins.


Step 11 — Read the governance trail

Every evaluation writes a guardrail_evaluation record. Those that changed the call's outcomeroute_to_approval, blocked, tripwire, but not a plain execute — are also mirrored into the audit log as platform-originated entries, so governance decisions are queryable on the same substrate as "who deleted that secret".

soat list-audit-entries --project-id "$PROJECT_ID" --action "guardrails:Evaluate" \
| jq '[.data[] | {resource_srn, class: .detail.class, decision: .detail.decision, approval_id: .detail.approval_id}]'

Expected output — the class-C route and the tripwire are recorded; the autonomous class-B execute from Step 8 is not (it is high-volume operational telemetry, kept only in the guardrail's own evaluation records):

[
{
"resource_srn": "soat:proj_...:guardrail:guard_...",
"class": "B",
"decision": "tripwire",
"approval_id": null
},
{
"resource_srn": "soat:proj_...:guardrail:guard_...",
"class": "C",
"decision": "route_to_approval",
"approval_id": "apr_..."
}
]

Each entry's detail also carries the context_snapshot — a flat map of only the vars the evaluation actually referenced, frozen at their evaluation-time values. It is the only way to answer "why did this pass?" after the application's context has moved on.


Step 12 — Raise the floor for the whole project

There is no override resource. To run a stricter posture, attach a tighter guardrail at the project scope — an always-C document forces sign-off on every tool call in the project.

Because composition is stricter-wins, this can only tighten: the amount: 150 call that executed autonomously in Step 8 now parks.

BASELINE_ID=$(soat create-guardrail \
--project-id "$PROJECT_ID" \
--name "Sign-off Baseline" \
--document '{"class": "C"}' | jq -r '.id')

soat update-project --project-id "$PROJECT_ID" \
--guardrail-ids "$BASELINE_ID" | jq '{guardrail_ids}'

RUN4=$(soat start-orchestration-run --orchestration-id "$ORCHESTRATION_ID" \
--input '{"amount":150}' --wait true)
printf '%s\n' "$RUN4" | jq '{status, node_id: .required_action.node_id}'
soat get-approval --approval-id "$(printf '%s\n' "$RUN4" | jq -r '.required_action.approval_id')" \
| jq '{policy_version}'

Expected output — the same input, now gated, and policy_version names the baseline as the governing guardrail:

{ "status": "awaiting_input", "node_id": "apply" }
{ "policy_version": "guard_...@1" }

Other projects — which don't carry the attachment — are untouched. A tenant can raise the floor, never lower it.


Step 13 — Edits are versioned, detach is gated

Every write to a document increments version and archives the prior one, so an approval item's policy_version always resolves to the exact text that governed it. See Versioning.

soat update-guardrail --guardrail-id "$GUARDRAIL_ID" --document '{
"default_class": "C",
"class": { "if": [{ "<": [{ "var": "args.amount" }, 500] }, "B", "C"] },
"guard": { "<": [{ "var": "args.amount" }, 100] }
}' | jq '{version}'

soat get-guardrail-version --guardrail-id "$GUARDRAIL_ID" --version 1 \
| jq '{version, guard: .document.guard}'

Expected output — the live guardrail is now version: 2, and version 1's original < 200 guard is still retrievable:

{ "version": 2 }
{ "version": 1, "guard": { "<": [{ "var": "args.amount" }, 200] } }

Attachments reference the guardrail's id, not a version, so this edit takes effect immediately everywhere it is attached — dry-run an edit before writing it when the guardrail is attached at scale.

Deletion refuses to do what detach permissions forbid. While the guardrail is still attached, delete-guardrail returns 409 listing every reference:

# → expect-fail
soat delete-guardrail --guardrail-id "$GUARDRAIL_ID"
{
"status": 409,
"error": {
"code": "GUARDRAIL_HAS_REFERENCES",
"message": "Guardrail 'guard_...' is still attached and cannot be deleted. Detach it from every tool, agent, and project first.",
"meta": { "references": { "tools": ["tool_..."], "agents": [], "projects": [] } }
}
}

Detach first — which requires guardrails:DetachGuardrail on top of tools:UpdateTool — and the delete succeeds:

soat update-tool --tool-id "$BUDGET_TOOL_ID" --guardrail-ids '[]' | jq '{guardrail_ids}'
soat delete-guardrail --guardrail-id "$GUARDRAIL_ID"

How It Works

  • A different layer than IAM. An IAM policy decides whether a caller may invoke an endpoint, at request time. A guardrail decides whether this call, with these arguments may run on its own, at the tool-execution boundary — after the arguments exist and before anything touches the outside world. Guardrails deliberately do not touch policies.
  • Fail-closed at both ends. At write time a var outside the three namespaces is rejected with 400. At evaluation time an unresolvable context.* key, a context-tool timeout, or an invalid class result resolves to default_class (C by default) in class, and counts as a failed guard in guard. Forgetting to supply context tightens the posture; it never loosens it.
  • Stricter-wins composition. Every guardrail that applies — project, agent, and tool scope — evaluates, and the strictest decision wins, ordered blocked > tripwire > route_to_approval > execute. Class A is the identity, so a guardrail that returns "A" simply defers to the others. Composition is order-independent and can only tighten.
  • A refusal is routable. A class-D block or a tripwire on an orchestration tool node records a { status, reason } artifact and branches by label rather than failing the run, so a fallback path can handle it. An unlabeled success edge never auto-follows a blocked node.
  • No LLM in the path. class and guard are JSON Logic, evaluated by the same engine orchestration mappings use. A model can propose an action but can never influence its classification.

Next Steps

  • Feed live values into guards with guardrail_context and a context_tool_id, and cap a runaway run with soat.usage.run_tokens — see Per-run spend ceilings.
  • Model an explicit human decision point in the graph instead of a guardrail-driven one with the approval node.
  • Cap aggregate spend rather than individual calls with Cap Spend Per End User.
  • Triage what a tripwire files — see Exceptions.