Permissions

September 8, 2026 · View on GitHub

Permissions are B4.run's human-in-the-loop gate. The runtime gates two workspace operations by default:

  • runBash commands (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 further gates build on the same interrupt machinery and are covered below: 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. Other modes are covered below.

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. 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.

Static entries remain in b4.config.ts as configuration; they are evaluated alongside runtime decisions and are not copied or seeded into 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, while custom stores and the Postgres store may persist them elsewhere.

How matching works

For every runBash call the runtime runs this sequence:

  1. Deny first — if any deny pattern is a prefix of the command string, the call is rejected immediately.
  2. Then allow — if any allow pattern is a prefix of the command string, the call proceeds.
  3. No match → "unknown" — the outcome depends on the mode.

Matching depends on the gate:

GateStored key and suggestedPattern granularityMatching
Bash commandbash; the first two command tokens, such as node scripts/fetch-source.mjsPrefix
Filesystem pathThe operation (readFile, writeFile, or listDir); canonical parent directory with a trailing slashPrefix
Memory writememory; workspace-and-route namespace with a trailing ``
Authored or capability tool approvalReserved tool; tool nameExact
Subagent approvalReserved subagent; serialized parent route id and parent-local child nameExact

Thus "ls" covers ls, ls -la, and ls workspace/corpus. Use longer bash, path, and memory prefixes to be more specific. Reserved tool and subagent entries never prefix-match.

Modes

ModeUnknown command behavior
interactive (default)Run pauses; an interrupt is sent to the client for human decision
non-interactiveUnknown commands are denied immediately (fail-closed)
bypassAll commands are allowed without checking — dev/test only

Override the mode for a single run without touching the config by setting the B4_PERMISSIONS_MODE environment variable:

B4_PERMISSIONS_MODE=non-interactive b4 dev

The env var takes precedence over permissions.mode in b4.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:

DecisionEffect
onceThis call runs. The next call to the same tool prompts again.
alwaysAdds the tool name under the reserved tool key in the configured permission store. Matching is exact-name, not prefix.
denyThe 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.

Pre-approval in config

Pre-approve a tool so it never prompts, by adding it to permissions.allow.tool in b4.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. b4 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).

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, while default: "approve" requires review for every child that does not have 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 uses the exact parent route id and parent-local registration name serialized in suggestedPattern; it does not use the target route id or input. Therefore always approves only that exact 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 exact 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 does not override static or constraint denial.

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.

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 memory key; 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, askauto. 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 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 run

Send a POST /threads/:thread_id/resume request with a resume entry for every interrupt currently pending on that root thread:

DecisionEffect
onceAllow this operation for this invocation only
alwaysAllow and add the gate's exact suggestedPattern to the configured permissions store
denyReject 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": "/research#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 requires exactly resume and route; 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; clients do not resume a child route or thread separately.

The former scalar resume body is removed and is not parsed as a compatibility form.

Here is the full sequence using 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 — 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 '{"resume":[{"interruptId":"perm-abc123","status":"resolved","payload":"once"}],"route":"/research#agent"}'
# The response streams the continuation as SSE

Choosing "always" is the only decision that persists: it adds the allow entry to the configured permissions store. once and deny are not persisted. With the default Node store, runtime entries live in .b4/permissions.json; a custom or Postgres store uses its configured backend. On subsequent runs the command is matched by the allow list and proceeds without prompting.

Testing

In @b4run/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.

<RelatedCards items={[ { href: "/docs/workspace", title: "Workspace Filesystem", subtitle: "the runBash tool and the four agent-facing workspace tools" }, { href: "/docs/configuration", title: "Configuration Reference", subtitle: "b4.config.ts reference including the full permissions block" }, ]} />