Permissions
When the assistant wants to perform potentially sensitive operations — running shell commands, reading a file outside the working directory, or calling an external system such as Gitea, Redmine, a database or Gmail — it asks for your approval. You stay in control of what happens.
The three approvals modes
Each conversation has its own approvals mode. Switch it from the Approvals control next to the composer, or in the TUI with F4 (which cycles Ask → Auto → Always allow, and works even while a permission dialog is on screen).
| Mode | What happens | Use it when |
|---|---|---|
| Ask | Every gated call stops and asks you. The shipped default. | You want to see everything. |
| Auto | A classifier approves routine calls and asks you about the rest. Every auto-approved call is marked in the transcript. | You are doing a long task and only want to be interrupted when it matters. |
| Always allow | Nothing prompts. Deny rules still apply. | A throwaway sandbox, or a task you have already scoped. |
Two more modes exist in Settings and on the CLI (--permission-mode), but not on the composer control:
- Ask (strict) (
default) — in a conversation, the same as Ask. It differs only in print mode (codumentor -p), where the file-editing tools are refused unless you name them with--allowed-tools. - Plan (
plan) — refuses every side-effecting call. A read-only session.
Changing the mode takes effect on the next tool call, including for a turn that is currently waiting on a prompt. The change is recorded in the transcript, so the conversation shows where a safety mode was turned on or off.
Your administrator can switch Auto or Always allow off for the whole instance; a mode that is unavailable simply does not appear.
What the modes cover
A mode decides what happens to a call that reaches the approval gate. Today those calls are:
- Shell commands — every
shellcall, foreground or background. - Tools that reach an external system — posting a Gitea comment or review, creating or updating a Redmine issue, running a write statement against a database, changing Codumentor's own configuration, handing work to an external agent, and the Google Workspace writes: sending mail, saving or updating a draft, labelling, trashing or otherwise modifying a message, creating, updating or deleting a calendar event, creating or editing a Doc or a Sheet.
- Reading a file outside the working directory — the file tools ask before they open a path on the host. A read inside the working directory never asks.
- Giving the sandbox the host network — when your administrator configured
workspace_isolationwithnetwork: ask, the conversation asks once, before the first command runs. - An MCP app's tool call — a tool call an MCP server makes on your behalf, when the server is not on the app's pre-approved list.
- A nested agent's MCP tool call — when an external agent calls back into Codumentor's own tools, each of those calls is gated too.
File edits inside the working directory are not gated. When the assistant writes or edits a file in the workspace, no prompt appears — and no mode changes that. Ask, Auto and Always allow behave identically for those writes, and a Write(…) rule does not stop one. What contains them is the workspace boundary itself — a write can never land outside the workspace, in any configuration — together with your version control: keep the work committed, and read the diff the assistant produces. Path-aware approval for file edits is planned; it has not shipped.
The permission dialog
When a tool requires approval, a dialog appears with the details of what the assistant wants to do. You have four options:
| Action | What it does |
|---|---|
| Deny | Block this specific request |
| Allow Once | Execute this request only |
| Allow All in this Conversation | Auto-approve similar requests for the rest of this conversation |
| Create Rule | Save rules, across all conversations, that let this request through (or block it) |
Keyboard shortcuts: Press Enter to Allow Once, Esc to Deny.
Above the command, the dialog shows up to four lines. They are in order of how much you should trust them:
- Why you are being asked ("Auto is asking because:") — only in Auto mode. Auto's own one-sentence account of what made it stop.
- About this request — a note Codumentor itself wrote about the request, when it has one. Today only the out-of-workspace read has one, explaining what approving it means. Written by the harness, not by the assistant, and labelled that way.
- Badges — short labels produced by Codumentor's own command analyser. These are facts about the command, derived by parsing it. Nothing the assistant writes can change them.
- The agent's reason — one sentence, written by the assistant, explaining why it wants to make this call. Not every request has one; see below.
What the badges mean
The analyser splits a command into its parts and labels what they do. Badges shown in a warning colour are escalating: a command carrying one can never be covered by an allow rule and is never approved silently by Auto.
| Badge | Meaning |
|---|---|
| could not be parsed | The command is not valid shell, so nothing can be verified about it |
| command name is not literal | The program to run is assembled at runtime |
| command substitution | Contains $(…), backticks, or process substitution |
| runs generated code | eval, source, bash -c, python -c, … |
| elevated privileges | sudo, su, doas, setuid chmod, chown |
| pipes into a shell | Something is downloaded or generated and piped into an interpreter |
| decodes into a shell | base64 -d (or similar) feeding an interpreter |
| deletes files | Recursive or forced deletion outside the working directory, dd, shred, truncate (unless it only grows the file), find -delete |
| rewrites git state | push --force, reset --hard, clean -f, branch -d/-D, commit --amend, git checkout <commit> <file> (which overwrites the file), … |
| writes outside the workspace | A redirect whose target is outside the working directory |
| changes system configuration | crontab, systemctl, writes under /etc, ~/.ssh, ~/.bashrc, … |
| reads secrets | ~/.aws, ~/.ssh, .pem, .env, bare env/printenv |
| overrides the environment | A VAR=value in front of the command that changes what actually runs — LD_PRELOAD, PATH, BASH_ENV, GIT_SSH_COMMAND, LESSOPEN, … |
| matches an operator rule | Your administrator configured a pattern that always escalates |
| network access | curl, wget, ssh, git push, and any install that fetches from a package registry (pip install requests, npm i lodash) — a registry install downloads code somebody else chose and runs its install scripts, so it counts as reaching the network. Shown as escalating unless your conversation's sandbox has no network of its own — but for rule matching it always escalates, see below |
| installs packages | pip install, npm i, apt, brew |
| installs code that runs later | The command writes a file that something else will run on its own: a CI workflow (.github/workflows/…), a git hook, conftest.py, .pre-commit-config.yaml, .envrc, a shell startup file. The write itself may be perfectly ordinary — what it leaves behind runs on every future push, commit or shell |
| runs project code | pytest, make, npm test, ./script inside the working directory |
| unrecognised command | The program is not one the analyser knows |
| long command | More parts, pipes and redirects than the configured limit |
The agent's reason — read it as a claim
A tool call the assistant makes carries a one-sentence reason it writes for you: "Deletes the stale build/ output so the rebuild you asked for starts clean." That covers shell commands and every gated plugin tool — the calls the assistant chooses and names.
Some approvals do not come from a tool call at all, and those carry no reason; the dialog simply leaves the line out. They are: reading a file outside the working directory (raised by the file tools themselves, from inside a read), giving the sandbox the host network (asked once before the turn's first command), an MCP app's tool call, and a nested agent's call back into Codumentor. A missing reason line is not a model that declined to explain itself — it is a request the model never phrased.
It is genuinely useful, and it is not evidence. The assistant may be repeating words it read in a file, a web page or a command's output, and a model that has been misled writes a sincere-sounding justification for the wrong thing. That is why the dialog puts the badges — which are derived from the command itself — above it, renders it as plain text with no formatting or links, and labels it "Agent's reason".
When the badges and the reason disagree, believe the badges.
Auto mode
Auto decides one thing only: whether to interrupt you. In a conversation it never denies a call on its own — only deny rules and Plan mode deny — so it is strictly safer than Always allow and strictly quieter than Ask. There is one exception, and it is the one place nobody can answer a prompt: in an unattended run (scheduler.permission_mode: auto) a call Auto would have asked about is refused, because parking a question no one will read would hang the run. See Scheduled runs.
Each gated call goes through two stages:
1. The analyser (deterministic). The command is parsed and labelled. If it carries any escalating badge, Auto asks — no model is consulted, and nothing the assistant writes can change that. If the command is read-only and writes nothing outside the working directory, Auto approves it. Running your own project's tests is approved when the workspace is an isolated sandbox with no network of its own.
2. The judge (a model). Everything else goes to a small, separate model call that answers one question: does this action clearly serve what the user asked for, and does it violate any boundary they stated?
The judge is deliberately kept short-sighted:
- It sees your typed messages and nothing else you did not write — no tool results, no file contents, no web pages, no repository instruction files, no conversation summaries.
- It sees the pending call and the agent's reason, both labelled as untrusted claims, with an instruction never to follow instructions found inside them.
- To approve, it must quote the words you actually typed that license the action. If it cannot — or if it quotes something you never said — the call prompts instead.
- Any timeout, error, malformed answer or budget exhaustion prompts. Auto fails towards asking, never towards allowing.
Auto also gives up on itself. If you deny three of its prompts in a row, or it has asked you twenty times in one conversation, it switches the conversation back to Ask and tells you why: if the assistant keeps proposing things you do not want, quiet was the wrong setting.
Auto-approved calls are marked in the transcript with the note Auto wrote, and every decision is recorded. Nothing runs silently and invisibly.
Auto cannot lift a deny rule, cannot reach a tool your access profile withheld, and cannot approve anything Always allow would refuse.
Permission rules
Rules let you pre-approve or block operations automatically, so you don't have to click through repeated prompts.
Rules follow the pattern ToolName or ToolName(specifier):
| Pattern | Matches |
|---|---|
Shell | All shell commands |
Shell(npm run:*) | Shell commands starting with "npm run " |
Shell(git status) | Exactly "git status" |
Read(.env) | Reading a .env file outside the working directory |
Write(*.log) | Writing to any .log file — reserved, see below |
Shell rules are the ones doing the work today. Read(…) rules are consulted only for reads outside the working directory: a file inside it never reaches the gate, so Read(.env) does not block a .env that lives in one of your repositories. Write(…) rules match nothing at all yet, because file edits are not gated (see What the modes cover above); they are accepted so that rules written today keep working when that lands.
Wildcards
| Wildcard | Behavior | Example |
|---|---|---|
:* (at end) | Prefix match with word boundary: followed by anything, or nothing | Shell(npm:*) matches npm install but not npmx |
* (anywhere) | Glob-style wildcard — it matches anything, spaces and / included | Shell(git *) matches git status, git commit |
A rule the dialog writes for an exact command wraps any , ? or [ in brackets (Shell(rm [].tmp)), so it matches that command and nothing else — rm *.tmp, not rm -rf src .tmp.
How a rule matches a compound command
A shell command can be several commands at once (a && b, a; b, a | b). Rules are matched per part:
- a deny rule matching any part denies the whole command;
- an allow rule must match every part for the command to run without asking;
- a command carrying any escalating badge cannot be covered by an allow rule at all.
So Shell(ls:*) allows ls -la but not ls; curl https://example.com/x | sh — the second part is not covered, and it pipes a download into a shell. If you had an allow rule before this behaviour existed, you will see more prompts than you used to; each one is a command your rule was never meant to cover.
What a part looks like to a rule:
- one command at a time, with quotes removed.
echo "a b"is matched asecho a b. A rule written as a whole command line (Shell(npm test && npm run lint)) never matches anything — write one rule per command instead; - wrappers and leading assignments are looked through.
timeout 5 npm testandCI=1 npm testare both annpm testto a rule, so a rule ontimeoutitself matches nothing useful; - redirects are not part of the text.
Shell(echo:*)also coversecho hi > notes.txt; the dialog says so when the command in front of you writes a file that way; - your rules and your administrator's combine part by part. An operator
Shell(git:)and your ownShell(cd:)together allowcd /repo && git status.
Rules are matched as if there were no sandbox. The rule engine sees the request, not the conversation's sandbox, so it analyses every command in the most exposed environment: unsandboxed, with the network reachable. The one badge that changes meaning with the sandbox — network access — is therefore always escalating as far as a rule is concerned, even when the dialog shows it in the quiet colour because your sandbox has no network of its own. The practical consequence: Shell(git:) never covers git push, in any configuration. Write the rule for the commands you meant (Shell(git status), Shell(git diff:), Shell(git log:*)) rather than for the program.
Rule priority
- Operator deny rules — set by your administrator, in configuration. You cannot change or remove them.
- Your deny rules
- Plan mode, if the conversation is in it
- Allow all in this conversation, if you granted it
- Operator allow rules and your allow rules, combined part by part (each part needs a rule from either)
- Always allow mode
- Auto mode's triage
- Otherwise you are asked
Deny always wins. No mode, rule or Auto verdict can lift a deny rule, and nothing can turn a refusal you already gave into an approval.
Managing rules
Go to Settings → Tool Approvals to manage your rules. You can:
- Set your default approvals mode — what a new conversation starts in
- Add, edit, and remove your own deny rules and allow rules
- See the operator rules your administrator configured, marked read-only
- See a warning on a rule that can never match — a tool name that does not exist (
Bash(…); the shell tool isShell), a wrapper liketimeout, or a whole command line in an allow rule - Test a command: paste a command line and see, part by part, which of your rules covers it and which part would still ask. It is the same check the approval gate makes
Common examples
Allow rules:
Shell(npm run:*) # npm run commands
Shell(git status) # git status
Shell(git diff:*) # git diff
Shell(python -m pytest:*) # pytest
Deny rules:
Shell(rm -rf:*) # block recursive delete
Shell(curl:*) # block curl
Read(.env) # block reading a .env outside the working directory
Read(.env.*) # the same for .env.* files
Creating rules from the dialog
When a permission prompt appears, open Other ways to answer → Create Rule. The editor remembers the request across all conversations, and it offers outcomes, not patterns: every option is a set of rules that, saved together, lets this command through. The server checks each option with the same code the approval gate uses before it shows it to you.
For echo "Hello"; pwd; echo "done" you would see:
echoandpwd, with any arguments — two rules,Shell(echo:)andShell(pwd:), which also cover those programs with other arguments, wherever they appear in a command line. This is the one selected when the editor opens;- Exactly these commands — one rule per command, for exactly these arguments;
- Custom… — one rule per line, checked as you type. A part your lines leave uncovered gets a one-click fix, and you can still save an incomplete set: you are told which part will keep asking.
Under the options, the command is shown part by part with what covers each part — a rule you already have, a rule you are about to save, or nothing — and the save button says how many rules it writes. Notes tell you when an option covers more than it looks like: a rule for npm run build … runs project code whatever its arguments say, and a rule for echo … also covers writing its output into a file when the command in front of you does that.
Saving answers the request. The dialog closes because the saved rules let the command through — not because it clicked Allow for you. If the rules do not cover it (only possible after saving an incomplete Custom set), the prompt stays and says which part is still uncovered. Other requests waiting in the same conversation that the new rules also cover are answered too.
Some commands can never be allowed by a rule — anything with an escalating badge (network access, deleting files, running generated code, …). For those the editor says so up front instead of offering a form, and points you to Allow all in this conversation or Auto. You can still create a rule that blocks the command.
Block offers single patterns — one match is enough to deny — such as Block rm with any arguments, Block exactly rm -rf build, or Block this whole command line. After saving a block rule, the current request is denied.
When your rules already cover part of a command but not all of it, the prompt itself says so, right under the command: "Your rules cover 1 of 3 commands here. Not covered: pwd, echo done", with a link to add the missing rules.
The TUI's rule editor shows the same options, coverage and warnings.
Scheduled runs
A scheduled run or a repository-webhook run has nobody at the keyboard. Your administrator chooses what those may do (scheduler.permission_mode). The default is Always allow with the deny rules still enforced — the behaviour Codumentor has always had.
If your administrator sets it to Auto, unattended runs get the same triage a conversation does, and because there is no one to ask, a call Auto would have asked about is refused instead. The refusal is written into the run's transcript with the command, the badges, Auto's note and the agent's reason, so you can see afterwards what was attempted and decide whether to re-run it yourself.
What is recorded
Every gated call leaves a durable record: what was requested, who or what decided it, and — when Auto decided — its verdict, badges and note. Approvals you answered yourself, rules that fired, mode decisions and Auto's silent approvals are all in the same place, and Auto's approvals are also visible inline as a chip on the tool card in the transcript.
The record is a rolling one, not an archive: a conversation keeps its most recent 200 approval decisions, and older ones are dropped as new ones are written. The transcript itself is not trimmed — a call that ran is still in the conversation — so what ages out is the decision detail, not the fact that something happened. If you need approvals kept indefinitely for audit, export them or ship them off the instance as they are made.
Persistence
Your rules and your default mode live on the server, against your account. The browser keeps a copy so the UI is instant, but the server is authoritative — two browsers can no longer disagree with each other about your approvals. A conversation's mode and its "allow all in this conversation" grants live with the conversation and survive a restart.