Permissions in Devin Local are granted not to tools in general but to specific actions with a specific scope. Five types - Read, Write, Exec, Fetch and mcp - and each has its own matching language. Until you tell these languages apart, any rule is either wider than the intent or narrower than it, and the agent does something other than what you meant.
The naive move is to think of permissions in broad strokes: "allow reading", "allow commands", "give internet". It seems the type is the unit of permission and the scope is a detail. From this grows the habit of writing Read or Exec as a whole and being surprised when the agent read too much or ran the wrong command.
It breaks on the fact that the unit of permission is the pair type plus scope, and each type has its own scope. Read(glob) and Write(glob) take a file glob; a directory path covers all of its contents. Exec(prefix) takes a command prefix that must match as a whole word. Fetch(pattern) takes a URL pattern. mcp__server__tool addresses a specific tool of a specific MCP server. Five types - five different ways to say "exactly this".
Glob semantics live separately, and people stumble on them most often. A single asterisk matches one path segment and does not cross a directory separator; a double asterisk is recursive and does cross directory boundaries. Read() means the workspace relative to the current directory, while absolute system paths must be given explicitly, with a leading separator. A tilde expands to the home directory. The difference between one and two asterisks is the difference between "here" and "everywhere deeper", and confusing it is costly. A directory in a scope, by the way, covers all of its contents, so Read(src) and Read(src/) mean nearly the same in practice, while Write(config) and Write(config/*.json) differ sharply in radius.
Exec has its own subtlety. The prefix matches as a whole word: Exec(git) covers git status and git commit but not gitk and not github-cli. That saves you from accidental widening - a permission for git does not spread to same-root binaries. But it also demands precision: if only reading status is needed, the prefix Exec(git) still covers mutating subcommands, and it must be narrowed to Exec(git status), Exec(git diff).
Fetch and mcp are about stepping outside files. Fetch(pattern) decides which URLs or domains the agent may reach over HTTP; this is the outbound-network boundary at the permission level, separate from the sandbox network filter. mcp__server__tool decides which specific tool of which server may be used: not "all of MCP" but a specific tool. Both categories are easy to grant too broadly, because the benefit is visible at once while the radius is not. It helps to remember that Fetch and the sandbox network filter are two different lines: the first decides whether the agent may reach a URL at all, the second whether a packet leaves the cage; a reliable policy leans on both rather than confusing them.
Why permissions are split down to the pair type plus scope rather than granted by whole tools. Because the risk lives not in the tool but in its application to a specific target. Read by itself is harmless - Read(.env*) already is not. Exec as a category is neutral - Exec(rm) is irreversible. Splitting lets you allow the useful and forbid the dangerous within one type, which a coarse grant of "all Read, all Exec" cannot do.
In practice it helps to run each action through one question: which type, which scope, and into which list - allow, ask or deny. Reading src - Read(src/*) in allow. Writing .env - Write(.env) in deny. Running a test - Exec(npm run test) in allow. npm install - in ask, because it pulls from the network and changes the tree. git commit - in ask as mutating. Production deploy - in deny or outside permissions entirely. One action, one deliberate address. Answers written down this way also turn into a ready draft of the allow, ask and deny sections - from a paper walkthrough to the config is one step.
You should verify a scope by the opposite action, not only by the one you allowed. If you allowed Read(src/*), confirm that reading outside src does not pass; if you put Write(.env) in deny, try to provoke a write and see the block. The agreement of "the allowed goes, the forbidden stops" on a pair of opposing checks proves that the glob and the prefix narrowed exactly what was needed.
The typical failures are about a confused scope language. "The agent read too much" - Read(**) captured more than you thought, or an absolute path was given without need. "The command did not run" - the prefix did not match as a whole word. "MCP does not work" - the permission was written for the server rather than its tool, or the reverse. "Fetch missed" - the domain pattern did not match the real URL. The sign is the same: a permission is discussed with the type named but not the scope. Name the scope too, and almost every "miss" is explained by the matching boundary.