Skip to main content

Gate a Dangerous Tool with Guardrails

A guardrail classifies every gated tool call into an action class with deterministic JSON Logic: A (always execute), B (execute if a guard passes), C (human sign-off), D (forbidden).

Build one guardrail over a budget-update tool, dry-run it, drive it from an orchestration tool node through all three outcomes (execute, park for sign-off, tripwire), read the governance trail (approvals, exceptions, audit log), then tighten the whole project with a second guardrail. No AI provider is required.

Prerequisites​

export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin​

See Users.

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

Step 2 — Create a project​

The project is also the broadest guardrail attach scope (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. A read-only builtin tool named update-budget stands in for the tool that would move money.

BUDGET_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "update-budget" \
--type "builtin" \
--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 class, guard, and the fail-closed default_class. Here: class B below 500, C at or above, guard requiring the amount under 200. Amounts 150, 900, and 450 exercise every outcome.

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"

Validated on write: every var must resolve to the args.*, context.*, or runtime.* namespaces; an out-of-catalog runtime.* key is a 400.


Step 5 — Dry-run every decision before attaching​

Dry-run evaluation runs the real pipeline against supplied arguments and returns the record a real call would produce; nothing executes, no approval is filed.

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):

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

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 class C). 450 classifies B but fails the < 200 guard: 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. 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 attached. At the tool scope the tool carries its gate wherever it is bound. guardrail_ids is a list; several guardrails compose on one tool.

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

Adding an id needs only tools:UpdateTool (it can only tighten); removing one additionally requires guardrails:DetachGuardrail (Step 13).


Step 7 — Drive the tool from an orchestration​

An orchestration tool node is gated at dispatch like an agent tool call; with no agent in scope it composes the project + tool scopes only. The apply node feeds the run's amount in as args.amount. The unlabeled edge is the success path; blocked / tripwire edges catch a refusal, which is a routable outcome, not a run failure.

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​

A run with amount: 150 classifies B, passes the guard, and dispatches the tool.

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 --orchestration-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​

amount: 900 classifies C: the tool is not dispatched, the run parks as awaiting_input, and an approval item carries 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 which guardrail and which version sent the call:

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

Approve, 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 --orchestration-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; the guardrail is not re-evaluated. On rejection or expiry the tool never runs 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: the action aborts.

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 --orchestration-run-id "$RUN3_ID" \
| jq '{status, outcome: .state.outcome, refusal: .artifacts.apply}'

Expected output (the run succeeds down the tripwire edge):

{
"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:

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

"escalate": true in the document routes a failing guard to the approvals queue instead of aborting. It is per-guardrail; 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 outcome (route_to_approval, blocked, tripwire, not plain execute) are also mirrored into the audit log as platform-originated entries.

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; the class-B execute from Step 8 stays only in the guardrail's own evaluation records):

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

Each entry's detail carries context_snapshot: only the vars the evaluation referenced, frozen at evaluation-time values.


Step 12 — Raise the floor for the whole project​

There is no override resource; attach a tighter guardrail at the project scope. An always-C document forces sign-off on every tool call in the project. Composition is stricter-wins, so the amount: 150 call from 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 (policy_version names the baseline as the governing guardrail):

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

Other projects are untouched. A tenant can raise the floor, never lower it.


Step 13 — Edits are versioned, detach is gated​

Every write that changes document increments version and archives it, so an approval item's policy_version resolves to the exact governing text. A version's config holds { document }. Metadata-only edits and unchanged documents archive nothing. 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: .config.document.guard}'

Expected output (version: 2 live; version 1's < 200 guard still retrievable):

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

Attachments reference the guardrail's id, not a version, so the edit applies immediately everywhere; dry-run edits first when attached at scale.

While the guardrail is 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 (guardrails:DetachGuardrail on top of tools:UpdateTool), then delete:

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

Next Steps​