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.
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.