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:

PermissionGrants access to
filesystemLocal file read/write
networkHTTP requests, API calls
shellShell command execution
browserBrowser 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:

  1. The content is wrapped with boundary markers and a data-only warning
  2. A security:injection_detected event 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:

LimitModeBehaviour on breach
max_tokensSoftEmits budget:exceeded, returns the partial run result.
max_cost_usdHardThrows BudgetExceededError with scope: 'run'.
max_cost_usd_per_dayHardThrows with scope: 'day'. Requires a RunCostStore on the runtime.
max_cost_usd_per_monthHardThrows 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 crosses warn_at_percent. Carries scope: 'run' | 'day' | 'month' and numeric limit.
  • budget:exceeded — emitted on breach. Same scope / limit fields. The hard-throw caps additionally throw BudgetExceededError (catch it on runtime.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:

ConfigDefaultDescription
max_tool_calls20Max tool calls per run()
tool_timeout_ms30000Per-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:

FactoryPurposeCommon 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 name or system_prompt
  • Negative max_turns or max_tool_calls
  • Delegates referencing agents that don’t exist
  • Invalid permission values

Run npx tutti-ai check to validate without running.

Best practices

  1. Least privilege — only grant the permissions each agent actually needs
  2. Set budgets — always set max_tokens or max_cost_usd in production
  3. Monitor events — subscribe to security:injection_detected and budget:warning
  4. Keep .env out of git — the .gitignore template already excludes it
  5. Pin dependencies — all Tutti packages pin exact dependency versions

Edit this page on GitHub →