From eccb789c0b6b7d333e4203df871c9742cf67fe26 Mon Sep 17 00:00:00 2001 From: "Claude (Pi session)" Date: Fri, 4 Sep 2026 16:39:12 +0200 Subject: [PATCH] docker: give each agent its own Gitea hands, and close the cross-approval hole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of docs/agents/GITEA-SETUP.md, plus a correction to Phase 2 that the runbook could not have known it needed. gitea-mcp v1.7.0 goes into both images, pinned by the sha256 the release publishes and smoke-tested with `--version` at build time, so a bad pin fails the build instead of the agent. Each entrypoint registers it at user scope for that container's own identity, remove-then-add so a restart is idempotent. The token is passed BY PATH. `-e GITEA_ACCESS_TOKEN=$(cat …)` would write it in cleartext into ~/.claude.json, which every session in the container reads; GITEA_ACCESS_TOKEN_FILE is new in the pinned version and leaves the secret in its read-only mount. Verified against the binary's own --help, not assumed. The tool filter stops being an experiment. The names are in the release README: each agent gets issues, notifications, labels, milestones and pull requests, and NOT `pull_request_review_write`. That one matters because separate identities open a hole the runbook did not name: Gitea refuses to let an author approve their own pull request, and does nothing about sylph-decoder approving sylph-port's. Two agents could satisfy `required_approvals = 1` between themselves and then merge, since branch protection blocks pushes to main and never blocked merges. Withholding the tool is defence in depth; the controls are in branch protection, and both docs now say so: approvals whitelisted to the human so an agent's approval does not count, merges whitelisted to the human so an approved PR is still merged by a person. Phase 2's check gains the step that actually tests it -- approve the throwaway PR yourself, then confirm the agent STILL has no merge button. Without that step, the check passes on an instance where the agents can merge each other's work. Also settles two entries on the runbook's own "not verified" list: the tool filter names, and the Gitea version (1.25.5, whose API schema carries enable_merge_whitelist and enable_approvals_whitelist under those names). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj --- docker/decoder/Dockerfile | 18 +++++ docker/decoder/entrypoint.sh | 48 +++++++++++++ docker/decoder/sylph-decoder | 25 +++++++ docker/port/Dockerfile | 18 +++++ docker/port/entrypoint.sh | 48 +++++++++++++ docker/port/sylph-port | 25 +++++++ docs/agents/GITEA-SETUP.md | 131 ++++++++++++++++++++++++++-------- docs/agents/WORKFLOW-gitea.md | 16 +++-- 8 files changed, 295 insertions(+), 34 deletions(-) diff --git a/docker/decoder/Dockerfile b/docker/decoder/Dockerfile index b3f85389..571d4ced 100644 --- a/docker/decoder/Dockerfile +++ b/docker/decoder/Dockerfile @@ -82,6 +82,24 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && npm cache clean --force \ && rm -rf /var/lib/apt/lists/* +# ── gitea-mcp ──────────────────────────────────────────────────────────────── +# The agent's hands on issues, pull requests and notifications — Gitea's own MCP +# server, so there is no second store of truth to drift out of sync with the +# first. +# +# PINNED AND CHECKSUMMED, not "whatever is at that URL today": this binary is +# handed a token that can write to the repository. The checksum is the one +# published in `gitea-mcp_1.7.0_checksums.txt` for the Linux x86_64 asset. +ARG GITEA_MCP_VERSION=1.7.0 +ARG GITEA_MCP_SHA256=bbc9a7b462facd3c56b1558ee6054e91f2fca27a2878b5599afddcf57d446b8d +RUN curl -fsSL -o /tmp/gitea-mcp.tar.gz \ + "https://gitea.com/gitea/gitea-mcp/releases/download/v${GITEA_MCP_VERSION}/gitea-mcp_Linux_x86_64.tar.gz" \ + && echo "${GITEA_MCP_SHA256} /tmp/gitea-mcp.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/gitea-mcp.tar.gz -C /usr/local/bin gitea-mcp \ + && chmod +x /usr/local/bin/gitea-mcp \ + && rm -f /tmp/gitea-mcp.tar.gz \ + && gitea-mcp --version + # ── The agent user ─────────────────────────────────────────────────────────── # NOT root, and not negotiable: Claude Code refuses --dangerously-skip-permissions # when it has root privileges. uid/gid 1000 matches the host account so files diff --git a/docker/decoder/entrypoint.sh b/docker/decoder/entrypoint.sh index f9d4e39b..525de7f7 100755 --- a/docker/decoder/entrypoint.sh +++ b/docker/decoder/entrypoint.sh @@ -208,6 +208,54 @@ python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER" "$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true chmod 600 "$HOME/.claude.json" 2>/dev/null || true +# ── The Gitea MCP server ───────────────────────────────────────────────────── +# Registered at USER scope rather than from a committed `.mcp.json`: the token +# differs per agent and none of it belongs in git. +# +# 🔴 THE TOKEN IS PASSED AS A PATH, NOT A VALUE. `-e GITEA_ACCESS_TOKEN=$(cat +# …)` would write the secret in cleartext into ~/.claude.json, where it is read +# by every session in this container and lands in any copy of that file. +# `GITEA_ACCESS_TOKEN_FILE` (gitea-mcp ≥ 1.7.0) leaves the token in its +# read-only mount and lets the server read it itself. +# +# Re-registered on every start, remove-then-add: `claude mcp add` refuses a name +# that already exists, and ~/.claude.json is re-seeded above — neither ordering +# survives alone. +GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.sylph-gitea-token}" +GITEA_HOST_URL="${SYLPH_GITEA_HOST:-https://git.mc02.dev}" +# Which tools this agent gets. Deliberately not all of them: +# +# * `pull_request_review_write` IS ABSENT, and that is the load-bearing one. +# Gitea will not let an author approve its own pull request — but the moment +# the two agents are separate people, nothing stops them approving each +# OTHER's and satisfying `required_approvals` between themselves with no +# human involved. Separate identities open that hole; withholding the tool +# closes it here, and the approvals whitelist on `main` closes it there. +# * the file / branch / repo WRITE tools are absent: a change reaches `main` +# as a reviewable commit through git, or it does not reach it. +# +# `pull_request_write` bundles `merge` into one tool and cannot be split, so +# merging stays blocked where the agent cannot reach it — the merge whitelist in +# branch protection. This list is defence in depth BEHIND that, never instead. +GITEA_MCP_TOOLS="${SYLPH_GITEA_TOOLS:-get_me,notification_read,notification_write,list_issues,issue_read,issue_write,attachment_read,search_issues,label_read,milestone_read,list_pull_requests,pull_request_read,pull_request_write}" +if [ ! -s "$GITEA_TOKEN_FILE" ]; then + echo "[entrypoint] no Gitea token at $GITEA_TOKEN_FILE — MCP not registered." + echo "[entrypoint] This agent cannot read its notifications or open a pull" + echo "[entrypoint] request, which is most of what its brief asks of it." +elif ! command -v gitea-mcp >/dev/null 2>&1; then + echo "[entrypoint] gitea-mcp is not in this image — rebuild it." >&2 +else + claude mcp remove gitea -s user >/dev/null 2>&1 || true + if claude mcp add -s user gitea \ + -e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \ + -- gitea-mcp -t stdio -H "$GITEA_HOST_URL" -O "$GITEA_MCP_TOOLS" >/dev/null 2>&1; then + echo "[entrypoint] gitea MCP registered against $GITEA_HOST_URL" + else + echo "[entrypoint] gitea MCP registration FAILED — the agent has no issues," >&2 + echo "[entrypoint] no pull requests and no notifications." >&2 + fi +fi + # ── Claude Code ────────────────────────────────────────────────────────────── if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then # Drop the image's default CMD first, or `claude` is handed the literal string diff --git a/docker/decoder/sylph-decoder b/docker/decoder/sylph-decoder index 29689f65..7c49e9f2 100755 --- a/docker/decoder/sylph-decoder +++ b/docker/decoder/sylph-decoder @@ -23,6 +23,8 @@ # SYLPH_REMOTE_NAME Remote Control session name (default: sylpheed-agent) # SYLPH_GIT_CREDENTIALS file with `https://:@host` for push-work # (default: $HOME/.sylph-git-credentials) +# SYLPH_GITEA_TOKEN this agent's own Gitea token file +# (default: $HOME/.sylph-gitea-token-decoder) # SYLPH_LOOP_INTERVAL fixed loop cadence, e.g. 30m (default: 45m) # SYLPH_CPUS / SYLPH_MEM_GB override the computed half set -euo pipefail @@ -229,6 +231,29 @@ docker_args() { echo " or point SYLPH_GIT_CREDENTIALS elsewhere." >&2 fi + # ── Gitea ── + # This agent's OWN token, for its OWN Gitea account — not the push credential + # and not the human's. Three reasons it is separate: `~/.sylph-git-credentials` + # is scoped `write:repository` and every issue endpoint REFUSES it; a pull + # request the agent authored is one a human can approve, which is the entire + # review gate; and revoking one agent then touches neither the other nor you. + # + # Mounted read-only and passed to the MCP server BY PATH — see the entrypoint + # for why the value must not go through the environment. + # Inert until the file exists: the container still runs, with no issues. + GITEATOK="${SYLPH_GITEA_TOKEN:-$HOME/.sylph-gitea-token-decoder}" + if [ -f "$GITEATOK" ]; then + _out+=( + -v "$GITEATOK:/sylph-home/re/.sylph-gitea-token:ro" + -e "GITEA_TOKEN_FILE=/sylph-home/re/.sylph-gitea-token" + ) + else + echo "==> NOTE: no Gitea token at $GITEATOK — this agent cannot read its" >&2 + echo " notifications, open an issue or open a pull request. Generate one" >&2 + echo " while logged in AS sylph-decoder: Settings -> Applications, scopes" >&2 + echo " write:repository, write:issue, write:notification, read:user." >&2 + fi + [ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY") [ -n "${SYLPH_VULKAN:-}" ] && _out+=(-e "SYLPH_VULKAN=$SYLPH_VULKAN") [ -n "${SYLPH_REMOTE:-}" ] && _out+=(-e "SYLPH_REMOTE=$SYLPH_REMOTE") diff --git a/docker/port/Dockerfile b/docker/port/Dockerfile index e0893494..e89e945d 100644 --- a/docker/port/Dockerfile +++ b/docker/port/Dockerfile @@ -62,6 +62,24 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && npm cache clean --force \ && rm -rf /var/lib/apt/lists/* +# ── gitea-mcp ──────────────────────────────────────────────────────────────── +# The agent's hands on issues, pull requests and notifications — Gitea's own MCP +# server, so there is no second store of truth to drift out of sync with the +# first. +# +# PINNED AND CHECKSUMMED, not "whatever is at that URL today": this binary is +# handed a token that can write to the repository. The checksum is the one +# published in `gitea-mcp_1.7.0_checksums.txt` for the Linux x86_64 asset. +ARG GITEA_MCP_VERSION=1.7.0 +ARG GITEA_MCP_SHA256=bbc9a7b462facd3c56b1558ee6054e91f2fca27a2878b5599afddcf57d446b8d +RUN curl -fsSL -o /tmp/gitea-mcp.tar.gz \ + "https://gitea.com/gitea/gitea-mcp/releases/download/v${GITEA_MCP_VERSION}/gitea-mcp_Linux_x86_64.tar.gz" \ + && echo "${GITEA_MCP_SHA256} /tmp/gitea-mcp.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/gitea-mcp.tar.gz -C /usr/local/bin gitea-mcp \ + && chmod +x /usr/local/bin/gitea-mcp \ + && rm -f /tmp/gitea-mcp.tar.gz \ + && gitea-mcp --version + # ── The agent user ─────────────────────────────────────────────────────────── # NOT root: Claude Code refuses --dangerously-skip-permissions with root # privileges. Ubuntu 24.04 ships its own `ubuntu` account at uid 1000, so the diff --git a/docker/port/entrypoint.sh b/docker/port/entrypoint.sh index c8c90ba0..ffebced2 100755 --- a/docker/port/entrypoint.sh +++ b/docker/port/entrypoint.sh @@ -87,6 +87,54 @@ python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER" "$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true chmod 600 "$HOME/.claude.json" 2>/dev/null || true +# ── The Gitea MCP server ───────────────────────────────────────────────────── +# Registered at USER scope rather than from a committed `.mcp.json`: the token +# differs per agent and none of it belongs in git. +# +# 🔴 THE TOKEN IS PASSED AS A PATH, NOT A VALUE. `-e GITEA_ACCESS_TOKEN=$(cat +# …)` would write the secret in cleartext into ~/.claude.json, where it is read +# by every session in this container and lands in any copy of that file. +# `GITEA_ACCESS_TOKEN_FILE` (gitea-mcp ≥ 1.7.0) leaves the token in its +# read-only mount and lets the server read it itself. +# +# Re-registered on every start, remove-then-add: `claude mcp add` refuses a name +# that already exists, and ~/.claude.json is re-seeded above — neither ordering +# survives alone. +GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.sylph-gitea-token}" +GITEA_HOST_URL="${SYLPH_GITEA_HOST:-https://git.mc02.dev}" +# Which tools this agent gets. Deliberately not all of them: +# +# * `pull_request_review_write` IS ABSENT, and that is the load-bearing one. +# Gitea will not let an author approve its own pull request — but the moment +# the two agents are separate people, nothing stops them approving each +# OTHER's and satisfying `required_approvals` between themselves with no +# human involved. Separate identities open that hole; withholding the tool +# closes it here, and the approvals whitelist on `main` closes it there. +# * the file / branch / repo WRITE tools are absent: a change reaches `main` +# as a reviewable commit through git, or it does not reach it. +# +# `pull_request_write` bundles `merge` into one tool and cannot be split, so +# merging stays blocked where the agent cannot reach it — the merge whitelist in +# branch protection. This list is defence in depth BEHIND that, never instead. +GITEA_MCP_TOOLS="${SYLPH_GITEA_TOOLS:-get_me,notification_read,notification_write,list_issues,issue_read,issue_write,attachment_read,search_issues,label_read,milestone_read,list_pull_requests,pull_request_read,pull_request_write}" +if [ ! -s "$GITEA_TOKEN_FILE" ]; then + echo "[entrypoint] no Gitea token at $GITEA_TOKEN_FILE — MCP not registered." + echo "[entrypoint] This agent cannot read its notifications or open a pull" + echo "[entrypoint] request, which is most of what its brief asks of it." +elif ! command -v gitea-mcp >/dev/null 2>&1; then + echo "[entrypoint] gitea-mcp is not in this image — rebuild it." >&2 +else + claude mcp remove gitea -s user >/dev/null 2>&1 || true + if claude mcp add -s user gitea \ + -e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \ + -- gitea-mcp -t stdio -H "$GITEA_HOST_URL" -O "$GITEA_MCP_TOOLS" >/dev/null 2>&1; then + echo "[entrypoint] gitea MCP registered against $GITEA_HOST_URL" + else + echo "[entrypoint] gitea MCP registration FAILED — the agent has no issues," >&2 + echo "[entrypoint] no pull requests and no notifications." >&2 + fi +fi + # ── The repository, cloned into THIS AGENT'S OWN volume ───────────────────── # Not a bind mount of a human's working tree. That arrangement bit this project # three times: an agent's `git config --local` captured a human's commits, a diff --git a/docker/port/sylph-port b/docker/port/sylph-port index 246c5101..f1f49065 100755 --- a/docker/port/sylph-port +++ b/docker/port/sylph-port @@ -14,6 +14,8 @@ # SYLPH_PORT_REPO repo to mount at /work (default: this script's parent) # SYLPH_DISC extracted disc root # SYLPH_GIT_CREDENTIALS file with `https://:@host` for push-work +# SYLPH_GITEA_TOKEN this agent's own Gitea token file +# (default: $HOME/.sylph-gitea-token-port) # SYLPH_LOOP_INTERVAL fixed loop cadence (default 45m) # # ── Two hard-won constraints ──────────────────────────────────────────────── @@ -133,6 +135,29 @@ docker_args() { echo " so its work dies with the container." >&2 fi + # ── Gitea ── + # This agent's OWN token, for its OWN Gitea account — not the push credential + # and not the human's. Three reasons it is separate: `~/.sylph-git-credentials` + # is scoped `write:repository` and every issue endpoint REFUSES it; a pull + # request the agent authored is one a human can approve, which is the entire + # review gate; and revoking one agent then touches neither the other nor you. + # + # Mounted read-only and passed to the MCP server BY PATH — see the entrypoint + # for why the value must not go through the environment. + # Inert until the file exists: the container still runs, with no issues. + local giteatok="${SYLPH_GITEA_TOKEN:-$HOME/.sylph-gitea-token-port}" + if [ -f "$giteatok" ]; then + _out+=( + -v "$giteatok:/sylph-home/port/.sylph-gitea-token:ro" + -e "GITEA_TOKEN_FILE=/sylph-home/port/.sylph-gitea-token" + ) + else + echo "==> NOTE: no Gitea token at $giteatok — this agent cannot read its" >&2 + echo " notifications, open an issue or open a pull request. Generate one" >&2 + echo " while logged in AS sylph-port: Settings -> Applications, scopes" >&2 + echo " write:repository, write:issue, write:notification, read:user." >&2 + fi + [ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY") # ── GPU ── diff --git a/docs/agents/GITEA-SETUP.md b/docs/agents/GITEA-SETUP.md index d28a4c5c..3b88696b 100644 --- a/docs/agents/GITEA-SETUP.md +++ b/docs/agents/GITEA-SETUP.md @@ -13,8 +13,10 @@ Steps are marked **👤 you** (a decision or a credential only you can make) or * ✅ The briefs already describe this workflow. **They describe tooling that does not exist yet** — which is why the agents are stopped and must stay stopped until Phase 7. -* ❌ Nothing exists on the instance: no agent users, no API tokens, no labels, no - branch protection, no MCP. +* ❌ Nothing exists on the instance: no agent users (only `fabi`), no API + tokens, no labels, no milestones, no branch protection. +* ⏳ The MCP wiring (Phase 5) is written and in the tree; the images have not + been rebuilt, and no token exists for it to use yet. ⚠️ **Do not start an agent before Phase 7.** Its brief will tell it to read notifications and open issues, and it will have no tool that can. @@ -65,6 +67,26 @@ Repo → Settings → Branches → *Protected Branches* → add rule for `main`: | Require approvals | **1** | the human gate, made native | | Dismiss stale approvals | **on** | an approval must describe the code that merges | | Block merge on rejected reviews | **on** | "changes requested" has to mean something | +| Enable Merge Whitelist | **on** → whitelist **`fabi` only** | approvals are not the last gate. *Merging* is | +| Enable Approvals Whitelist | **on** → whitelist **`fabi` only** | only a human's approval counts toward the 1 | + +### 🔴 The hole that separate identities open, and why the last two rows close it + +Phase 1 makes the agents distinct people so that a human *can* approve their +work. The same change makes something else possible for the first time: **Gitea +refuses to let an author approve their own pull request — it does not stop +`sylph-decoder` approving `sylph-port`'s.** With `required_approvals = 1` and +nothing else, the two agents satisfy the human gate between themselves, and the +author can then press Merge, because branch protection blocks *pushes* to `main` +and never blocked *merges*. + +Neither whitelist is decoration, and neither replaces the other: + +* **approvals whitelist** — an agent's approval stops counting toward the 1. +* **merge whitelist** — even a legitimately approved PR is merged by you. + +Withholding the review tool from the agents (Phase 5) is defence in depth behind +these, not a substitute: an agent still has a browser-shaped API token. > ### Check — and actually run it, do not assume it > @@ -74,10 +96,14 @@ Repo → Settings → Branches → *Protected Branches* → add rule for `main`: > 1. As `sylph-port`, push a throwaway branch and open a PR into `main`. > 2. Confirm **no Merge button** is offered to that account. > 3. Confirm **you** can approve it, and that *it* cannot approve itself. -> 4. Close the PR, delete the branch. +> 4. Approve it yourself, then look at `sylph-port` again: **still no Merge +> button**, now that an approval exists. This is the step that tests the +> merge whitelist rather than the absence of an approval — without it, steps +> 2 and 3 pass on an instance where the agents can merge each other's work. +> 5. Close the PR, delete the branch. > -> If step 2 offers a Merge button, stop — the rest of this runbook assumes it -> does not. +> If step 2 or step 4 offers a Merge button, stop — the rest of this runbook +> assumes neither does. --- @@ -132,34 +158,72 @@ filter turns out to be insufficient. ## Phase 5 · The MCP server 🤖 -`gitea-mcp` **v1.7.0** — `gitea-mcp_Linux_x86_64.tar.gz` from -`https://gitea.com/gitea/gitea-mcp/releases`. Confirmed flags: `-t stdio`, -`-H `, token via `GITEA_ACCESS_TOKEN`. +**Done — in the tree, not yet in an image.** `gitea-mcp` **v1.7.0**, Linux +x86_64, sha256 `bbc9a7b4…d446b8d` from the release's own `checksums.txt`. The +flags are no longer taken on trust: the arm64 build of the same release was run +and its `--help` read, so `-t stdio`, `-H `, `-O/--tools`, `-S/--scope`, +`-r/--read-only` and `GITEA_ACCESS_TOKEN_FILE` are confirmed, not assumed. -Three edits per image, which I make: +Three edits per image, made: -1. **`Dockerfile`** — fetch and unpack the release binary to `/usr/local/bin`, - pinned to v1.7.0 with a checksum. -2. **`entrypoint.sh`** — register it for that agent's own identity: +1. **`Dockerfile`** — fetch the release tarball, verify the checksum, unpack + `gitea-mcp` into `/usr/local/bin`, and run `--version` at build time so a bad + pin fails the build rather than the agent. +2. **`entrypoint.sh`** — register it at user scope for that agent's identity, + remove-then-add so a restart is idempotent: ```bash - claude mcp add -s user gitea -e "GITEA_ACCESS_TOKEN=$(cat "$GITEA_TOKEN_FILE")" \ - -- gitea-mcp -t stdio -H https://git.mc02.dev + claude mcp add -s user gitea -e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \ + -- gitea-mcp -t stdio -H https://git.mc02.dev -O "$GITEA_MCP_TOOLS" ``` - User scope, not a committed `.mcp.json` — the token differs per agent and none - of it belongs in git. -3. **`sylph-decoder` / `sylph-port`** — mount the matching token file read-only. + 🔴 **By path, not by value.** The earlier draft of this line read + `GITEA_ACCESS_TOKEN=$(cat …)`, which writes the token in cleartext into + `~/.claude.json` — read by every session in the container and carried into any + copy of that file. `GITEA_ACCESS_TOKEN_FILE` is new in the version we pin and + leaves the secret in its read-only mount. +3. **`sylph-decoder` / `sylph-port`** — mount `~/.sylph-gitea-token-{decoder,port}` + read-only and pass its path. Inert until the file exists: without a token the + container still starts, says plainly that the agent has no issues and no pull + requests, and carries on. -Then rebuild both images. ⚠️ `CARGO_BUILD_JOBS=4` and limited `-j`; a -full-parallel build has OOM-crashed this box. +**👤 Yours:** rebuild both images on the agent box, where the containers run. +⚠️ `CARGO_BUILD_JOBS=4` and a limited `-j`; a full-parallel build has OOM-crashed +that machine. -📌 **Worth trying, unverified:** the server takes `--tools` / `GITEA_TOOLS` to -filter which tools it exposes. If a merge tool can be excluded by name, that is -defence in depth behind Phase 2 — belt *and* braces. I have not confirmed the -tool names, so this is an experiment at install time, **not** a substitute for -branch protection. +```bash +docker/decoder/sylph-decoder build +docker/port/sylph-port build +``` + +### The tool filter is a control now, not an experiment + +The tool names were unknown when this was written; they are in the release's +README, and the set each agent gets is pinned in the entrypoint +(`SYLPH_GITEA_TOOLS` overrides it): + +``` +get_me, notification_read, notification_write, list_issues, issue_read, +issue_write, attachment_read, search_issues, label_read, milestone_read, +list_pull_requests, pull_request_read, pull_request_write +``` + +What is **absent** is the point: + +* **`pull_request_review_write`** — the tool that approves, dismisses and + resolves reviews. Without it an agent cannot approve the *other* agent's pull + request through the MCP. Pair it with the approvals whitelist in Phase 2; the + whitelist is the control, this is the layer in front of it. +* **the file, branch, tag and repo write tools** — a change reaches `main` as a + reviewable commit through git, or it does not reach it. +* `label_write` / `milestone_write` — agents *apply* labels (that is + `issue_write`); they do not get to redefine the state machine. + +`pull_request_write` bundles `merge` into one action-based tool and **cannot be +split**, which is exactly why merging is blocked by the merge whitelist instead. > **Check:** in each container, `claude mcp list` shows `gitea` connected, and a -> read call returns this repo's labels. +> read call returns this repo's labels. The entrypoint also says which of the two +> it did on every start, so a missing token is visible in `logs` rather than as +> an agent quietly improvising. --- @@ -222,9 +286,16 @@ Said plainly, because a runbook that hides its soft spots is worse than one that does not: * **that Gitea hides Approve from a PR's own author.** Widely true; Phase 2's - check tests it directly rather than trusting me. -* **the `--tools` filter names** (Phase 5) — an experiment, not a control. + check tests it directly rather than trusting me. What I no longer assume is + that it is *enough* — it says nothing about one agent approving the other, + which is what the approvals whitelist is for. * **Gitea's Projects API**, which is why Phase 4 creates no board. -* **the exact Gitea version** — the API was unreachable from my sandbox on the - last three attempts. Every screen named here has been stable for many - releases, but if a menu is not where I say it is, that is why. + +Two things on this list have since been settled rather than assumed: + +* ~~the `--tools` filter names~~ — read out of the pinned release, and the + binary's `--help` run directly. Phase 5 lists the set. +* ~~the exact Gitea version~~ — **1.25.5**. `enable_merge_whitelist`, + `enable_approvals_whitelist` and `block_admin_merge_override` are all present + in this instance's own API schema, so the Phase 2 settings exist under those + names on the Branches screen. diff --git a/docs/agents/WORKFLOW-gitea.md b/docs/agents/WORKFLOW-gitea.md index e0ae0911..0234eaf8 100644 --- a/docs/agents/WORKFLOW-gitea.md +++ b/docs/agents/WORKFLOW-gitea.md @@ -67,10 +67,18 @@ One PR per item, closing its issue: too big to be an item. The discipline stops depending on an agent's judgement. 🔴 **Agents must not merge their own pull requests.** The MCP's -`pull_request_write` includes `merge`, so this cannot be left to instruction — -it goes in **branch protection on `main`**, requiring review. Same principle that -fixed the build-jobs cap: policy belongs where the agent cannot reach it, not in -a document asking it not to. +`pull_request_write` includes `merge` and the tool cannot be split, so this +cannot be left to instruction — it goes in **branch protection on `main`**. Same +principle that fixed the build-jobs cap: policy belongs where the agent cannot +reach it, not in a document asking it not to. + +⚠️ **"Requiring review" is not the rule that does it.** Gitea stops an author +approving their own pull request; it does not stop *the other agent* approving +it, and it never blocked merging in the first place — `Enable Push: off` blocks +pushes. The rule that holds is the pair of whitelists: **approvals whitelisted to +the human**, so an agent's approval does not count, and **merges whitelisted to +the human**, so an approved PR is still merged by a person. See +[`GITEA-SETUP.md`](GITEA-SETUP.md) Phase 2. ### 🔴 The wiki is NOT for the RE corpus