Skip to main content

Bound an Agent with a Boundary Policy

When a caller runs an agent, SOAT forwards that caller's credential into every builtin action the agent performs. Permissions are re-checked live at each hop, so an agent can never grant power — a chain is capped by whatever the original caller's token allows.

That cap alone is not least privilege. A summarizer agent that only needs to read documents still executes under a caller who may also write them, so a prompt injection in that agent's context runs at the caller's full ceiling.

boundary_policy closes the gap. It is a policy document stored on the agent that limits which builtin actions that agent may perform, whoever calls it. The effective permission is the intersection of the caller's policy and the agent's boundary — the same pattern as API keys.

In this tutorial you give alice broad document permissions, bind an agent to a document-writing tool, and then watch the agent be refused the write anyway — because its boundary allows reads only. Alice then performs the same write directly, proving the ceiling that stopped the agent was the agent's, not her token's.

Prerequisites

  • SOAT running locally with Ollama. Follow the Quick Start guide.
  • New to SOAT? Read Key Concepts for projects, users, and the IAM model before starting.
  • An Ollama instance accessible at http://ollama:11434 with model qwen2.5:0.5b pulled (ollama pull qwen2.5:0.5b).
  • CLI, SDK, or curl available. The server is at http://localhost:5047.
  • For production hardening (secrets, env vars), see Configuration.
  • Familiar with builtin tools and preset_parameters? If not, run Agent SOAT Tools first — this tutorial reuses both.
export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin

Admin is the built-in superuser role and bypasses policy evaluation entirely — see IAM — Authentication.

soat login-user --username admin --password Admin1234!
soat configure # paste the token when prompted

Step 2 — Create a project and an AI provider

Every resource lives inside a project. The AI provider here is a local Ollama instance so the tutorial runs without external credentials. To connect xAI, OpenAI, Anthropic, or Amazon Bedrock instead, see Connect Third-Party LLMs.

PROJECT_ID=$(soat create-project --name "Boundary Project" | jq -r '.id')

PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')

echo "Project: $PROJECT_ID"
echo "Provider: $PROVIDER_ID"

Step 3 — Create the document the agent will try to overwrite

Create a document and note its exact content. Step 7 asserts it is still there.

DOC_ID=$(soat create-document \
--project-id "$PROJECT_ID" \
--title "Quarterly Note" \
--content "ORIGINAL CONTENT" \
--path "/notes/quarterly.txt" | jq -r '.id')
echo "Document: $DOC_ID"

Step 4 — Create alice with broad document permissions

This is the part that makes the demo meaningful. Alice is not restricted: she may read and write every document in the project. She is the "my full token" caller.

See Users and Policies for the resources involved, and IAM — SOAT Resource Names (SRNs) for the resource scoping used below.

ALICE_ID=$(soat create-user --username alice-boundary --password Alice1234! | jq -r '.id')

POLICY_ID=$(soat create-policy \
--name "alice-boundary-full-documents" \
--document '{
"statement": [
{
"effect": "Allow",
"action": [
"documents:*",
"agents:CreateAgentGeneration",
"agents:GetAgent",
"traces:GetTrace",
"traces:ListTraces"
],
"resource": ["srn:'"$PROJECT_ID"':*:*"]
}
]
}' | jq -r '.id')

soat attach-user-policies \
--user-id "$ALICE_ID" \
--policy-ids '["'"$POLICY_ID"'"]'

soat login-user --username alice-boundary --password Alice1234!
soat configure --profile alice

Step 5 — Bind a write tool to the agent

The agent gets a real builtin tool for update-document, with document_id pinned by preset_parameters so the model cannot aim it anywhere else. Nothing here is restricted yet — the tool is fully functional.

WRITE_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "docs" \
--type builtin \
--actions '["update-document"]' \
--preset-parameters '{"document_id": "'"$DOC_ID"'"}' | jq -r '.id')
echo "Write tool: $WRITE_TOOL_ID"

