Permissions

Which commands the agent may run is decided by rules in plain text files, and by you when no rule covers a call.

The rule format

One rule per line, as action program [subcommand] [args...]:

allow cargo test
ask cargo publish
deny git branch -D
deny sudo
deny rm

The sudo axis is part of the format on every platform, so a rule file written on Linux still loads on a machine that has no sudo. What changes is the invocation rather than the rule: on Windows a command asking for sudo is refused with a sentence, and the prefix is never rendered into what you are shown, because a command displayed with a sudo that never ran is not the command that ran.

ActionMeaning
allowRun it without asking
askStop and ask, even where a broader allow would have covered it
denyRefuse it

ask sitting between the other two is what makes allow cargo together with ask cargo publish read the way it behaves. At equal specificity the stronger action wins.

Two details that decide a lot of cases

A leading - is a flag and never a subcommand, so allow cargo on its own covers cargo --offline while rules naming a subcommand stay out of it.

A program some rule allows may be asked about itself: --version, -V, --help, and -h alone need no rule, because they run nothing and they are how the agent finds out which toolchain is in front of it. A deny still wins — rm --help is denied like every other rm — and a program no rule allows is not covered by this at all.

Where the rules live

FileWhat it is
~/.config/task-agent/permissionsThe global rules, written on first run with built-in defaults: the cargo and git subcommands, deny sudo, deny rm
~/.config/task-agent/projects/<repository path with the slashes turned into dashes>/permissionsThe rules for one project, written when you allow something always or accept a rule the agent proposed. The folder is named after the full path of the main repository, so two checkouts with the same directory name keep their own rules, and a worktree shares the rules of the repository it belongs to

Project rules override global ones, which override the built-in defaults. Nothing is permanently immune.

Project rules live in the config directory rather than in the repository, and that is the point rather than an inconvenience: everything inside the working directory is something the agent may write, so a rule file the agent could edit would not be a rule. What it costs is that rules do not travel with a clone and cannot be committed. A .claude/permissions left over from an earlier version is copied across on first start and then ignored; the old file is not deleted, and editing it changes nothing.

Being asked

In the terminal, a command no rule covers stops the turn and asks:

AnswerEffect
yAllow it this once
cAlways allow it: the program and its first argument — allow git commit rather than the exact line. For a file tool it is that one file, for a web tool that one host
aAlways allow every variant: the program with any arguments — allow git. For a file tool it is any file that tool reaches, for a web tool any host
sLet the agent decide the rule. The call runs this once, no rule is written, and the model is told to propose one with RequestPermission — which reaches you as its own prompt naming the rule and the reason, so nothing is written that you have not read
nDeny it this once
dNever allow it: the same breadth as c, written as a deny
xNever allow any variant: the same breadth as a, written as a deny
tLet the agent decide the refusal. The call is refused, and the model is asked to propose a deny rule the same way

Every prompt has this shape and no other: two rows, yes above and no below, four answers each, always the same keys. The row a key sits in says which verdict it is, and the two rule keys of each row say how wide it would be. The four answers are project-scoped; a rule for every directory exists only through the agent, which is what s and t are for. Esc is the same as n. A deny is never written as confined, because a deny that stops at the edge of the project is not a deny.

The prompt takes no answer for the first three quarters of a second, and every further keystroke starts that pause over, so a permission is never granted by a letter meant for the input line. A key that means nothing here restarts the pause like any other. The prompt says which state it is in: grey while it is still holding, red once a keystroke has landed in it, and bold with the answers spelled out once it is listening.

In headless mode — -p or piped standard input — nobody can answer, so an uncovered command is denied with a message and the model is told it was refused. --allow-all runs it anyway, and never overrides a deny.

--auto is the mode that never asks and never pauses, and it moves one thing only: a permission that would raise a prompt is granted. That covers both an ask rule — which --allow-all deliberately leaves asking — and a call no rule covers at all. Every deny stays a deny, and specificity decides between rules the way it always does, so the rules you wrote still describe what this session may not do. The other half is the tools: AskQuestions, RequestPermission and RequestFileGrant are refused with a sentence saying nobody is at the prompt, and the system prompt tells the model to decide open questions itself and to park a genuine blocker with AskInbox, which writes an entry you answer whenever rather than a turn that waits for you.

