Permissions
Permissions are Dawn's human-in-the-loop gate. The runtime gates two workspace operations by default:
runBashcommands (kind: "command") — shell commands are matched against allow and deny lists before executing.- Filesystem paths outside
workspace/(kind: "path") — file reads, writes, and directory listings that would escape the workspace root are permission-gated.
Two further gates build on the same interrupt machinery and are covered below: opt-in per-tool approval (kind: "tool") and memory-write approval (kind: "memory").
Unknown operations pause the run and ask the human rather than failing or silently proceeding.
Configuration
Set allow and deny lists in dawn.config.ts:
export default {
permissions: {
// mode?: "interactive" | "non-interactive" | "bypass" (default "interactive")
allow: { bash: ["ls", "cat"] },
deny: { bash: ["rm -rf", "sudo"] },
},
}allow and deny each map a tool name (bash) to an array of pattern strings. The research scaffold, for example, allows safe read-only commands and denies destructive ones — but leaves the network-fetch script off the allow list so the first run surfaces a prompt.
How matching works
For every runBash call the runtime runs this sequence:
- Deny first — if any deny pattern is a prefix of the command string, the call is rejected immediately.
- Then allow — if any allow pattern is a prefix of the command string, the call proceeds.
- No match → "unknown" — the outcome depends on the mode.
Prefix matching means "ls" covers ls, ls -la, and ls workspace/corpus. Use longer patterns to be more specific.
Modes
| Mode | Unknown command behavior |
|---|---|
interactive (default) | Run pauses; an interrupt is sent to the client for human decision |
non-interactive | Unknown commands are denied immediately (fail-closed) |
bypass | All commands are allowed without checking — dev/test only |
Override the mode for a single run without touching the config by setting the DAWN_PERMISSIONS_MODE environment variable:
DAWN_PERMISSIONS_MODE=non-interactive dawn devThe env var takes precedence over permissions.mode in dawn.config.ts.
Per-tool approval
Alongside the runBash and path gates, a route can require human approval before any named tool call — authored route tool or capability tool — via the third tools knob, approve:
export default agent({
model: "gpt-5",
systemPrompt: "…",
tools: { approve: ["deployProd"] },
})Every call to deployProd pauses the run and emits a kind: "tool" interrupt, unless the tool is pre-approved (see below) or a prior "Always" decision already covers it.
An argument constraint can escalate a specific call to this same prompt by returning { approve: true } — e.g. allow staging deploys silently but require approval for prod. The "Always" decision is still name-level (it persists the tool name), so it auto-approves future escalations of that tool; use an outright deny in the predicate if a case should never run.
The tool interrupt payload
event: interrupt
data: {
"interruptId": "perm-ghi789",
"type": "permission-request",
"kind": "tool",
"detail": {
"toolName": "deployProd",
"argsPreview": "{\"env\":\"prod\",\"version\":\"1.4.2\"}",
"suggestedPattern": "deployProd"
}
}detail.argsPreview is a display-only JSON preview of the call's arguments (truncated around 500 characters) — it is shown to the human but never matched against or persisted. detail.suggestedPattern is always the tool name itself.
Decisions are name-level
Resuming a kind: "tool" interrupt uses the same once / always / deny decisions as the other gates, but the semantics are tool-name-level rather than pattern-level:
| Decision | Effect |
|---|---|
once | This call runs. The next call to the same tool prompts again. |
always | Persists the tool name under the reserved tool key in .dawn/permissions.json — { "allow": { "tool": ["deployProd"] } }. Matching against this key is exact-name, not prefix — unlike bash and path patterns. |
deny | The call is blocked. Unlike the workspace gates (which surface a thrown error), the denial reason is returned as the tool result — the model sees it as a normal tool response and can adapt. |
Pre-approval in config
Pre-approve a tool so it never prompts, by adding it to permissions.allow.tool in dawn.config.ts:
export default {
permissions: {
allow: { tool: ["deployProd"] },
},
}Mode behavior
approve respects the same permissions.mode as the other gates: non-interactive denies an unapproved tool call immediately (fail-closed), and bypass skips the gate entirely (dev/test only).
Coexistence with the bash and path gates
runBash, readFile, writeFile, and listDir keep their own pattern-aware allow/deny gates — putting them in approve is redundant and would double-prompt. dawn check warns when a route's approve list:
- names one of these internally-gated tools (redundant — already gated),
- overlaps with
deny(a dead entry, since deny wins), or - approves a capability tool on a subagent that the subagent hasn't also granted itself via
allow(a no-op until allow-listed).
Memory write approval (writes: "ask")
Routes with long-term memory can gate belief changes: with memory: { writes: "ask" } in dawn.config.ts, a remember call that would supersede an existing active memory interrupts with the old and new values. New facts and idempotent refreshes never prompt.
- Once — this supersede proceeds.
- Always — persists the route's namespace prefix under the
memorykey; all future overwrites in the route proceed silently. - Deny — the old memory stays active; the agent is told which memory was kept.
Unlike bash/path/tool gates, ask allows through when no human can answer (non-interactive mode): headless, ask ≡ auto. It is a supervision affordance, not a security boundary. Explicit deny entries are honored in every mode except bypass.
Hand-authored patterns should keep the trailing | terminator: "workspace=app|route=/a|" cannot collide with route=/ab.
The interrupt payload
When a command or path is "unknown" in interactive mode, the agent run pauses and the runtime emits an SSE event. The kind field tells you which gate fired.
kind: "command" — a runBash command was not on the allow list:
event: interrupt
data: {
"interruptId": "perm-abc123",
"type": "permission-request",
"kind": "command",
"detail": {
"command": "node scripts/fetch-source.mjs https://example.com/api",
"suggestedPattern": "node scripts/fetch-source.mjs"
}
}kind: "path" — a filesystem operation targeted a path outside workspace/:
event: interrupt
data: {
"interruptId": "perm-def456",
"type": "permission-request",
"kind": "path",
"detail": {
"operation": "readFile",
"path": "/Users/me/private/notes.md",
"suggestedPattern": "/Users/me/private/"
}
}detail.suggestedPattern is the prefix Dawn suggests you add to the allow list so the operation is approved automatically on future runs. The run stays paused until you resume it.
Resuming an interrupted run
Send a POST /threads/:thread_id/resume request with the interrupt_id from the SSE event and a decision:
| Decision | Effect |
|---|---|
once | Allow this command for this invocation only |
always | Allow and persist the suggestedPattern to .dawn/permissions.json |
deny | Reject the command; the run continues with an error result |
Here is the full sequence using curl (start the server with dawn dev --port 2024):
# 1. Create a thread
THREAD=$(curl -sX POST http://127.0.0.1:2024/threads \
-H 'Content-Type: application/json' \
-d '{}' | jq -r .thread_id)
# 2. Start a run — stream until the interrupt fires
curl -N http://127.0.0.1:2024/threads/$THREAD/runs/stream \
-H 'Content-Type: application/json' \
-d '{"input":{"messages":[{"role":"user","content":"fetch the API docs"}]},"route":"/research#agent"}'
# ...SSE output...
# event: interrupt
# data: {"interruptId":"perm-abc123","type":"permission-request","kind":"command","detail":{"command":"node scripts/fetch-source.mjs ...","suggestedPattern":"node scripts/fetch-source.mjs"}}
# 3. Resume with a decision
curl -X POST http://127.0.0.1:2024/threads/$THREAD/resume \
-H 'Content-Type: application/json' \
-d '{"interrupt_id":"perm-abc123","decision":"once"}'
# The response streams the continuation as SSEChoosing "always" persists the allow entry to .dawn/permissions.json. On subsequent runs the command is matched by the allow list and proceeds without prompting.
Testing
In @dawn-ai/testing, use expectInterrupt and harness.resume to drive the approval flow in automated tests without a live server. See the testing docs for the full pattern.