Skip to content
titah

Capabilities

Permissions & undo

The check order, why a headless run auto-denies, and exactly what one `undo` reverts.

The check order

Every tool that changes something goes through the permission engine. The checks run in this order:

  1. permission.<tool> from the active agent, then from the config — "deny" refuses, "allow" skips the dialog.
  2. permission.allowlist from 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).
  3. --auto mode.
  4. No client connected → auto-denied.
  5. 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:

DimensionQuestionExample
classWhat kind of harm?"bash": "ask"
argumentWhich call, exactly?"bash(git *)": "allow"
situationWhat 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:

AxisDefaultWhat 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:

  1. A rule-level deny is absolute. Nothing can open it — that is how you write a wall: "network(*)": "deny".

    A class-level deny is a default deny: an explicit allow rule 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 every allow rule under a denied class silently dead — a written rule that never fires.

  2. Between ask and allow, the most specific pattern wins — measured in non-wildcard characters, not file order. git push * (10) beats git * (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

KeyMeaning
yAllow once
aAlways — adds the pattern to the session allowlist
nDeny

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.