Bound an Agent with a Boundary Policy
SOAT forwards the caller's credential into every builtin action an agent performs, so a chain is capped by the caller's token. That cap is not least privilege: a read-only summarizer running under a caller who may write documents executes at the caller's ceiling.
boundary_policy is a policy document stored on the agent that limits which builtin actions that agent may perform, whoever calls it. Effective permission = caller policy ∩ agent boundary, as with API keys.
You give alice broad document permissions, bind an agent to a document-writing tool, watch the agent's write be refused by a read-only boundary, then perform the same write directly as alice.
Prerequisites
- SOAT running locally (Quick Start); Key Concepts; Configuration.
- Ollama at
http://ollama:11434withqwen2.5:0.5bpulled (ollama pull qwen2.5:0.5b). - CLI, SDK, or curl; server at
http://localhost:5047. - Builtin tools and
preset_parameters— see Agent SOAT Tools.
- 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 bypasses policy evaluation — 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
A project and a local Ollama AI provider. For other providers 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; Step 7 asserts its content is unchanged.
- 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
Alice may read and write every document in the project. See Users, Policies and SRNs for the resource scoping.
- 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
A builtin tool for update-document, with document_id pinned by preset_parameters. The tool itself is unrestricted.
- 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
boundary_policyallowsdocuments:GetDocumentonly. Boundaries are deny-by-default, so the boundupdate-documenttool is refused.step_rulesforces the tool call on step 1 so the refusal does not depend onqwen2.5:0.5bvolunteering it. 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
An allow-list stays correct as the agent gains tools: a newly bound builtin action is refused until the boundary is widened. A deny-list permits every action nobody thought to deny.
:::
Step 7 — Run the agent as alice, and watch the write fail
The boundary is evaluated before dispatch: the tool returns an error result and the generation 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:
{ "error": "Forbidden: boundary policy denies update-document" }
Step 8 — Prove the ceiling was the agent's, not alice's
Same user, token and document, written directly via Documents.
- 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'
The forwarded credential caps the chain from above; the boundary caps each agent from below. A prompt injection inherits the agent's ceiling, not alice's.
Step 9 — A mistyped action is rejected at write time
Action strings are validated on agent create and update, so a mis-named action is rejected instead of silently matching nothing.
- 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 '.'
Action names: Permissions Reference.
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 tool types the boundary cannot reach, use Guardrails or Approvals.
A boundary applies per hop: a sub-agent called through create-agent-generation resolves its tools under its own boundary; the orchestrator's permissions do not widen it.
Next Steps
- Agents — SOAT Action Permissions —
boundary_policyreference - Permissions in Practice
- Multi-Agent Sonnet with Nested Agent Calls — a boundary per sub-agent
- Gate a Tool with Guardrails