Govern Duo flow runs by how they are executed

What does this MR do and why?

The Duo CLI changed the workflow_definition it sends from chat to developer/v1, to move off the legacy chat engine. That string does two jobs: it picks the engine, and it picks which governance applies.

So an interactive session someone runs in their terminal started being measured against AI catalog governance — the rules that decide what GitLab may run unattended, on a runner, under a service account. Run outside a GitLab project, the session is scoped to your default Duo group, which holds no item consumer for the flow, and creation was refused.

Fixes gitlab-org/editor-extensions/gitlab-lsp#2701

This makes governance depend on how a run is executed rather than on which flow it is:

who performs the run governed by
Background — the caller asked GitLab to run it a runner, as a service account AI catalog governance, unchanged
Client-executed — the caller runs it itself the caller Duo access + the flow's own entitlement

Per-container flow enablement (Settings → Automate → Flows) governs what GitLab runs unattended. Nobody asked GitLab to run a session someone is sitting in front of, so it does not apply to one.

The classification is self-enforcing: "client-executed" means the caller did not ask Rails to run this, so a caller cannot claim it and still get background execution. Nothing the client asserts about itself is read, including environment.

What still governs a client-executed run

control applies?
Instance Duo switches, and the Duo CLI admin switch yes
Owner's foundational flows switch no — it governs the unattended flows surface, see below
Minimum role — Developer, from config/authz/roles/developer.yml via :duo_workflow yes, hard floor
Minimum role — configurable, ai_minimum_access_level_execute via access_duo_agentic_chat yes
Plan tier, beta consent, per-flow feature flag yes
Minimum role for asynchronous execution, ai_minimum_access_level_execute_async no — see below
Per-container flow allowlist no — the point of this MR

Two layers, because the conflation exists twice

  1. Ai::DuoWorkflows::FlowExecutionAuthorizer replaces the catalog gate at the API layer.
  2. CreateWorkflowService required :create_duo_workflow_for_ci, which is :duo_workflow plus the minimum role for asynchronous execution. A run the caller performs itself is not asynchronous execution, so it now answers to :duo_workflow alone. The classification travels as a constructor argument, never a request parameter, so a caller cannot assert it.

Blast radius

Behind duo_client_executed_flow_governance, default off.

Only three definitions are governed by this class at all — developer/v1, code_review/v1, fix_pipeline/v1. chat, agentic_chat/v1 and software_development are not foundational catalog flows, so web chat, IDE chat and IDE flow mode are untouched. Of the three, only developer/v1 is client-executed in practice, and the CLI only sends it when duo_cli_default_flow is also on.

The GraphQL mutation and the agent delegation endpoint pass caller_can_execute: false — no client of either performs the run itself — so both keep today's behaviour exactly and the allowlist still applies there.

For security review

Deliberate changes, all flag-gated:

  • A client-executed run escapes the per-container flow allowlist. REST only. This is the intended fix, not a side effect.
  • A client-executed run is not subject to the owner's foundational flows switch (duo_foundational_flows_enabled, group or project). The switch governs the unattended flows surface; the CLI session is an interactive feature that merely reuses the flow definition — before duo_cli_default_flow it sent chat and never answered to this switch. The interactive surface keeps its own kill switches: the instance-level Duo CLI admin setting, duo_features_enabled, the agentic chat minimum role, and the instance Duo ban. Waived explicitly in WAIVED_ITEM_CONSUMER_CONDITIONS with the accounting spec enforcing the list.
  • The asynchronous minimum-role setting no longer reaches a client-executed run. It governs unattended execution.
  • No service account is resolved, so the AI Gateway subject is the user rather than a service account.
  • access_duo_agentic_chat is newly required, which is a tightening: it adds the configurable interactive minimum role and the instance-wide Duo ban switch.
  • A flow with no catalog item is still permitted, as before, now with a warning log. Nothing enforces the flow-to-item invariant — seeding is a worker-run service, not a constraint — so refusing would turn a transient state into a 403 for a user.

Before this flag rolls out

The client-executed path adds access_duo_agentic_chat on top of :duo_workflow, so a client-executed run needs both the agentic_chat and ai_workflows licensed features, where the old path needed ai_workflows alone.

At the tier level this is settled: both features sit in PREMIUM_FEATURES (ee/app/models/gitlab_subscriptions/features.rb), and both stage checks (:agentic_chat, :duo_workflow/:foundational_flows) are credits-eligible in Gitlab::Llm::StageCheck. No tier or credits add-on grants one without the other.

What remains open is the seat / unit-primitive level: the old path checks allowed_to_use?(:duo_agent_platform), the new path additionally checks allowed_to_use_for_resource?(:agentic_chat, unit_primitive_name: :duo_chat). Question for Fulfillment before rollout: can any seat assignment or add-on grant the duo_agent_platform unit primitive without also granting agentic_chat/duo_chat? If yes, those users lose CLI session creation when both flags are on — no customer can reach the new path today.

How to set up and validate locally

Prerequisites — enable both feature flags in a Rails console (duo_cli_default_flow is what makes the CLI send developer/v1 in the first place):

Feature.enable(:duo_client_executed_flow_governance)
Feature.enable(:duo_cli_default_flow)

You also need a group the token user can use as scope — the user's default Duo group works. Find it with User.find_by(username: 'root').default_duo_namespace.id.

REST

export GL=http://gdk.test:3000 TOK=<your-token>

# 1. Client-executed, outside a project: 403 with the flag off, 201 with it on.
#    The created session must have "service_account_id": null.
curl -s -X POST "$GL/api/v4/ai/duo_workflows/workflows" -H "PRIVATE-TOKEN: $TOK" \
  -H 'Content-Type: application/json' \
  -d '{"namespace_id":"<group-id>","workflow_definition":"developer/v1","goal":"test"}'

# 2. Asking GitLab to run it stays catalog-governed: 403 without an item consumer,
#    with the flag on or off.
curl -s -X POST "$GL/api/v4/ai/duo_workflows/workflows" -H "PRIVATE-TOKEN: $TOK" \
  -H 'Content-Type: application/json' \
  -d '{"project_id":"<project-id>","workflow_definition":"developer/v1","goal":"test","start_workflow":true}'

Each attempt also writes a Duo flow execution classified line to log/application_json.log with execution, client_executable and feature_flag_enabled — with the flag off you should see execution: background, client_executable: true, which is the rollout measurement.

End to end with the CLI

From a checkout of gitlab-lsp on the branch from gitlab-org/editor-extensions/gitlab-lsp!3834 (the CLI-side fix; without it the CLI refuses to start outside a project before it ever calls Rails):

mkdir -p /tmp/duo-no-project   # any directory with no GitLab remote
export GITLAB_BASE_URL=http://gdk.test:3000 GITLAB_TOKEN=<your-token>
bun run cli run --cwd /tmp/duo-no-project -g "Say the single word: acknowledged."

Expected: the run completes and prints acknowledged.; the created workflow is scoped to your default Duo group, has no project and no service account.

MR acceptance checklist

Edited by Thomas Schmidt

Merge request reports

Loading