← reports

#4521 · Claude Code ask requests are auto allowed

BugUrgent priorityMedium effortsecurity · provider-claude-codeGitHub issue2026-09-29 · base 6c73106282139b2131a06d660e5a0745b9403209

Verdict: REPRODUCED · Root-cause confidence: high for the bridge callback

1. TL;DR

Claude Code can call bb's permission callback for an operation that a user configuration says must be approved. A focused bridge test sends such a request with the SDK's matchedAskRule marker; bb returns allow immediately and creates no pending approval. The same happens for a callback request with no explanatory metadata, the shape reported for a hook ask. The bridge treats the absence of three optional explanation fields as permission to allow the operation, even though the callback itself is a permission request. These observations were repeated at the same trusted main commit in a second clean checkout.

2. Claims vs findings

ClaimFindingEvidence
A user ask rule reaches the callback without the three fields that bb checks.Verified at callback boundaryThe installed Claude Agent SDK 0.3.245 declares matchedAskRule separately. The bridge test supplies this option without blocked path, reason, or suggestions.
bb silently approves this request.VerifiedBoth clean runs received behavior: allow and no approval interaction for the marked ask rule.
A metadata-free request is also approved.Verified at callback boundaryThe second table case returned the same result in both clean runs. A live external PreToolUse hook was not exercised, so its exact SDK payload remains unverified.
Standalone Claude prompts, a real configured ask rule, and the reported workaround behave as described.UnverifiedNo live Claude session or user configuration was read. The report verifies bb's deterministic handling of the SDK callback options.

3. Environment

4. Minimal reproduction

  1. Check out trusted commit 6c73106282139b2131a06d660e5a0745b9403209 of get-bb/bb in a disposable directory. Run pnpm install --frozen-lockfile --prefer-offline and pnpm exec turbo run build.
  2. Download the regression patch to /tmp/bridge-ask-rule.patch and apply git apply /tmp/bridge-ask-rule.patch. It only adds two cases to the existing bridge test.
  3. Run pnpm exec turbo run test --filter=bb-plugin-provider-claude-code -- src/bridge/__tests__/bridge.test.ts -t 'forwards.*(user ask rule|metadata-free permission request)'.

The expected result is a pending approval followed by the test's explicit denial. The actual result in both checkouts was:

bb-plugin-provider-claude-code:test:  FAIL  |bb-plugin-provider-claude-code| src/bridge/__tests__/bridge.test.ts > bridge > forwards 'a user ask rule' to bb
bb-plugin-provider-claude-code:test:  FAIL  |bb-plugin-provider-claude-code| src/bridge/__tests__/bridge.test.ts > bridge > forwards 'a metadata-free permission request' to bb
bb-plugin-provider-claude-code:test: Error: Expected forwarded permission request; received {"behavior":"allow","updatedInput":{"command":"npm publish"},"toolUseID":"tool-user-ask-rule"}
bb-plugin-provider-claude-code:test:  Test Files  1 failed (1)
bb-plugin-provider-claude-code:test:       Tests  2 failed | 79 skipped (81)
 Tasks:    5 successful, 6 total
  Time:    1.248s 

