SentinelFlow did not exist this morning. A FastAPI service, a three-stage CI pipeline, and a Duo agent definition - all from an empty folder.
SentinelFlow is a DevSecOps pipeline platform built for the GitLab Transcend Hackathon. Today was day one: an empty workspace, a deadline, and a clear list of what had to exist before anything else mattered. By the end of the session the service was running on port 8080, both endpoints returned healthy JSON, the Swagger UI confirmed the schema, the GitLab CI pipeline had three working stages, and a custom Duo agent was defined to review every merge request autonomously. The pip install produced a file-lock warning on the first run and the uvicorn script was never written to the venv - two small blockers that resolved in a few minutes each.
SentinelFlow is a DevSecOps pipeline platform. The goal is to plug a GitLab Duo agent into a real CI/CD pipeline so it can review merge requests, triage SAST findings, diagnose pipeline failures, and open remediation MRs - autonomously, on every push. This transmission covers day one.
The constraint
Starting from an empty folder. No service, no pipeline, no agent definition. The GitLab Transcend Hackathon required a working scaffold before any of the interesting agent work could begin, so the first session had one job: make something real exist.
The minimum that had to be true by the end:
- A FastAPI service with a
/healthendpoint, because every Cloud Run deployment gate checks it - A multi-stage Dockerfile that runs as a non-root user, because the AGENTS.md rules written for this project prohibit root runtime containers
- A three-stage GitLab CI pipeline (
test → build → deploy) wired to the GitLab Container Registry and Google Cloud Run - An
AGENTS.mdfile so GitLab Duo Agentic Chat has project-level instructions to read before it touches anything - A custom Duo agent definition that can review MRs, read job logs, and post structured comments without needing a human to prompt it each time
None of it is complicated individually. The session was about doing all of it in the right order.
The proof
The service
Five files across the project root and app/.
app/main.py - FastAPI application. Two endpoints: GET / for root liveness and GET /health for container orchestrator probes. Both return the same payload. The response type is dict rather than a Pydantic model here because the shape is fixed and trivial - one field added later will warrant a schema. Async handlers throughout; this is an I/O-bound service.
@app.get("/health")
async def health() -> dict:
return {"status": "healthy", "service": "sentinel-flow"}
requirements.txt - three dependencies, all pinned to minimum verified versions: fastapi>=0.111.0, uvicorn[standard]>=0.29.0, pydantic>=2.7.0. The [standard] extra pulls in httptools and websockets. Bare uvicorn was the alternative - skipped because the standard extras cost nothing at install time and removing them later is harder than keeping them.
Dockerfile - two stages. The builder stage installs everything into /install with pip install --prefix. The runtime stage copies only /install from builder, adds the application source, creates a non-root user (appuser), and hands control to Uvicorn. No pip, no build tools, no wheel cache in the final image. Port 8080 exposed - Cloud Run requires it.
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
...
USER appuser
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
A single-stage build was the obvious shortcut. It was skipped because the runtime image would carry pip, the compiler toolchain, and the wheel cache - all attack surface with no operational value.
The install
The first pip install completed with one warning:
ERROR: Could not install packages due to an OSError: [WinError 32]
The process cannot access the file because it is being used by another process:
'.venv\Lib\site-packages\websockets\speedups.cp313-win_amd64.pyd'
The file lock cleared on its own - a background process was holding the .pyd during the first install. The second run completed cleanly.
The uvicorn binary was not written to .venv\Scripts\. The venv used the system Python (3.13) and only linked pip.exe. Running python -m uvicorn directly resolved it - the module was present, the entry-point script was not.
The service coming up
INFO: Started server process [1]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
Both endpoints hit immediately after startup:
status service
------ -------
healthy sentinel-flow
healthy sentinel-flow
The Swagger UI confirmed the OpenAPI schema at /docs:

Fig 1: SentinelFlow v0.1.0 · OAS 3.1 - root and health endpoints confirmed, schema live.
The pipeline
.gitlab-ci.yml - three stages in sequence. The test stage runs pytest then pip-audit and saves the audit report as a CI artifact. pip-audit was added here rather than as a separate security stage because it runs in under ten seconds against the existing virtualenv - it costs nothing to gate it at test and it means no build runs with a known CVE present. The build stage uses Docker-in-Docker (docker:24.0.5) to build and push two tags to $CI_REGISTRY_IMAGE: :$CI_COMMIT_SHORT_SHA for immutability and :latest for convenience. The deploy stage calls gcloud run deploy with the SHA-pinned tag, decodes $GCP_SERVICE_KEY_BASE64 into a temporary file, and deletes the file in after_script. It runs only on main.
after_script:
- rm -f /tmp/gcp-key.json
That deletion is not optional. A credential file left on a shared runner is a credential exposed to every job that runs on the same host afterward.
The agent definition
AGENTS.md - project-level instructions GitLab Duo reads before any agentic action. Four sections: project role, code standards (FastAPI typing, Pydantic v2, no hardcoded credentials), CI/CD and deployment rules (Cloud Run port 8080, health-check enforcement, non-root Docker runtime), and security guidelines (SAST triage flow, pip-audit CVE thresholds, secret detection escalation). The file is a contract. Duo reads it; the agent acts within it.
.gitlab/duo-agents/sentinel-review-agent.yml - the custom agent. Name: sentinel-review-agent. Three responsibilities: MR code review (posts a structured comment, opens a remediation MR for Critical/High findings), pipeline failure diagnosis (reads job logs, returns root cause and a proposed fix in under five sentences), and Cloud Run compliance verification. Permissions are read-only on repository and pipeline resources; the one write permission is posting MR comments. The agent cannot merge, cannot delete, cannot push.
Both YAML files validated cleanly:
[OK] .gitlab-ci.yml -- valid YAML, 6 top-level keys: ['stages', 'default', 'variables', 'unit-test', 'build-image', 'deploy-cloud-run']
[OK] .gitlab/duo-agents/sentinel-review-agent.yml -- valid YAML, 6 top-level keys: ['name', 'description', 'prompt', 'permissions', 'tools', 'triggers']
Where this sits
| Piece | Status |
|---|---|
app/main.py - GET / and GET /health | Done |
app/__init__.py | Done |
requirements.txt - pinned versions | Done |
Dockerfile - multi-stage, non-root, port 8080 | Done |
.gitignore - bytecode, venvs, secrets, build artifacts | Done |
README.md | Done |
.gitlab-ci.yml - test, build, deploy | Done |
AGENTS.md - Duo project-level rules | Done |
.gitlab/duo-agents/sentinel-review-agent.yml | Done |
| Service running on port 8080, both endpoints verified | Done |
| Swagger UI schema confirmed | Done |
GitLab CI variables (GCP_PROJECT_ID, GCP_SERVICE_KEY_BASE64, GCP_REGION) | Pending - must be set in GitLab before the pipeline can deploy |
| First push to GitLab, first pipeline run | Pending |
| Duo agent active on a real merge request | Pending |
The scaffold is real and the service runs. Nothing has been pushed to GitLab yet, which means the agent has not reviewed anything and the pipeline has not built anything - that is next.