Enforce HITL tool governance for background Duo flows

What does this MR do and why?

Tool governance (HITL guardrails) rules were not enforced for background Duo flows — flows that run with no human in the loop, such as the Duo Developer foundational flow on CI/CD runners. These flows resolve tool policy through the web surface, which allows ask (meaningless when no human is present) and gave admin Always Allow / Always Deny rules no dedicated control.

This MR adds a dedicated background governance surface to AI tool governance, end to end:

  • Model / DB: new background_access column on ai_tool_rules (allow/deny only; ask is excluded at the database CHECK constraint, the model enum, and the API, because no human is present to approve on a background flow).
  • Enforcement: a :background surface routed through ToolRule#access_for, and a shared Ai::ToolRules::GovernanceSurface helper that derives the surface at the three resolution paths (WorkflowContextGenerationService, the workflows API, and CreateWorkflowService). It returns :background only when the workflow runs in a background environment (web or ambient), the flow is allowlisted, and the feature flag is on.
  • API: background_access exposed on the AiToolRule GraphQL type, the update and bulk-update mutations, and the resolver. It is typed with a dedicated AiBackgroundToolPermission enum (only ALLOW/DENY, so ask is unrepresentable at the schema level) and marked experiment while flag-gated, so the frontend can read and write it.

Why key off web/ambient (not a new environment)

Governance only needs to distinguish interactive vs background flows: interactive sessions (chat) have an approval channel (ask works); background sessions do not (allow/deny only). The flow registry already models this — ambient is the hands-off background environment, and web is its deprecated predecessor (both live in ENVIRONMENTS_FROM_PIPELINE). The Duo Developer flow is stored as web, so deriving the surface from environment.in?(%w[web ambient]) reaches it with no catalog relabel — no new runner environment, and the DWS flow schema is untouched.

Rollout scoping

Because web/ambient is broad, an ALLOWLISTED_FLOWS list in GovernanceSurface scopes governance to just the Duo Developer flow while behind the flag. The incoming workflow_definition is resolved through the flow catalog first, and the allowlist is then matched against the catalog's own reference base name (developer/v1 -> developer). So a flow version bump does not silently drop governance, and a caller-supplied reference cannot opt itself in. The list is removed at GA, when all background flows are governed uniformly.

Because the governance UI column ships separately in gitlab-org/gitlab#606081, an admin cannot set background_access from Settings yet, only through GraphQL. The flag should therefore not be enabled for any group before that lands. background_access is a separate governance axis rather than an extension of web_access: a Web rule covers browser sessions and does not carry over to background flows, so admins configure the background column explicitly. Confirmed with @jeanvdw on #604842.

Opt-in and safe by default: gated behind the duo_workflow_background_tool_governance feature flag (disabled by default); an unset background_access resolves to allow (fail-open) so existing pipelines are unaffected; non-background (web-as-interactive/local/IDE) and non-allowlisted flows are byte-identical when the flag is off. DWS (the external executor) needs no change; it enforces whatever the Rails-issued JWT tool_access_policies claim contains.

Enforcement model (aligned on this MR)

