name: agent run # Prototype of the agent-in-CI substrate (2026-08-27). # # The bet: the trigger, RBAC, audit trail, isolation and rollback should come # from the CI platform rather than from a bespoke dispatcher. dispatch's watcher # reimplemented all five and produced three control-plane defects in one day of # real use (dispatch#53, #54, #55) plus an eight-week silent outage. Its # EXECUTOR, by contrast, produced five commits with green CI — so this keeps the # executor (`dispatch run`) and deletes the rest. # # What the platform gives us for free here: # trigger — issues.labeled, no polling CronJob to go silently dead # RBAC — who may apply the label is Gitea's permission model # audit — one Actions run per attempt, linked to the issue, retained # isolation — the runner, not a shared long-lived pod # rollback — revert the PR # # THE AGENT NEVER PUSHES. It edits files in the checkout; this job creates the # branch, commits, and opens the PR. That is the important difference from the # issue flow, where the agent held a push credential and pushed to main. Agent # edits to workflows or tests now arrive as a reviewable diff (dispatch#55). # # CORRECTED 2026-09-17: this said "behind branch protection", and there was none. # The API returned {"protected": false} for this repo, dispatch and hyperguild # alike. What actually held the line was "Assert origin/main did not move" below, # which is a DETECTOR, not a preventer -- it caught both real escapes after the # fact. Protection with a push whitelist and a rubric review on the agent lane # landed in ADR-0010; the required-status-check half is #112 and is not live yet, # so do not read this line as a wall again until that closes. # # Deliberately NOT `status:dispatch`: this repo is still in dispatch-repos.txt, # so reusing that label would fire the CronJob and this workflow for the same # issue. # # REQUIRED SECRETS (all set on this repo since 2026-09-02): # LITELLM_API_KEY — gateway key. A rotated one fails the run in ~60ms # with a LiteLLM 401 indistinguishable from a # missing key, and nothing alerts on it. # LITELLM_BASE_URL — http://litellm.ai-stack.svc.cluster.local:4000/v1 # DISPATCH_BOT_TOKEN_RO — read:repository + read:issue, dispatch-bot account # # The runner is an in-cluster pod (`gitea-runner` namespace), so the service DNS # resolves and there are no NetworkPolicies in either namespace to block it. # This is the same URL the dispatch CronJob already uses successfully. on: issues: types: [labeled] concurrency: # One agent run per issue. Also stops a label-flap from starting a fleet. group: agent-run-${{ github.event.issue.number }} cancel-in-progress: false jobs: run: # RESUMED 2026-09-03 (dispatch#67, risk/high). Was `if: false` after run 5228 # against cobalt-dingo#14 proved "THE AGENT NEVER PUSHES" (line 19) false: # the agent used the GITEA_TOKEN handed to the "Run the agent" step to write # straight to main via the gitea MCP write tool, bypassing the run_bash # denylist that was supposed to be the only push-capable surface. Resuming # now that fix #1 has landed (dispatch@0044a4f): `dispatch run`'s CI path # constructs its executors with CIPolicy, not DefaultPolicy, and CIPolicy # has no forge-mutating MCP tool at all — file_write_branch is not merely # denylisted, it is not granted. Fix #4 below is the verifier: it does not # trust that claim, it checks origin/main directly. # # Fix #2 landed 2026-09-05: the two steps that hand a credential to the # UNTRUSTED agent now use `secrets.DISPATCH_BOT_TOKEN_RO`, a token owned by # the dedicated `dispatch-bot` account and scoped `read:repository` + # `read:issue`. Read-only comes from the SCOPE, not from per-repo # permission: scope is the half that travels with the token, so a # read-only-by-permission credential would still be push-capable against # every other repo its account can write to -- the escape would relocate # rather than close. Verified before wiring: GetIssue 200, clone OK, and a # real `git push` REFUSED. # # The "Open a PR" step below deliberately KEEPS the write-capable # secrets.GITEA_TOKEN. That credential is the JOB's, used after the agent # has finished, and opening the PR is the one thing the design requires be # writable. Do not "tidy" it to the read-only token. # # The secret is NOT named GITEA_TOKEN_RO as dispatch#67 originally proposed: # Gitea reserves the GITEA_/GITHUB_ secret-name prefix and rejects creating # one with 400 invalid variable or secret name. The env var inside each step # is still GITEA_TOKEN, which is what `dispatch run` reads. # # Still not done here (issue's fix #5): not trusting the agent's own summary # line is a downstream-consumer discipline change, not a workflow change. # PAUSED 2026-09-07 after run 5915 on issue #11 escaped a second time, and # RESUMED 2026-09-15 once both halves of that escape were closed. # # The pause comment blamed "a hosted MCP server carrying its own # credential". That was wrong, and is corrected here rather than left to # mislead the next reader: the agent used plain `git commit` + `git push` # from the checkout. Two things had to be true at once, and each is now # false independently -- # # CAPABILITY: CIPolicy rewrote MCPTools but inherited BashPrefixes # verbatim, and DefaultPolicy carries a bare "git", which authorises # `git push` as much as `git status`. Only COMPOUND commands were ever # refused (the log shows `|` and `&&` denied); the escape used neither. # Closed by dispatch#72: CI mode gets git status|diff|log|show. # # CREDENTIAL: actions/checkout had planted a write-capable auth header # in .git/config. Closed by persist-credentials: false above. # # Fix #4 below stays exactly as it is. It caught BOTH escapes within a # minute, because it checks the fact rather than trusting the policy -- # including on this resume, where the policy is once again believed correct. if: >- github.event.label.name == 'agent/ci-run' || contains(github.event.issue.labels.*.name, 'agent/ci-run') runs-on: self-hosted timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v4 with: # dispatch#67 round two. By default this action writes # http..extraheader = AUTHORIZATION: basic *** # -- the JOB's write-capable token -- into .git/config, and only # strips it in a post-step that runs AFTER the agent has finished. # For the whole agent window the untrusted model sits inside a # repository that can push to main with no credential of its own. # That is how run 5915 escaped while the read-only PAT handed to the # agent step (fix #2) was working exactly as designed: that variable # was never the credential in play. The PR step supplies its own. persist-credentials: false # dispatch#67 fix #4, the verifier for fix #1. "The agent's tools no # longer include a forge write" is a claim about CIPolicy; this checks # the fact CIPolicy exists to guarantee, independent of trusting the # policy code, the executor's own report, or anything the agent said. # Per the estate's own rule, a gate without a case proving it fails when # it should is a claim, not a control -- this is that case. - name: Record origin/main before the agent runs run: git rev-parse origin/main > /tmp/main-before.txt - name: Build the executor env: GITEA_TOKEN: ${{ secrets.DISPATCH_BOT_TOKEN_RO }} # The runner's filesystem PERSISTS between jobs — a fixed clone path # works on the first run and fails on every one after with # "destination path already exists". Unique dir per run; the binary # path is a file so overwriting it is fine. run: | set -euo pipefail git config --global credential.helper '!f() { echo username=ci; echo password=$GITEA_TOKEN; }; f' src="$(mktemp -d)" git clone --depth 1 https://git.d-ma.be/mathias/dispatch.git "$src" ( cd "$src" && go build -o /tmp/dispatch-bin ./cmd/dispatch ) /tmp/dispatch-bin run 2>&1 | head -1 || true - name: Run the agent env: GITEA_TOKEN: ${{ secrets.DISPATCH_BOT_TOKEN_RO }} LITELLM_BASE_URL: ${{ secrets.LITELLM_BASE_URL }} LITELLM_API_KEY: ${{ secrets.LITELLM_API_KEY }} # infra#347/dispatch#68: repointed from the dead berget/kimi-k2.6. LITELLM_EXECUTORS: "kimi=berget/kimi-k3,qwen=koala/qwen36-35b-a3b,glm=berget/glm-4.7-fp8" DISPATCH_KIMI_MAX_TURNS: "80" # signals#47: event values reach the shell only through env, never # through `${{ }}` expansion inside run:. ISSUE_NUMBER: ${{ github.event.issue.number }} # Exit status is the contract: non-zero means no work happened, and the # job stops rather than opening an empty PR. run: /tmp/dispatch-bin run "$GITHUB_REPOSITORY" "$ISSUE_NUMBER" . # dispatch#67 fix #4. Independent of the diff check below: a forge write # does not touch this checkout's working tree at all, so a clean `git # diff` here is exactly what run 5228 saw while a commit was landing on # main through a different door. Fails the job outright -- this is not # "no PR to open", it is "the sandbox did not hold". - name: Assert origin/main did not move # Runs even if "Run the agent" itself failed. A step-failure default # would skip this on exactly the run where an escape is most plausible # -- the agent doing something out-of-band and THEN erroring out never # gets checked otherwise. if: always() run: | git fetch origin main -q before="$(cat /tmp/main-before.txt)" after="$(git rev-parse origin/main)" if [ "$before" != "$after" ]; then echo "::error::origin/main moved during the run ($before -> $after)." \ "The agent wrote to the forge outside this job's own commit/PR path." >&2 exit 1 fi - name: Open a PR if the agent changed anything env: # cobalt-dingo#101. The PR is opened as `dispatch-bot`, NOT as the # Actions identity, because Gitea does not fire workflows for events # its own identity creates. A PR opened by gitea-actions therefore got # zero runs -- no CI, and no rubric review either, so ADR-0010's # "on the agent lane it BLOCKS" could never fire on the agent lane. # Verified 2026-09-17: a dispatch-bot-authored PR produces # pull_request runs; a gitea-actions one produces none. # # The token is scope write:repository, which in Gitea is per-CATEGORY # and therefore INCLUDES merge. The bot is stopped from merging by # branch protection (enable_merge_whitelist on main), not by the # scope -- an identity gate is only as real as the identity # separation beneath it (agentsquad#47). Verified mechanically with # this exact credential before wiring it here: # open a PR -> 201 # merge that PR -> 405 User not allowed to merge PR # commit to main -> 403 user cannot commit to repo # Re-run those three after any branch-protection change. GITEA_TOKEN: ${{ secrets.AGENT_PR_BOT_TOKEN }} AGENT_PR_DEPLOY_KEY: ${{ secrets.AGENT_PR_DEPLOY_KEY }} # signals#47: the issue title is attacker-controlled (anyone who can # open an issue writes it). It used to be expanded by `${{ }}` straight # into the curl -d string below, i.e. into the shell source, in the # step that holds the PR bot token and the write deploy key. A title # containing `$(...)` or a `"` ran as code. It now arrives only as an # env var and is JSON-encoded by python3, never parsed by the shell. ISSUE_NUMBER: ${{ github.event.issue.number }} ISSUE_TITLE: ${{ github.event.issue.title }} run: | # dispatch#74: `git diff` does not see UNTRACKED files, so an agent # whose work is entirely NEW files -- adding a test file to a repo # that has none, say -- produces two clean diffs and is reported as # having done nothing. veckomeny run 8238 wrote internal/web/index_test.go, # verified it by breaking the template on purpose, and had the whole # run discarded here. cobalt-dingo never hit it because its agent # tasks modified files that already existed. # # git status --porcelain covers modified, staged, deleted AND # untracked, and respects .gitignore so generated output is not # counted as work. if [ -z "$(git status --porcelain)" ]; then # dispatch#67 fix #3: "no changes" used to exit 0. The job cannot # distinguish "the agent did nothing" from "the agent did it # somewhere this checkout can't see" -- run 5228 was the second # case wearing the first case's exit code. Fail and let a human # look; a genuinely idle run costs one red job, which is cheap # next to what a silent one already cost. echo "::error::agent made no changes in the checkout — nothing to open." \ "Verify by hand this was truly a no-op before treating it as one." >&2 exit 1 fi branch="agent/issue-$ISSUE_NUMBER" git config user.name "agent-ci" git config user.email "agent-ci@d-ma.be" git checkout -b "$branch" git add -A git commit -m "agent: address #$ISSUE_NUMBER" -m "Opened by the agent-run workflow. Closes nothing automatically — a human reviews, merges and closes." # Push over SSH, not HTTPS. persist-credentials: false left this # checkout with no auth, and the obvious replacement -- a credential # helper feeding secrets.GITEA_TOKEN -- gets 403: the Actions token # is not push-capable on this instance (run 7157 committed # agent/issue-15 fine, then "fatal: ... The requested URL returned # error: 403"). # # SSH from the runner is the pattern the rest of the estate already # uses for exactly this: dispatch's own CD pins the infra manifest # via ssh://git@10.0.1.20:30022 and notes "every repo's self-hosted # runner -- no new credential needed here". Port 30022 is the # NodePort gitea actually serves SSH on; the advertised port 22 is # refused. # # This does not hand the agent a push path: `git push` and `git # remote` are both outside CIPolicy's git verbs (dispatch#72), and # this step runs after the agent has finished. # AGENT_PR_DEPLOY_KEY is a write deploy key for THIS repo only # (cobalt-dingo#98), mirroring the INFRA_DEPLOY_KEY shape dispatch's # CD uses one repo over. Narrower than a PAT on purpose: git only, no # API surface, and it cannot reach any other repo -- where a # write-scoped PAT would, since effective access is scope n permission # and only the scope half travels with the token (dispatch#67 fix #2). # # It is bound to THIS step, which runs after the agent has finished, # and persist-credentials: false keeps it out of the checkout the # agent works in. That placement is the whole point: run 5915 escaped # on a credential that was in the working tree, not on one the job # held afterwards. # # The runner filesystem persists between jobs, so the key is removed # on the way out whether or not the push succeeds. mkdir -p ~/.ssh trap 'rm -f ~/.ssh/id_agent_pr' EXIT printf '%s\n' "$AGENT_PR_DEPLOY_KEY" > ~/.ssh/id_agent_pr chmod 600 ~/.ssh/id_agent_pr ssh-keyscan -p 30022 10.0.1.20 >> ~/.ssh/known_hosts 2>/dev/null export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_agent_pr -o IdentitiesOnly=yes" git push "ssh://git@10.0.1.20:30022/$GITHUB_REPOSITORY.git" "$branch" # --fail-with-body: the branch is already pushed by this point, so a # rejected PR creation must fail the job loudly rather than exit 0 and # leave a branch nobody opened anything for. The Actions token cannot # git-push on this instance; whether it can create a PR through the # API is a separate permission and is being learned here. payload="$(BRANCH="$branch" python3 -c ' import json, os n = os.environ["ISSUE_NUMBER"] print(json.dumps({ "head": os.environ["BRANCH"], "base": "main", "title": "agent: #%s %s" % (n, os.environ["ISSUE_TITLE"]), "body": "Opened by `agent-run` for #%s.\n\nCI is the ground-truth verdict. A human reviews, merges and closes \u2014 this workflow does none of those." % n, })) ')" curl -sS --fail-with-body -X POST \ -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ --data-binary "$payload" \ "https://git.d-ma.be/api/v1/repos/$GITHUB_REPOSITORY/pulls"