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_accesscolumn onai_tool_rules(allow/deny only;askis 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
:backgroundsurface routed throughToolRule#access_for, and a sharedAi::ToolRules::GovernanceSurfacehelper that derives the surface at the three resolution paths (WorkflowContextGenerationService, theworkflowsAPI, andCreateWorkflowService). It returns:backgroundonly when the workflow runs in a background environment (weborambient), the flow is allowlisted, and the feature flag is on. - API:
background_accessexposed on theAiToolRuleGraphQL type, the update and bulk-update mutations, and the resolver. It is typed with a dedicatedAiBackgroundToolPermissionenum (onlyALLOW/DENY, soaskis unrepresentable at the schema level) and markedexperimentwhile 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
CreateWorkflowwhen a required tool is denied): #607134 - Superseded by the ambient rekey (catalog
runnerdeclaration 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:migrateThen 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 reference1. 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) # => false4. 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_all6. 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.rbPlus 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) -
askis rejected at the DB, model, and API layers - Surfaces are isolated: web/local/IDE unaffected by background rules
- GraphQL
background_accessread/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.