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
Ai::DuoWorkflows::FlowExecutionAuthorizerreplaces the catalog gate at the API layer.CreateWorkflowServicerequired:create_duo_workflow_for_ci, which is:duo_workflowplus the minimum role for asynchronous execution. A run the caller performs itself is not asynchronous execution, so it now answers to:duo_workflowalone. 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 — beforeduo_cli_default_flowit sentchatand 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 inWAIVED_ITEM_CONSUMER_CONDITIONSwith 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_chatis 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
- Behind a default-off feature flag.
- Follows the code review guidelines and security guidelines.