Permissions
Permissions are B4.run'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.
Three more gates use the same interrupt machinery: opt-in per-tool approval (kind: "tool"), parent-owned subagent approval (kind: "subagent") and memory-write approval (kind: "memory").
In an interactive agent run, unknown operations pause and ask the human.
ConfigurationCopy link to section: Configuration
Set allow and deny lists in b4.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 gate key (bash, readFile, memory, and so on) to an array of pattern strings. For example, the navlog scaffold sets tools: { deny: ["runBash"], approve: [{ tool: "fileFlightPlan", allowAlways: false }] } on its route, so the agent never runs a shell command and a person approves every flight-plan filing.
Static entries stay in b4.config.ts. The runtime evaluates them alongside its own decisions but keeps them out of the runtime permission store. Only an always decision adds an allow entry to that store. The default Node store persists runtime entries in .b4/permissions.json. Custom stores and the Postgres store persist them in their own backend.
How matching worksCopy link to section: 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.
Matching depends on the gate:
| Gate | Stored key | suggestedPattern granularity | Matching |
|---|---|---|---|
| Bash command | bash | The first two command tokens, such as node scripts/fetch-source.mjs | Prefix |
| Filesystem path | The operation (readFile, writeFile, or listDir; editFile is gated as a readFile and a writeFile) | Canonical parent directory with a trailing slash | Prefix |
| Memory write | memory | Workspace-and-route namespace with a trailing pipe character | Prefix |
| Authored or capability tool approval | Reserved tool | Tool name | Exact |
| Subagent approval | Reserved subagent | Serialized parent route id and parent-local child name | Exact |
| Client-provided tool | Reserved clientTool | None (never prompts) | Exact |
Thus "ls" covers ls, ls -la, and ls workspace/corpus. Use longer bash, path, and memory prefixes to be more specific. Reserved tool, subagent and clientTool entries never prefix-match.
ModesCopy link to section: Modes
| Mode | Unknown command behavior |
|---|---|
interactive (default) | Run pauses and sends the client an interrupt for a human decision |
non-interactive | Unknown commands are denied immediately (fail-closed) |
bypass | All commands are allowed without checking (dev/test only) |
To override the mode for a single run, set the B4_PERMISSIONS_MODE environment variable. It takes precedence over permissions.mode in b4.config.ts:
B4_PERMISSIONS_MODE=non-interactive b4 devPer-tool approvalCopy link to section: Per-tool approval
A route can also require human approval before any named tool call (authored route tool or capability tool) with the tools.approve knob:
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 }. For example, 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 payloadCopy link to section: The tool interrupt payload
event: interrupt
data: {
"interruptId": "perm-ghi789",
"type": "permission-request",
"kind": "tool",
"toolCallId": "call_deploy_1",
"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). The human sees it, but it's never matched against or persisted. detail.suggestedPattern is always the tool name itself.
toolCallId is the model's id for the gated call, so a client can show the prompt on that call. Command and memory gates carry it too; path gates don't carry it yet.
Decisions are name-levelCopy link to section: Decisions are name-level
Resuming a kind: "tool" interrupt uses the same once / always / deny decisions as the other gates, but they apply to the tool name, not a pattern:
| Decision | Effect |
|---|---|
once | This call runs. The next call to the same tool prompts again. |
always | Adds the tool name under the reserved tool key in the configured permission store. Matching is exact-name, not prefix. |
deny | The call is blocked and nothing is persisted. 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. |
Approve every callCopy link to section: Approve every call
Some tools should never get a standing approval: filing, sending or paying is a per-call decision, and behind a shared permission store one person's "Always" would approve the tool for everyone. Write the entry as an object with allowAlways: false:
export default agent({
model: "gpt-5-mini",
systemPrompt: "…",
tools: { approve: [{ tool: "fileFlightPlan", allowAlways: false }] },
})A bare name and the object form can be mixed in one list. For a tool approved this way:
- Every call prompts in
interactivemode, even when the permission store holds an allow rule for the tool, whether configured underpermissions.allow.toolor persisted by an earlier "Always" answer. - The interrupt envelope carries
"allowAlways": false, and the AG-UI interrupt advertisesresponseSchema.enum: ["once", "deny"], so the activity kit's approval card shows only Allow once and Deny. - A client that answers
alwaysanyway getsonce: the call runs, nothing is persisted, and the step recordsonce. bypassstill skips the gate, a deny rule still denies, andnon-interactivemode fails closed, so a headless run of such a tool needsbypass.
A constraint on the same tool wins over its approve entry, and a { approve: true } escalation still offers "Always". b4 check warns when a route has both.
Pre-approval in configCopy link to section: Pre-approval in config
To pre-approve a tool so it never prompts, add it to permissions.allow.tool in b4.config.ts:
export default {
permissions: {
allow: { tool: ["deployProd"] },
},
}Mode behaviorCopy link to section: 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 gatesCopy link to section: Coexistence with the bash and path gates
runBash, readFile, writeFile, editFile and listDir keep their own pattern-aware allow/deny gates. Putting them in approve is redundant and would double-prompt. b4 check warns when a route's approve list:
- names one of these internally-gated tools (redundant, since they're 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).
Subagent approvalCopy link to section: Subagent approval
A parent route can require approval before dispatching a direct child with delegation. Omitting delegation allows dispatch by default. default: "deny" creates an allowlist, and default: "approve" requires review for every child without an explicit rule:
export default agent({
model: "gpt-5-mini",
systemPrompt: "Coordinate support work.",
subagents: { researcher },
delegation: {
rules: {
researcher: {
action: "approve",
reason: "Research may send customer context to a specialist.",
},
},
},
})The dispatch pauses before the child starts and emits a kind: "subagent" interrupt on the root parent stream:
event: interrupt
data: {
"interruptId": "perm-subagent-123",
"type": "permission-request",
"kind": "subagent",
"callId": "parent-task-call-1",
"detail": {
"parentRouteId": "/support",
"subagentName": "researcher",
"subagentRouteId": "/support/subagents/researcher",
"inputPreview": "Find the applicable refund policy.",
"reason": "Research may send customer context to a specialist.",
"suggestedPattern": "[\"/support\",\"researcher\"]"
}
}The input preview is bounded, display-only text. Approval persistence keys on the parent route id and parent-local registration name serialized in suggestedPattern. The target route id and the input play no part. So always approves only that one edge. The same child name under another parent, another name under the same parent, and a nested child dispatch each require their own decision.
Pre-approve that registration under the reserved subagent key:
export default {
permissions: {
allow: { subagent: ['["/support","researcher"]'] },
},
}The subagent key uses exact matching, like the reserved tool key. Explicit deny entries win. In non-interactive mode, an unapproved dispatch fails closed. bypass skips the approval gate, but static and constraint denials still apply.
task is internal and is invalid in every tool-policy field: tools.allow, tools.deny, tools.approve and tools.constrain. Configure dispatch only through delegation. b4 check and route preparation report B4_E1004 for any tools.*.task reference.
Client-provided toolsCopy link to section: Client-provided tools
A route opted in to client-provided tools gates each call the model makes to one under the reserved clientTool key, matched against the name the client registered (openPanel, not client_openPanel).
- Allowed by default. The gate never prompts, so an unmatched name is allowed in every mode,
non-interactiveincluded. There is no prompt whose absence could fail closed. denyrefuses. A matchingpermissions.deny.clientToolentry refuses the call: it is never recorded or parked, and the model gets the denial (B4_E3001) as the tool result, as it does for a denied server tool.- Exact match, like
toolandsubagent:deny: { clientTool: ["open"] }does not coveropenEverything. - Never inherits
tool:<name>. A client tool nameddeployProdis not covered by anallow.toolordeny.toolentry for the server's owndeployProd. The name is caller-authored, so it must not borrow a decision made about a server tool. alwaysis never offered. Its pattern would be a caller-authored string saved into the operator's permissions.bypassallows every call.
export default {
permissions: { deny: { clientTool: ["deleteAccount"] } },
server: { agui: { clientTools: ["/chat"] } },
}The decision is taken once, before the call parks. A deny added while a call is parked does not discard a result the client already produced.
Memory write approval (writes: "ask")Copy link to section: Memory write approval (writes: "ask")
Routes with long-term memory can gate belief changes. With memory: { writes: "ask" } in b4.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, so all future overwrites in the route proceed silently. - Deny: the old memory stays active, and 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 behaves like auto. It helps a human supervise, so don't rely on it as a security control. Explicit deny entries are honored in every mode except bypass.
Hand-authored patterns should keep the trailing | terminator: "workspace=app|route=/a|" can't collide with route=/ab.
The interrupt payloadCopy link to section: 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 wasn't on the allow list:
event: interrupt
data: {
"interruptId": "perm-abc123",
"type": "permission-request",
"kind": "command",
"toolCallId": "call_bash_1",
"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 B4.run 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 runCopy link to section: Resuming an interrupted run
Send a POST /threads/:thread_id/resume request with a resume entry for every interrupt currently pending on that root thread:
| Decision | Effect |
|---|---|
once | Allow this operation for this invocation only |
always | Allow and add the gate's suggestedPattern to the configured permissions store |
deny | Reject the operation without persisting a decision. The run continues with an error result |
The ordinary HTTP endpoint accepts one strict envelope:
{
"resume": [
{
"interruptId": "perm-abc123",
"status": "resolved",
"payload": "once"
},
{
"interruptId": "perm-def456",
"status": "cancelled"
}
],
"route": "/navlog#agent"
}resume may contain one or many entries, but it must address the complete current pending set exactly once. A resolved entry requires a payload of once, always, or deny. A cancelled entry omits payload and maps to denial. The top-level object takes resume and route and nothing else. Entries also reject unknown or mixed fields.
Nested child interrupts use the same endpoint and the root parent's thread_id. B4.run maps each public interruptId to the correct nested checkpoint, so clients never resume a child route or thread separately.
The old scalar resume body is gone, and B4.run won't parse it as a compatibility form.
Let's walk through the full sequence with curl (start the server with b4 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 and 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":"/navlog#agent"}'
# ...SSE output...
# event: interrupt
# data: {"interruptId":"perm-abc123","type":"permission-request","kind":"command","toolCallId":"call_bash_1","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 '{"resume":[{"interruptId":"perm-abc123","status":"resolved","payload":"once"}],"route":"/navlog#agent"}'
# The response streams the continuation as SSEOnly "always" persists. It adds the allow entry to the configured permissions store (.b4/permissions.json with the default Node store), or, for a thread with its own permissions, to that thread's record. On later runs the command matches the allow list and proceeds without prompting.
Per-thread permissionsCopy link to section: Per-thread permissions
An app whose sandbox.thread resolver returns permissions gives that thread its own lists:
- The thread's
allowreplaces the app'spermissions.allowfor that thread. The app's allow-list, and grants other threads saved to.b4/permissions.json, don't apply to it. A thread that sets onlydenygets an empty allow-list: nothing the app allowed applies to it. - Denials add up: the thread's
denyand the app's denials both apply, and a denial wins over any allow. - The mode is the app's. In
bypassmode nothing is checked, as for any thread. - An Always decision in such a thread is saved to that thread's record in the workspace installation, never to
.b4/permissions.jsonor a configuredpermissions.store. It applies to later turns of that thread and to its subagents, not to other threads. Deleting the thread deletes its grants. - An empty pattern (
"") is refused, because it matches every candidate and is almost always a mistake, and so is a whitespace-only one, which names nothing. A deliberately broad pattern, such as"/"for every path, is accepted. - A grant is kept only if its pattern is at most 4096 characters. A longer suggested pattern is allowed for that one call, as Once would be, and not saved, with a warning in the server log.
A thread whose resolver returns no permissions uses the app's store unchanged.
TestingCopy link to section: Testing
In @b4run/testing, use expectInterrupt and harness.resume to drive the approval flow in tests without a live server. See the testing docs.