Chapter 33Lesson 02~410 minutes

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.

FixturesTool GovernanceRemediationAuditVerification

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.
Availability and governance baseline — verified 2026-08-22 against GitLab 19.3. Everything required below runs locally with Python and static fixtures. No GitLab account, AI add-on, credits, runner, cloud service, vulnerability scanner, or network call is required. Optional live inspection is read-only unless you already have an authorized sandbox. Never enable on-demand billing, change privacy controls, or submit real private data for this lab.

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.

Safety: all names, sessions, users, vulnerabilities, and usage values in this lesson are synthetic. Do not replace them with employer code or real vulnerability details.

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
Denied does not mean “agent malfunction.” A governance system is working correctly when it prevents an operation that exceeds the declared task or risk budget.

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?

Why does the safe proposal pass while the string-prefix proposal fails?

What does the approval record bind together?

Does zero fixture credit usage prove a real Agent Platform session would be free?

Why inspect live Duo settings before enabling anything?

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.

Next lesson

Configuration, Design Choices, and Tradeoffs

Design the policy architecture: deterministic versus AI workflows, central guardrails, privacy, runner isolation, and total cost.

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.