Approve or deny a Duo agent's action from Slack

What does this MR do and why?

When a Duo agent session pauses because an admin tool rule requires a human decision, the requesting user gets an ephemeral in the Slack thread — only they can see the buttons — listing the pending tool call(s) — one or many — with Approve / Deny buttons. One decision resumes the whole pause, mirroring agentic web chat. With no admin rules, behaviour is unchanged.

The click runs through DuoApprovalHandler → ResolveToolApprovalService: only the session owner, only for the still-pending request, serialized under a lease — then the run continues. Ownership is verified server-side even though the ephemeral is private: a click payload is not proof of visibility, and the button value is guessable. The service also requires live update_duo_workflow permission (an owner who lost project access cannot resume it), and every applied decision is audited (duo_tool_call_approved / duo_tool_call_denied, approval_source: slack) and tracked (ai_duo_messaging_tool_approval_resolved) for rollout metrics. Refusals always get a response_url reply. Deferred to follow-ups: approve-for-session, deny-with-reason modal.

Everything sits behind the default-off slack_duo_api_flow flag — and it's safe to merge now because the code is dormant by design: the approval hook exists but nothing invokes it yet, and any early decision is refused cleanly instead of erroring. Buttons first, then the wiring. 🚦

References

Screenshots or screen recordings

No UI changes in GitLab itself. Slack-side rendering (captured locally with the flow simulated up to the pause, since the blocking issues have not merged):

Single pending tool call Multiple pending tool calls (one decision resumes the batch)
slack_approval_single slack_approval_batch

How to set up and validate locally

Verified locally against a sandbox Slack workspace with the GitLab for Slack app installed. In short:

  1. Enable the flag: Feature.enable(:slack_duo_api_flow).
  2. Stand in for the missing runtime with a throwaway StartRunService stub (the real one lands with issue 628435) — see below, do not commit it.
  3. Seed a session paused in tool_call_approval_required whose ui_chat_log ends with an approval request, and fire Ai::Messaging::Adapters::Slack#on_approval_required — the ephemeral with Approve / Deny renders in the channel, visible only to the requesting user.
  4. Click a button (with the app's interactivity delivering to your instance), or send the equivalent signed payload to /api/v4/integrations/slack/interactions. Expected:
    • Approve: the ephemeral is replaced with "Approved. GitLab Duo is continuing.", the session transitions to running, the stub logs the { type: :tool_approval, approved: true } event.
    • A second click on the same buttons: "This request can no longer be answered."
    • A different Slack user: "Only the person who started this session can respond to this request."
  5. Delete the stub afterwards.
Console snippets for steps 2 and 3
# ee/app/services/ai/duo_workflows/start_run_service.rb — throwaway stub, do not commit
module Ai
  module DuoWorkflows
    class StartRunService
      def initialize(workflow, event:, runtime: nil)
        @workflow = workflow
        @event = event
      end

      def execute
        @workflow.resume if @workflow.can_resume?
        ServiceResponse.success(payload: { workflow: @workflow, event: @event })
      end
    end
  end
end
# Rails console: seed a paused session and post the ephemeral.
# Requires a bot-installed GitLab for Slack app and a ChatName linking your
# Slack user to a GitLab user. The Slack user must be a member of the channel.
workflow = Ai::DuoWorkflows::Workflow.new(
  user: chat_name.user, project: project, goal: 'verify approvals',
  workflow_definition: 'developer/v1', environment: :web,
  status: 8, # tool_call_approval_required
  agent_privileges: [Ai::DuoWorkflows::Workflow::AgentPrivileges::READ_WRITE_FILES],
  pre_approved_agent_privileges: []
).tap { |w| w.save!(validate: false) }

workflow.merge_messaging_callback_context!(
  'adapter' => 'slack', 'team_id' => team_id, 'channel_id' => channel_id,
  'thread_ts' => nil, 'message_ts' => nil, 'user_id' => slack_user_id
)

Ai::DuoWorkflows::Checkpoint.create!(
  workflow: workflow, project: project, thread_ts: Gitlab::Utils.uuid_v7,
  metadata: { 'source' => 'local-verification' },
  checkpoint: { 'channel_values' => { 'ui_chat_log' => [
    { 'message_type' => 'request',
      'content' => 'Tool `create_issue` requires approval',
      'tool_info' => { 'name' => 'create_issue', 'args' => { 'title' => 'A title' } } }
  ] } }
)

ctx = workflow.reload.messaging_callback_context
Ai::Messaging::Adapters::Slack.from_callback_context(ctx)
  .on_approval_required(callback_context: ctx, workflow: workflow)

Append more trailing request entries to the log to see the batch rendering with Approve all / Deny all. The button value is <workflow_id>:<fingerprint>; the fingerprint is printed by Ai::DuoWorkflows::PendingToolApproval.for(workflow).fingerprint.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Jannik Lehmann

Merge request reports

Loading
Loading