Skip to content

Commit cd8e8a9

Browse files
feat(llm): integrate GitLab DWS tool approval with permission system (#19955)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
1 parent 8bdcc22 commit cd8e8a9

File tree

7 files changed

+80
-10
lines changed

7 files changed

+80
-10
lines changed

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/opencode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@
137137
"drizzle-orm": "catalog:",
138138
"effect": "catalog:",
139139
"fuzzysort": "3.1.0",
140-
"gitlab-ai-provider": "6.0.0",
140+
"gitlab-ai-provider": "6.4.2",
141141
"glob": "13.0.5",
142142
"google-auth-library": "10.5.0",
143143
"gray-matter": "4.0.3",

packages/opencode/src/provider/provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,7 @@ export namespace Provider {
574574
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
575575
const model = sdk.workflowChat(sdkModelID, {
576576
featureFlags,
577+
workflowDefinition: options?.workflowDefinition as string | undefined,
577578
})
578579
if (workflowRef) {
579580
model.selectedModelRef = workflowRef

packages/opencode/src/session/llm.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import { Plugin } from "@/plugin"
1515
import { SystemPrompt } from "./system"
1616
import { Flag } from "@/flag/flag"
1717
import { Permission } from "@/permission"
18+
import { PermissionID } from "@/permission/schema"
19+
import { Bus } from "@/bus"
20+
import { Wildcard } from "@/util/wildcard"
21+
import { SessionID } from "@/session/schema"
1822
import { Auth } from "@/auth"
1923
import { Installation } from "@/installation"
2024

@@ -231,6 +235,7 @@ export namespace LLM {
231235
// and results sent back over the WebSocket.
232236
if (language instanceof GitLabWorkflowLanguageModel) {
233237
const workflowModel = language
238+
workflowModel.sessionID = input.sessionID
234239
workflowModel.systemPrompt = system.join("\n")
235240
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
236241
const t = tools[toolName]
@@ -253,6 +258,57 @@ export namespace LLM {
253258
return { result: "", error: e.message ?? String(e) }
254259
}
255260
}
261+
262+
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
263+
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
264+
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
265+
return !match || match.action !== "ask"
266+
})
267+
268+
const approvedToolsForSession = new Set<string>()
269+
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
270+
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
271+
// Auto-approve tools that were already approved in this session
272+
// (prevents infinite approval loops for server-side MCP tools)
273+
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
274+
return { approved: true }
275+
}
276+
277+
const id = PermissionID.ascending()
278+
let reply: Permission.Reply | undefined
279+
let unsub: (() => void) | undefined
280+
try {
281+
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
282+
if (evt.properties.requestID === id) reply = evt.properties.reply
283+
})
284+
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
285+
try {
286+
const parsed = JSON.parse(t.args) as Record<string, unknown>
287+
const title = (parsed?.title ?? parsed?.name ?? "") as string
288+
return title ? `${t.name}: ${title}` : t.name
289+
} catch {
290+
return t.name
291+
}
292+
})
293+
const uniquePatterns = [...new Set(toolPatterns)] as string[]
294+
await Permission.ask({
295+
id,
296+
sessionID: SessionID.make(input.sessionID),
297+
permission: "workflow_tool_approval",
298+
patterns: uniquePatterns,
299+
metadata: { tools: approvalTools },
300+
always: uniquePatterns,
301+
ruleset: [],
302+
})
303+
for (const name of uniqueNames) approvedToolsForSession.add(name)
304+
workflowModel.sessionPreapprovedTools = [...workflowModel.sessionPreapprovedTools, ...uniqueNames]
305+
return { approved: true }
306+
} catch {
307+
return { approved: false }
308+
} finally {
309+
unsub?.()
310+
}
311+
})
256312
}
257313

258314
return streamText({

packages/opencode/src/session/message-v2.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,12 @@ export namespace MessageV2 {
573573
}))
574574
}
575575

576+
function providerMeta(metadata: Record<string, any> | undefined) {
577+
if (!metadata) return undefined
578+
const { providerExecuted: _, ...rest } = metadata
579+
return Object.keys(rest).length > 0 ? rest : undefined
580+
}
581+
576582
export const toModelMessagesEffect = Effect.fnUntraced(function* (
577583
input: WithParts[],
578584
model: Provider.Model,
@@ -741,7 +747,8 @@ export namespace MessageV2 {
741747
toolCallId: part.callID,
742748
input: part.state.input,
743749
output,
744-
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
750+
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
751+
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
745752
})
746753
}
747754
if (part.state.status === "error")
@@ -751,18 +758,18 @@ export namespace MessageV2 {
751758
toolCallId: part.callID,
752759
input: part.state.input,
753760
errorText: part.state.error,
754-
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
761+
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
762+
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
755763
})
756-
// Handle pending/running tool calls to prevent dangling tool_use blocks
757-
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
758764
if (part.state.status === "pending" || part.state.status === "running")
759765
assistantMessage.parts.push({
760766
type: ("tool-" + part.tool) as `tool-${string}`,
761767
state: "output-error",
762768
toolCallId: part.callID,
763769
input: part.state.input,
764770
errorText: "[Tool execution was interrupted]",
765-
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
771+
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
772+
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
766773
})
767774
}
768775
if (part.type === "reasoning") {

packages/opencode/src/session/processor.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export namespace SessionProcessor {
161161
tool: value.toolName,
162162
callID: value.id,
163163
state: { status: "pending", input: {}, raw: "" },
164+
metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
164165
} satisfies MessageV2.ToolPart)
165166
return
166167

@@ -180,7 +181,9 @@ export namespace SessionProcessor {
180181
...match,
181182
tool: value.toolName,
182183
state: { status: "running", input: value.input, time: { start: Date.now() } },
183-
metadata: value.providerMetadata,
184+
metadata: match.metadata?.providerExecuted
185+
? { ...value.providerMetadata, providerExecuted: true }
186+
: value.providerMetadata,
184187
} satisfies MessageV2.ToolPart)
185188

186189
const parts = MessageV2.parts(ctx.assistantMessage.id)

packages/opencode/src/session/prompt.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1371,7 +1371,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
13711371
)
13721372
// Some providers return "stop" even when the assistant message contains tool calls.
13731373
// Keep the loop running so tool results can be sent back to the model.
1374-
const hasToolCalls = lastAssistantMsg?.parts.some((part) => part.type === "tool") ?? false
1374+
// Skip provider-executed tool parts — those were fully handled within the
1375+
// provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.
1376+
const hasToolCalls =
1377+
lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
13751378

13761379
if (
13771380
lastAssistant?.finish &&

0 commit comments

Comments
 (0)