GitLab Duo and Agentic DevSecOps: AI Features, Security Remediation, Governance, and Usage Controls: Guided Hands-On Workflow and Core Operations
Practice a no-paid, fixture-driven AI remediation workflow that inspects availability, classifies tool permissions, validates a proposed fix, records approval, and audits what would change.
Learning objectives
- Perform a no-paid preflight that distinguishes feature availability from permission, credit, preview, and runner constraints.
- Create a synthetic agent session with read/write/delete tool requests and resolve each request against an explicit governance policy.
- Review both a safe and an unsafe AI-generated remediation using runnable tests and security invariants.
- Record one denied high-impact action and explain why denial is correct rather than treating it as an agent failure.
- Produce sanitized session, approval, and usage artifacts that preserve auditability without including real source, credentials, or personal data.
1. Scenario: a synthetic AI agent proposes a security fix
Your fictional project
learner-example/duo-governance-lab has a
path-containment bug in a template loader. An AI system proposes a
remediation. Your job is not to trust or distrust AI categorically.
Your job is to evaluate specific evidence under a tool
policy.
The fixture includes two candidate implementations. The safer
proposal resolves both the base and requested path, then uses
Path.relative_to() to prove containment. The unsafe
proposal uses a string-prefix comparison, which can confuse a
sibling such as templates-backup with the intended
templates directory.
2. Create the disposable fixture workspace
from pathlib import Path
import json, textwrap
root=Path("gitlab-ch33-lab")
root.mkdir(exist_ok=True)
(root/"feature_state.json").write_text(json.dumps({
"captured_at":"2026-08-22T02:00:00Z",
"offering":"GitLab.com",
"version_baseline":"19.3",
"tier":"Free",
"duo_enabled":False,
"agent_platform_enabled":False,
"credits_available":False,
"beta_experimental_enabled":False,
"live_actions_allowed":False
}, indent=2))
(root/"tool_policy.json").write_text(json.dumps({
"read_repository":"always_allow",
"read_issue":"always_allow",
"write_file":"always_ask",
"create_branch":"always_ask",
"create_merge_request":"always_ask",
"delete_branch":"always_deny",
"delete_project":"always_deny",
"run_external_mcp_tool":"always_deny"
}, indent=2))
(root/"agent_session.json").write_text(json.dumps({
"session_id":"SESSION_FAKE_001",
"actor":"USER_A",
"project":"PROJECT_X",
"goal":"Remediate synthetic template path traversal",
"tool_requests":[
{"tool":"read_repository","action":"inspect loader and tests"},
{"tool":"write_file","action":"modify loader in feature branch"},
{"tool":"delete_project","action":"reset environment after failed test"}
]
}, indent=2))
(root/"safe_candidate.py").write_text(textwrap.dedent('''\
from pathlib import Path
def template_path(base: Path, name: str) -> Path:
base = base.resolve()
candidate = (base / name).resolve()
try:
candidate.relative_to(base)
except ValueError as exc:
raise ValueError("template escapes approved directory") from exc
return candidate
'''))
(root/"unsafe_candidate.py").write_text(textwrap.dedent('''\
from pathlib import Path
def template_path(base: Path, name: str) -> Path:
base = base.resolve()
candidate = (base / name).resolve()
if not str(candidate).startswith(str(base)):
raise ValueError("template escapes approved directory")
return candidate
'''))
(root/"test_candidates.py").write_text(textwrap.dedent('''\
from pathlib import Path
import tempfile, importlib.util
def load(path):
spec=importlib.util.spec_from_file_location(path.stem, path)
mod=importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
return mod
def check(path):
mod=load(path)
with tempfile.TemporaryDirectory() as td:
root=Path(td)
base=root/"templates"; base.mkdir()
sibling=root/"templates-backup"; sibling.mkdir()
assert mod.template_path(base,"welcome.html") == (base/"welcome.html").resolve()
for bad in ["../secret.txt", "../templates-backup/secret.txt"]:
try: mod.template_path(base,bad)
except ValueError: pass
else: raise AssertionError(f"TRAVERSAL ACCEPTED: {bad}")
for name in ["safe_candidate.py","unsafe_candidate.py"]:
try:
check(Path(__file__).parent/name)
print(name, "PASS")
except Exception as exc:
print(name, "FAIL", type(exc).__name__, str(exc))
'''))
print(root.resolve())
Run the script from an empty disposable directory, then inspect the files before executing the tests. The act of inspecting fixtures first mirrors the broader course rule: know what a command will read or change before you run it.
3. Preflight the product state without enabling anything
The feature_state.json fixture represents a learner
with no live AI entitlement or credits. That is not a blocker: the
governance concepts are still fully testable. In a real authorized
namespace, replace only the observations—not the safety rule—with
values you read from the GitLab UI and current docs.
import json
from pathlib import Path
s=json.loads(Path("gitlab-ch33-lab/feature_state.json").read_text())
for k,v in s.items(): print(f"{k:28} {v}")
assert s["live_actions_allowed"] is False
print("MANDATORY PATH: fixture-only; no AI call or credit consumption")
Interpretation matters: credits_available=false is a
commercial/usage state, not a GitLab project permission error.
agent_platform_enabled=false is a namespace/product
configuration state, not proof that the user lacks Developer access.
Keep these failure domains separate.
4. Resolve tool requests against the governance policy
Start with a policy, not with the agent’s desired plan. In the fixture, read operations are allowed, interactive writes require approval, and destructive/external actions are denied.
import json
from pathlib import Path
root=Path("gitlab-ch33-lab")
policy=json.loads((root/"tool_policy.json").read_text())
session=json.loads((root/"agent_session.json").read_text())
for req in session["tool_requests"]:
mode=policy.get(req["tool"],"always_deny")
print(f"{req['tool']:24} {mode:14} {req['action']}")
| Requested tool | Policy | Decision | Why |
|---|---|---|---|
read_repository |
Always Allow | Proceed | Read is required for diagnosis; source is synthetic and approved |
write_file |
Always Ask | Pause for review | A code mutation changes repository content even when reversible |
delete_project |
Always Deny | Block | Resetting a failed test does not justify project deletion |
5. Test the remediation instead of judging prose quality
Run the candidate tests locally. The unsafe implementation sounds reasonable—“make sure the path starts with the base path”—but the representation is wrong. A string prefix is not a filesystem ancestry proof.
python gitlab-ch33-lab/test_candidates.py
# Expected:
# safe_candidate.py PASS
# unsafe_candidate.py FAIL AssertionError TRAVERSAL ACCEPTED: ../templates-backup/secret.txt
The failed negative test is stronger evidence than confidence language in a Chat response. A production review would also run the project’s full test suite, relevant SAST/scanner checks, formatting/linting, dependency review, and whatever merge approvals your repository requires.
6. Review scope and provenance before approving the write
An acceptable remediation should be narrowly scoped. The safe fixture changes one path-validation function and adds no dependency, network call, secret, role grant, runner privilege, or CI bypass. If an AI proposal also disabled SAST, loosened branch protection, or added a broad token to “make tests pass,” those unrelated changes would be reasons to reject even if the focused unit test passed.
PROPOSAL REVIEW
Source: synthetic AI fixture
Target: one loader function in feature branch
Security invariant: candidate path is a descendant of resolved base
Functional invariant: valid template path remains valid
Side effects: none expected
New dependencies: none
Permission change: none
CI/policy bypass: none
Evidence: focused tests + full project CI + security scan in real workflow
Decision boundary: write requires explicit human approval
7. Create an approval record before the simulated write
Approval should capture what is approved, not “AI can edit this project.” Tie it to the proposal hash, scope, evidence, and allowed action.
from pathlib import Path
import json, hashlib
root=Path("gitlab-ch33-lab")
proposal=root/"safe_candidate.py"
sha=hashlib.sha256(proposal.read_bytes()).hexdigest()
record={
"session_id":"SESSION_FAKE_001",
"proposal_sha256":sha,
"approved_action":"write_file_on_disposable_feature_branch",
"approved_by":"REVIEWER_A",
"evidence":["test_candidates.py:safe_candidate.py PASS"],
"not_approved":["delete_project","external_mcp","direct_protected_branch_write"],
"decision":"approved_for_simulated_branch_write"
}
(root/"approval.json").write_text(json.dumps(record,indent=2))
print(json.dumps(record,indent=2))
In a real GitLab workflow, the normal Git controls still apply after this human decision: create a branch, commit, open a merge request, run CI/security checks, satisfy protected-branch/approval rules, and merge only through the established governance path.
8. Build sanitized AI audit and usage fixtures
GitLab’s AI audit event report is a product feature with its own current tier/status requirements. The mandatory lab instead creates a simple artifact that demonstrates what a governed AI record should let an operator reconstruct.
from pathlib import Path
import json
root=Path("gitlab-ch33-lab")
audit={
"session_id":"SESSION_FAKE_001",
"actor":"USER_A",
"project":"PROJECT_X",
"data_class":"synthetic_only",
"model":"MODEL_FIXTURE",
"events":[
{"seq":1,"tool":"read_repository","decision":"allowed","executed":True},
{"seq":2,"tool":"write_file","decision":"asked","executed":False,"reason":"awaiting approval"},
{"seq":3,"tool":"delete_project","decision":"denied","executed":False},
{"seq":4,"tool":"write_file","decision":"approved","executed":True,"scope":"fixture branch only"}
],
"real_prompt_text_stored":False,
"secrets_present":False
}
usage={"session_id":"SESSION_FAKE_001","credits_consumed":0,"source":"fixture","billable":False}
(root/"ai_audit_fixture.json").write_text(json.dumps(audit,indent=2))
(root/"usage_fixture.json").write_text(json.dumps(usage,indent=2))
print("audit + usage fixtures written")
A useful audit record answers who initiated the session, what resource scope was involved, which tools were requested, which decisions occurred, and what actually executed. Do not put live token values, raw credentials, customer data, or unnecessary private source in the record.
9. Optional live path: inspect, do not enable by default
If you already have a sanctioned GitLab sandbox with eligible Duo/Agent Platform access, perform read-only inspection only: record the exact GitLab version/offering, current Duo/Agent Platform state, credit/usage state, beta/experimental setting, tool-governance matrix, and available AI sessions. Stop before changing enablement, purchasing/accepting billing, sending private data, or launching a flow.
OPTIONAL LIVE OBSERVATION
namespace: __________________________
offering/version: ___________________
role: ______________________________
Duo availability: ___________________
Agent Platform availability: ________
credits/usage status: _______________
preview features enabled?: __________
tool governance reviewed?: __________
flow runner requirements satisfied?: _
NO SETTINGS CHANGED: yes / no
10. Challenge: choose the control, not the click path
An agent suggests three actions: read a failing job log, modify
.gitlab-ci.yml, and delete the old release tag.
Classify them before looking at a UI. Reading may be allowed if the
log contains no prohibited sensitive data. Editing CI configuration
changes future execution and should normally require explicit
approval. Deleting a release tag is destructive and should be denied
unless the task explicitly authorizes it under release governance.
Then ask a second question: even if the tool policy allows the action, does the execution identity have the GitLab permission and protected-resource authorization to perform it? The two answers must both be valid.
Knowledge check
The AI requests delete_project after a unit test
fails. What should the learner infer from an Always Deny
result?
The governance boundary is functioning. Project deletion is outside the remediation task and must not be treated as a recovery shortcut.
Why does the safe proposal pass while the string-prefix proposal fails?
Filesystem ancestry must be checked structurally. A sibling path such as templates-backup can share a string prefix with templates without being inside that directory.
What does the approval record bind together?
The exact proposal identity/hash, human reviewer, allowed action/scope, supporting evidence, and explicitly unapproved actions.
Does zero fixture credit usage prove a real Agent Platform session would be free?
No. The fixture makes no AI call. Real usage depends on current feature status, credits policy, tier, and billing configuration.
Why inspect live Duo settings before enabling anything?
It separates availability/permission diagnosis from configuration changes and avoids accidentally enabling billable, preview, or data-sharing behavior merely to discover state.
11. Lesson summary and bridge
- The mandatory workflow remains fully local and consumes no AI credits.
- Tool policy is evaluated before action: reads can be allowed, writes approval-gated, and unrelated destructive actions denied.
- Runnable negative tests can reject a plausible AI remediation that prose review might accept.
- Approval is scoped to an exact proposal and action rather than blanket agent authority.
- Sanitized audit and usage artifacts preserve decision history without exposing real private data.
Next, you will turn these mechanics into policy architecture: decide when AI is appropriate at all, how strict group and project controls should be, and how to balance productivity, privacy, verification burden, and usage cost.
Primary sources and version notes
These lessons were finalized against current official GitLab documentation on 2026-08-22 with GitLab 19.3 as the release baseline. GitLab Duo and the Agent Platform are unusually version-volatile: feature maturity, model selection, credits, availability, governance behavior, UI placement, and data-processing details can change between releases. Re-check the documentation for the exact offering, subscription, add-on, and GitLab version you use. The local policy JSON is a teaching fixture, not a literal export format for GitLab tool governance. Use current GitLab UI/API/product documentation for live policy configuration.
- GitLab 19.3 release
- GitLab Duo Agent Platform
- GitLab Duo non-agentic feature summary
- GitLab Credits and usage billing
- GitLab Duo data usage and privacy
- Control GitLab Duo availability
- Control Agent Platform availability
- Agents
- Flows
- Foundational flows
- Configure flow execution
- Agent tool governance
- Audit AI events
- GitLab Duo prompt guardrails
- GitLab Duo contextual awareness
- GitLab Duo Chat
- Explain vulnerabilities with AI
- Resolve vulnerabilities with AI
- Troubleshoot the Agent Platform
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.