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.
What the pack grants — and denies
Section titled “What the pack grants — and denies”| The agent can | The agent cannot |
|---|---|
| Read any repo/file/branch/MR the token allows | Write to main or any non-evershell/ branch |
Create evershell/… branches | Push to branches outside the evershell/ namespace |
Commit onto evershell/… branches | Use any other write endpoint |
Open merge requests from evershell/… branches | Delete branches, tags, or repos |
| Read the public GitLab API docs | Force-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.
Build the pack
Section titled “Build the pack”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 readEverything 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: trueThis 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: trueDon’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.
Validate and package
Section titled “Validate and package”Check it with the same parser the platform runs, locally
(evershell caps):
evershell caps validate caps.yaml # parse, schema, and CEL type-checksevershell caps compile caps.yaml # prints the exact policy the platform enforcesThen zip the contents (so pack.yaml sits at the archive root):
cd pack && zip -r -X ../gitlab-contributor.zip pack.yaml behavior.md guidesThe ready-made archive: gitlab-contributor.zip.
Use it
Section titled “Use it”- 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.
- Store it as a credential named
gitlab-token(providergitlab_token). The name must match what the pack references. - Upload the pack — Content 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.) - Attach it to a role in the
role editor; its capabilities copy
into the role. Don’t also attach the broad
gitlabpack — that would undo the scoping. - Run it —
evershell 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.