← reports

#4797 · ACP bridge ignores updated per-prompt permission settings

BugUrgentEffort: Mediumproviders · provider-acp · securityopen on GitHub

2026-10-03 · trusted main db9777f421f02f165c62fe2177e8c84eb3f45eda

Verdict: REPRODUCED · Root-cause confidence: high · reproduction label: confirmed-repro

1. TL;DR

The ACP bridge keeps the permission mode and allowed write directories from session startup, instead of using the settings supplied with the next prompt. After Full Access is narrowed to workspace access, a follow-up or queued steer can still auto-approve a permission request. Removing an additional writable directory also leaves writes to that directory allowed; the reverse changes remain unnecessarily restricted. Two clean checkouts of trusted main produced the same eight failing change cases and eight passing unchanged-setting controls. This is a real-bridge, stdio-fixture reproduction, not a third-party agent acceptance test; native-flag restart and cancellation interoperability remain unverified.

No automatic fix or pull request: correcting this changes permission enforcement, which explicitly falls outside this rule's simple-fix authorization. No production code was modified or pushed.

2. Claims vs findings

ClaimStatusEvidence
A follow-up can retain Full Access after workspace access is requested.VerifiedNo runtime approval interaction; fixture receives permission:yes instead of the user-denied permission:no.
A queued steer can retain the original approval policy.VerifiedThe same transition fails after cancellation of a pending fixture prompt; the steer stays within one logical turn on this no-restart path.
Writable roots are stale across subsequent prompts.VerifiedRemoving an extra root still creates a file there; adding a root still produces write:denied. Both delivery paths fail.
Loosening permissions should take effect too.Verified defectWorkspace-to-Full Access still asks the runtime for approval; the test denies it and receives permission:no.
Native launch flags must trigger a restart with correct context and turn continuity.Unverified dynamicallyMain only checks environment changes and cancellation failures before restarting. No native-flag agent acceptance run or restart-lifecycle experiment was performed.
External CI and live-agent results establish an acceptable fix.Not relied onExternal logs and linked branch code were not executed. The linked proposal was inspected statically only.

3. Environment

4. Minimal reproduction

  1. Obtain the trusted baseline and its existing dependencies/build outputs:
    git clone https://github.com/get-bb/bb.git bb-4797-repro
    cd bb-4797-repro
    git checkout --detach db9777f421f02f165c62fe2177e8c84eb3f45eda
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  2. Insert the complete regression block below inside describe("acp bridge", ...) in packages/provider-bridge-acp/src/bridge/bridge.test.ts, immediately before the existing auto-allows permission requests in full mode test. It uses that trusted file's existing setup, request helpers, and cleanup. No production edit is needed.
  3. Run the focused experiment:
    pnpm exec turbo run test --filter=@bb/provider-bridge-acp --force -- --testNamePattern='issue 4797 permission refresh' --reporter=verbose

The approval fixture sends a hypothetical command description but does not execute it. The filesystem fixture writes only hello from agent into a test-owned temporary directory. The tests deny every approval interaction that arrives, and wait for completion rather than treating a timeout as evidence.

Expected and actual

Transition (both follow-up and steer)ExpectedObserved on main
Full → workspaceOne approval interaction, denied by test; permission:noZero interactions; permission:yes
Workspace → FullNo approval interaction; permission:yesOne interaction; denied by test; permission:no
Extra root absent → presentFile created; write:okNo file; write:denied
Extra root present → absentNo file; write:deniedFile created; write:ok
Unchanged settingsStartup policy remains correctAll eight controls pass

Verbatim assertion messages and result counters, with runner prefixes omitted:

AssertionError: expected [] to have a length of 1 but got +0
AssertionError: expected [ 'echo:warmup', 'permission:yes' ] to include 'permission:no'
AssertionError: expected [ 'permission:yes' ] to include 'permission:no'
AssertionError: expected [ 'echo:warmup', 'permission:no' ] to include 'permission:yes'
AssertionError: expected false to be true // Object.is equality
AssertionError: expected true to be false // Object.is equality
AssertionError: expected [ 'write:ok' ] to include 'write:denied'

Test Files  1 failed | 18 skipped (19)
     Tests  8 failed | 8 passed | 352 skipped (368)

