Permission rules answer the question of what the agent is allowed in principle, but they answer statically: a rule looks at the shape of an action, not at its content. As soon as policy grows more complex than a list of "this is allowed, this is to be asked", a need appears for a check that reads the concrete command, decides by an exit code, and leaves a trace. The naive move is to write this logic in words into a prompt or a rule: "never run destructive commands". It seems sufficient right up to the first case where it matters that the ban fires for sure.
Such a ban breaks on the fact that an instruction in a prompt is a request to the model, not a guarantee. It depends on how the agent understood the wording and leaves no provable trace. Real policy needs a mechanism that runs deterministically, does not depend on interpretation, and is visible in the log. In Devin this is hooks - verifiable policy around tool calls, living in the file .devin/hooks.v1.json, where the hooks object is the entire content of the file, with no wrapper.
A hook is attached to a lifecycle event, and there are eight events. PreToolUse fires before a tool runs, PostToolUse after; PermissionRequest at the moment a permission decision is needed; UserPromptSubmit when the user submits a message; Stop when the agent is about to stop; PostCompaction after context compaction completes successfully; SessionStart and SessionEnd at the session boundaries. Different events answer different questions: PreToolUse - "should this action be let through", SessionStart - "what to prepare in advance", Stop - "may the work be considered finished".
Within an event a rule is built uniformly: the matcher field is a regular expression checked against the tool name (tool_name), and the hooks array lists what to run on a match. Each element has a type - command for an external script or prompt for a model instruction - and an optional timeout. This chapter's example attaches to exec the script validate-agent-command.sh with a ten-second limit: it gets control before the command runs.
The command-hook contract is worth understanding precisely, because it is what makes the policy verifiable. The script receives the event data as JSON on stdin and returns its decision through stdout and the exit code. Code 0 is success, the action proceeds; code 2 is a block; any other code is treated as an error that is logged but does not stop the action. In stdout you can return a decision field with the value approve or block and a reason, and through hookSpecificOutput inject context or rewrite the tool's input. These are exactly the three options of a hook: allow, change, or block, leaving a trace.
Why the policy is moved into a script rather than left to the agent's judgment. The script sees the real command, not the intent; it is the same one for allowed, forbidden, and malformed input; its decision is reproducible and loggable. That moves the ban from the realm of "agent, please do not" into the realm of "the system will not let you". The difference is the same as between a "do not enter" sign and a locked door: the first relies on good will, the second on a mechanism.
You pay for this with discipline in writing the hook. It must be fast, because it stands on the hot path before every matching call and is bounded by timeout. It must be deterministic, because a policy that gives different answers to one input is not a policy. And it must fail closed for a forbidden action: if the check could not run, the safe outcome is a block, not a pass. A hook that silently lets a command through on failure creates a false sense of protection - the worst of states.
You verify a hook not by the fact that it is "written" but on three inputs explicitly. An allowed command must pass and return code 0. A forbidden one must be blocked with code 2 and a clear reason. Malformed, unexpected, or empty input must neither fail silently nor accidentally pass through: the correct reaction is a controlled block. Until these three cases are run by hand, all that is known about the hook is that it exists, not that it works.
Separately, do not confuse these hooks with Cascade's hooks. Cascade is a Windsurf-era agent with its own model, and its hooks live at different paths, listen to different events, and obey a different contract. You cannot mechanically move one file into the other; when migrating an old project a dedicated migration tool carries them over, and it is wise to do so with a preliminary dry run rather than blindly. Naming the agent matters here as everywhere in Devin: a hook that "did not fire" often means it was written for a different environment.
Hooks have their own typical failures. A slow or hanging script hits the timeout and turns protection into a drag on every action. A hook that logs but passes through on error leaves a feeling of control without the control itself. A policy is written into the prompt and considered fulfilled, though there is no provable trace. And, finally, the wrong event is chosen - a check is hung on PostToolUse when interference was needed in PreToolUse, before execution. The sign of all these mistakes is the same: the hook is considered ready without ever being checked on a forbidden and on a malformed input. Run both, and most of the surprises will not happen.
{
"PreToolUse": [
{
"matcher": "exec",
"hooks": [
{
"type": "command",
"command": "./scripts/validate-agent-command.sh",
"timeout": 10
}
]
}
]
}