From fa96985b109bbc19ba0946a70df7ecd55eb4029c Mon Sep 17 00:00:00 2001 From: Garry-TI Date: Thu, 12 Nov 2026 20:45:51 -0500 Subject: [PATCH 1/3] fix(opencode): discard stale inherited child permission rules --- .../src/server/adapter.test.ts | 175 ++++++++++++++++++ .../provider-opencode/src/server/adapter.ts | 34 +++- 2 files changed, 200 insertions(+), 9 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 91400661e6ee..d3497d056c98 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -682,6 +682,181 @@ describe("OpenCodeAdapterV2", () => { ), ); + it.effect.each([false, true])( + "restores a child's policy after OpenCode appends a parent policy change (nested=%s)", + (nested) => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const root = "ses_root"; + const child = "ses_child"; + const fullAccess = runtimePolicy("full-access"); + const inherited = openCodePermissionRules(runtimePolicy("approval-required")); + const taskDeny = { permission: "task", pattern: "*", action: "deny" as const }; + const agentDeny = { permission: "bash", pattern: "rm *", action: "deny" as const }; + const childTaskRule = nested ? { ...taskDeny, action: "allow" as const } : taskDeny; + const nativePermissions = new Map([ + [root, inherited], + [ + child, + [ + ...inherited.filter( + (rule) => rule.action === "deny" || rule.permission === "external_directory", + ), + childTaskRule, + agentDeny, + ], + ], + ]); + const harness = yield* makeOpenCodeRuntimeHarness("appended-child-policy", root, { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }), + get: async ({ sessionID }: { sessionID: string }) => ({ + data: { + id: sessionID, + ...(sessionID === child ? { parentID: root } : {}), + permission: nativePermissions.get(sessionID), + time: { created: 1, updated: 2 }, + }, + }), + // OpenCode's update endpoint appends rules rather than replacing them. + update: async (input: { + sessionID: string; + permission: ReturnType; + }) => { + const permission = [ + ...(nativePermissions.get(input.sessionID) ?? []), + ...input.permission, + ]; + nativePermissions.set(input.sessionID, permission); + return { + data: { id: input.sessionID, permission, time: { created: 1, updated: 2 } }, + }; + }, + promptAsync: async () => ({ data: true }), + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + runtimePolicy: fullAccess, + }); + yield* harness.startTurn(); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: root, + part: { + id: "prt_task", + sessionID: root, + messageID: "msg_assistant", + type: "tool", + tool: "task", + callID: "call_task", + state: { + status: "running", + input: { + description: "Research", + prompt: "read and research", + subagent_type: "general", + }, + title: "Research", + metadata: { parentSessionId: root, sessionId: child }, + time: { start: 3 }, + }, + }, + }, + }), + ); + const permission = nativePermissions.get(child)!; + assert.equal(permissionAction(permission, "read"), "allow"); + assert.equal(permissionAction(permission, "webfetch"), "allow"); + assert.equal(permissionAction(permission, "edit"), "allow"); + assert.equal(permissionAction(permission, "task"), nested ? "allow" : "deny"); + assert.deepEqual(permission.at(-1), agentDeny); + for (const policy of [ + fullAccess, + runtimePolicy("approval-required"), + fullAccess, + fullAccess, + ]) { + yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + runtimePolicy: policy, + }); + const updated = nativePermissions.get(child)!; + assert.equal(permissionAction(updated, "read"), "allow"); + assert.equal( + permissionAction(updated, "webfetch"), + policy.runtimeMode === "full-access" ? "allow" : "ask", + ); + assert.equal(permissionAction(updated, "task"), nested ? "allow" : "deny"); + assert.deepEqual(updated.at(-1), agentDeny); + } + const ruleCount = nativePermissions.get(child)!.length; + yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + runtimePolicy: fullAccess, + }); + assert.equal(nativePermissions.get(child)!.length, ruleCount); + if (nested) { + const grandchild = "ses_grandchild"; + nativePermissions.set(grandchild, [ + ...nativePermissions + .get(child)! + .filter((rule) => rule.action === "deny" || rule.permission === "external_directory"), + taskDeny, + ]); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: child, + info: { id: "msg_child", sessionID: child, role: "user", time: { created: 4 } }, + }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: child, + part: { + id: "prt_nested_task", + sessionID: child, + messageID: "msg_child_assistant", + type: "tool", + tool: "task", + callID: "call_nested", + state: { + status: "running", + input: { + description: "Nested research", + prompt: "read", + subagent_type: "general", + }, + title: "Nested research", + metadata: { parentSessionId: child, sessionId: grandchild }, + time: { start: 5 }, + }, + }, + }, + }), + ); + const nestedPermission = nativePermissions.get(grandchild)!; + assert.equal(permissionAction(nestedPermission, "read"), "allow"); + assert.equal(permissionAction(nestedPermission, "bash"), "deny"); + assert.deepEqual(nestedPermission.at(-2), agentDeny); + assert.equal(permissionAction(nestedPermission, "task"), "deny"); + } + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect("applies a changed runtime policy to existing subagent sessions", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 4459a219aa7d..89e068e3cdd4 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -381,7 +381,7 @@ interface OpenCodeThreadState { nextChildTurnOrdinal: number; nextAdmissionGeneration: number; permission: PermissionRuleset; - /** Parent rules last written into this child's `permission`; empty for root sessions. */ + /** Parent policy history installed in this child, excluding agent-specific restrictions. */ inheritedPermission: PermissionRuleset; } @@ -714,8 +714,9 @@ export function openCodeChildPermissionRules( ...previousParentRules, ]; const childSpecificRules = childRules.filter( - (childRule) => - !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)), + (childRule, index) => + !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)) && + !childRules.slice(index + 1).some((laterRule) => permissionRuleEquals(childRule, laterRule)), ); return [...parentRules, ...childSpecificRules]; } @@ -1406,6 +1407,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const childPermission = openCodeChildPermissionRules( turn.runtimePolicy, nativeChildSession.permission ?? [], + state.parentSubagent === null ? state.permission : state.inheritedPermission, ); yield* sdkCall( "session.update", @@ -1429,7 +1431,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, permission: childPermission, - inheritedPermission: openCodePermissionRules(turn.runtimePolicy), + inheritedPermission: + state.parentSubagent === null ? state.permission : state.inheritedPermission, }); yield* emitProviderEvent({ type: "app_thread.created", @@ -2871,16 +2874,29 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ? parentRules : openCodeChildPermissionRules(policy, previous, target.inheritedPermission); if ( - permission.length !== previous.length || - !permission.every((rule, index) => permissionRuleEquals(rule, previous[index]!)) + permission.length > previous.length || + !permission.every((rule, index) => + permissionRuleEquals(rule, previous[previous.length - permission.length + index]!), + ) ) { const sessionID = target.nativeSessionId; - yield* sdkCall("session.update", { sessionID, permission }, () => + const response = yield* sdkCall("session.update", { sessionID, permission }, () => client.session.update({ sessionID, permission }), ); - target.permission = permission; + // OpenCode appends updates; children inherit even the parent's superseded deny rules. + target.permission = unwrapData("session.update", response).permission ?? permission; + } + if (target.parentSubagent !== null) { + target.inheritedPermission = [ + ...target.inheritedPermission, + ...parentRules.filter( + (rule) => + !target.inheritedPermission.some((previousRule) => + permissionRuleEquals(rule, previousRule), + ), + ), + ]; } - if (target.parentSubagent !== null) target.inheritedPermission = parentRules; for (const child of threads.values()) { if (child.parentSubagent?.parentState === target) pending.push(child); } From 2bb59dc651586b49664a6d8e62ba1e4b9e625fcd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 06:36:18 +0000 Subject: [PATCH 2/3] refactor(opencode): compute inherited child rules once Co-Authored-By: Claude Opus 5.5 --- packages/provider-opencode/src/server/adapter.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index b64c01558069..cf62a075abf3 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -1451,10 +1451,12 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function () => client.session.get({ sessionID: childSessionId }), ); const nativeChildSession = unwrapData("session.get", childSessionResponse); + const inheritedPermission = + state.parentSubagent === null ? state.permission : state.inheritedPermission; const childPermission = openCodeChildPermissionRules( turn.runtimePolicy, nativeChildSession.permission ?? [], - state.parentSubagent === null ? state.permission : state.inheritedPermission, + inheritedPermission, ); yield* sdkCall( "session.update", @@ -1487,8 +1489,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function nextChildTurnOrdinal, nextAdmissionGeneration: 1, permission: childPermission, - inheritedPermission: - state.parentSubagent === null ? state.permission : state.inheritedPermission, + inheritedPermission, }); yield* emitProviderEvent({ type: "app_thread.created", From c045d02132c7e9f3e099017edf30a199c4f0cf90 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 06:54:56 +0000 Subject: [PATCH 3/3] fix(opencode): keep configured primary tool denies on child sessions OpenCode adds a child-only deny for each experimental.primary_tools entry but omits it when an identical inherited rule exists. Treating every historical parent rule as inherited then dropped that deny (for example bash:*:deny after a supervised to Full access switch). Read primary_tools once per session and always keep those denies when rewriting a child. Co-Authored-By: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 10 ++++++- .../provider-opencode/src/server/adapter.ts | 27 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 1cfdec486aeb..ed786d75234f 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -674,7 +674,11 @@ describe("OpenCodeAdapterV2", () => { const fullAccess = runtimePolicy("full-access"); const inherited = openCodePermissionRules(runtimePolicy("approval-required")); const taskDeny = { permission: "task", pattern: "*", action: "deny" as const }; - const agentDeny = { permission: "bash", pattern: "rm *", action: "deny" as const }; + const agentDeny = { + permission: "codesearch", + pattern: "secret/*", + action: "deny" as const, + }; const childTaskRule = nested ? { ...taskDeny, action: "allow" as const } : taskDeny; const nativePermissions = new Map([ [root, inherited], @@ -690,6 +694,8 @@ describe("OpenCodeAdapterV2", () => { ], ]); const harness = yield* makeOpenCodeRuntimeHarness("appended-child-policy", root, { + // OpenCode omits the child's bash deny because the inherited supervised seed matches it. + config: { get: async () => ({ data: { experimental: { primary_tools: ["bash"] } } }) }, event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, session: { create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }), @@ -757,6 +763,7 @@ describe("OpenCodeAdapterV2", () => { assert.equal(permissionAction(permission, "webfetch"), "allow"); assert.equal(permissionAction(permission, "edit"), "allow"); assert.equal(permissionAction(permission, "task"), nested ? "allow" : "deny"); + assert.equal(permissionAction(permission, "bash"), "deny"); assert.deepEqual(permission.at(-1), agentDeny); for (const policy of [ fullAccess, @@ -775,6 +782,7 @@ describe("OpenCodeAdapterV2", () => { policy.runtimeMode === "full-access" ? "allow" : "ask", ); assert.equal(permissionAction(updated, "task"), nested ? "allow" : "deny"); + assert.equal(permissionAction(updated, "bash"), "deny"); assert.deepEqual(updated.at(-1), agentDeny); } const ruleCount = nativePermissions.get(child)!.length; diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index cf62a075abf3..5f3153b9f4f8 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -726,11 +726,13 @@ function permissionRuleEquals( /** * Children inherit only deny/external-directory rules; restore parent policy while retaining agent restrictions. * `previousParentRules` are the parent rules already written into `childRules`. + * `childToolDenies` are OpenCode's child-only denies, which it omits when an inherited rule matches. */ export function openCodeChildPermissionRules( runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, childRules: PermissionRuleset, previousParentRules: PermissionRuleset = [], + childToolDenies: PermissionRuleset = [], ): PermissionRuleset { const parentRules = openCodePermissionRules(runtimePolicy); const inheritedRules = [ @@ -741,7 +743,8 @@ export function openCodeChildPermissionRules( ]; const childSpecificRules = childRules.filter( (childRule, index) => - !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)) && + (childToolDenies.some((deny) => permissionRuleEquals(childRule, deny)) || + !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule))) && !childRules.slice(index + 1).some((laterRule) => permissionRuleEquals(childRule, laterRule)), ); return [...parentRules, ...childSpecificRules]; @@ -1085,6 +1088,20 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ), ); + // OpenCode adds a child deny per configured primary tool; without the config, keep no extra denies. + const childToolDenies = yield* Effect.cached( + sdkCall("config.get", {}, () => client.config.get()).pipe( + Effect.map((response): PermissionRuleset => + (response.data?.experimental?.primary_tools ?? []).map((permission) => ({ + permission, + pattern: "*", + action: "deny", + })), + ), + Effect.orElseSucceed((): PermissionRuleset => []), + ), + ); + const abortDescendants = Effect.fn("OpencodeAdapter.abortDescendants")(function* ( rootId: string, ) { @@ -1457,6 +1474,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function turn.runtimePolicy, nativeChildSession.permission ?? [], inheritedPermission, + yield* childToolDenies, ); yield* sdkCall( "session.update", @@ -2943,7 +2961,12 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const permission = target.parentSubagent === null ? parentRules - : openCodeChildPermissionRules(policy, previous, target.inheritedPermission); + : openCodeChildPermissionRules( + policy, + previous, + target.inheritedPermission, + yield* childToolDenies, + ); if ( permission.length > previous.length || !permission.every((rule, index) =>