Permission rules are what turn the wish "be careful" into a checkable rule. Three lists - deny, ask and allow - decide the fate of every tool call, and the order between them is not cosmetic but strict: first deny, then ask, then allow. Understanding this order matters more than memorizing the syntax: the syntax can be looked up, while a wrong priority model silently distorts every rule at once.
The naive move is to write a generous allow so the agent does not nag over trifles, and add a couple of deny rules "just in case", thinking a broad allow and a pointed deny will coexist by common sense. The expectation: rules combine however is convenient for a human, and if something is both allowed and forbidden, the newer or the more specific one wins.
It breaks on the actual order of checking. Rules are evaluated strictly: deny blocks, ask requires approval, allow auto-approves, and with no match the default applies - a prompt. If the same call matches several rules, the stricter outcome wins: deny always beats ask and allow. There is no "more specific wins" - specificity does not cancel strictness.
To the order within one config is added the order between sources. Organizational rules rank above session grants, those above project-local (.devin/config.local.json), those above project (.devin/config.json), and below all of them the user config. And over everything is a hard rule: organizational deny cannot be overridden by project or user config. Your generous allow is powerless against an org deny, and that is by design. The practical takeaway from the hierarchy is simple: you write your own config as the weakest voice in the choir, and it can count only on what is not forbidden above.
The working principle is to start narrow. Allow specific safe commands rather than whole tools: not Exec as a whole but Exec(git status), Exec(git diff), Exec(npm run test), Exec(npm run lint); read with separate prefixes. Keep in ask what changes the working tree: Write(src/), Write(tests/), Exec(git). Put in deny the irreversible and the secret: Write(.env), Write(**/.pem), Exec(rm), Exec(sudo). Below is how this looks in .devin/config.json. The point of a narrow start is not distrust of the agent but the cheapness of verification: a list of a dozen explicit allow rules is read by eye in a minute, while a broad "allow everything in the project" cannot be verified at all.
A subtlety easy to overlook: Exec(git) covers not only reads but also mutating subcommands - commit, push, reset. By allowing Exec(git) as a whole, you also allowed what you meant to keep under question. So for status and diff it is better to use separate narrow prefixes in allow, while keeping the general Exec(git) in ask, where it will raise a card before a change.
Why the order is exactly deny, ask, allow and not the reverse. Because a safe system should err toward refusal: if rules conflict, it is cheaper to ask once more or to block than to silently allow the irreversible. The strict priority of deny makes a refusal predictable - it cannot be accidentally overridden by an allow added later or in another file. The predictability of a refusal is exactly what rules are set up for.
The cost of a wrong priority model is concrete. Writing a broad allow and hoping a deny somewhere backs it up still works, because deny is stronger. But the reverse - relying on allow where the org config silently holds a deny - leaves you with an agent that "for some reason does not do the allowed thing". And the most expensive one: Exec(git) in allow, taken for safe, will one day commit or push without a prompt.
You should verify rules not by reading the config with your eyes but by behavior at the boundary. Hand the agent an action that should prompt and confirm the card came up; hand it one that should be blocked and confirm it was blocked, not slipped through. Reading the config and running a check call are different methods, and the agreement of their answers is the proof that the rules are set as intended. Such a check is worth repeating after every config edit and after connecting a new rule source: a single added org policy can silently change an outcome that was different yesterday.
The typical failures are about a wrong priority model. "I forbade it and it ran" - almost always means the deny was written on the wrong scope, not that deny is weak. "I allowed it and it asks" - an ask or an org policy stands higher in the hierarchy. "The rule does not apply" - it is in the user config, overridden by the project or the organization. The sign is the same: rules are argued with without naming either the rule's source or its exact scope. Name both, and the order explains the outcome.
// .devin/config.json
{
"permissions": {
"allow": [
"Read(src/**)",
"Read(tests/**)",
"Exec(git status)",
"Exec(git diff)",
"Exec(npm run test)",
"Exec(npm run lint)"
],
"ask": [
"Write(src/**)",
"Write(tests/**)",
"Exec(git)"
],
"deny": [
"Write(.env*)",
"Write(**/*.pem)",
"Exec(rm)",
"Exec(sudo)"
]
}
}