Rules / CFG104
CFG104 a committed Devin allow rule removes the approval prompt
Severity: warn
OWASP: LLM03:2026 – Excessive Agency
What cfgaudit checks
permissions.allow in .devin/config.json, its project-local twin .devin/config.local.json, and the permissions frontmatter of a .devin/skills/<name>/SKILL.md (whose allow entries, per Devin's skill reference, "are auto-approved during skill execution"), for two shapes only:
| Shape | Example | Why |
|---|---|---|
| a pattern that constrains nothing | Read(**), Write(**), Fetch(*), Exec(*), and the bare tool names exec, edit, read ("Match by tool name to control entire tools") |
every path or command in that category is auto-approved |
| a privileged or destructive binary with no argument constraint | Exec(sudo), Exec(rm), Exec(chmod), Exec(chown), Exec(docker) |
Devin matches a prefix as a whole word, so the rule covers the binary with any arguments |
Devin's matching is documented as prefix-with-word-boundary: "Exec(git) matches "git", "git status", "git commit -m 'msg'" but NOT "gitk" or "github-cli"." So Exec(rm) auto-approves rm -rf just as much as rm file.txt.
What a committed allow rule actually does
Devin's default when nothing matches is a prompt:
When the permission system evaluates a tool call and finds no matching rule ... you're prompted for approval
So a committed allow converts everyone is asked into nobody is asked.
It does not override a contributor who has denied the same thing. The lists merge across levels and deny is evaluated first over the merged set ("Deny rules — Checked first. If matched, the action is blocked immediately", and "Permission lists are merged (combined) across levels"). That is why this is warn rather than error, and why the message says "every teammate who has not written their own deny" instead of claiming the repository wins outright.
The precedence table does put the project file above the user's own config (organization > session > project local > project > user), and only organization-level denials are documented as un-overridable. The behaviour above was read from the documented evaluation order and corroborated by the client's message set, not confirmed by running a tool call, which needs an authenticated session. If that reading is ever shown wrong, the severity is what changes, not the finding.
What is deliberately not reported, with the numbers
Across 51 committed .devin/config.json and .devin/config.local.json files, 34 carry permissions.allow, together 500 rules, 297 of them distinct. The common entries are ordinary development commands:
13 Exec(find) 13 Exec(mkdir) 12 Exec(ls)
10 Exec(git status) 8 Exec(git diff) 8 Exec(cat)
A rule that reported every allow would fire on two thirds of the population and teach people to ignore it. What the tiers looked like:
| Tier | Rules | Files (of 51) | Reported |
|---|---|---|---|
| bare wildcard | 4 | 4 | yes |
| privilege / destructive | 9 | 5 | yes |
bare interpreter (Exec(bash), Exec(python3), Exec(node)) |
23 | 15 | no |
bare network binary (Exec(curl), Exec(ssh), Exec(scp)) |
9 | 7 | no |
The bare interpreters auto-approve arbitrary code just as effectively as Exec(sudo) does. They are left out because 15 of 51 files carry one: at that rate the rule would be describing how people ordinarily drive the tool rather than finding something wrong with a repository. That is the same call #459 made for CFG095's build-cache warn and CFG098 made for unpinned git sources.
Argument-constrained rules (Exec(git status), Exec(npm run)) are never a finding. deny and ask are the narrowing direction and are not read.
The false-positive run
The finished rule was run against the same 51 real files. It fires on 10 of them, 15 findings in total:
| Rule reported | Count |
|---|---|
Read(**) |
4 |
Exec(rm) |
4 |
Exec(chmod) |
3 |
Write(**) |
1 |
Exec(sudo) |
1 |
Exec(docker) |
1 |
Exec(chown) |
1 |
Every one of them is a real grant rather than a pattern that merely looks broad, which is what the tiers were chosen for. One in five files at warn is the rate this rule is meant to have; the two tiers left out would have taken it past one in two.
The local twin counts
.devin/config.local.json is gitignored by design, and 88 repositories commit one anyway; every sampled one carries an allow list. It outranks the shared file in Devin's precedence, so the same rule applies to it and the finding names which of the two files it came from.
Example
// .devin/config.json — committed
{
"permissions": {
"allow": [
"Exec(git status)", // fine, argument-constrained
"Exec(npm run)", // fine
"Read(**)", // CFG104
"Exec(sudo)", // CFG104
"Exec(rm)" // CFG104
]
}
}
Fix
Constrain the rule to what the project actually needs (Exec(git status) rather than Exec(git), a directory glob rather than **), or drop it and let each person answer the prompt once. A deny rule in a contributor's own user config keeps working either way.
Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation