Security
Permissions, secret management, prompt injection defense, and budgets
Tutti has multiple layers of security built in. This guide covers each one and how to configure them.
Permission system
Voices declare required_permissions. Agents must explicitly grant them.
{
coder: {
voices: [new FilesystemVoice()], // requires: ["filesystem"]
permissions: ["filesystem"], // must be granted explicitly
},
}
If permissions are missing, the runtime throws before executing anything:
Voice filesystem requires permissions not granted: filesystem
Grant them in your score file:
permissions: ['filesystem']
The four permission types:
| Permission | Grants access to |
|---|---|
filesystem | Local file read/write |
network | HTTP requests, API calls |
shell | Shell command execution |
browser | Browser automation |
:::tip
The runtime logs a warning for agents with filesystem or shell permissions, since these are the most powerful.
:::
Secret redaction
The SecretsManager automatically redacts known API key patterns from:
- Event bus payloads (so logging doesn’t leak keys)
- Tool error messages (so the LLM doesn’t see raw keys)
Detected patterns: Anthropic keys (sk-ant-*), OpenAI keys (sk-*), GitHub tokens (ghp_*), Google API keys (AIza*), Bearer tokens.
SecretsManager.redact("Key is sk-ant-api03-abcdef...")
// → "Key is [REDACTED]"
Path traversal protection
The filesystem voice blocks access to system paths:
/etc/passwd,/etc/shadow,/etc/hosts~/.ssh,~/.aws/proc,/sys,/dev
It also enforces a maximum file size on reads (default 10MB).
> Read /etc/passwd
Error: Access to system path not allowed: /etc/passwd
URL sanitization
The Playwright voice blocks dangerous URL schemes:
file:,javascript:,data:— local file access, script injection- Private network ranges (
10.x,172.16-31.x,192.168.x) — SSRF prevention
Prompt injection defense
Tool results are scanned by PromptGuard for common injection patterns:
- “Ignore all previous instructions”
- “You are now…”
- “New instructions:”
- “System prompt:”
- “Forget everything”
- “Your new role/purpose/goal”
When a pattern is detected:
- The content is wrapped with boundary markers and a data-only warning
- A
security:injection_detectedevent is emitted
tutti.events.on("security:injection_detected", (e) => {
console.warn(`Injection detected in ${e.tool_name}:`, e.patterns);
});
:::note The PromptGuard does not block content — it wraps it with markers that instruct the LLM to treat it as data, not instructions. The original content is preserved. :::
Budgets
Set per-agent budgets to prevent runaway costs:
{
assistant: {
budget: {
max_tokens: 50_000, // soft cap on total tokens (event + return partial)
max_cost_usd: 1.00, // hard per-run USD cap (throws BudgetExceededError)
max_cost_usd_per_day: 50.00, // hard daily cap, aggregated across all runs
max_cost_usd_per_month: 500.00, // hard monthly cap
warn_at_percent: 80, // emit warning at 80% per scope (default)
},
},
}
Two enforcement modes:
| Limit | Mode | Behaviour on breach |
|---|---|---|
max_tokens | Soft | Emits budget:exceeded, returns the partial run result. |
max_cost_usd | Hard | Throws BudgetExceededError with scope: 'run'. |
max_cost_usd_per_day | Hard | Throws with scope: 'day'. Requires a RunCostStore on the runtime. |
max_cost_usd_per_month | Hard | Throws with scope: 'month'. Requires a RunCostStore on the runtime. |
Daily/monthly caps query a RunCostStore at run start and add this run’s accumulating cost to the snapshot. Use PostgresRunCostStore in any deployment with more than one worker so every process shares the same total — InMemoryRunCostStore is for dev and tests.
import { TuttiRuntime, PostgresRunCostStore } from "@tuttiai/core";
const runtime = new TuttiRuntime(score, {
runCostStore: new PostgresRunCostStore({
connection_string: process.env.DATABASE_URL!,
}),
});
Events:
budget:warning— emitted when usage crosseswarn_at_percent. Carriesscope: 'run' | 'day' | 'month'and numericlimit.budget:exceeded— emitted on breach. Samescope/limitfields. The hard-throw caps additionally throwBudgetExceededError(catch it onruntime.run()).
import { BudgetExceededError } from "@tuttiai/core";
try {
await runtime.run("assistant", "do the thing");
} catch (err) {
if (err instanceof BudgetExceededError) {
console.log(`Stopped: ${err.scope} budget hit ($${err.current} of $${err.limit})`);
} else {
throw err;
}
}
Tool rate limiting
Two rate limits protect against runaway tool execution:
| Config | Default | Description |
|---|---|---|
max_tool_calls | 20 | Max tool calls per run() |
tool_timeout_ms | 30000 | Per-tool timeout in milliseconds |
{
assistant: {
max_tool_calls: 10,
tool_timeout_ms: 15_000,
},
}
If a tool exceeds the timeout, the error is returned as a tool_result (not thrown), so the LLM can recover gracefully.
Guardrails (beforeRun / afterRun)
Wrap agent inputs and outputs with policy hooks. Each hook runs synchronously around the agent loop and can block, rewrite, or annotate the run.
import { defineScore, profanityFilter, piiDetector, topicBlocker } from "@tuttiai/core";
export default defineScore({
provider: new AnthropicProvider(),
agents: {
assistant: {
name: "Assistant",
system_prompt: "You are a customer-support agent.",
voices: [],
// Input guardrails — run before the first LLM call
beforeRun: [
profanityFilter({ on_match: "block" }),
piiDetector({ on_match: "redact" }),
],
// Output guardrails — run after the agent's final response
afterRun: [
topicBlocker({ topics: ["legal advice", "medical diagnosis"] }),
],
},
},
});
Each factory returns a GuardrailHook. Three built-in factories ship with @tuttiai/core:
| Factory | Purpose | Common config |
|---|---|---|
profanityFilter(opts) | Block / warn on profane input or output | { on_match: "block" | "warn" | "redact" } |
piiDetector(opts) | Detect / redact emails, phone numbers, SSNs, credit cards | { on_match, patterns? } |
topicBlocker(opts) | Refuse to answer prompts about listed topics | { topics: string[], on_match } |
You can also write your own by implementing the GuardrailHook contract — any (context) => Promise<GuardrailResult> function with action: "allow" | "block" | "rewrite". See Guardrails for the full API.
Approval gates (HITL)
Gate destructive tool calls behind human approval with requireApproval:
{
assistant: {
requireApproval: [
{ tool: "delete_file" },
{ tool: "push_branch", when: "branch === 'main'" },
],
},
}
When the agent attempts a matching tool call, the runtime emits hitl:requested, writes an interrupt to the configured InterruptStore, and pauses the run. Review and resolve via the tutti-ai interrupts TUI, the /interrupts/* HTTP endpoints, or the Studio UI.
:::tip[Routing impact]
When the smart router is enabled, agents with destructive tools loaded automatically bias toward larger, safer models. The cost of an LLM mistake on a destructive call (a posted tweet, a voided invoice, a deleted message) is far higher than the cost of using a smarter model for that turn — SmartProvider factors this into every routing decision.
:::
Score validation
Score files are Zod-validated when loaded. Common mistakes are caught early:
- Missing
provider - Agent with empty
nameorsystem_prompt - Negative
max_turnsormax_tool_calls - Delegates referencing agents that don’t exist
- Invalid permission values
Run npx tutti-ai check to validate without running.
Best practices
- Least privilege — only grant the permissions each agent actually needs
- Set budgets — always set
max_tokensormax_cost_usdin production - Monitor events — subscribe to
security:injection_detectedandbudget:warning - Keep .env out of git — the
.gitignoretemplate already excludes it - Pin dependencies — all Tutti packages pin exact dependency versions