Skip to content

A governed GitLab contributor

This guide builds one content pack end to end and puts it to work. The result is an agent you can hand a task — “fix the typo on the login page” — and leave alone: it reads the repo, writes code, and opens a merge request, but cannot push to main, delete branches, rewrite history, or merge its own work. The point of the exercise is how little it takes: a pack is one small YAML file.

The agent canThe agent cannot
Read any repo/file/branch/MR the token allowsWrite to main or any non-evershell/ branch
Create evershell/… branchesPush to branches outside the evershell/ namespace
Commit onto evershell/… branchesUse any other write endpoint
Open merge requests from evershell/… branchesDelete branches, tags, or repos
Read the public GitLab API docsForce-push, rewrite history, or merge its own MR

The contract holds because the proxy checks the request that actually leaves, not the agent’s intentions — so it stands no matter how the agent behaves.

A pack is just a folder (anatomy):

gitlab-contributor/
├── pack.yaml # the rules
├── behavior.md # optional: plain-language guidance for the agent
└── guides/ # optional: reference links the agent can read

Everything lives in pack.yaml’s capabilities — full capability specs, the same shape a role uses inline. We’ll build them up one intention at a time.

Let it read. Reading can’t damage anything, so be generous. Path globs split on /: ** matches any depth, * matches a single segment.

- name: read
domains: [gitlab.com]
methods: [GET]
paths: [/api/v4/user, /api/v4/groups/**, /api/v4/projects, /api/v4/projects/**]

Let it create a branch — but only an agent branch. Add a match predicate: a one-line CEL expression over the request. GitLab passes the new branch name in the ?branch= query parameter, so require it to start with evershell/:

- name: create-branch
domains: [gitlab.com]
methods: [POST]
paths: [/api/v4/projects/*/repository/branches]
match:
expr: '"branch" in request.query && request.query["branch"].all(b, b.startsWith("evershell/"))'

Let it commit — but only onto an agent branch. This endpoint carries the branch in the JSON body, so opt into body inspection (requires_body + request_format) and gate the field:

- name: commit
domains: [gitlab.com]
methods: [POST]
paths: [/api/v4/projects/*/repository/commits]
request_format: application/json
request_schema:
type: object
properties:
branch: { type: string }
commit_message: { type: string }
actions: { type: array }
match:
expr: 'request.body.branch.startsWith("evershell/")'
requires_body: true

This rule is the linchpin: committing is the only way to write file content, and it can only target a evershell/ branch — so nothing reaches main except through a merge request.

Let it open a merge request — from an agent branch. Same idea, gating source_branch:

- name: open-merge-request
domains: [gitlab.com]
methods: [POST]
paths: [/api/v4/projects/*/merge_requests]
request_format: application/json
request_schema:
type: object
properties:
source_branch: { type: string }
target_branch: { type: string }
title: { type: string }
match:
expr: 'request.body.source_branch.startsWith("evershell/")'
requires_body: true

Don’t let it delete or rewrite anything. There’s nothing to add — look at what was never granted: no DELETE, no PUT. So branch/tag deletion, force-push, and history rewrites simply aren’t in the pack. In a permission model, what you leave out is as powerful as what you put in.

Authenticate it, without handing it the token. One auth transform attaches a stored credential to every request above — the agent never sees the secret:

transforms:
- auth: { credential: gitlab-token, provider: gitlab_token }

Wrap those rules in a capability named gitlab-contributor and that’s the whole pack. Optionally add a behavior.md — it’s folded into the agent’s system prompt, so you can describe the workflow in plain language (“branch as evershell/…, commit, open an MR, never touch main”). That makes the agent efficient; the rules above are what make it safe.

Check it with the same parser the platform runs, locally (evershell caps):

Terminal window
evershell caps validate caps.yaml # parse, schema, and CEL type-checks
evershell caps compile caps.yaml # prints the exact policy the platform enforces

Then zip the contents (so pack.yaml sits at the archive root):

Terminal window
cd pack && zip -r -X ../gitlab-contributor.zip pack.yaml behavior.md guides

The ready-made archive: gitlab-contributor.zip.

  1. Get a GitLab token with access to the project(s) the agent should reach, able to read code and create commits, branches, and merge requests.
  2. Store it as a credential named gitlab-token (provider gitlab_token). The name must match what the pack references.
  3. Upload the packContent Packs → Upload, or POST /v1/packs/upload. (You’ll see one advisory about a body-less endpoint without a schema — expected; the branch-creation rule takes its data in the URL, not a body.)
  4. Attach it to a role in the role editor; its capabilities copy into the role. Don’t also attach the broad gitlab pack — that would undo the scoping.
  5. Run itevershell run <role> "Fix the typo in the login heading and open an MR" (CLI) — then review the merge request it opens.

Does it work on one repo or all of them? As written, every project the token can reach — the * in the paths matches any project ID. To pin it to one repo, scope the GitLab token to that project (strongest), and/or replace /api/v4/projects/*/… with the numeric project ID and narrow the reads to /api/v4/projects/<id>/**.

Why the REST API and not git? REST is the surface the policy can actually govern: the target branch travels in the URL query or the JSON body, so a match predicate can require evershell/. A raw git push carries its ref updates and objects inside a packfile body the policy can’t inspect — and this pack doesn’t grant the git smart-HTTP paths anyway, only /api/v4/**. So writes are funnelled through the inspectable, branch-gateable channel by design.

What stops it from writing to main? The commit rule is the only path to write file content, and its match requires the body’s branch to start with evershell/. A commit (or MR) targeting main doesn’t match the rule, so the proxy denies it. The branch restriction is enforced per request, not left to the prompt.

Can I change the prefix, or point at self-hosted GitLab? Yes — swap evershell/ in the three match expressions for your convention (agent/, bot/, …), and replace gitlab.com with your instance’s domain in every rule. Rebuild the zip and re-upload.

Where do I see what it’s allowed to reach? The governance canvas in the console renders the role’s capabilities as edges to each domain, plus a data-flow analysis that surfaces any path by which data read from a sensitive source could egress — so the residual surface is visible, not hidden.