Per the discussion on this MR, the target enforcement is to block a background flow at creation when a required tool is denied (a clear policy error), rather than run it with tools removed. Background flows have no approver, so a denied required tool is a dead end there; interactive Web and Local sessions keep the Ask-and-approve model and are unaffected. That block-at-start enforcement is a follow-up (#607134); this MR ships the surface + rules plumbing (flag-gated, fail-open), and the flag stays off until the follow-up lands.

Background default at GA (decided, out of scope here)

An unset background_access resolves to allow (fail-open) during this opt-in phase, so existing pipelines are unaffected. The GA direction is agreed in outline and tracked in gitlab-org/gitlab#606829: at GA, un-ruled tools flip to deny (fail-closed) with a flow's declared catalog agent_privileges as the allow baseline, rolled out via a dry-run/shadow phase plus release notes so admins can configure rules first. File-read tools are included in that flip, decided on the issue after this MR was opened. That flip is a separate follow-up and is not part of this MR.

References

  • Backend issue: #604842
  • Follow-up (block background flows at CreateWorkflow when a required tool is denied): #607134
  • Superseded by the ambient rekey (catalog runner declaration no longer needed): #606261
  • Follow-up (GA fail-closed default): #606829
  • Frontend counterpart (background column in the governance UI): #606081
  • Secure-default coordination: !244013 (merged) / #605706
  • Parent epic: &21985

Screenshots or screen recordings

No UI changes in this MR; it is backend and GraphQL API only. The governance UI column is tracked separately in gitlab-org/gitlab#606081.

How to set up and validate locally

Run the migration first:

bin/rails db:migrate

Then open the Rails console (bin/rails console) and run the steps below. Each step prints its expected result inline.

Setup: enable both flags for a top-level group and pick a tool from each risk tier.

group = Group.roots.first   # or Group.find_by_full_path('your-group')
Feature.enable(:gitlab_duo_governance_settings, group)
Feature.enable(:duo_workflow_background_tool_governance, group)

Reg       = Ai::ToolRules::Registry
GS        = Ai::ToolRules::GovernanceSurface
run_tool  = Reg.all_tool_names.find { |n| Reg::PRIVILEGE_GROUP_FOR[n] == :run_commands }     # an execute tool (e.g. run_command)
read_tool = Reg.all_tool_names.find { |n| Reg::PRIVILEGE_GROUP_FOR[n] == :read_only_files }  # a read tool (e.g. find_files)
resolve   = ->(surface) { Ai::ToolRules::ResolutionService.new(namespace: group, surface: surface).execute.payload }
dev_flow  = 'developer/v1'   # the allowlisted Duo Developer flow reference

1. The surface is background-environment only, allowlist-scoped (via the flow catalog), and flag-gated.

GS.for(environment: 'web',     container: group, workflow_definition: dev_flow)               # => :background
GS.for(environment: 'ambient', container: group, workflow_definition: dev_flow)               # => :background  (web is the legacy form of ambient; both reach the flow)
GS.for(environment: 'chat',    container: group, workflow_definition: dev_flow)               # => nil          (interactive surface, not background)
GS.for(environment: 'web',     container: group, workflow_definition: 'security_review/v1')   # => nil          (real background flow, but not allowlisted)
GS.for(environment: 'web',     container: group, workflow_definition: 'developer/pwn')        # => nil          (not a catalog flow; a caller-supplied reference cannot opt in)
Feature.disable(:duo_workflow_background_tool_governance)
GS.for(environment: 'web',     container: group, workflow_definition: dev_flow)               # => nil          (flag off; the caller falls back to web)
Feature.enable(:duo_workflow_background_tool_governance)

2. An unset background permission resolves to allow (existing pipelines unaffected).

Ai::ToolRule.where(namespace_id: group.id).delete_all
resolve.call(:background)[:denied_tools]                            # => []
resolve.call(:background)[:pre_approved_tools].include?(run_tool)   # => true  (the shared `ask` default is coerced to allow on a background flow)

3. An explicit deny is enforced on the background surface.

Ai::ToolRule.create!(namespace: group, tool_name: run_tool, background_access: 'deny')
resolve.call(:background)[:denied_tools].include?(run_tool)         # => true
resolve.call(:background)[:pre_approved_tools].include?(run_tool)   # => false

4. ask is rejected at every layer (no human to approve on a background flow).

Ai::ToolRule.new(namespace: group, tool_name: read_tool, background_access: 'ask')
# => ArgumentError: 'ask' is not a valid background_access   (model enum)
# DB:      a raw INSERT with background_access = 1 is rejected by CHECK constraint chk_ai_tool_rules_background_access_enum (PG::CheckViolation).
# GraphQL: the AiBackgroundToolPermission enum only defines ALLOW/DENY, so `backgroundAccess: ASK` is rejected by the schema (see step 6).

5. Surfaces are isolated (a web-only rule leaves the background surface unset).

Ai::ToolRule.where(namespace_id: group.id).delete_all
Ai::ToolRule.create!(namespace: group, tool_name: read_tool, web_access: 'deny')   # background_access stays nil
resolve.call(:background)[:pre_approved_tools].include?(read_tool)  # => true   (background unset resolves to allow)
resolve.call(:web)[:pre_approved_tools].include?(read_tool)        # => false  (web deny is honored)
Ai::ToolRule.where(namespace_id: group.id).delete_all

6. GraphQL API (optional, via GraphiQL at /-/graphql-explorer). updateAiToolRule and bulkUpdateAiToolRules accept backgroundAccess: DENY/ALLOW; backgroundAccess: ASK is rejected by the schema because AiBackgroundToolPermission only defines ALLOW/DENY; the tool-rules resolver returns the effective backgroundAccess.

Automated coverage (334 examples, 0 failures):

bundle exec rspec \
  ee/spec/services/ai/tool_rules/ \
  ee/spec/models/ai/tool_rule_spec.rb \
  ee/spec/lib/ai/tool_rules/permissions_spec.rb \
  ee/spec/graphql/mutations/ai/update_ai_tool_rule_spec.rb \
  ee/spec/graphql/mutations/ai/bulk_update_ai_tool_rules_spec.rb \
  ee/spec/graphql/resolvers/ai/tool_rules_resolver_spec.rb \
  ee/spec/graphql/types/ai/tool_rule_type_spec.rb \
  ee/spec/graphql/types/ai/bulk_tool_rule_input_type_spec.rb \
  ee/spec/graphql/types/ai/background_tool_permission_enum_spec.rb \
  ee/spec/services/ai/duo_workflows/create_workflow_service_spec.rb \
  ee/spec/services/ai/duo_workflows/workflow_context_generation_service_spec.rb

Plus the JWT-path coverage for the background surface — the path that actually carries the policy to DWS (2 examples, 0 failures):

bundle exec rspec ee/spec/requests/api/ai/duo_workflows/workflows_spec.rb \
  -e 'allowlisted background flow'

What this validates (issue #604842 requirements):

  • Background surface is derived only for catalog-verified, allowlisted background flows with the flag on; flag off means no behaviour change
  • Unset background permission resolves to allow (fail-open); existing pipelines unaffected
  • Explicit background deny is enforced (tool lands in denied_tools)
  • ask is rejected at the DB, model, and API layers
  • Surfaces are isolated: web/local/IDE unaffected by background rules
  • GraphQL background_access read/write works (update, bulk-update, resolver)

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 Rahul Barnwal

Merge request reports

Loading