Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 31 additions & 23 deletions .github/workflows/loop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,16 @@ concurrency:
group: loop
cancel-in-progress: false

# Least privilege: GitHub reads use GITHUB_TOKEN; writes (only the
# execute_writes=true rehearsal) go through LOOP_GH_TOKEN and exist only in
# default-branch contexts — fork events carry no secrets (platform guarantee).
# The loop's writes use the job token, and the *ruleset* is what keeps it out of
# `main`: `Main branch` requires every change to arrive through a pull request with
# one approving review, and its only bypass actor is the repository owner in
# `pull_request` mode — so an unapproved direct push is refused for every credential
# including the loop's. `permissions:` carries no `administration`, so the loop
# cannot weaken that ruleset either.
permissions:
contents: read
issues: read
pull-requests: read
contents: write
issues: write
pull-requests: write

jobs:
loop:
Expand Down Expand Up @@ -91,18 +94,18 @@ jobs:
LOOP_EVENT_ACTION: ${{ github.event.action }}
LOOP_EVENT_JSON: ${{ toJSON(github.event) }}
LOOP_REPO: ${{ github.repository }}
# For non-dispatch events github.event.inputs is null → default to report (report-only)
LOOP_EXECUTE_WRITES: ${{ github.event.inputs.execute_writes || 'false' }}
# Write level for non-dispatch events. A1 shipped `false` (report-only); L2 flips
# it by setting the repository variable LOOP_EXECUTE_WRITES=true — no code change,
# and one place for a human to turn it back off. `workflow_dispatch` can still
# override per run (the rehearsal path).
LOOP_EXECUTE_WRITES: ${{ github.event.inputs.execute_writes || vars.LOOP_EXECUTE_WRITES || 'false' }}
LOOP_SMOKE: ${{ github.event.inputs.smoke || 'false' }}
LOOP_MODEL: ${{ github.event.inputs.model || 'deepseek/deepseek-v4-flash' }}
LOOP_RUN_TIMEOUT_MS: '3300000' # 55min, leaving headroom for push (job timeout 60min)
GH_TOKEN: ${{ secrets.LOOP_GH_TOKEN || github.token }}
# Handed to the agent subprocess in place of GH_TOKEN under the report
# boundary (see runPi in shared/agent.mjs). `github.token` here is capped
# at read by the `permissions:` block above, so the agent cannot write even
# if LOOP_GH_TOKEN is a write-capable PAT. Host-side writes still use
# GH_TOKEN, in the host's own process.
GH_READ_TOKEN: ${{ github.token }}
# The loop's only GitHub credential: the job token. What it may do to `main`
# is decided by the `Main branch` ruleset, not by this file — see the
# `permissions:` block above. No PAT is involved anywhere in the loop.
GH_TOKEN: ${{ github.token }}
# Credentials: secrets only
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
Expand Down Expand Up @@ -136,6 +139,12 @@ jobs:
# pull_request_target.
- uses: actions/checkout@v4
with:
# Its default is `true`, and it writes the job token into the repository's git
# config so that later `git` invocations pick it up implicitly — including the
# agent's. The loop does not need that: the host pushes through
# `http.extraheader` with an explicit token. Defence in depth, not the
# mechanism — what bounds the loop is the `Main branch` ruleset.
persist-credentials: false
ref: >-
${{
github.event_name == 'workflow_dispatch' && github.ref
Expand Down Expand Up @@ -174,14 +183,13 @@ jobs:
fi
echo "model $want is available"

# Measure what the GitHub credential can actually do, before the agent can
# reach it. `GH_TOKEN` is `secrets.LOOP_GH_TOKEN || github.token`, so
# configuring a PAT silently changes every `gh` call in this job — it can
# narrow the read-only ones sweep depends on, or widen what the agent can
# write. Neither is visible from this file. Probes are side-effect free:
# they call write endpoints against an impossible id, so `403` means the
# scope is absent and `404`/`422` means it is present.
- name: Check GitHub credential
# Assert the loop's safety property before anything can act on it. The property
# is "nothing reaches `main` without a human", and it lives in repository
# configuration no file here can express — so it is measured, not restated:
# an idempotent write to `refs/heads/main` must be refused for a pull request,
# and the credential must not be able to edit the ruleset that refuses it.
# Side-effect free: the ref probe writes back the SHA it just read.
- name: Check the main-branch boundary
run: node scripts/loop/gh-check.mjs

- name: Pull state from R2
Expand Down
11 changes: 11 additions & 0 deletions docs/agents/ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,14 @@ Never do work outside the declared stage; never modify norms-layer files
`loop-task: #<n>`.
- Don't claim a task whose lock is live; don't fight a live lock.
- No self-merge; no norms-layer edits without a PR; no npm publish by the loop.
- **`main` is protected by the `Main branch` ruleset**, and that — not a credential
— is what makes the line above true: every change to `main` must arrive through a
pull request carrying one approving review, the only bypass actor is the repository
owner, and the job token has no `administration` scope, so the loop cannot weaken
the rule. A direct push to `main` is refused for every credential the loop can
hold. Do not plan around it, and do not try to reproduce it with token scoping: a
PAT acts as its owner, who *is* the bypass actor. Details and the exact payload:
`scripts/loop/README.md` § "Branch protection is the gate".
- The job token cannot push changes to `.github/workflows/**` (no `workflows`
permission exists for `permissions:` to grant). A task that needs one is not
loop work — route it to `needs-triage`/`ready-for-human`.
Loading
Loading