Permissions
Devplane approves nothing on its own. It refuses a tool call (deny) or puts one in front of you (ask), in Claude Code's rule syntax, per repository, and allows only what you wrote in your own grant — failing closed when the rules will not load.
Devplane approves nothing on its own. A rule refuses a call (deny) or puts it in front of a person (ask). The only allow is a grant you wrote in your own ~/.devplane/policy.toml, never in a repository.
# devplane.toml
[policy]
deny = [
"Bash(rm *)",
"Read(.env)",
"mcp__*",
]
ask = [
"Bash(git push *)",
]~/.devplane/policy.tomlholds a machine-wide[policy]of the same shape.- The same rules answer watched sessions’ hooks (no host needed), driven runs’ permission requests and Devplane’s own actions. Every verdict is in the decision log, naming the rule.
Test a rule before you commit it:
devplane config explain 'pnpm test && rm -rf /'
devplane config explain --tool Read .env
devplane config explain --replay # every observed call against the current rules
devplane config check # does devplane.toml parse; is any rule refusedThe syntax is Claude Code’s
A prohibition copies between settings.json and devplane.toml unchanged. devplane doctor prints the baseline: rule syntax modelled on Claude Code 2.1.273. A rule here reaches more calls, always toward a prompt (stricter).
Tools
| You write | It matches |
|---|---|
Read, Bash, Bash(*) | every use of that tool |
mcp__github, mcp__github__* | every tool from the github MCP server |
mcp__github__get_* | that server’s get_ tools |
mcp__github__create_issue | one tool |
mcp__*, * | a glob over the whole tool name |
Commands
For Bash, PowerShell and Monitor the specifier matches tokens, not text. The line splits into simple commands on &&, ||, ;, |, & and newlines, with quotes removed as the shell would. A rule is a token prefix: the program, then its flags as a set, then operands.
| You write | Matches | Does not match |
|---|---|---|
Bash(npm run build) | npm run build | npm run build --watch |
Bash(npm run *) | npm run build, npm run test --watch, npm run | npm install |
Bash(npm run:*) | the same — :* is the form the permission dialog writes | |
Bash(ls *) | ls -la, ls | lsof |
Bash(rm *) | rm x, rm -rf /, /bin/rm -rf /, RM -rf /, r''m -rf / | rmdir x |
Bash(rm -rf /) | rm -rf /, rm -r -f /, rm -fr / | rm -rfv / |
:*counts only at the end; inBash(git:* push)the colon is literal.- A
Bash(…)rule also governsMonitor. config checkwarns when a rule’s second token is a flag cluster;Bash(rm *)is robust.- PowerShell is literal tokens.
PowerShell(Remove-Item *)stopsRemove-Item x; aliases (rm,del) are not resolved and go to a person under aPowerShell(…)rule.
A prohibition reaches every simple command on the line
A deny fires when any simple command matches. Bash(rm *) refuses all of these:
| Shape | Example |
|---|---|
| a compound line | ls && rm -rf / |
a subshell, backtick or sh -c (four levels deep) | ( cd /x && rm -rf . ), echo `rm -rf /`, sh -c 'rm -rf /' |
| a wrapper and its flags | sudo --user root rm -rf /, env -P /bin rm x, timeout 30 rm x, nohup, flock, busybox, builtin |
| a function body or coprocess | function f { rm -rf /; }, coproc rm -rf / |
| a brace expansion | {rm,-rf,/} |
| a here-string given to a shell | bash <<< 'rm -rf /' |
| a program named by its path or in another case | /usr/bin/rm -rf /, ./rm x, RM -rf / |
| a globbed program name | /bin/r? -rf x, /bin/r[m] -rf x — the shell picks the program, so a person is asked |
| a long flag abbreviated | rm --rec --for / reads as rm -rf / |
| a wrapper the reader does not know | pkexec rm -rf /, unbuffer rm x, taskset 1 rm x — a program that may run its arguments, one of which a rule prohibits, is put to a person |
Quoted text is data: echo "a; rm -rf x" meets no rm rule; grep -e 'rm -rf' f is a pattern.
A
Bashrule reads the line. It cannot follow a program that decides at run time what to execute; for containment, use a sandbox.
Paths
For Read and Edit the specifier is a gitignore pattern (* within a segment, ** across). Edit(…) covers every built-in writing tool; Read(…) every reading one, Grep, Glob and LSP included. A Read deny also blocks writing that path with a file tool, except NotebookEdit (needs an Edit deny).
| You write | Anchored at | Example |
|---|---|---|
//path | the filesystem root | Read(//tmp/**) |
~/path | your home directory | Read(~/.ssh/**) |
/path | the file the rule is written in | Read(/secrets/**) in a devplane.toml means that repository’s secrets |
path, ./path | the agent’s working directory | Edit(src/**) |
A bare filename matches at any depth (Read(.env) is Read(**/.env)). Symlinks resolve at both ends: config/key -> ~/.ssh/id_rsa does not get past Read(~/.ssh/**).
Path rules reach shell commands
A Read or Edit rule also governs the files a shell command names:
| Command | Reached by |
|---|---|
cat .env, grep TOKEN .env, head .env, less .env, base64 < .env | Read — the operands of known readers, and input redirections |
cp .env /tmp/b, mv .env x, rm .env | Read — never looking at a file also means never moving or replacing it |
cp /tmp/a .env, tee .env, touch .env, dd of=.env, ln -s x .env | Edit and Read |
echo x > .env | Edit only — an output redirection is Edit business |
grep -r key secrets | a recursive command reaches everything under its directory |
cat .en?, cat conf* | a glob operand that could expand onto the protected file |
To protect a file from a shell, write both halves (config check notes a missing one):
[policy]
deny = ["Read(.env)", "Edit(.env)"]Not reached: a program that opens the file itself (a script, a build), a path inside an option value (grep -f.env x), and cat * (does not expand onto dotfiles).
Exceptions, with !
[policy]
deny = ["Bash(git *)", "!Bash(git status *)"]- An exception applies per simple command:
git status && git push --forceis refused. - It is scoped to its own file, so a project cannot cancel a machine-wide prohibition.
Hosts and parameters
[policy]
deny = [
"WebFetch(domain:evil.example)",
"Agent(isolation:worktree)",
]domain: matches the parsed host, not a substring. Tool(param:value) matches a top-level input field (* wildcard); an omitted parameter never matches.
Devplane’s own files are protected
A built-in prohibition sits beneath your rules. An agent may not edit Devplane’s files in ~/.devplane (policy.toml, agents.toml, the store, token, host.json), read the token, or edit its repository’s devplane.toml.
- It covers file tools, and shell commands that write, truncate, move, remove or redirect into those files or remove, move or chmod
~/.devplane, including aftercd,pushdorgit -C. - The same on a directory above them (
rm -rf ~,rm -rf .in the project) is asked about.
$ devplane config explain 'echo x > devplane.toml'
deny Bash
by writes `/home/you/code/saas/devplane.toml` (built in: an agent may not change Devplane's own rules, records or credentials, nor read its token)- An unknown program is refused on
~/.devplanefiles and asked about ondevplane.toml(git add devplane.tomlis ordinary,sed -iis not). - A name the reader cannot resolve (a variable, a glob, an untraceable
cd) is asked about. A spelling that never names them (~/.dev*/tok*), or a script opening the file itself, is not seen. - Reading the rest stays allowed. Edit
devplane.tomlyourself and commit it.
Order: deny wins
deny, across both filesask- unresolved: the line hides what it runs, so a person is asked
- your own grant, where a person was about to be asked
- undecided: no answer; your agent asks as usual
Specificity does not change the order, and neither file cancels the other’s prohibitions. The governing devplane.toml is at the root of the checkout the command runs in. A worktree takes the rules and gates of its owning checkout, so relaxing a rule on the agent’s branch changes nothing.
Rules that cannot work are refused
devplane config check and devplane change start report these before an agent starts:
| Refused | Why |
|---|---|
Write(src/**), Glob(src/**), NotebookEdit(x) | file permissions are checked only against Read(…) and Edit(…); these are never consulted |
mcp__github(create_issue) | an mcp__ rule with brackets is skipped on load |
Bash(command:rm *) | ignored because a compound command bypasses it; write Bash(rm *) |
Agent(researcher) | that tool has no field for a bare specifier to match |
Bash(rm -rf * | the bracket is never closed |
And one warning, because its list is a snapshot of Claude Code’s tool reference:
| Warned about | Why |
|---|---|
Bahs(rm *), Stop Task | not a tool name Claude Code documents, so the rule matches nothing. A transcript’s label is not always the rule name: Stop Task is written TaskStop |
config check notes that Cd(<path>) rules govern a slash command, not a tool call. Keep them in settings.json.
Rules that do nothing
devplane config check reports a valid rule an earlier rule in the same list already covers:
$ devplane config check
policy 2 deny, 0 ask
deny Bash(rm *)
deny Bash(rm -rf /tmp/build)
unused `Bash(rm -rf /tmp/build)` does nothing: `Bash(rm *)` above it already covers
every call it names- It decides by pattern containment, says nothing when it cannot prove it, and skips lists with a
!exception. - It also names allow rules in
.claude/settings.jsonthat grant more than they look like (Bash(python:*)approvespython -cwith any code). It writes nothing.
Where Devplane is stricter than Claude Code
Always toward a prompt:
- wrappers and
-care looked through; - program names match through the path, case-folded;
- flags are a set, even in an exact rule;
- more writers meet
Edit(cp,install,rsync,ln,truncate,dd of=); - an unreadable line is asked about.
When a line cannot be read, a person is asked
$ devplane config explain 'rm$IFS-rf node_modules' # deny = ["Bash(rm *)"]
ask — unreadable Bash
because the program is named by a variable the shell expands; `Bash(rm *)` constrains what Bash may run| Shape | Example |
|---|---|
| a program name the shell builds | rm$IFS-rf x, $(echo rm) -rf x, X=rm; $X -rf / |
| a command substitution anywhere on the line | echo $(ls), echo `date` |
| an argument the shell rewrites that could make a rule match | rm $F / under Bash(rm -rf /); cat $F under Read(.env) |
| a variable that names something run later | GIT_SSH_COMMAND=… git fetch, LD_PRELOAD=… ls |
| a command line built from input | xargs rm -rf, eval "$cmd" |
| code in a string, file or stdin | python -c "…", node -e "…", python3 script.py, curl … | sh |
| a command run somewhere else | ssh host …, docker run …, su, chroot |
find that runs or deletes | find . -delete, find . -exec … |
| a wrapper flag the reader does not know | sudo --made-up x rm … |
| more than 8 wrappers before the command | sudo sudo … sudo ls |
| a line past 65,536 characters | — |
- This applies only where the project has rules for the tool. A rule that answers the call wins.
- The audit outcome is
unresolved, underrule: your rules made it a question. - A heredoc fed to a program that does not execute it (
cat,tee,grep,jq, …) is data. Fed tobash,| sudo bashor| python3, it is read.
When the file is broken, every call is asked
A devplane.toml that will not load fails closed: every gated call in that repository goes to a person, naming the file and the error. A broken ~/.devplane/policy.toml does the same machine-wide. A critical inbox item stays until the file loads.
$ devplane config explain 'cat .env'
ask — unreadable Bash
because devplane.toml is not valid: … unknown field `polcy` …; until it loads, a person decides every callAuto mode
Auto mode never prompts, so PermissionRequest never fires in it. devplane agent connect claude installs two synchronous hooks:
| Hook | Fires | Carries |
|---|---|---|
PreToolUse | before every tool call, in every mode | a prohibition, or nothing |
PermissionRequest | when a person was going to be asked | the same, plus the request to show you |
PreToolUse never answers allow, because an allow there would skip Claude Code’s own checks. PermissionRequest answers allow only for your own grant. devplane status --modes shows which sessions run without prompts; devplane doctor probes the gate and prints the round trip.
Answering a watched session’s permission
A permission in a session you started can be answered from Devplane when the project sets a hold:
[questions]
hold = true # or "45s"- A call your
askrules matched becomes an ask, and the hook waits for the hold. - Answer in the Inbox or with
devplane inbox answer <ask> --allow(or--deny). The first answer wins, recorded asperson. Unanswered, it is recorded asnobodyand your agent shows its own dialog. - A prohibition applies first and is never held.
- An agent cannot answer its own permission (Security).
- With the app running, the hold’s notification carries Allow and Deny (below).
Answering from the notification
With devplane up --window running, each permission or question that can be answered gets one notification.
| On the notification | What happens |
|---|---|
| a permission | Allow (this one call) and Deny, nothing wider. An agent’s always allow is answered in the window, where its scope is shown. A watched session’s permission has the actions only while its hold lasts |
| a question with one field | every option as a numbered action, or none. With too many options, several questions or free text, the body says how many options wait |
| a click | opens the answer window on that item |
| an action | answers as you, through the window’s short undo hold (z). Dismissing or a timeout answers nothing |
| answered elsewhere | taken back where the platform allows; a kept one answers nothing and opens the item, saying who answered |
Quitting the app takes every notification back. Nothing retries a notification, raises its interruption level or plays its own sound; Do Not Disturb and Focus hold it like any other.
What each platform offers is in the window.
Where an answer came from
Every answer is recorded as yours, with its path, in the decision log, devplane inbox --all and the answer window:
| Path | Recorded when |
|---|---|
window | the app’s own window, proved per question with a key held only in the app’s memory |
notification | an action pressed on the app’s notification, handled inside the app |
cli | anything else holding the control token: devplane inbox answer, a script, a browser tab on devplane up |
No request header or body changes the path. It cannot tell you from a program driving your desktop (scripting, accessibility), or from a process running as you that reads the window’s key from memory.
Grants: your own rule, per project
A grant is an allow you wrote for one project in your own file: your answer, given in advance.
# ~/.devplane/policy.toml
[grants] # when one is offered; default 5, at least 2
offer_after = 5
offer_again_after = 5
[projects."/home/you/code/saas"]
allow = [
"Bash(cargo test)",
{ rule = "Bash(cargo clippy --all-targets)", until = "2026-12-01" },
][projects."<root>"] may set its own offer_after and offer_again_after. until takes a date (start of that day, UTC) or a timestamp; past it, the grant answers nothing and the item says it lapsed.
- Offered, not asked for. After you answer the same call the same way
offer_aftertimes from the window or a notification, with no refusal among them, its permission item offers the exact rule and the lines it adds. Nothing is written until you confirm. Command-line answers do not count: any holder of the token can send one. - Only after every prohibition. Deny, ask and an unreadable line all win. A grant answers a driven run’s request (with the agent’s allow once) and a Claude Code
PermissionRequest; neverPreToolUse, Codex or Copilot hooks. - Narrower than an allow list. A line is allowed only when every command on it is granted, no argument is rewritten by the shell, nothing is redirected to a file and no wrapper (
sudo,env) stands in front. A call whose input never arrived is never granted. A path grant reaches only files inside the project. - Refused by name, by
devplane config checkand on every load: a machine-wide[policy] allow, a wildcard command (Bash(*)), a program that runs any code (Bash(python *)), a forge write (git push,gh), any tool butBash,ReadandEdit, a path outside the project, and a threshold below 2. A repository’sdevplane.tomlcannot grant. - Recorded and named. Every call a grant allows is a
ruledecision naming the grant. Where the agent’s own settings would have asked, the record, the offer,devplane config checkanddevplane doctorsay the grant is widening the agent. - Revoking lists what it did.
devplane config grantsshows each grant, the calls it decided and how many were later reversed by a failed gate.devplane config revoke '<rule>'removes it and lists every decision it made. - Devplane does not know what a granted command itself touches, and the offer says so.
- The built-in rule above keeps agents from editing
policy.toml. A process running as you can still write it.
Tuning the rules
- Asked too often: an inbox permission carries the narrowest allow rule and the
settings.jsonfor it.devplane config explain --replaysuggests allow rules from past calls. - A rule too tight: five refusals in one run raise a
refuseditem naming the rule.
Something wrong or out of date? Edit this page.