Access Control

Four independent controls decide what a running route can do: tool scoping, permissions, the execution sandbox and guarded delegation. Each one answers a different question, so they compose. A route uses the controls it needs.

ControlQuestion it answersDocs
Tool scopingWhich tools can the model call?Tools
PermissionsWhether a given call runs (human-in-the-loop approval)Permissions
Execution sandboxWhat an allowed, approved call can actually touchExecution Sandbox
Guarded delegationWhether a child receives the delegated input before any of its tools executeSubagents

AuthenticationCopy link to section: Authentication

src/auth.ts is the one place an app turns a request into a principal. It default-exports defineAuth, and B4.run calls its authenticate once per request, before middleware and before the thread-access policy:

src/auth.ts
import { defineAuth, reject } from "@b4run/sdk"
 
export default defineAuth({
  authenticate: async ({ headers }) => {
    const session = await verifySession(headers.authorization)
    if (!session) return reject(401, { error: "unauthorized" })
    return { id: session.userId, org: session.orgId }
  },
})

What authenticate returns decides the request:

ResultWhat happens
A principal: an object with a string idPassed to every consumer, shallow-frozen.
undefinedThe request is anonymous. principal is undefined, and each consumer decides what that means.
reject(status, body)That response, before any endpoint logic runs.
A throw, or anything elseA 500. Never downgraded to anonymous.

The same value reaches every consumer, so none of them parses a header for identity:

  • req.principal in middleware, including the after hook's run.principal
  • req.principal in the thread-access policy
  • ctx.principal in every tool, and in subagents' tools

memory.resolveScope receives it too, so each caller can get its own long-term memory, and /memory/candidates scopes review to the caller's own namespaces. Add canReviewMemory(principal) to defineAuth to let an admin review every namespace.

Resolution runs for every runtime endpoint except /healthz, /readyz and CORS preflight. The principal is never persisted: it's not written to checkpoints, thread metadata or run config, and a resumed run gets the resumer's principal. Keep secrets out of it anyway. setup and dispose hooks work the way they do on middleware.

b4 typegen declares the type your authenticate resolves to, so req.principal.org and ctx.principal.org type-check without a cast. It writes .b4/auth.generated.d.ts, which .b4/b4.generated.d.ts references.

Comparing headersCopy link to section: Comparing headers

headers has lowercase keys, and repeated headers arrive joined with ", ". X-User-Id: victim plus X-User-Id: attacker is the single string "victim, attacker". That's safe under === and unsafe under includes, startsWith or split(","). Compare with strict equality, compare secrets in constant time, and prefer a signed token over a trusted header wherever the deployment allows it.

When auth fails to loadCopy link to section: When auth fails to load

A src/auth.ts that exists must default-export defineAuth(...). A missing default, a plain object, an import that throws or a path that can't be probed fails the boot with B4_E3005, because an auth file the runtime ignored would leave every request anonymous. The node and web build targets carry the file in their build. The langsmith target compiles it to LangGraph's auth layer, with a few differences (an anonymous request is a 401 there); see LangSmith.

TestingCopy link to section: Testing

createAgentHarness runs the agent directly, so src/auth.ts never runs. Pass principal to give tools the caller they'd see behind the server. createAgentProtocolInjector({ auth }) and createThreadAccessHarness's principal field drive the gates.

Tool scopingCopy link to section: Tool scoping

agent({ tools: { allow, deny, approve, constrain } }) controls the surface offered to the model:

  • deny revokes a tool so it's never wired into the generated entry.
  • allow grants back a capability tool withheld from a subagent, such as readFile or runBash.
  • deny wins when a name appears in both.
  • Denying writeFile also withholds editFile, unless allow names editFile. An allow that names writeFile doesn't grant editFile.

B4.run enforces this at composition time, so the model has no way to call a denied tool. Here's a route that removes runBash:

src/app/ops/index.ts
export default agent({ model: "gpt-5", systemPrompt: "…", tools: { deny: ["runBash"] } })

Scoping controls the surface. Once a tool is granted, it does whatever its implementation permits. A granted writeFile can still write anywhere it's allowed to. See Tools.

PermissionsCopy link to section: Permissions

Two gates are on by default for the built-in workspace tools. runBash commands are matched against allow/deny patterns, and filesystem paths outside workspace/ are permission-gated. An unmatched ("unknown") call pauses the run and asks a human, unless permissions.mode is set to non-interactive (fail-closed) or bypass (dev/test only).

Two more gates ride the same interrupt machinery. tools: { approve: [...] } requires human approval before a named tool call, and memory: { writes: "ask" } gates belief-contradiction memory writes. Use tools.approve for a separately authored tool such as deployProd. The workspace command and path tools already have their pattern-aware gates. See Permissions for the interrupt payloads and resume flow.

Execution sandboxCopy link to section: Execution sandbox

Even a tool that's allowed and approved still runs somewhere. By default that's the local workspace/ directory on the host. Add a sandbox key to b4.config.ts and every readFile, writeFile, editFile, listDir and runBash call for a thread runs in an isolated environment: filesystem, shell and (optionally) network. B4.run ships a Docker reference provider and a Kubernetes provider behind the same SandboxProvider interface. See Execution Sandbox for what any provider guarantees.

Guarded delegationCopy link to section: Guarded delegation

A parent's delegation policy runs at the last step of dispatch, before the child starts. Its allow, deny, approve or constraint decision determines whether the child receives the delegated input at all. The child's own tool policies and permission gates run only after that decision allows dispatch. See Subagents.

How they composeCopy link to section: How they compose

A route that uses pattern-gated shell commands in an isolated environment, separately approves an authored deployment tool, and reviews delegated input uses all four controls. First, the config:

b4.config.ts
import { config } from "@b4run/cli"
import { dockerSandbox } from "@b4run/sandbox"
 
export default config({
  permissions: {
    allow: { bash: ["ls", "cat"] },
    deny: { bash: ["rm -rf", "sudo"] },
  },
  sandbox: { provider: dockerSandbox({ scope: "my-app", image: "node:24-slim" }) }, // what runBash can touch
})

Then the route:

src/app/ops/index.ts
import { agent } from "@b4run/sdk"
import researcher from "./subagents/researcher/index.js"
 
export default agent({
  model: "gpt-5",
  systemPrompt: "…",
  // Denying writeFile also withholds editFile, so this route can't write files.
  tools: { deny: ["writeFile"], approve: ["deployProd"] },
  subagents: { researcher },
  delegation: {
    rules: {
      researcher: { action: "approve", reason: "Review delegated customer context." },
    },
  },
})