#4797 · ACP bridge ignores updated per-prompt permission settings
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
| Claim | Status | Evidence |
|---|---|---|
| A follow-up can retain Full Access after workspace access is requested. | Verified | No runtime approval interaction; fixture receives permission:yes instead of the user-denied permission:no. |
| A queued steer can retain the original approval policy. | Verified | The 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. | Verified | Removing 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 defect | Workspace-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 dynamically | Main 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 on | External logs and linked branch code were not executed. The linked proposal was inspected statically only. |
3. Environment
- Public target repository:
get-bb/bb; main commitdb9777f421f02f165c62fe2177e8c84eb3f45eda, fetched and checked again after reproduction with no newer main commit. - Linux x86_64, Node
v22.19.0, pnpm9.15.0, Turbo2.10.12, Vitest4.1.1. The ACP fixture uses the Node executable running the tests. - Two separate clean detached worktrees at that commit; frozen installs and normal Turbo builds succeeded in both. The second test run used
--forceso it actually executed. - No user's BB instance, provider account, or runtime data was accessed. No dependency was added. No application/server ports or persistent data directories were needed; fixture workspaces and the out-of-workspace write target were fresh temporary directories, cleaned by the test.
- Provider: repository-owned
fake-acp-agent.mjs, connected over actual child-process stdio to the production ACP bridge. No independently installed ACP agent was used.
4. Minimal reproduction
- 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
- Insert the complete regression block below inside
describe("acp bridge", ...)inpackages/provider-bridge-acp/src/bridge/bridge.test.ts, immediately before the existingauto-allows permission requests in full modetest. It uses that trusted file's existing setup, request helpers, and cleanup. No production edit is needed. - 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) | Expected | Observed on main |
|---|---|---|
| Full → workspace | One approval interaction, denied by test; permission:no | Zero interactions; permission:yes |
| Workspace → Full | No approval interaction; permission:yes | One interaction; denied by test; permission:no |
| Extra root absent → present | File created; write:ok | No file; write:denied |
| Extra root present → absent | No file; write:denied | File created; write:ok |
| Unchanged settings | Startup policy remains correct | All 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
- 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.
- Initial session construction maps the requested mode and writable roots into startup parameters. startAgentSession copies those values into
session.policyonce:policy: { permissionMode: params.permissionMode, workspaceWriteRoots: params.workspaceWriteRoots, } - 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.constructionand changes onlyenvVars, not the incoming permission options. - 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. - 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.
| Check | Result |
|---|---|
| First clean checkout: focused regression | Exit 1: eight changed-setting cases fail, eight unchanged-setting controls pass. |
| Second clean checkout: forced focused regression | Exit 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 excluded | 19 files pass; 348 tests pass, 20 skipped (16 excluded reproduction cases plus four existing skipped cases). |
| ACP bridge typecheck with added test | Pass. |
| Frozen installs and Turbo builds | Pass in both checkouts; existing build warnings do not cause failures. |
| Whitespace and scope checks | git 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