Run OpenCode agents under the permission rules they were given (#5296)

* fix(opencode): set permission rules on the OpenCode session

OpenCode stores permission rules on the session and its prompt_async
request schema has no permission field, so rules built from provider
options and Hub tool grants were dropped on every turn: an agent
configured with external_directory: allow still asked before reading
outside its worktree.

Send the rules where OpenCode reads them — on session.create, and on
session.update when resuming a session Paseo did not just create — and
stop attaching them to prompts. A resume that cannot apply its rules now
fails instead of running under a more permissive policy than configured.

The real-server test is taken from Gurvan Campion's #5295, which reached
the same diagnosis independently.

Co-authored-by: Gurvan Campion <32047986+gurvancampion@users.noreply.github.com>

* test(opencode): use function declarations for the permission test helpers

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Gurvan Campion <32047986+gurvancampion@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-23 21:56:19 +02:00
committed by GitHub
co-authored by Gurvan Campion
parent 222d45a2fc
commit 2c7b38bc7e
3 changed files with 225 additions and 48 deletions
@@ -2385,49 +2385,41 @@ describe("OpenCode adapter startTurn error handling", () => {
});
test("sends exact Hub MCP permission grants without approving unrelated tools", async () => {
const promptAsync = vi.fn(async () => ({ data: {}, error: undefined }));
const fakeClient = {
global: {
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({
stream: {
async *[Symbol.asyncIterator](): AsyncGenerator<OpenCodeEvent> {
yield { type: "server.connected", properties: {} } as OpenCodeEvent;
await waitForAbort(signal);
},
},
})),
},
session: { promptAsync },
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/test",
providerOptions: { permission: { bash: "ask", hub_reply: "deny" } },
cwd,
providerOptions: { permission: { bash: "ask", question: "deny" } },
toolPolicy: {
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
},
},
fakeClient,
"ses_unit_test",
createTestLogger(),
);
await session.startTurn("finish");
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
permission: [
});
try {
const created = openCode.calls.sessionCreate[0] as {
permission?: Array<{ permission: string; pattern: string; action: string }>;
};
expect(created.permission).toEqual([
{ permission: "hub_finish_execution", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "hub_reply", pattern: "*", action: "deny" },
],
}),
);
expect(promptAsync.mock.calls[0]?.[0].permission).not.toContainEqual(
expect.objectContaining({ permission: "bash", action: "allow" }),
);
await session.close();
{ permission: "question", pattern: "*", action: "deny" },
]);
expect(created.permission).not.toContainEqual(
expect.objectContaining({ permission: "bash", action: "allow" }),
);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("waits for the stop abort and provider idle before starting the next prompt", async () => {
@@ -6733,3 +6725,124 @@ describe("OpenCode snapshot summary false-idle regression", () => {
await session.close();
});
});
describe("OpenCode session permission rules", () => {
const logger = createTestLogger();
function permissionConfig(cwd: string): AgentSessionConfig {
return {
provider: "opencode",
cwd,
providerOptions: { permission: { bash: "ask", external_directory: "allow" } },
toolPolicy: { preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }] },
};
}
// OpenCode stores permission rules on the session (POST /session, PATCH /session/{id}) and
// drops an unknown `permission` field on prompt_async, so rules sent with a prompt never
// reach the server.
const expectedRules = [
{ permission: "hub_finish_execution", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "external_directory", pattern: "*", action: "allow" },
];
test("creates the session with the agent's permission rules", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.createSession(permissionConfig(cwd));
try {
expect(openCode.calls.sessionCreate[0]).toEqual(
expect.objectContaining({ permission: expectedRules }),
);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("applies the agent's permission rules to a resumed session", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.resumeSession(
{ provider: "opencode", sessionId: "ses_resumed_rules", metadata: { cwd } },
permissionConfig(cwd),
);
try {
expect(openCode.calls.sessionUpdate[0]).toEqual(
expect.objectContaining({
sessionID: "ses_resumed_rules",
permission: expectedRules,
}),
);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("fails the resume when the rules cannot be applied", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
openCode.sessionUpdateResponse = { error: { data: { message: "session is archived" } } };
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
// Resuming without the rules would run the agent under a more permissive policy than
// the one it was configured with, which is the failure this guards.
await expect(
client.resumeSession(
{ provider: "opencode", sessionId: "ses_archived", metadata: { cwd } },
permissionConfig(cwd),
),
).rejects.toThrow("Failed to apply OpenCode session permission rules");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("leaves a session without rules untouched", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.resumeSession({
provider: "opencode",
sessionId: "ses_resumed_no_rules",
metadata: { cwd },
});
try {
expect(openCode.calls.sessionUpdate).toEqual([]);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
});
@@ -1452,12 +1452,22 @@ export class OpenCodeAgentClient implements AgentClient {
directory: openCodeConfig.cwd,
});
// OpenCode stores permission rules on the session, so they are set here and on resume
// rather than sent with each prompt, which drops them.
const permission = buildOpenCodePermissionRules(
openCodeConfig.providerOptions,
openCodeConfig.toolPolicy,
);
try {
// Creating the first session for a directory is part of OpenCode coming up, so it
// shares the server startup budget instead of a shorter one that fails agent
// creation on contended cold starts.
const response = await withTimeout(
client.session.create({ directory: openCodeConfig.cwd }),
client.session.create({
directory: openCodeConfig.cwd,
...(permission ? { permission } : {}),
}),
OPENCODE_SERVER_STARTUP_TIMEOUT_MS,
`OpenCode session.create timed out after ${Math.round(
OPENCODE_SERVER_STARTUP_TIMEOUT_MS / 1000,
@@ -1527,6 +1537,7 @@ export class OpenCodeAgentClient implements AgentClient {
});
try {
await this.applySessionPermissionRules(client, openCodeConfig, handle.sessionId);
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
const unbindBridge = this.bindBridgeSession(handle.sessionId, launchContext);
@@ -1550,6 +1561,23 @@ export class OpenCodeAgentClient implements AgentClient {
}
}
private async applySessionPermissionRules(
client: OpencodeClient,
config: OpenCodeAgentConfig,
sessionId: string,
): Promise<void> {
const permission = buildOpenCodePermissionRules(config.providerOptions, config.toolPolicy);
if (!permission) return;
const response = readOpenCodeRecord(
await client.session.update({ sessionID: sessionId, directory: config.cwd, permission }),
);
if (response?.error) {
throw new Error(
`Failed to apply OpenCode session permission rules: ${toDiagnosticErrorMessage(response.error)}`,
);
}
}
private acquireServer(
config: OpenCodeAgentConfig,
launchContext?: AgentLaunchContext,
@@ -3510,10 +3538,6 @@ class OpenCodeAgentSession implements AgentSession {
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
const permission = buildOpenCodePermissionRules(
this.config.providerOptions,
this.config.toolPolicy,
);
const model = this.parseModel(this.config.model);
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
const effectiveVariant = this.config.thinkingOptionId ?? undefined;
@@ -3525,7 +3549,6 @@ class OpenCodeAgentSession implements AgentSession {
messageID: promptId,
parts,
...(systemPrompt ? { system: systemPrompt } : {}),
...(permission ? { permission } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
@@ -3850,10 +3873,6 @@ class OpenCodeAgentSession implements AgentSession {
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
const permission = buildOpenCodePermissionRules(
this.config.providerOptions,
this.config.toolPolicy,
);
const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
@@ -3868,7 +3887,6 @@ class OpenCodeAgentSession implements AgentSession {
}
: {}),
...(systemPrompt ? { system: systemPrompt } : {}),
...(permission ? { permission } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
@@ -11,6 +11,52 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
import { OpenCodeBridge } from "./opencode/bridge.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
test("real OpenCode server persists provider permissions across creation and resume", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-"));
const cwd = path.join(root, "repo");
const logger = createTestLogger();
const manager = new OpenCodeServerManager({ logger, resolveHomeDir: () => root });
const client = new OpenCodeAgentClient(logger, undefined, { serverManager: manager });
let session: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>> | undefined;
let inspection: Awaited<ReturnType<OpenCodeServerManager["acquireCurrent"]>> | undefined;
try {
await mkdir(cwd);
session = await client.createSession({
provider: "opencode",
cwd,
providerOptions: { permission: { external_directory: "allow" } },
});
inspection = await manager.acquireCurrent();
const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: cwd });
async function readPermission(sessionId: string) {
const response = await sdk.session.get({ sessionID: sessionId, directory: cwd });
if (response.error) throw new Error(JSON.stringify(response.error));
return response.data?.permission;
}
const handle = session.describePersistence()!;
expect(await readPermission(handle.sessionId)).toEqual([
{ permission: "external_directory", pattern: "*", action: "allow" },
]);
await session.close();
// OpenCode appends session-update rules and the last matching rule wins, so a resume
// with a different policy leaves both entries in order.
session = await client.resumeSession(handle, {
providerOptions: { permission: { external_directory: "deny" } },
});
expect(await readPermission(handle.sessionId)).toEqual([
{ permission: "external_directory", pattern: "*", action: "allow" },
{ permission: "external_directory", pattern: "*", action: "deny" },
]);
} finally {
await inspection?.release();
await session?.close();
await manager.shutdown();
await rm(root, { recursive: true, force: true });
}
}, 120_000);
test("real OpenCode server shares one process while shell.env stays session-scoped", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-real-"));
const firstCwd = path.join(root, "first");