Fail closed: the only safe default for agent enforcement

When an agent doesn't have permission, two things can happen: it stops, or it doesn't. One of those is safe. The gap between availability and enforcement is where most agent incidents happen.

enforcementsecurity

When a permission check fails or is unavailable, your integration has to decide what to do. The options are: stop the agent, or let it proceed anyway.

The second option is called fail open. It is never the right default for an agent acting on behalf of a user.

Fail closed means the agent throws on denial and the code path that would execute the action never runs. The action does not happen. The user is notified. The decision is logged.

fail-closed pattern
const result = await behalf.verify({
  agentId,
  action: "purchase",
  vendor: "coachella.com",
  amount: 742
});

if (!result.allowed) {
  throw new Error(`Blocked: ${result.reason}`);
}

// Only reachable after explicit allow.
await charge(vendor, amount);

The executor — the charge call, the file write, the API call to an external service — is only reachable after an explicit allowed: true response. There is no fallback path that skips the check.

Fail open treats a check that doesn't return denied as permission. It looks like this:

fail-open (don't do this)
let allowed = false;
try {
  const result = await behalf.verify({ agentId, action, vendor, amount });
  allowed = result.allowed;
} catch {
  // Network error — assume allowed to avoid blocking the user.
  allowed = true;
}

if (allowed) {
  await charge(vendor, amount);
}

The problem is the catch block. An agent operating at scale will encounter network errors, timeouts, cold-start delays, and transient 5xx responses. If each of those gaps becomes a silent permission grant, the permission system is effectively optional.

Availability gaps should not become permission grants. If your enforcement layer is unavailable, the correct outcome is a failed action, not a silently allowed one.

Most agent incidents don't happen because the permission system returned the wrong answer. They happen because it wasn't called at all — or its answer was ignored.

  • The check is added to the happy path but not to the retry or fallback path.
  • The check is called but the result is not awaited before the executor runs.
  • The check is bypassed for performance — cached too long, or skipped for "low-risk" actions.
  • The check exists, but the executor can also be reached through a different entry point that has no check.

Each of these is a structural fail-open. The fix is the same: the executor must only be reachable after an explicit allow decision.

When BehalfID is unreachable, throw with a specific message that signals the permission layer was unavailable, not a generic error:

error handling
let result;
try {
  result = await behalf.verify({ agentId, action, vendor, amount });
} catch (err) {
  throw new Error(
    `Permission check unavailable. Action blocked. (${err.message})`
  );
}

if (!result.allowed) {
  throw new Error(`Blocked: ${result.reason}`);
}

await executor.run(action, { vendor, amount });

The user sees a clear failure. The agent logs it. The action does not happen. That is the correct outcome when the permission layer is unavailable.