Duo CLI developer flow refuses valid seats used outside their own namespace (regression vs chat)

Summary

Running GitLab Duo CLI in a repository whose top-level group is not the one that holds your Duo seat now fails with a 403, even though your seat is valid and you would be billed against your own namespace. It worked before because the old chat path allowed it. The switch to the interactive Duo Developer flow (developer/v1) moved the CLI onto a stricter access check that was never aligned with chat.

This issue documents the root cause and proposes a fix. It also captures a longer-term idea about where local (client-executed) flows should sit in the governance model.

Who hit this

Community contributors get a Duo seat through Developer membership on the gitlab-community group. They ran Duo CLI inside a checkout of a gitlab-org project (for example gitlab-org/gitlab-development-kit). It worked a few days ago, then broke.

What they see:

GitLab Duo access: ✗ Unavailable GitLab Duo Agent Platform is not available for this namespace or project.

The seat is fine. The project is public. Only the flow was refused.

What changed

Duo CLI moved its default from the chat flow to developer/v1 (driven by the duo_cli_default_flow flag). GitLab reads that label to pick which access rules apply. The two flows use different checks, and only chat has a "your seat is valid anywhere" escape hatch.

Root cause — two gates that disagree

A CLI session creates a workflow via POST /api/v4/ai/duo_workflows/workflows. That request runs two authorization steps.

Gate 1 — FlowExecutionAuthorizer (workflows.rb#L1026, caller_can_execute: true). For a locally-run flow this classifies as client-executed and calls authorize_client_executed:

  • agentic_chat_allowed? → :access_duo_agentic_chat → passes (chat's escape hatch, see below)
  • flow_available? → passes (beta/tier/flag checks for the flow itself)

This gate implements the intended model: a flow you run yourself is governed like chat, not like an unattended background flow. It works correctly.

Gate 2 — CreateWorkflowService#check_access (create_workflow_service.rb#L191) runs right after. For any non-chat flow it calls check_duo_workflow_access → the :duo_workflow ability → duo_workflow_available:

@user.allowed_to_use?(:duo_agent_platform, root_namespace: @subject.root_ancestor)

This is bound to the project's root namespace (gitlab-org). The community member's seat is on gitlab-community, so it fails → 403 "forbidden to access duo workflow" → the CLI shows the generic "not available for this namespace".

So Gate 1 authorizes the run, and Gate 2 refuses it. The client-executed model was applied to Gate 1 but never carried through to Gate 2.

Why chat allowed it and developer does not

The chat gate (access_duo_agentic_chat) checks the user with allowed_to_use_for_resource?:

(resource.root_ancestor.id in the user's addon namespaces) ||
  user_preference.duo_default_namespace_with_fallback.present?

The || ... present? clause means "you hold a Duo seat somewhere, so any project is fine". The developer gate uses plain allowed_to_use?(root_namespace: ...) — scoped to the visited project, no escape hatch.

!201092 (merged) added this escape hatch to the chat path (chat in a public project you are not a member of). The developer path never got the same treatment.

Chat (legacy) Developer flow (now)
Access gate access_duo_agentic_chat :duo_workflow → duo_workflow_available
Entitlement chat seat (duo_chat) DAP seat (duo_agent_platform)
Bound to visited namespace? No (seat-anywhere) Yes

Billing is not the problem

Credit checks use governing_namespace. When the user is not a member of the visited root namespace, it already falls back to their own default Duo namespace. So the credits would correctly come from the user's own account. Only the access gate is in the way.

Proposal

Option 1 — trust Gate 1. For client-executed runs, skip Gate 2's check_access. FlowExecutionAuthorizer already authorized the run one step earlier (chat-style access + flow availability), so the second check is redundant and inconsistent.

  • Smallest change; lives where client_executed? is already known.
  • One source of truth for local runs — this class of bug cannot recur.
  • Restores the pre-developer/v1 behavior: a locally-run flow needs a chat seat plus flow availability, exactly as the legacy chat path required.

Option 2 — fix Gate 2 instead. Give duo_workflow_available the same seat-anywhere escape hatch, scoped to client-executed runs. This keeps a DAP seat requirement for local runs.

  • Harder to scope: the check lives in a policy condition that does not see the client-executed classification.
  • It would require a DAP seat where the legacy path only required a chat seat — an unintended product change, not a straight bug fix.

Recommendation: Option 1. It matches prior behavior. Whether a locally-run developer flow should require a DAP seat (Option 2 direction) is a separate product decision, not something to change silently while fixing this regression.

Longer-term idea

The clean model is: who runs the work decides governance. GitLab-run (unattended) flows keep the strict, namespace-bound checks. Client-executed flows are governed like chat — valid seat anywhere, billed to your own namespace — so Duo CLI behaves like other local coding tools. Option 1 is the first concrete step toward that. A future step could re-scope the session container to the user's own namespace when the visited project is not entitled; that needs a product + security decision (session visibility, discussed in gitlab-org/editor-extensions/gitlab-lsp#2701 (closed)).

References

Edited by 🤖 GitLab Bot 🤖