Governance tool rules (Ai::ToolRule) are not applied to Duo Agent Platform tool approvals on local surfaces (CLI / Chat v2)

Everyone can contribute. Help move this issue forward while earning points, leveling up and collecting rewards.

Summary

Admin-configured governance rules (Ai::ToolRule, allow/ask/deny per tool) have no effect on tool approval for Duo Agent Platform sessions started from local surfaces (Duo CLI, Duo Chat v2 in the IDE). Repro: set run_commandAllow (local access) at the group level, start a CLI or Chat v2 session, ask the agent to run a command — the user is still prompted for approval.

Originally filed in gitlab-lsp as "wire up client-side governance fetch", and separately suspected to be an AI Gateway gap. Code tracing shows both framings are wrong: the gateway (Duo Workflow Service) is already fully wired to consume governance; the bug is in how Rails resolves and forwards the rules.

What already works (verified on master)

The gateway consumes governance through two channels:

  1. Privilege-level preapprovalToolsRegistry preapproves every tool whose privilege group is in the workflow's pre_approved_agent_privileges_names (duo_workflow_service/components/tools_registry.pyconfigure() and approval_required()), sourced from the Rails workflow record.
  2. Per-tool JWT claim — Rails embeds tool_access_policies: {allow, deny} into the cloud-connector token (ee/lib/ai/duo_workflow/duo_workflow_service/client.rb#cloud_connector_token), DWS GenerateToken propagates the claim (server.py _PROPAGATED_EXTRA_CLAIMS), and AbstractWorkflow._merge_jwt_governance_claims() applies it at workflow start (allow-list is the preapproval ceiling, deny-list is merged into denied tools).

Root causes (Rails)

1. Governance is resolved against the wrong surface

Ai::ToolRule has separate web_access / local_access columns and ToolRule#access_for(surface) maps web/ambientweb_access, everything else (ide, chat, chat_partial) → local_access. But both call sites that compute the tool_access_policies JWT claim hardcode the surface to :web:

  • ee/app/services/ai/duo_workflows/workflow_context_generation_service.rb (resolve_tools, surface: :web)
  • ee/lib/api/ai/duo_workflows/workflows.rb (get :ws direct-access endpoint, surface: :web)

A rule configured for local access is therefore never resolved for the surfaces it targets; resolution reads the (typically unset) web_access and falls back to ask. Only CreateWorkflowService#build_resolution_service passes the real environment.

2. Client-supplied privileges bypass governance resolution

  • CreateWorkflowService#resolve_agent_privileges returns early when the client sends agent_privileges — and both clients do: the CLI sends agentPrivileges + preApprovedAgentPrivileges computed from the local agent mode (packages/cli/src/backend/gitlab/workflow_strategy.ts), Chat v2 sends preApprovedAgentPrivileges: [].
  • UpdateAgentPrivilegesService (used by the CLI on mode switch) writes client-sent privilege lists with no governance clamp.

Net effect: governance allow never reaches pre_approved_agent_privileges_names, and a client can in principle widen ask to preapproved.

3. Gating conditions worth checking during repro

resolve_tools returns empty policies unless the request has a container and Feature.enabled?(:gitlab_duo_governance_settings, container).

Proposed fix

  1. Pass the workflow's actual surface/environment into Ai::ToolRules::ResolutionService at both JWT-claim call sites (executor token path and :ws direct-access path).
  2. In CreateWorkflowService#resolve_agent_privileges and UpdateAgentPrivilegesService, merge client-requested privileges with governance resolution instead of skipping it: governance deny/ask is a ceiling the client cannot widen; governance allow is applied as preapproval regardless of what the client requests.

No AI Gateway changes required. (Optional follow-up: a GOVERNANCE variant in the ApprovalSource proto enum for audit clarity.)

Acceptance criteria

  • run_command local_access = allow → CLI and Chat v2 auto-run commands, no prompt
  • run_command local_access = deny → tool blocked on both surfaces
  • run_command local_access = ask → prompt shown (current behavior)
  • Web/ambient surfaces unaffected (still resolved against web_access)
  • Client-supplied pre_approved_agent_privileges cannot widen a governance ask/deny

Out of scope / follow-up

Client-side consumption of aiToolRules (fetchGovernanceRules() scaffolding on db/persistent-approvals-config-file) is a separate concern: per the delegation direction, local policy answers governance ask (PreToolUse hooks, prerequisite gitlab-org/editor-extensions/gitlab-lsp!3610) — it must not be the enforcement mechanism for allow/deny. Follow-up issue in gitlab-lsp if still wanted.

Edited by 🤖 GitLab Bot 🤖