← reports

#4806 · Retained ACP permission policy on follow-ups

BugPriority: MediumEffort: Mediumproviders · provider-acp · security GitHub issue2026-10-03 · base cd12e7a9dfb372432cd748eafcd73d142e15c16f

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

1. TL;DR

An ACP thread can keep the permissions chosen when its session was created, even after the next turn explicitly requests a different mode. The live bridge does not compare the follow-up permission mode with its construction settings. Even an environment-triggered rebuild copies the old mode. This affects both the agent's command-line permission flags and the bridge's own approval policy: a full-to-accept-edits downgrade still auto-approves a synthetic command permission request without asking the user. Five focused regression cases fail for those intended reasons in each of two clean trusted checkouts.

2. Claims vs findings

ClaimFindingEvidence
The next turn's permission selection is ignored by a retained ACP session.VerifiedBoth mode transitions retain the old process flags; direct turn/start options contain the new mode.
Changing environment settings rebuilds the session, but permission changes alone do not.Verified mechanismThe only rebuild predicates are cancellation recovery and environment inequality. Both transition tests still fail when a changed environment forces reconstruction.
The fault is in the shared bridge, not one agent adapter.Verified at bridge boundaryThe real bridge launches the repository's trusted fake ACP process over its real ACP connection; there is no mocked bridge or dialect implementation.
A process rebuild is enough to pick up the new permission selection.Not sufficient on current mainThe rebuild spreads the old construction object and replaces only envVars. It retains the original permission mode.
The same symptom occurs with real Cursor and Codex processes or through the composer.Not exercised hereNo provider credentials, paid turns, live UI, or reporter fork were used. The verified result is the shared bridge contract, not an end-to-end composer test.
Unsupported automatic approval mode needs explicit treatment.Partial static finding onlySession construction already rejects auto in buildAcpSessionParams; follow-up handling does not call that validation. No auto-mode test or policy change was made.

3. Environment

4. Minimal reproduction

  1. Clone and build the recorded trusted revision using the commands below.
  2. Save the complete inline regression test at the listed path.
  3. Run the focused Turbo test. Exit code 1 with five assertion failures is the reproduced defect, not a harness failure.
git clone --single-branch --branch main https://github.com/get-bb/bb.git bb-repro-4806
cd bb-repro-4806
git checkout --detach cd12e7a9dfb372432cd748eafcd73d142e15c16f
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build

Save the inline test below as:
packages/provider-bridge-acp/src/bridge/permission-followup.repro.test.ts

pnpm exec turbo run test --filter=@bb/provider-bridge-acp -- src/bridge/permission-followup.repro.test.ts

Existing owner tests:
pnpm exec turbo run test --filter=@bb/provider-bridge-acp -- src/bridge/bridge.test.ts src/session-params.test.ts
pnpm exec turbo run typecheck --filter=@bb/provider-bridge-acp

The first four cases establish a completed initial turn, then submit the opposite permission mode. Two also change a harmless environment variable to exercise session reconstruction. The final case establishes full-mode automatic approval, then requests accept-edits and checks that the subsequent command permission request reaches the user-approval boundary.

TransitionExpectedActual
accept-edits → full, unchanged or changed environmentargv:--always-approveargv:--workspace-write
full → accept-edits, unchanged or changed environmentargv:--workspace-writeargv:--always-approve
full → accept-edits, synthetic command permission requestaskedForApproval: trueaskedForApproval: false; permission:yes

Verbatim observations from the first corrected run:

@bb/provider-bridge-acp:test: {"initialMode":"accept-edits","nextMode":"full","changeEnv":false,"actual":"argv:--workspace-write"}
@bb/provider-bridge-acp:test: {"initialMode":"full","nextMode":"accept-edits","changeEnv":false,"actual":"argv:--always-approve"}
@bb/provider-bridge-acp:test: {"initialMode":"accept-edits","nextMode":"full","changeEnv":true,"actual":"argv:--workspace-write"}
@bb/provider-bridge-acp:test: {"initialMode":"full","nextMode":"accept-edits","changeEnv":true,"actual":"argv:--always-approve"}
@bb/provider-bridge-acp:test: {"nextMode":"accept-edits","askedForApproval":false,"actual":"permission:yes"}

Verbatim failing assertion evidence:

Expected: "argv:--always-approve"
Received: "argv:--workspace-write"

Expected: "argv:--workspace-write"
Received: "argv:--always-approve"

AssertionError: expected false to be true
Tests  5 failed (5)

Complete reproduction test