Complete regression block

  describe("issue 4797 permission refresh", () => {
    const deliveries: ("follow-up" | "steer")[] = ["follow-up", "steer"];
    const modes: ("full" | "accept-edits")[] = ["full", "accept-edits"];

    async function primeSession(
      providerThreadId: string,
      delivery: "follow-up" | "steer",
      options: Record<string, unknown>,
    ): Promise<number> {
      const initialId = sendTurnRequest("turn/start", providerThreadId, {
        input: [
          {
            type: "text",
            text: delivery === "steer" ? "hang" : "warmup",
            mentions: [],
          },
        ],
        options,
      });
      expect((await waitForResponse(initialId)).error).toBeUndefined();
      if (delivery === "follow-up") {
        await waitForTurnCompleted();
      }
      return threadEventsOfType("turn/completed").length;
    }

    it.each(
      deliveries.flatMap((delivery) =>
        modes.flatMap((startupMode) =>
          modes.map((currentMode) => ({ delivery, startupMode, currentMode })),
        ),
      ),
    )(
      "$delivery permissions $startupMode -> $currentMode",
      async ({ delivery, startupMode, currentMode }) => {
        const { providerThreadId } = await startThread({
          permissionMode: startupMode,
        });
        const completedBefore = await primeSession(
          providerThreadId,
          delivery,
          executionOptions({ permissionMode: startupMode }),
        );
        const promptId = sendTurnRequest(
          delivery === "steer" ? "turn/steer" : "turn/start",
          providerThreadId,
          {
            ...(delivery === "steer" ? { expectedTurnId: "turn-1" } : {}),
            input: [{ type: "text", text: "request-permission", mentions: [] }],
            options: executionOptions({ permissionMode: currentMode }),
          },
        );
        expect((await waitForResponse(promptId)).error).toBeUndefined();
        await waitFor(
          () =>
            notifications("interaction/request").length > 0 ||
            threadEventsOfType("turn/completed").length > completedBefore
              ? true
              : undefined,
          "permission interaction or completed prompt",
        );
        const forwarded = notifications("interaction/request").at(-1);
        if (forwarded !== undefined) {
          handleLine(
            JSON.stringify({
              jsonrpc: "2.0",
              id: forwarded.id,
              result: { decision: "deny" },
            }),
          );
        }
        await waitFor(
          () => threadEventsOfType("turn/completed")[completedBefore],
          "current prompt completion",
        );
        expect
          .soft(notifications("interaction/request"))
          .toHaveLength(currentMode === "accept-edits" ? 1 : 0);
        expect
          .soft(agentMessageTexts())
          .toContain(
            currentMode === "accept-edits" ? "permission:no" : "permission:yes",
          );
        expect(threadEventsOfType("turn/started")).toHaveLength(
          delivery === "steer" ? 1 : 2,
        );
        expect(notifications("session/replaced")).toHaveLength(0);
      },
    );

    it.each(
      deliveries.flatMap((delivery) =>
        [false, true].flatMap((hadRoot) =>
          [false, true].map((hasCurrentRoot) => ({
            delivery,
            hadRoot,
            hasCurrentRoot,
          })),
        ),
      ),
    )(
      "$delivery writable root $hadRoot -> $hasCurrentRoot",
      async ({ delivery, hadRoot, hasCurrentRoot }) => {
        const outsideDir = mkdtempSync(join(tmpdir(), "bb-acp-4797-outside-"));
        const targetPath = join(outsideDir, "outside.txt");
        try {
          const { providerThreadId } = await startThread({
            permissionMode: "accept-edits",
            additionalWorkspaceWriteRoots: hadRoot ? [outsideDir] : [],
            envVars: { FAKE_ACP_WRITE_PATH: targetPath },
          });
          const completedBefore = await primeSession(
            providerThreadId,
            delivery,
            executionOptions({
              permissionMode: "accept-edits",
              providerOptions: {
                additionalWorkspaceWriteRoots: hadRoot ? [outsideDir] : [],
              },
            }),
          );
          const promptId = sendTurnRequest(
            delivery === "steer" ? "turn/steer" : "turn/start",
            providerThreadId,
            {
              ...(delivery === "steer" ? { expectedTurnId: "turn-1" } : {}),
              input: [{ type: "text", text: "write-file", mentions: [] }],
              options: executionOptions({
                permissionMode: "accept-edits",
                providerOptions: {
                  additionalWorkspaceWriteRoots: hasCurrentRoot
                    ? [outsideDir]
                    : [],
                },
              }),
            },
          );
          expect((await waitForResponse(promptId)).error).toBeUndefined();
          await waitFor(
            () => threadEventsOfType("turn/completed")[completedBefore],
            "current write completion",
          );
          expect.soft(existsSync(targetPath)).toBe(hasCurrentRoot);
          expect
            .soft(agentMessageTexts())
            .toContain(hasCurrentRoot ? "write:ok" : "write:denied");
          expect(threadEventsOfType("turn/started")).toHaveLength(
            delivery === "steer" ? 1 : 2,
          );
          expect(notifications("session/replaced")).toHaveLength(0);
        } finally {
          rmSync(outsideDir, { recursive: true, force: true });
        }
      },
    );
  });

The public reports repository prohibits separate test files and raw log artifacts. Therefore the full reproducer and sanitized results are inline here; raw evidence remains outside the published repository.

