Permissions
How stella's per-tool permission model separates read-only tools from mutating tools, and how PreToolUse hooks can gate or block tool calls.
Every tool the agent can call, whether it's built in, custom, or provided by an MCP server, has a per-tool permission model. This is what lets stella treat a harmless file read differently from a shell command that could change your system.
Both outcomes here are real: an allowed call runs and then fires PostToolUse, and a refused call goes back to the model as a refusal it can read and react to. The decision happens at the tool boundary, never by asking the model nicely in a prompt.
Read-only vs. mutating tools
Tools fall into two groups.
Read-only tools only look, at the workspace, session state, or the environment. They never change anything or cause side effects. These tools are marked read_only: true. The full read-only set in the built-in catalog is read_file, search, task_list, get_state, list_state, and get_environment.
Mutating tools cause side effects. Among the built-ins, that means running a shell command, writing or deleting a workspace file, changing the task board, writing scratch state, or handing work to a sub-agent. bash, write_file, edit_file, delete_file, task_create, task_assign, delegate, save_state, and delete_state are all examples. A custom or MCP tool can do the same, or reach out over the network.
The read_only: true marker is the signal that a tool is safe to run without changing anything. Treat any tool without it as capable of side effects.
Only the speculation-safe subset of the read-only tools can run speculatively during the model's stream. A schema declares that separately, with speculation_safe: true, because a retried stream can run a speculated call twice, and "safe to run twice" is a stronger claim than "changes nothing."
Read-only versus mutating is a property of the tool, not of one particular call. A tool family can split across that line. In the file family, read_file is read-only while write_file, edit_file, and delete_file are mutating. On the task board, task_list is read-only while the other five board tools and delegate are mutating. In the scratch-state family, get_state and list_state are read-only while save_state and delete_state are mutating.
The two flags can also split from each other. search is read_only: true but not speculation-safe: it doesn't change any workspace file, but its semantic ranking step writes embeddings into codegraph.db as it works, so running it twice isn't free.
Gating with hooks
The permission model is enforced before every tool call, and you can extend it with lifecycle hooks. A PreToolUse hook runs before a tool executes and can block it: if the hook exits with a non-zero status, the tool doesn't run, and the model gets the hook's message instead.
This gives you a programmable gate in front of any tool. A PreToolUse hook's matcher is a glob over the tool name, so you can target one tool, such as task_assign, or a family of tools, such as mcp__github__*.
If you want to block by pattern without writing a hook script, stella also has native workspace-rule guards enforced at the tool boundary. guard-tool denies a tool by name, enforced on every call.
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__github__*",
"hooks": [
{
"type": "command",
"command": "./scripts/guard.sh",
"timeoutMs": 5000
}
]
}
]
}
}For the full hook model, including events, matchers, timeouts, and the JSON payload delivered on stdin, see Hooks.
Containment
Hooks and guard rules decide whether a command runs. Containment limits what it can reach once it does run. That difference matters when instructions hidden in a file the agent read steer the model into running something you never asked for.
stella does not sandbox individual commands. Setting STELLA_BASH_SANDBOX has no effect, on any value or any platform. Nothing reads it.
If STELLA_BASH_SANDBOX is set in a shell profile, a CI job, or a service unit, it's inert and prints no warning. Remove it. If you were counting on it for containment, replace it with a container instead, as described below.
A setting that only covers one way of starting a process isn't a real boundary, because custom script tools, MCP servers, and hooks all start their own processes outside it. A boundary you can walk around by starting a process a different way isn't a boundary, and one that people believe in but can't rely on is worse than one that's clearly absent.
Run the whole stella process inside a container instead: Docker, Podman, or a remote sandbox. The boundary then sits outside every code path that spawns a process, so nothing can route around it, and it holds for file writes, network access, and running programs alike.
There's no official CLI image today, so build one that puts stella on PATH (the install script works inside a debian:bookworm-slim stage), then mount only the repository you want the agent to touch:
docker run --rm -it \
-v "$PWD:/work" -w /work \
-e OPENROUTER_API_KEY \
your-stella-image \
stella run "run the migration and fix what breaks"Everything outside /work is the container's own filesystem, and it's thrown away when the container exits. That's the guarantee a per-command sandbox could never really give you. For a stricter boundary, add --network none. Know what that costs first: a session with no network can't reach your model provider, fetch dependencies, or run git push. It suits a task you can define fully offline, not a general session.
Inside the container, anything a custom tool, MCP server, or hook starts runs with the container's own privileges, and the PreToolUse policy chain still gates every tool call. The gate and the container work together. Neither one replaces the other.
Hooks
Run shell commands on stella's agent lifecycle events. Covers events inside a turn, and events around the self-driving loop's runs, cycles, issues, pull requests, and checks. Configured under the hooks key in settings.json.
Examples & recipes
Copy a settings file that matches your keys and budget, paste it in, and verify with stella config.