Inline source is the public artifact, per the reports repository's publication policy. Raw logs and a local copy remain outside the published git tree.

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, expect, it } from "vitest";
import { z } from "zod";
import {
  assembleCapturedThreadEvents,
  captureBridgeJsonRpcOutput,
} from "@bb/provider-bridge-protocol/testing";
import type { CapturedBridgeJsonRpcOutput } from "@bb/provider-bridge-protocol/testing";
import { handleLine } from "./bridge.js";

type Mode = "accept-edits" | "full";
let output: CapturedBridgeJsonRpcOutput;
let workspaceDir: string;
let serial = 480600;
let threadId: string;
let providerThreadId: string;

function options(permissionMode: Mode, envVars: Record<string, string> = {}) {
  return {
    permissionMode,
    permissionScope: permissionMode === "full" ? "full" : "workspace",
    approvalReviewer: permissionMode === "full" ? null : "user",
    permissionEscalation: permissionMode === "full" ? null : "ask",
    envVars,
  };
}

function send(method: string, params: object): number {
  serial += 1;
  handleLine(JSON.stringify({ jsonrpc: "2.0", id: serial, method, params }));
  return serial;
}

async function waitUntil(condition: () => boolean): Promise<void> {
  const deadline = Date.now() + 10_000;
  while (!condition()) {
    if (Date.now() > deadline) throw new Error("Reproduction timed out");
    await new Promise((resolveTick) => setTimeout(resolveTick, 10));
  }
}

async function response(id: number): Promise<void> {
  await waitUntil(() => output.messages.some((message) => message.id === id));
  expect(
    output.messages.find((message) => message.id === id)?.error,
  ).toBeUndefined();
}

function events() {
  return assembleCapturedThreadEvents(output.messages, "acp");
}

function text(): string {
  return events()
    .flatMap((event) => {
      if (event.type !== "item/agentMessage/delta") return [];
      return [event.delta];
    })
    .join("");
}

async function start(permissionMode: Mode): Promise<void> {
  const id = send("thread/start", {
    threadId,
    cwd: workspaceDir,
    instructionMode: "append",
    options: {
      ...options(permissionMode),
      providerOptions: {
        acpLaunchSpec: {
          displayName: "Trusted ACP test fixture",
          command: process.execPath,
          args: [
            join(dirname(fileURLToPath(import.meta.url)), "fake-acp-agent.mjs"),
          ],
          env: { FAKE_ACP_LOAD_SESSION: "1" },
          permissionCli: {
            full: ["--always-approve"],
            workspaceWrite: ["--workspace-write"],
            insertAfterArgs: 1,
          },
        },
      },
    },
  });
  await response(id);
  providerThreadId = z
    .object({ providerThreadId: z.string() })
    .parse(
      output.messages.find((message) => message.id === id)?.result,
    ).providerThreadId;
}

async function prompt(
  permissionMode: Mode,
  promptText: string,
  changeEnv = false,
): Promise<void> {
  await response(
    send("turn/start", {
      threadId,
      providerThreadId,
      clientRequestId: "creq_23456789ab",
      options: options(
        permissionMode,
        changeEnv ? { REPRO_REVISION: "2" } : {},
      ),
      input: [{ type: "text", text: promptText, mentions: [] }],
    }),
  );
}

beforeEach(() => {
  workspaceDir = mkdtempSync(join(tmpdir(), "acp-permission-repro-"));
  threadId = `permission-repro-${++serial}`;
  output = captureBridgeJsonRpcOutput();
});

afterEach(async () => {
  try {
    await response(
      send("thread/stop", {
        threadId,
        providerThreadId,
        intent: "interrupt",
        activeTurnId: null,
      }),
    );
  } finally {
    output.restore();
    rmSync(workspaceDir, { recursive: true, force: true });
  }
});

it.each([
  ["accept-edits", "full", false, "--workspace-write", "--always-approve"],
  ["full", "accept-edits", false, "--always-approve", "--workspace-write"],
  ["accept-edits", "full", true, "--workspace-write", "--always-approve"],
  ["full", "accept-edits", true, "--always-approve", "--workspace-write"],
] as const)(
  "applies follow-up permission args %s -> %s (env change=%s)",
  async (initialMode, nextMode, changeEnv, initialFlag, nextFlag) => {
    await start(initialMode);
    await prompt(initialMode, "echo-argv");
    await waitUntil(() =>
      events().some((event) => event.type === "turn/completed"),
    );
    expect(text()).toBe(`argv:${initialFlag}`);
    output.takeMessages();
    output.messages.length = 0;
    await prompt(nextMode, "echo-argv", changeEnv);
    await waitUntil(() =>
      events().some((event) => event.type === "turn/completed"),
    );
    process.stderr.write(
      `${JSON.stringify({ initialMode, nextMode, changeEnv, actual: text() })}\n`,
    );
    expect(text()).toBe(`argv:${nextFlag}`);
  },
);

