From dd3f607744fa671a19af5dd9177325267a02150b Mon Sep 17 00:00:00 2001 From: mathias Date: Tue, 6 Oct 2026 13:25:49 +0000 Subject: [PATCH] ci: add agent-run workflow (copied from cobalt-dingo) --- .gitea/workflows/agent-run.yml | 309 +++++++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 .gitea/workflows/agent-run.yml diff --git a/.gitea/workflows/agent-run.yml b/.gitea/workflows/agent-run.yml new file mode 100644 index 0000000..758be82 --- /dev/null +++ b/.gitea/workflows/agent-run.yml @@ -0,0 +1,309 @@ +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"