Permission scopes the runtime actually enforces

Most frameworks document a permission model. Tutti enforces one. The difference is the runtime refuses to start when an agent has voices it didn't grant.

Chihab
Building Tutti AI · · 5 min read

Read the security section of any popular agent framework and you'll find a list of recommendations. "Don't let the agent write to disk in production." "Validate URLs before fetching them." "Be careful with shell commands."

Recommendations are not a security model.

A real permission model has three properties: declared by the producer (the voice author says what it touches), granted by the consumer (the agent author says what it allows), and enforced by the runtime (load fails if there's a mismatch). Documentation has none of those properties. It's a vibe.

Tutti has all three.

How a voice declares permissions

Every voice in `@tuttiai/*` sets a `required_permissions` array on its Voice object:

required_permissions: ['network', 'browser']

The available scopes are deliberately small: `filesystem`, `network`, `shell`, `browser`. They're broad on purpose — fine-grained scopes encourage the failure mode where the producer ticks every box "to be safe" and the consumer never reviews them. Four buckets fit on a screen and force a real review.

How an agent grants them

agents: {
  qa: {
    name: 'QA',
    model: 'claude-sonnet-4-6',
    system_prompt: '...',
    voices: [new PlaywrightVoice()],
    permissions: ['network', 'browser'],
  },
}

Granting is positive. You list the scopes you allow, no more. There's no "deny" list because the default is deny.

How the runtime enforces them

When the runtime constructs an agent, it walks every voice and calls:

PermissionGuard.check(agent.permissions, voice.required_permissions)

If a voice needs `browser` and the agent didn't grant `browser`, the agent fails to load. Not the first call — the load. The error names the voice and the missing scope. Your CI catches it. Your prod deploy never starts.

This sounds basic. Almost no framework does it.

Why it has to be at the runtime

The temptation is to put this in a linter or a documentation page. Both fail.

A linter only catches what's in your repo. As soon as a voice is dynamically loaded — from an MCP bridge, from a plugin marketplace, from a community package — the linter is blind. The runtime is the only place that sees what's actually about to execute.

Documentation only catches readers. The whole point of an agent system is that an LLM is making decisions you didn't write down. A voice you forgot to gate is a voice the model will eventually call. The runtime guard is the difference between "we haven't seen that bug yet" and "that bug can't happen."

What it doesn't replace

Permission scopes aren't the whole security model. They're the load-time gate. Tool inputs are still sanitised (`PathSanitizer`, `UrlSanitizer`), tool outputs are still wrapped (`PromptGuard`), destructive operations are still gated (HITL). Each layer catches a different class of mistake.

But the load-time gate is the one that gets skipped most often, because it's the least dramatic. It just refuses to start. It saves you from a class of bugs that are otherwise very hard to spot — the agent that has had `shell` access for three weeks because someone added a voice and nobody noticed.

Tags #security #permissions #design
Older post
Voices: the plugin model agent frameworks need
6 min · Engineering
Newer post
Prompt injection is an architecture problem, not a UX one
7 min · Engineering

Start conducting.

One install. Your first agent running in 60 seconds. No signup. No telemetry.