Step 6 — Create the agent with a read-only boundary

Two fields carry the whole lesson:

  • boundary_policy allows documents:GetDocument and nothing else. Boundaries are deny-by-default: anything the document does not allow is refused, so the bound update-document tool is dead on arrival.
  • step_rules forces the tool call on step 1. Without it, whether the model volunteers the call is up to qwen2.5:0.5b — and this tutorial is demonstrating the refusal, not the model's judgment. See Step Rules.
AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$PROVIDER_ID" \
--name "Bounded Summarizer" \
--instructions "You summarize notes. Use your tools when asked." \
--tool-bindings "[{\"tool_id\":\"$WRITE_TOOL_ID\"}]" \
--max-steps 2 \
--step-rules '[{"step":1,"tool_choice":{"type":"tool","tool_name":"docs_update-document"}}]' \
--boundary-policy '{
"statement": [
{
"effect": "Allow",
"action": ["documents:GetDocument"],
"resource": ["*"]
}
]
}' | jq -r '.id')
echo "Agent: $AGENT_ID"

:::tip Write boundaries as allow-lists A boundary that allows only what the agent needs stays correct as the agent gains tools: bind a new builtin action tomorrow and it is refused until someone widens the boundary on purpose. A boundary written as a list of denials has the opposite property — every action nobody thought to deny is permitted. :::


Step 7 — Run the agent as alice, and watch the write fail

Alice may write this document. The agent may not. The boundary is evaluated before the action is dispatched, so the tool returns an error result instead of performing the update — the generation itself still completes.

soat --profile alice create-agent-generation --wait true \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"Replace the note content with: OVERWRITTEN BY THE AGENT."}]' \
| jq '.status'

# The boundary refused the write — content is untouched.
soat --profile alice get-document --document-id "$DOC_ID" | jq -r '.content'

# Verify it: this exits non-zero if the agent managed to change anything.
soat --profile alice get-document --document-id "$DOC_ID" \
| jq -r '.content' | grep -qx "ORIGINAL CONTENT"

The tool result the model received names the action that was refused:

{ "error": "Forbidden: boundary policy denies update-document" }

Nothing about alice changed. The agent was refused because of what the agent is allowed to do.


Step 8 — Prove the ceiling was the agent's, not alice's

Same user, same token, same document — performed directly against Documents instead of through the agent.

soat --profile alice update-document \
--document-id "$DOC_ID" \
--content "UPDATED BY ALICE DIRECTLY" | jq '.id'

CONTENT=$(soat --profile alice get-document --document-id "$DOC_ID" | jq -r '.content')
echo "Content after alice wrote directly: $CONTENT"

That is the whole point. The forwarded credential caps the chain from above; the boundary caps each agent from below. A prompt injection reaching the summarizer inherits the summarizer's ceiling, not alice's.


Step 9 — A mistyped action is rejected at write time

Boundaries fail closed on a typo only if the typo is caught. Action strings are validated when an agent is created or updated, so a mis-named action cannot be quietly accepted and then match nothing at evaluation time.

# → expect-fail
soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$PROVIDER_ID" \
--name "Typo Agent" \
--boundary-policy '{"statement":[{"effect":"Allow","action":["documents:GetDocumnet"],"resource":["*"]}]}'

See the Permissions Reference for the enforceable module:Operation action names.


What the boundary does and does not cover

Governed by boundary_policy
builtin tools (platform actions)Yes — every action is checked before dispatch
The built-in write_memory toolYes — fails closed when the boundary denies the memory write actions
http, mcp, client toolsNo — these execute outside the platform

For the tool types the boundary cannot reach, use Guardrails to gate the call, and Approvals to put a human in front of it.

A boundary is per agent, so it applies per hop. When an orchestrator calls a sub-agent through create-agent-generation, the sub-agent's own generation resolves its tools under its own boundary — the orchestrator's permissions do not widen it.

Next Steps