Devplane

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.toml holds 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 refused

The 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 writeIt 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_issueone 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 writeMatchesDoes not match
Bash(npm run build)npm run buildnpm run build --watch
Bash(npm run *)npm run build, npm run test --watch, npm runnpm install
Bash(npm run:*)the same — :* is the form the permission dialog writes
Bash(ls *)ls -la, lslsof
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; in Bash(git:* push) the colon is literal.
  • A Bash(…) rule also governs Monitor.
  • config check warns when a rule’s second token is a flag cluster; Bash(rm *) is robust.
  • PowerShell is literal tokens. PowerShell(Remove-Item *) stops Remove-Item x; aliases (rm, del) are not resolved and go to a person under a PowerShell(…) rule.

A prohibition reaches every simple command on the line

A deny fires when any simple command matches. Bash(rm *) refuses all of these:

ShapeExample
a compound linels && 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 flagssudo --user root rm -rf /, env -P /bin rm x, timeout 30 rm x, nohup, flock, busybox, builtin
a function body or coprocessfunction f { rm -rf /; }, coproc rm -rf /
a brace expansion{rm,-rf,/}
a here-string given to a shellbash <<< '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 abbreviatedrm --rec --for / reads as rm -rf /
a wrapper the reader does not knowpkexec 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 Bash rule 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 writeAnchored atExample
//paththe filesystem rootRead(//tmp/**)
~/pathyour home directoryRead(~/.ssh/**)
/paththe file the rule is written inRead(/secrets/**) in a devplane.toml means that repository’s secrets
path, ./paththe agent’s working directoryEdit(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:

CommandReached by
cat .env, grep TOKEN .env, head .env, less .env, base64 < .envRead — the operands of known readers, and input redirections
cp .env /tmp/b, mv .env x, rm .envRead — 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 .envEdit and Read
echo x > .envEdit only — an output redirection is Edit business
grep -r key secretsa 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 --force is 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 after cd, pushd or git -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 ~/.devplane files and asked about on devplane.toml (git add devplane.toml is ordinary, sed -i is 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.toml yourself and commit it.

Order: deny wins

  1. deny, across both files
  2. ask
  3. unresolved: the line hides what it runs, so a person is asked
  4. your own grant, where a person was about to be asked
  5. 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:

RefusedWhy
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 aboutWhy
Bahs(rm *), Stop Tasknot 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.json that grant more than they look like (Bash(python:*) approves python -c with any code). It writes nothing.

Where Devplane is stricter than Claude Code

Always toward a prompt:

  • wrappers and -c are 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
ShapeExample
a program name the shell buildsrm$IFS-rf x, $(echo rm) -rf x, X=rm; $X -rf /
a command substitution anywhere on the lineecho $(ls), echo `date`
an argument the shell rewrites that could make a rule matchrm $F / under Bash(rm -rf /); cat $F under Read(.env)
a variable that names something run laterGIT_SSH_COMMAND=… git fetch, LD_PRELOAD=… ls
a command line built from inputxargs rm -rf, eval "$cmd"
code in a string, file or stdinpython -c "…", node -e "…", python3 script.py, curl … | sh
a command run somewhere elsessh host …, docker run …, su, chroot
find that runs or deletesfind . -delete, find . -exec …
a wrapper flag the reader does not knowsudo --made-up x rm …
more than 8 wrappers before the commandsudo 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, under rule: your rules made it a question.
  • A heredoc fed to a program that does not execute it (cat, tee, grep, jq, …) is data. Fed to bash, | sudo bash or | 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 call

Auto mode

Auto mode never prompts, so PermissionRequest never fires in it. devplane agent connect claude installs two synchronous hooks:

HookFiresCarries
PreToolUsebefore every tool call, in every modea prohibition, or nothing
PermissionRequestwhen a person was going to be askedthe 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 ask rules 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 as person. Unanswered, it is recorded as nobody and 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 notificationWhat happens
a permissionAllow (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 fieldevery option as a numbered action, or none. With too many options, several questions or free text, the body says how many options wait
a clickopens the answer window on that item
an actionanswers as you, through the window’s short undo hold (z). Dismissing or a timeout answers nothing
answered elsewheretaken 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:

PathRecorded when
windowthe app’s own window, proved per question with a key held only in the app’s memory
notificationan action pressed on the app’s notification, handled inside the app
clianything 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_after times 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; never PreToolUse, 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 check and 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 but Bash, Read and Edit, a path outside the project, and a threshold below 2. A repository’s devplane.toml cannot grant.
  • Recorded and named. Every call a grant allows is a rule decision naming the grant. Where the agent’s own settings would have asked, the record, the offer, devplane config check and devplane doctor say the grant is widening the agent.
  • Revoking lists what it did. devplane config grants shows 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.json for it. devplane config explain --replay suggests allow rules from past calls.
  • A rule too tight: five refusals in one run raise a refused item naming the rule.

Something wrong or out of date? Edit this page.