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:11434with modelqwen2.5:0.5bpulled (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.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
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.
- CLI
- SDK
- curl
soat login-user --username admin --password Admin1234!
soat configure # paste the token when prompted
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: session } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: session!.token,
});
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
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.
- CLI
- SDK
- curl
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"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'Boundary Project' },
});
const projectId = project!.id;
const { data: provider } = await adminSoat.aiProviders.createAiProvider({
body: {
project_id: projectId,
name: 'Ollama',
provider: 'ollama',
default_model: 'qwen2.5:0.5b',
},
});
const providerId = provider!.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Boundary Project"}' | jq -r '.id')
PROVIDER_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/ai-providers" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"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.
- CLI
- SDK
- curl
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"
const { data: doc } = await adminSoat.documents.createDocument({
body: {
project_id: projectId,
title: 'Quarterly Note',
content: 'ORIGINAL CONTENT',
path: '/notes/quarterly.txt',
},
});
const docId = doc!.id;
DOC_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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.
- CLI
- SDK
- curl
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
const { data: alice } = await adminSoat.users.createUser({
body: { username: 'alice-boundary', password: 'Alice1234!' },
});
const { data: policy } = await adminSoat.policies.createPolicy({
body: {
name: 'alice-boundary-full-documents',
document: {
statement: [
{
effect: 'Allow',
action: [
'documents:*',
'agents:CreateAgentGeneration',
'agents:GetAgent',
'traces:GetTrace',
'traces:ListTraces',
],
resource: [`srn:${projectId}:*:*`],
},
],
},
},
});
await adminSoat.users.attachUserPolicies({
path: { user_id: alice!.id },
body: { policy_ids: [policy!.id] },
});
const { data: aliceSession } = await soat.users.loginUser({
body: { username: 'alice-boundary', password: 'Alice1234!' },
});
const aliceSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: aliceSession!.token,
});
ALICE_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"alice-boundary","password":"Alice1234!"}' | jq -r '.id')
POLICY_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/policies" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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')
curl -s -X PUT "$SOAT_BASE_URL/api/v1/users/$ALICE_ID/policies" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"policy_ids\": [\"$POLICY_ID\"]}"
ALICE_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"alice-boundary","password":"Alice1234!"}' | jq -r '.token')
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.
- CLI
- SDK
- curl
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"
const { data: writeTool } = await adminSoat.tools.createTool({
body: {
project_id: projectId,
name: 'docs',
type: 'soat',
actions: ['update-document'],
preset_parameters: { document_id: docId },
},
});
const writeToolId = writeTool!.id;
WRITE_TOOL_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents/tools" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"project_id\": \"$PROJECT_ID\",
\"name\": \"docs\",
\"type\": \"soat\",
\"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_policyallowsdocuments:GetDocumentand nothing else. Boundaries are deny-by-default: anything the document does not allow is refused, so the boundupdate-documenttool is dead on arrival.step_rulesforces the tool call on step 1. Without it, whether the model volunteers the call is up toqwen2.5:0.5b— and this tutorial is demonstrating the refusal, not the model's judgment. See Step Rules.
- CLI
- SDK
- curl
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"
const { data: agent } = await adminSoat.agents.createAgent({
body: {
project_id: projectId,
ai_provider_id: providerId,
name: 'Bounded Summarizer',
instructions: 'You summarize notes. Use your tools when asked.',
tool_bindings: [{ tool_id: writeToolId }],
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: ['*'],
},
],
},
},
});
const agentId = agent!.id;
AGENT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"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.
- CLI
- SDK
- curl
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"
const { data: generation } = await aliceSoat.agents.createAgentGeneration({
path: { agent_id: agentId },
query: { wait: true },
body: {
messages: [
{
role: 'user',
content: 'Replace the note content with: OVERWRITTEN BY THE AGENT.',
},
],
},
});
console.log('Status:', generation!.status);
const { data: after } = await aliceSoat.documents.getDocument({
path: { document_id: docId },
});
console.log('Content after the agent ran:', after!.content);
// → "ORIGINAL CONTENT" — the boundary refused the write
curl -s -X POST "$SOAT_BASE_URL/api/v1/agents/$AGENT_ID/generate?wait=true" \
-H "Authorization: Bearer $ALICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"prompt":"Replace the note content with: OVERWRITTEN BY THE AGENT."}' | jq '.status'
CONTENT=$(curl -s "$SOAT_BASE_URL/api/v1/documents/$DOC_ID" \
-H "Authorization: Bearer $ALICE_TOKEN" | jq -r '.content')
echo "Content after the agent ran: $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.
- CLI
- SDK
- curl
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"
await aliceSoat.documents.updateDocument({
path: { document_id: docId },
body: { content: 'UPDATED BY ALICE DIRECTLY' },
});
const { data: updated } = await aliceSoat.documents.getDocument({
path: { document_id: docId },
});
console.log('Content after alice wrote directly:', updated!.content);
// → "UPDATED BY ALICE DIRECTLY"
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/documents/$DOC_ID" \
-H "Authorization: Bearer $ALICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content":"UPDATED BY ALICE DIRECTLY"}' | jq '.id'
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.
- CLI
- SDK
- curl
# → 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":["*"]}]}'
// Rejected with 400 VALIDATION_FAILED:
// statement[0].action: "documents:GetDocumnet" is not a known action
// — see the Permissions Reference (/docs/permissions)
await adminSoat.agents.createAgent({
body: {
project_id: projectId,
ai_provider_id: providerId,
name: 'Typo Agent',
boundary_policy: {
statement: [
{
effect: 'Allow',
action: ['documents:GetDocumnet'],
resource: ['*'],
},
],
},
},
});
curl -s -X POST "$SOAT_BASE_URL/api/v1/agents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"project_id\": \"$PROJECT_ID\",
\"ai_provider_id\": \"$PROVIDER_ID\",
\"name\": \"Typo Agent\",
\"boundary_policy\": {\"statement\":[{\"effect\":\"Allow\",\"action\":[\"documents:GetDocumnet\"],\"resource\":[\"*\"]}]}
}" | jq '.'
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 tool | Yes — fails closed when the boundary denies the memory write actions |
http, mcp, client tools | No — 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
- Agents — SOAT Action Permissions — the field reference for
boundary_policy - Permissions in Practice — policies, users, and project-scoped API keys
- Multi-Agent Sonnet with Nested Agent Calls — put a boundary on each sub-agent in a real graph
- Gate a Tool with Guardrails — gating for the tool types a boundary does not cover