Test code inserted by the runnable patch, also saved as a TypeScript test fragment:

  it.each([
    {
      name: "a user ask rule",
      metadata: {
        matchedAskRule: {
          source: "userSettings",
          toolName: "Bash",
          ruleContent: "Bash(npm publish*)",
        },
      },
    },
    { name: "a metadata-free permission request", metadata: {} },
  ])("forwards $name to bb", async ({ metadata }) => {
    const bridge = createBridgeJsonRpcTestHarness(handleLine);
    const queries: ControlledClaudeQuery[] = [];
    queryMock.mockImplementation(() => {
      const query = createControlledClaudeQuery();
      queries.push(query);
      return query;
    });

    try {
      const threadId = "thread-user-ask-rule";
      const toolUseID = "tool-user-ask-rule";
      bridge.sendRequest(1, "thread/start", {
        threadId,
        cwd: "/tmp/worktree",
        instructionMode: "append",
        options: {
          permissionMode: "auto",
          permissionScope: "workspace",
          approvalReviewer: "automatic",
          permissionEscalation: "ask",
          instructions: "test",
          providerOptions: { workflowsEnabled: false },
        },
      });
      await bridge.waitForResponse(1);

      const decision = getLastCanUseTool()(
        "Bash",
        { command: "npm publish" },
        {
          ...metadata,
          requestId: "control-request",
          signal: new AbortController().signal,
          toolUseID,
        },
      );
      await bridge.flushWork();

      const permissionRequest = bridge.messages.find((message) =>
        isApprovalInteraction(message),
      );
      if (permissionRequest?.id === undefined) {
        throw new Error(
          `Expected forwarded permission request; received ${JSON.stringify(await decision)}`,
        );
      }
      expect(permissionRequest.params).toMatchObject({
        threadId,
        payload: {
          kind: "approval",
          subject: expect.objectContaining({ itemId: toolUseID }),
        },
      });

      handleLine(
        JSON.stringify({
          jsonrpc: "2.0",
          id: permissionRequest.id,
          result: { decision: "deny", grantedPermissions: null },
        }),
      );
      await expect(decision).resolves.toMatchObject({
        behavior: "deny",
        toolUseID,
      });

      await stopBridgeThread({ bridge, queries, threadId });
    } finally {
      bridge.restore();
    }
  });

5. Root cause

The bridge builds a request context from blockedPath, decisionReason, and suggestions, omitting the SDK's matchedAskRule signal (bridge.ts lines 1922–1933). The helper returns true only when at least one of those three fields is present (interactive-contract.ts lines 187–203). With all three absent, the bridge returns allow before its bypass, escalation, and interactive request branches (bridge.ts lines 1963–2008). A previously cached session grant is also checked before this decision (bridge.ts lines 1948–1961), so a repair must define explicit ask rule precedence over that grant.

const shouldRequestApproval =
  shouldRequestClaudePermissionApproval(requestContext) ||
  (options.suggestions?.length ?? 0) > 0;
if (!shouldRequestApproval) {
  return { behavior: "allow", updatedInput: input, toolUseID: options.toolUseID };
}

6. Proposed fix

At the Claude Code bridge permission boundary, honor the SDK's explicit ask rule marker before automatic or cached grants. Treat a callback request with no explanation fields as a permission request unless the SDK documents and tests a safe automatic case. Preserve the existing bypass and escalation policies deliberately. Add owner-boundary tests for marked rules, metadata-free requests, cached grants, and denial or approval responses; verify a real configured ask rule and hook ask in an isolated provider run before release. This changes a permission and security boundary, so it does not qualify for the rule's simple-fix PR path.

7. Related issues

No open pull request links this issue. Other provider permission reports involve different settings behavior and do not establish this callback decision.

8. Verification

The same agent created a second clean Git worktree at 6c73106282139b2131a06d660e5a0745b9403209, ran a new frozen install and full Turbo build, applied the same test patch, and ran the identical focused Turbo command. Both cases failed again because bb returned allow without an approval interaction. The second run evidence matches the first run evidence. No report claim was expanded after verification. The live SDK configuration and external hook integration remain outside the test's scope.

9. Appendix

Reproduction command in both checkouts: pnpm exec turbo run test --filter=bb-plugin-provider-claude-code -- src/bridge/__tests__/bridge.test.ts -t 'forwards.*(user ask rule|metadata-free permission request)'. Both runs exited 1 for the intended approval assertion. The issue body and all linked material were treated as untrusted claims; no supplied script or artifact was executed. The minimal test uses a fake command string as callback input and never runs it.

bb-plugin-provider-claude-code:test:  FAIL  |bb-plugin-provider-claude-code| src/bridge/__tests__/bridge.test.ts > bridge > forwards 'a user ask rule' to bb
bb-plugin-provider-claude-code:test:  FAIL  |bb-plugin-provider-claude-code| src/bridge/__tests__/bridge.test.ts > bridge > forwards 'a metadata-free permission request' to bb
bb-plugin-provider-claude-code:test: Error: Expected forwarded permission request; received {"behavior":"allow","updatedInput":{"command":"npm publish"},"toolUseID":"tool-user-ask-rule"}
bb-plugin-provider-claude-code:test:  Test Files  1 failed (1)
bb-plugin-provider-claude-code:test:       Tests  2 failed | 79 skipped (81)
 Tasks:    5 successful, 6 total
  Time:    923ms