Over the JSON-lines protocol and in a detached session, the prompt goes to the client as an ask_user event and the turn waits for an answer. A prompt raised with nobody attached blocks its turn for as long as it takes rather than being answered by a rule; it is waiting in the snapshot the next client receives.

The agent asking for a rule

RequestPermission lets the agent propose one rule in the syntax above, a scope — this project or every directory — and one sentence you read before answering. Every call reaches you, and the prompt names the rule and the reach before anything is written, which is what makes it the only way a rule for every directory is written at all. No rule can approve this tool in advance and --allow-all does not cover it, because an agent that could grant itself the right to grant itself rights has no rule system at all. In headless mode it is refused like any other unconfirmed call.

The agent asking for one file outside the sandbox

RequestFileGrant is the one way past the working directory, and it is deliberately the narrowest one that is still useful: the agent names one absolute path and one sentence saying why, you answer at the permission prompt, and from then on the file tools reach that single file the way they reach a file inside the sandbox. It is what makes editing a dotfile or a config file in your home directory possible without starting a second session there.

What a grant isWhat it is not
Exactly one file, canonicalized before you are askedNever a directory, a glob, or a path prefix — each is refused by name
Alive for this session only, held in memoryNever written to a rule file, so it cannot outlive the process or be granted in advance
Answered by you at the promptRefused outright for a background child, which has no voice at a prompt
Listed by AgentInfo, so the reach of a session stays checkableRefused for a path leading through .git, and for one that is inside the sandbox already

What the sandbox actually guarantees

Every path the file tools resolve is canonicalized against the working directory. A path leading outside it is refused, and so is anything inside .git. The guarantee is therefore "this process reads and writes under its root, plus the individual files a grant named" — no flag widens it further, and there is deliberately no second escape hatch beside RequestFileGrant. When the program itself needs a file from elsewhere — the global CLAUDE.md, the skills directory, your config — it reads that directly at startup, outside the file layer the agent reaches, because that is the program's own configuration rather than the agent's reach.

A symlink is where reading and writing part, and it is worth knowing which is which. A read of a link that points outside the root resolves the link first and is refused, because the path it really names is outside. A write to that same link is not refused: the target is created or replaced through the link, so the bytes land where the link points. That is what makes a linked-in file — settings kept in one place and linked into several projects — writable at all, and it is the reason a link into somewhere you do not want written is a link worth not making. Nothing else about the guarantee changes: the link itself has to sit inside the root, and a path leading through .git is refused either way.

The kernel layer under a command

The whitelist decides which programs run; it cannot see what an allowed program does once it has started. cargo build executes build scripts, git honors hooks, make runs whatever the Makefile says. So every command goes through a second, kernel-enforced layer: on Linux a Landlock ruleset applied to the child just before it starts, with bubblewrap as the fallback where Landlock is unavailable and bwrap is installed. The command may read everywhere and write only under the working directory, under /tmp, into the caches a toolchain keeps outside the project — $CARGO_HOME or ~/.cargo, $RUSTUP_HOME or ~/.rustup, $XDG_CACHE_HOME or ~/.cache, and $TMPDIR where it is set, each only when it exists — and into the files a grant named. That set is read out of the file layer rather than configured a second time, so the two boundaries cannot drift apart. The caches are there because a boundary that forbids them forbids the ordinary work: cargo fetching a crate it does not have, rustup target add, and every pip or npm download write outside the project by design, and a sandbox that breaks them would be turned off rather than fixed. Existing device nodes under /dev stay writable, because a command that cannot open /dev/null is a command that does not run; creating or deleting anything there is still refused.

Two things are deliberately outside it. The network is untouched: which programs may run is the whitelist's answer, and a program that reaches the network was allowed to. And a command you granted sudo runs unconfined, because the escalation you approved and a sandbox that forbids gaining privileges cannot both hold.

When neither backend is available the command still runs, and the session says so once at startup rather than refusing to work — the whitelist stands either way, and a session that can run nothing is worse than one that names its missing second layer. AgentInfo reports which backend is in force.

The kernel layer is what makes it a boundary, and it stops at writes. cargo test and cargo build are allowed by default, and both execute code from the project — build.rs, procedural macros, the tests themselves — and the agent is what writes that code. Landlock keeps that code from writing outside the working directory; it does not keep it from reading your home directory or from opening a socket.

What the engine above it protects against is unintended reach: the stray rm -rf, the git push --force on the wrong branch, the command that was meant for another repository. For confinement of reads as well, run the whole program inside a container.