The check order
Every tool that changes something goes through the permission engine. The checks run in this order:
permission.<tool>from the active agent, then from the config —"deny"refuses,"allow"skips the dialog.permission.allowlistfrom the config, then the session allowlist built from “always” answers — except for a dialog raised by a sub-agent, whose “always” is scoped to the coordinator’s turn instead (see Sub-agents).--automode.- No client connected → auto-denied.
- Only then is the dialog shown.
Why point 4 is non-negotiable
Hanging while waiting for an answer would freeze the agent in CI. Auto-allowing would turn headless mode into a silent security hole.
Neither is acceptable, so a bare titah serve with nobody attached refuses and
logs the reason. For automation the path is --auto or an explicit
allowlist — a deliberate act, recorded in a config file, rather than a default
nobody chose.
Three dimensions, one function
Permission is not one question but three, and they are orthogonal — so they run in sequence rather than competing:
| Dimension | Question | Example |
|---|---|---|
| class | What kind of harm? | "bash": "ask" |
| argument | Which call, exactly? | "bash(git *)": "allow" |
| situation | What is happening around it? | a repeating call → doom_loop |
All three are decided in one function (src/core/decide.ts), and
titah permission explain calls that same function — so what is explained can
never differ from what runs.
titah permission explain bash "git push origin main"
titah permission explain network https://docs.python.org/3/
The classes
{
"permission": {
"edit": "ask",
"write": "ask",
"bash": "ask",
"network": "ask",
"delete": "ask",
"mcp": "ask",
"allowlist": ["git *", "npm test"]
}
}
Each takes "ask", "allow" or "deny". Which tool sits on which axis is in
Tools.
The axes are separate because they protect different things. network is not a
flavour of bash: the web tools touch no files, and what they risk is
confidentiality — they, and artifact, are the only tools
that send repository content off the machine. delete is not a flavour of write: an agent given write: "allow"
to mean “may create files” was never given “may delete mine”. mcp is separate
because an MCP server is code Titah did not write and cannot classify; forcing
it onto edit or bash would ask you to approve something other than what
happens.
Two further axes behave differently from those six:
| Axis | Default | What it is |
|---|---|---|
external_directory | "deny" | Paths outside the working directory |
doom_loop | "ask" | A call detected as repeating |
external_directory never defaults to ask. The working-directory boundary is
a structural wall that every filesystem tool relies on, and making it a runtime
question would trade a guarantee for a policy. The way out is per path,
named in rules, and evaluated only at config load — there is no blanket
allow that opens everything:
{ "permission": { "rules": { "external_directory(/Users/me/other-repo/*)": "allow" } } }
doom_loop never allows anything — it only interrupts something already
allowed. "allow" therefore means “never interrupt me” and "deny" means “stop
as soon as it starts going round”.
The argument dimension
{
"permission": {
"bash": "ask",
"rules": {
"bash(git *)": "allow",
"bash(git push *)": "deny",
"webfetch(https://docs.*)": "allow",
"network(*)": "deny"
}
}
}
Two combining rules, and they are the whole design:
-
A rule-level
denyis absolute. Nothing can open it — that is how you write a wall:"network(*)": "deny".A class-level
denyis a default deny: an explicitallowrule can narrow it. That is what makes “refuse everything except this” expressible at all, and it is the shape every firewall and IAM policy uses. Treating it as a wall instead made everyallowrule under a denied class silently dead — a written rule that never fires. -
Between
askandallow, the most specific pattern wins — measured in non-wildcard characters, not file order.git push *(10) beatsgit *(4), which beats a class-level rule (0).Order in the file is the thing most easily changed by accident; adding a line in the wrong place must not silently change what another rule means. Ties go to
ask: two equally specific rules that disagree are an ambiguous config, and guessing loose on ambiguity is how you get permissions nobody intended.
The situation dimension can only tighten: it turns allow into ask, never
the other way round.
Answering a dialog
| Key | Meaning |
|---|---|
y | Allow once |
a | Always — adds the pattern to the session allowlist |
n | Deny |
Esc aborts the whole turn, not just the running tool. A half abort would
leave a tool_call without its tool_result, corrupting the history and causing
errors on the next request.
An “always” answered at top level lasts the whole session. An “always” answered for a sub-agent’s dialog lasts only the coordinator’s turn — and for that turn it covers every sub-agent, not only the one that asked. Five agents doing the same job would otherwise ask the same question five times, and dialogs that repeat are dialogs that stop being read.
Snapshots
Before the first change of every turn, Titah takes a snapshot into a
shadow git repo under ~/.local/share/titah/snapshot/ — a separate git dir
whose work tree points at your project.
Your own .git is never touched: no stray commits, no disturbed staging area,
no surprise stashes. That is what makes “always allow write” feel safe.
Undo
titah undo # revert the entire last turn
titah undo -s <id> # for a specific session
Or Ctrl+X U in the TUI.
One snapshot per turn means one undo reverts the whole turn, not a single tool. Files the agent created are deleted too — a half undo that leaves new files behind is not an undo.
Build Auto still takes snapshots, so undo works exactly the same even though
you were not watching the turn happen.
The sharp edge: /undo after a /tim turn
Sub-agents change nothing about the mechanism of undo: it reverts to the last assistant message, in the given session, that actually took a snapshot. What changes is which message that turns out to be.
A sub-agent’s own writes take their snapshot on the sub-agent’s own (child)
session, not on the coordinator’s turn. So if a /tim turn’s coordinator
dispatches work and writes nothing itself, its own turn has no snapshot at
all. Undo does not stop there — it walks the session’s messages for the most
recent one that does have a snapshot, which skips straight past the empty
/tim turn to whatever the coordinator last wrote in an earlier turn,
reverting that older turn as well, silently, in the same undo.
!!! danger “Concretely”
Edit a file in Build mode, then run /tim … where only sub-agents write a
dozen new files, then press Ctrl+X U. It does not report “nothing to
undo.” It reverts the Build-mode edit and deletes all twelve files the
sub-agents just created — files created after a snapshot are removed, same as
any other undo — and reports it as one ordinary “undo: N files restored.”
There is no way to undo just one sub-agent’s slice of a /tim run, and no way
to undo a /tim turn in isolation if its own coordinator wrote nothing. You
either revert back to the coordinator’s last real write, however far back that
is, or there is nothing to revert at all.
Ctrl+X U and a bare titah undo never reach into a sub-agent’s session
directly. They operate on the session you are in, or — for a bare titah undo —
on the most recently updated top-level session in the current directory.
titah undo -s <id> targets a specific one; a child session is never picked
automatically.
Sub-agents cannot be given more than the coordinator has
This was a real hole and it is closed. Previously the task tool carried no
permission check of its own, and a dispatched sub-agent ran under whatever
permission block its own config gave it — so a Plan-mode coordinator could
call /tim and have a sub-agent configured "bash": "allow" run a shell
command with no confirmation, in the one mode whose description promises the
opposite.
A sub-agent’s effective permission is now computed from its parent, not from the global config, and then clamped by the stricter of the two on every axis. One sentence: a parent can never hand out more than it holds.
It clamps in both directions. A child declaring "write": "allow" under a
plan coordinator whose write is "deny" still gets deny; a child that
declares nothing inherits the parent’s deny rather than falling back to the
global default.
The same change fixed the mirror-image annoyance. build-auto opens every axis
so it never interrupts — but sub-agents used to fall back to the user’s global
block, which is usually ask everywhere. The mode promised “no confirmations”
and kept that promise exactly until it delegated, at which point every ls and
cat raised a dialog. Inheriting from the parent removes the dialog without
widening anything: a child still cannot do what the parent could not have done
itself, directly, without asking anyone.
rules and allowlist are merged rather than intersected, because they
work at a different layer: the axis decides the class of action, the rule
decides the argument. A parent’s deny rule still applies because it is carried
along, and a child’s allow rule cannot open an axis that is already deny —
decide() checks the axis first.
Details in Sub-agents.