it("asks for command approval after full -> accept-edits on a follow-up", async () => {
  await start("full");
  await prompt("full", "request-permission");
  await waitUntil(() =>
    events().some((event) => event.type === "turn/completed"),
  );
  expect(text()).toBe("permission:yes");
  output.takeMessages();
  output.messages.length = 0;
  await prompt("accept-edits", "request-permission");
  await waitUntil(
    () =>
      output.messages.some(
        (message) => message.method === "interaction/request",
      ) || events().some((event) => event.type === "turn/completed"),
  );
  const askedForApproval = output.messages.some(
    (message) => message.method === "interaction/request",
  );
  process.stderr.write(
    `${JSON.stringify({ nextMode: "accept-edits", askedForApproval, actual: text() })}\n`,
  );
  expect(askedForApproval).toBe(true);
});

5. Root cause

turn/start session reuse and reconstruction derives envVars and tests only restartAfterCancelError or changed environment values. It never consumes params.options.permissionMode. Reconstruction passes { ...session.construction, envVars }, so the new process also gets the old permission mode.

if (
  session.restartAfterCancelError ||
  !isDeepStrictEqual(envVars ?? {}, session.construction.envVars ?? {})
) {
  session = await startAgentSession({
    kind: "resume",
    params: { ...session.construction, envVars },
    resumeProviderThreadId: previousProviderThreadId,
  });
}

Permission CLI argument selection and Agent launch argument construction derive flags from the construction mode. Session policy initialization stores that same mode separately as the live permission policy. Automatic command approval bypasses user interaction whenever that retained policy equals full. Therefore this is not just stale process configuration: lowering permissions can also leave automatic approvals active inside BB.

Construction-time permission validation rejects auto during construction, but the retained-turn path does not reconstruct those typed settings. That additional gap is not covered by the five runtime tests and is not part of this report's proven verdict.

6. Proposed fix and safety decision

An authorized permission-policy change should validate the incoming mode at the turn boundary, compare it to the effective session mode, and apply it consistently to both agent launch arguments and the live approval policy before submitting the prompt. Reconstruction must explicitly carry the validated incoming mode rather than preserve it through an old construction-object spread. Verify both directions, reconstruction with and without restoration support, pending approvals, unsupported modes, and steering behavior before release.

No automatic fix or pull request: this repair changes permission enforcement and command authorization. It fails the new-issue-autopilot simple-fix condition that excludes permission, authorization, and security-boundary changes, regardless of its possible line count. No production edit, branch, fix commit, or code push was made. No open issue-linked pull request was found by timeline metadata or the open-PR search at investigation time.

7. Related issues

The repository's permission-related ACP issue #4797 is related context, not proof of this defect. No linked patch or fork was fetched or executed. Existing coverage tests construction-time permission flags and direct permission interactions but misses changing the mode between turns.

8. Verification

The same agent repeated the reproduction in a second clean clone, not an independent reviewer. Its initial git status was clean and HEAD was detached at cd12e7a9dfb372432cd748eafcd73d142e15c16f. After a separate frozen install and full successful build, only the locally authored test file was copied in. The same focused command failed all five assertions for the same retained flags and auto-approval outcome. No correction to the final root cause or verdict was needed.

@bb/provider-bridge-acp:test: {"initialMode":"accept-edits","nextMode":"full","changeEnv":false,"actual":"argv:--workspace-write"}
@bb/provider-bridge-acp:test: {"initialMode":"full","nextMode":"accept-edits","changeEnv":false,"actual":"argv:--always-approve"}
@bb/provider-bridge-acp:test: {"initialMode":"accept-edits","nextMode":"full","changeEnv":true,"actual":"argv:--workspace-write"}
@bb/provider-bridge-acp:test: {"initialMode":"full","nextMode":"accept-edits","changeEnv":true,"actual":"argv:--always-approve"}
@bb/provider-bridge-acp:test: {"nextMode":"accept-edits","askedForApproval":false,"actual":"permission:yes"}

9. Appendix and limits

The preliminary harness run was rejected for incomplete request parameters, not counted as evidence. The harness was corrected to use the protocol's provider-thread identity and valid client-request ID before both reported runs. Assertions check the real child process's flags and the real bridge's emitted approval request; no production seam, mock permission policy, database, dependency, or generated tracked file was added.

Trust boundary: issue content was treated only as untrusted claims. Issue-provided instructions, links, scripts, fork changes, and patches were not followed, fetched, or run. No workflow or subagent was started. The public report contains the complete locally authored test and exact expected/actual evidence; original build and test logs are retained locally only.

Existing suite result:

Test Files  2 passed (2)
Tests  127 passed (127)