5. Root cause

  1. The runtime already places current execution options on both turn/start and turn/steer. This static trace rules out the hypothesis that the bridge request contract lacks current options.
  2. Initial session construction maps the requested mode and writable roots into startup parameters. startAgentSession copies those values into session.policy once:
    policy: {
      permissionMode: params.permissionMode,
      workspaceWriteRoots: params.workspaceWriteRoots,
    }
  3. turn/start compares only environment variables or a previous cancellation failure. If no rebuild is necessary, it reuses the original policy. Even that rebuild reuses session.construction and changes only envVars, not the incoming permission options.
  4. AcpPendingTurnInput does not carry execution options. turn/steer queue insertion saves only the input and request identity:
    session.queuedInputs.push({
      clientRequestId: params.clientRequestId,
      input: params.input,
      requestId: null,
    });
    The queued prompt loop simply replaces the pending input and continues without refreshing policy.
  5. The actual enforcement sites use that obsolete policy: handlePermissionRequest auto-allows requests when the startup mode was full; handleFsWriteTextFile checks the startup writable roots. This explains both over-permission after tightening and stale denial after loosening.

Native permission flags are also derived from the launch permission mode by resolveAgentLaunchArgs. An in-memory update alone cannot establish that changed process arguments take effect. That path needs an explicit restart decision and real-agent verification, not an untested policy assignment.

6. Proposed fix from first principles

Normalize the current per-prompt permission settings at the existing request boundary, and carry those typed settings with each queued input. Before sending every prompt, refresh writable roots and the bridge's permission policy. Preserve the running session when process launch settings do not change; otherwise restart/resume from current settings, report context loss when necessary, and preserve one logical turn with pending tool requests correctly settled. Test tightening, loosening, root changes, native-flag changes, restart failure, and cancellation while approvals/tools are outstanding. Real-agent acceptance verification is still required before declaring this repair complete.

The observed security-relevant enforcement change is specifically forbidden by the autopilot's simple-fix gate, regardless of how few files or lines a future patch might touch. No repair branch was created or pushed.

7. Linked proposal: static review only

PR #4795 is closed and unmerged according to GitHub metadata; its diff changes two files, adding 156 and deleting 29 text lines. No open PR connected to issue #4797 was found by cross-reference metadata, open-PR search, or the closing-issue references of all currently open PRs.

The untrusted diff introduces storage for per-input execution options and a session-refresh path, targeting the omissions demonstrated above. It also changes queued-steer restart handling and suppresses a session reset during continued turns. Verdict: statically relevant, not acceptance-verified. Its branch, tests, external CI logs, and binaries were never checked out or executed. Cancellation interoperability, native flag transitions, failure handling, and logical-turn continuity through a replacement session are not established by this report.

8. Related scope and limits

No additional duplicate issue was established. Main's existing ACP tests cover startup approvals, startup filesystem roots, ordinary steering, and cancellation recovery, but not changing permissions between prompts; they pass despite this defect. The reported external live-agent cancellation failure is outside this reproduction and is neither confirmed nor attributed to this root cause.

9. Verification

The same investigator repeated the complete reproduction in a second clean detached checkout at db9777f421f02f165c62fe2177e8c84eb3f45eda, not an independent agent review. Each checkout received only the investigator-authored test addition; all production code remained byte-for-byte trusted main. The regression additions were identical in both checkouts. The second checkout used its own frozen install, build, temporary fixture workspaces, and write targets, with --force on the focused Turbo run.

CheckResult
First clean checkout: focused regressionExit 1: eight changed-setting cases fail, eight unchanged-setting controls pass.
Second clean checkout: forced focused regressionExit 1: the same eight failures and eight passing controls; all failures are assertions on permission results or file existence, not timeouts.
Existing ACP bridge suite, new regression group excluded19 files pass; 348 tests pass, 20 skipped (16 excluded reproduction cases plus four existing skipped cases).
ACP bridge typecheck with added testPass.
Frozen installs and Turbo buildsPass in both checkouts; existing build warnings do not cause failures.
Whitespace and scope checksgit diff --check passes; the only investigation change is 157 added test lines in one existing test file.

No claim correction was required after the second run. The report deliberately does not certify third-party ACP behavior, Windows behavior, native-flag restart/resume, or split-turn recovery.

10. Appendix: commands and evidence

Relevant validation commands, from each checkout root where applicable:

git rev-parse HEAD
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec oxfmt packages/provider-bridge-acp/src/bridge/bridge.test.ts
pnpm exec turbo run test --filter=@bb/provider-bridge-acp -- --testNamePattern='issue 4797 permission refresh'
pnpm exec turbo run test --filter=@bb/provider-bridge-acp --force -- --testNamePattern='issue 4797 permission refresh' --reporter=verbose
pnpm exec turbo run test --filter=@bb/provider-bridge-acp -- --testNamePattern='^(?!.*issue 4797 permission refresh)'
pnpm exec turbo run typecheck --filter=@bb/provider-bridge-acp
git diff --check
git diff --numstat

Repository metadata and the closed proposal were read using gh. All GitHub mutations were routed through the supplied SlopCop identity. No workflow, delegated agent, dependency addition, live application server, production log tail, or issue-provided executable was used. The public report includes no account credentials, private logs, personal paths, or machine identifiers.

Investigator-authored regression diff SHA-256: 54c9c2921736386855eefc6f22d157fa896f9f4137e259d9aeeea2e3195f7836.

AGENT GENERATED