diff --git a/.github/workflows/loop.yml b/.github/workflows/loop.yml index 0710ebd..b9d5007 100644 --- a/.github/workflows/loop.yml +++ b/.github/workflows/loop.yml @@ -97,6 +97,12 @@ jobs: 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 }} # Credentials: secrets only DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} @@ -168,6 +174,16 @@ 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 + run: node scripts/loop/gh-check.mjs + - name: Pull state from R2 id: pull run: node scripts/loop/r2-sync.mjs pull diff --git a/scripts/loop/README.md b/scripts/loop/README.md index 5278dc2..84c837b 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -47,20 +47,132 @@ pnpm loop summary | view # summary / task list | Level | Effect | | --- | --- | | `report` | State layer + report only. GitHub write actions are **listed, never executed** (A1 semantics). | -| `auto` | Executes labels/comments/close (the A0 closed-loop boundary; L2 default, and the rehearsal mode). | +| `auto` | Executes labels/comments/close (the A0 closed-loop boundary; L2 default, and the rehearsal mode). Note this is *permission* to write, not *ability*: with `LOOP_GH_TOKEN` unset, `auto` still cannot write — see the credential section below. | In `report` mode the pending actions are appended to `state/reports/.md` as a checklist and surfaced in the Actions Step Summary, so a human can execute them by hand. +### GitHub write credential (`LOOP_GH_TOKEN`, unset today) + +`GH_TOKEN: ${{ secrets.LOOP_GH_TOKEN || github.token }}`. The secret is +**deliberately not configured**: `github.token` is capped by the workflow's +`permissions: {contents: read, issues: read, pull-requests: read}`, so today a +run that ignores its prompt and calls `gh pr merge` gets a 403. Two independent +locks, and the platform one does not depend on the model behaving. + +Configuring the secret lifts only the platform lock — `writeLevel` still gates +`applyActions` in `state.mjs`, and its default is `report`, so ordinary runs +keep reporting. + +It must not also lift the **agent's** lock. `runPi` spawns the agent with +`env: process.env`, which would hand it whatever `GH_TOKEN` holds — and since +that is now a PAT, the agent would inherit the PAT's full reach. So under the +report boundary the child gets `GH_READ_TOKEN` instead: `github.token`, which +`permissions:` caps at read. The agent therefore reaches GitHub read-only even +if it ignores its prompt, while host-side writes keep using the real `GH_TOKEN` +in the host's own process. Under `auto` the agent gets the write token, which is +the point of that mode. + +Both are measurable without spending a token: +`node scripts/loop/gh-check.mjs` probes each credential's scopes side-effect +free (write endpoints against an impossible id — `403` means absent, `404`/`422` +means present) and reports what the host holds and what the agent will hold. + +**Do not grant the agent's credential `Contents: write`.** Merging a PR is not +under "Pull requests" — it is under "Contents" (GitHub's endpoint→permission +table): + +| Action | gh command | Permission required | +| --- | --- | --- | +| label / comment an **issue** | `gh issue edit` / `comment` | `Issues: write` | +| label / comment a **PR** | `gh pr edit` / `comment` | `Issues: write` **or** `Pull requests: write` — both sections list `POST /issues/{n}/labels` and `/issues/{n}/comments` | +| close an **issue** | `gh issue close` | `Issues: write` | +| close a **PR** | `gh pr close` | `Pull requests: write` (GraphQL `closePullRequest`, not an `/issues/` call) | +| open a PR | `gh pr create` | `Pull requests: write` **plus** `Contents: write` — the head branch must exist first | +| **merge a PR** | `gh pr merge` | **`Contents: write`** — `PUT /pulls/{n}/merge` | +| push / edit a file / delete a branch | `git push`, `PUT /contents/{path}`, `DELETE /git/refs/{ref}` | `Contents: write` | + +Two consequences that are easy to get wrong: + +1. **`Pull requests: write` cannot merge.** It covers `POST /pulls`, + `PATCH /pulls/{n}`, reviews and review comments — the merge endpoint sits in + the `Contents` section, because merging means writing commits to the target + branch. +2. **"Can open a PR" and "can merge a PR" are the same permission** — within a + single credential. Opening a PR needs a head branch; creating one needs + `Contents: write`; and that is the merge permission. Separating them means + splitting the *roles*, which is what L2c below does. + +So the L2 step has three sizes rather than two. Two are pure scope choices; the +third is the one that satisfies "loop may open PRs but must never merge them", +and it needs a small host change. + +| | Scopes / changes | Loop can | Loop cannot | +| --- | --- | --- | --- | +| **L2a** | `Issues: RW` + `Pull requests: RW` + `Contents: read` | label, comment, close, review | open PRs, merge, touch code or branches | +| **L2c** | L2a, **plus** the host pushes `loop/*` branches with its own token | label, comment, close, review, **open PRs** | **merge anything** | +| **L2b** | L2a + `Contents: RW` on the loop's own credential | everything, PR creation included | — (nothing; this is the unchecked size) | + +Prefer a **fine-grained** PAT scoped to `Alex1990/tiny-oss`; a classic `repo` +token grants all of the above at once, defeating the point. Set an expiry and +record who renews it, or L2 fails silently later. + +#### L2c — the loop opens PRs and still cannot merge + +Worth stating up front: **a single credential cannot express this.** Opening a +PR needs a head branch, creating one needs `Contents: write`, and that is the +same permission `PUT /pulls/{n}/merge` requires. The split has to happen across +*roles*, not inside one scope list: + +| Credential | Held by | Scopes | Used for | +| --- | --- | --- | --- | +| `GITHUB_TOKEN` (job) | the workflow's own steps | `contents: write` | creating and pushing the `loop/-*` branch | +| `LOOP_GH_TOKEN` (PAT) | the agent subprocess | `Issues: RW`, `Pull requests: RW` — **no `Contents`** | labels, comments, opening the PR | + +Two details are required and both are easy to miss: + +- `actions/checkout` must set **`persist-credentials: false`**. Its default is + `true` and writes the job token into the repository's git config so scripts + can run authenticated git commands — which would hand the agent exactly the + `Contents: write` this design exists to remove. +- Pushing is the *only* thing that has to move to a host step. Committing is a + local operation with no network credential, so the agent can still stage and + commit its work in the workspace; the host pushes the branch and opens the PR + from the agent's proposed metadata. That also fits the existing division of + labour — the agent proposes, the host executes. + +Result: the agent has no route to `PUT /pulls/{n}/merge` (403 — no `Contents`), +and no route to push anything anywhere. The loop still produces PRs. `no +self-merge` becomes a platform guarantee instead of a prompt instruction, which +is the whole point. + +One side effect to plan for: a PR created with `GITHUB_TOKEN` produces a +`pull_request` event whose workflow runs start in an **approval-required** state +(except `closed`/`labeled`/`edited`, which do not create runs at all). So the +loop will not triage or review its own PR, and CI will not run on it, until a +human clicks "Approve workflows to run". For this loop that is a feature — it +removes the recursive self-review the `pr-review` route would otherwise perform +on the loop's own output, and it matches D26's direction. A human merging the PR +still fires `pull_request_target.closed` normally, which is what drives the +`metrics` gate. + +Server-side protection does **not** substitute for any of this: this is a solo +repository (one collaborator, `admin: true`), so a ruleset requiring approvals +would block the owner's own PRs forever (GitHub forbids self-approval), and +adding an admin `bypass_actor` would let the loop's PAT bypass it too. The +existing `Main branch` ruleset is `enforcement: disabled` and contains only +`deletion` and `non_fast_forward` — it never restricted merging. The guarantee +has to come from the credential split. + ## A1 architecture (Actions + R2) ``` GitHub event ─▶ loop.yml (concurrency group `loop` = platform-level single writer) ├─ setup node/pnpm ▸ install pi ▸ print `pi --list-models deepseek` ├─ r2-sync pull (state layer → job-local state/) - ├─ entry.mjs (route → inbox | metrics | run) - ├─ r2-sync push (if: always()) + ├─ entry.mjs (route → inbox | metrics | terminal | run) + ├─ r2-sync push (if: always() && pull did not skip — D34) └─ upload pi sessions (artifact, 90d) ``` @@ -351,7 +463,12 @@ was *correct*, and whether new events produce proposals that match reality. `Install engine (pi)`, before `entry.mjs`, so they write no run row at all — see the failure-classification item above. (Distinction worth keeping: 21 workflow executions, 18 run records.) -- [ ] No drift **and** no incorrect proposal after a week → human decides on L2 +- [ ] No drift **and** no incorrect proposal after a week → human decides on L2. + Owner's target is **L2c**: the loop opens PRs but can never merge. That + needs the credential split (host pushes branches with `GITHUB_TOKEN`; the + agent's PAT carries no `Contents`) plus `persist-credentials: false` — see + "GitHub write credential" above for the endpoint-level evidence, and note + that a single credential cannot express it. ### Not reachable under A1 (structural — decide before the L2 switchover) @@ -365,7 +482,7 @@ validated at the report boundary however long it runs: | `pr-review` route | also needs a loop PR; A1 never produces one | untested here — exercised in A0 | | External-PR read-only analysis | D28 — a fork/Dependabot `pull_request` run carries no secrets, so not even a read-only analysis can reach the LLM | accepted for A1; needs its own credentials to enable | | Label/comment effects on GitHub | the report boundary never writes | proposals must be judged on *correctness*, not on effect | -| `execute_writes` rehearsal | `LOOP_GH_TOKEN` is unset, so `auto` cannot write even when requested | correct as defence in depth — but it means **no write path has ever executed** | +| `execute_writes` rehearsal | `LOOP_GH_TOKEN` is unset, so `auto` cannot write even when requested | correct as defence in depth — but it means **no write path has ever executed**. Configuring the PAT is a prerequisite for testing any of it; see "GitHub write credential" | | R2 bucket versioning | R2 offers no object versioning | accepted deviation from 05 §8 (see Environment facts); `push`/`seed` never use `--delete` | Consequence for the L2 decision: a clean observation week proves the loop is diff --git a/scripts/loop/entry.mjs b/scripts/loop/entry.mjs index 4d73726..cdb559f 100644 --- a/scripts/loop/entry.mjs +++ b/scripts/loop/entry.mjs @@ -243,6 +243,7 @@ async function doRun({ decision, ctx, repo, writeLevel }) { prompt, cwd: ROOT, sessionDir, model: process.env.LOOP_MODEL, timeoutMs, + writeLevel, log, }); const events = parseEvents(res.stdout); diff --git a/scripts/loop/gh-check.mjs b/scripts/loop/gh-check.mjs new file mode 100644 index 0000000..13141f4 --- /dev/null +++ b/scripts/loop/gh-check.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node +/** + * GitHub credential self-check — invoked as a standalone workflow step. + * + * Usage: node scripts/loop/gh-check.mjs [owner/repo] + * + * Why this exists: `GH_TOKEN` is `secrets.LOOP_GH_TOKEN || github.token`. The + * moment a real PAT is configured it replaces the job token for **every** `gh` + * call, including the read-only ones sweep depends on. A PAT that is narrower + * than the job token therefore breaks reads silently, and one that is broader + * silently widens what the loop can do. Neither is visible from the workflow + * file, so it has to be measured. + * + * How permissions are measured — two methods, because the obvious one is wrong: + * + * 1. GitHub's own `permissions` object for the credential. No inference. + * 2. **Idempotent writes** for scopes the object does not describe (Issues and + * Pull requests). The probe reads the current value and writes it straight + * back, so nothing changes — but a `2xx` means the write really executed, + * which only happens with the write scope. + * + * The tempting method — call a write endpoint against an impossible id and read + * `403` as "no permission", `404` as "permission present" — does **not** work. + * Measured against a `github.token` capped to read-only by `permissions:`, the + * write probes returned `404`, not `403`. So `404` means only "the credential + * has *some* scope for this resource", read or write, and the method reports + * false positives. `403` remains meaningful for a scope the token lacks + * entirely, which is why the Actions and Secrets checks still use it. + * + * The token itself is never printed. Only the login it authenticates as. + */ + +import { spawnSync } from 'node:child_process'; + +const IMPOSSIBLE = '999999999'; // beyond any real issue/PR number here +const SEP = '\u0001'; // unlikely in a real title + +/** Run `gh`, returning the HTTP status it saw and whether it succeeded. */ +function gh(args, { token = null } = {}) { + const env = { ...process.env, GH_PAGER: '', NO_COLOR: '1' }; + if (token) env.GH_TOKEN = token; + const r = spawnSync('gh', args, { encoding: 'utf8', env }); + const all = `${r.stdout || ''}\n${r.stderr || ''}`; + const m = /HTTP (\d{3})/.exec(all); + return { + ok: r.status === 0, + status: m ? Number(m[1]) : (r.status === 0 ? 200 : null), + out: (r.stdout || '').trim(), + err: (r.stderr || '').trim(), + }; +} + +/** A scope the credential lacks entirely answers `403`; anything else is "has some access". */ +const hasAnyScope = (status) => status !== null && status !== 403 && status !== 401; + +/** Identity is the decisive fact for a credential we did not choose. */ +function identity(token) { + const r = gh(['api', 'user', '--jq', '.login'], { token }); + return r.ok ? r.out : null; +} + +/** + * Write nothing, change nothing — but require the write scope to succeed. + * Returns `true` (2xx), `false` (refused), or `null` (no target to probe with). + */ +function idempotentWrite(label, { read, write }, token) { + const cur = gh(read, { token }); + if (!cur.ok || !cur.out) { + console.log(` UNKNOWN ${label} (nothing to probe against)`); + return null; + } + const w = gh(write(cur.out), { token }); + console.log(` ${w.ok ? 'present' : 'ABSENT '} ${label} (HTTP ${w.status ?? 'n/a'})`); + return w.ok; +} + +const repo = process.argv[2] || process.env.GITHUB_REPOSITORY || ''; +if (!repo) { + console.error('[loop:gh] error: pass owner/repo or set GITHUB_REPOSITORY'); + process.exit(1); +} + +const hostToken = process.env.GH_TOKEN || null; +const agentToken = process.env.GH_READ_TOKEN || null; + +console.log(`[loop:gh] repo: ${repo}`); + +const hostWho = identity(hostToken); +if (!hostWho) { + // A token that cannot even identify itself is the one hard failure: every + // later `gh` call in the job would fail too, so fail loudly and early. + console.error('[loop:gh] error: GH_TOKEN cannot authenticate'); + process.exit(1); +} +const hostIsJob = hostWho === 'github-actions[bot]'; +// Locally there is no GH_TOKEN — `gh` falls back to the developer's own login, +// so saying "github.token" there would be plainly wrong. +const hostSource = !hostToken + ? '(GH_TOKEN unset — gh used its own local login)' + : hostIsJob ? 'github.token (job token)' : 'LOOP_GH_TOKEN (a PAT, replacing the job token)'; +console.log(`[loop:gh] host credential: ${hostSource} — authenticates as ${hostWho}`); + +console.log('\n[loop:gh] reads — the host depends on these; a miss breaks sweep:'); +// sweep lists open issues/PRs and calls `gh release list`; repo metadata is the +// cheapest liveness check. +const reads = [ + ['repo metadata (GET /repos)', ['api', `repos/${repo}`, '--jq', '.full_name']], + ['issues list (sweep)', ['issue', 'list', '-R', repo, '--state', 'open', '--limit', '1']], + ['pull requests list (sweep)', ['pr', 'list', '-R', repo, '--state', 'open', '--limit', '1']], + ['releases list (sweep)', ['release', 'list', '-R', repo, '--limit', '1']], +]; +const readResults = reads.map(([label, args]) => { + const r = gh(args, { token: hostToken }); + console.log(` ${r.ok ? 'OK ' : 'FAILED '} ${label}` + + `${r.ok ? '' : ` — ${(r.err || r.status).toString().slice(0, 90)}`}`); + return { label, ok: r.ok }; +}); + +console.log('\n[loop:gh] host write scopes (idempotent probes — each restores the value it read):'); +const issueProbe = { + read: ['api', `repos/${repo}/issues?state=open&per_page=1`, '--jq', `.[0] | "\\(.number)${SEP}\\(.title)"`], + write: (cur) => { + const [n, title] = cur.split(SEP); + return ['api', '--method', 'PATCH', `repos/${repo}/issues/${n}`, '-f', `title=${title}`]; + }, +}; +const prProbe = { + read: ['api', `repos/${repo}/pulls?state=open&per_page=1`, '--jq', `.[0] | "\\(.number)${SEP}\\(.title)"`], + write: (cur) => { + const [n, title] = cur.split(SEP); + return ['api', '--method', 'PATCH', `repos/${repo}/pulls/${n}`, '-f', `title=${title}`]; + }, +}; +const canLabel = idempotentWrite('Issues: write (label/comment/close)', issueProbe, hostToken); +idempotentWrite('Pull requests: write (open/edit PR, reviews)', prProbe, hostToken); + +// `PATCH /git/refs/{ref}` is in the Contents write set, and setting a ref to the +// SHA it already has is a no-op — so a 2xx means real write ability and nothing +// changes. +// +// This is the only sound way to measure Contents. The repository's +// `.permissions` object is NOT a token scope report: it describes the +// *authenticated user's* role, so it answers `push: true` for any token +// belonging to an admin, no matter how narrowly the token was scoped. Reading it +// here reported a Contents Read-only PAT as write-capable. +const defaultBranch = gh(['api', `repos/${repo}`, '--jq', '.default_branch'], + { token: hostToken }).out || 'main'; +const refProbe = { + read: ['api', `repos/${repo}/git/refs/heads/${defaultBranch}`, '--jq', '.object.sha'], + write: (sha) => ['api', '--method', 'PATCH', + `repos/${repo}/git/refs/heads/${defaultBranch}`, '-f', `sha=${sha}`], +}; +const canPush = idempotentWrite( + `Contents: write (MERGE + push + release)`, refProbe, hostToken, +); +const hostPerm = gh(['api', `repos/${repo}`, '--jq', '.permissions'], { token: hostToken }); +if (hostPerm.ok) { + console.log(` (info) repo .permissions = ${hostPerm.out.replace(/\s+/g, ' ')}` + + ' — the *user\'s* role, not this token\'s scopes; do not read it as a scope report'); +} + +console.log('\n[loop:gh] scopes reached by a credential that lacks them entirely:'); +const absent = [ + ['Actions: write (trigger/disable workflows)', ['api', '--method', 'POST', + `repos/${repo}/actions/workflows/${IMPOSSIBLE}/dispatches`, '-f', 'ref=main']], +]; +for (const [label, args] of absent) { + const r = gh(args, { token: hostToken }); + console.log(` ${hasAnyScope(r.status) ? 'present' : 'ABSENT '} ${label} (HTTP ${r.status ?? 'n/a'})`); +} +// Read-only probe: listing secret *names* requires the Secrets permission. The +// listing itself is never printed — only whether the permission is present. +const secrets = gh(['api', `repos/${repo}/actions/secrets`], { token: hostToken }); +console.log(` ${secrets.ok ? 'present' : 'ABSENT '} Secrets access (list/overwrite CI secrets)`); + +const brokenReads = readResults.filter((r) => !r.ok); +console.log(''); +if (canPush) { + console.log('[loop:gh] WARNING: the host holds Contents:write. That single scope grants merging' + + ' (PUT /pulls/{n}/merge), branch pushes and POST /releases — none of which the loop needs' + + ' until L2. See "GitHub write credential" in scripts/loop/README.md. Recreate the PAT with:' + + ' Issues Read and write, Pull requests Read and write, Contents Read-only, and Nothing for' + + ' Actions/Secrets/Administration/Workflows.'); +} else if (canPush === false) { + console.log('[loop:gh] host holds no Contents:write — merging, branch pushes and releases are' + + ' impossible for it.'); +} +console.log(`[loop:gh] label/comment/close: ${canLabel ? 'available' : 'NOT available — the loop cannot do its job'}`); +console.log(`[loop:gh] reads: ${brokenReads.length + ? `${brokenReads.length} FAILED — the host's sweep will break: ${brokenReads.map((r) => r.label.trim().split(/\s+/)[0]).join(', ')}` + : 'all OK'}`); + +// The credential the *agent* will hold. `runPi` swaps GH_TOKEN for GH_READ_TOKEN +// under the report boundary, so this decides whether the agent could write if it +// ignored its prompt. +// +// Identity cannot be read from `GET /user` here: `github.token` is a GitHub App +// installation token and is refused there entirely, which is why the check below +// combines two signals that *are* observable — whether the credential can read +// the repository at all, and whether a write is refused. +console.log('\n[loop:gh] credential handed to the agent under the report boundary:'); +if (!agentToken) { + console.log(' (GH_READ_TOKEN is not set — runPi would pass GH_TOKEN through unchanged,' + + ' i.e. the agent inherits the host credential above.)'); +} else { + const canRead = gh(['api', `repos/${repo}`, '--jq', '.full_name'], { token: agentToken }); + const asUser = gh(['api', 'user', '--jq', '.login'], { token: agentToken }); + console.log(` can read the repo: ${canRead.ok ? 'yes' : `NO — the agent cannot read GitHub (${canRead.status})`}`); + console.log(` acts as a user: ${asUser.ok ? `yes (${asUser.out}) — a PAT or user token` : 'no — a GitHub App token, i.e. github.token'}`); + const agentWrite = idempotentWrite('Issues: write', issueProbe, agentToken); + const agentPush = idempotentWrite('Contents: write', refProbe, agentToken); + + if (!canRead.ok) { + console.log(' WARNING: the agent cannot read GitHub, so triage/sweep will find nothing.'); + } else if (asUser.ok) { + console.log(` WARNING: the agent holds a *user* token (${asUser.out}) — it inherits that` + + " user's reach, so the report boundary is a prompt instruction only."); + } else if (agentWrite || agentPush) { + console.log(' WARNING: the agent can write (github.token is not capped to read — check the' + + " workflow's `permissions:` block)."); + } else { + console.log(' OK: github.token, read-only by the workflow\'s `permissions:` block — the agent' + + ' reaches GitHub read-only even if it ignores its prompt. The report boundary is a' + + ' platform guarantee.'); + } +} diff --git a/scripts/loop/shared/agent.mjs b/scripts/loop/shared/agent.mjs index 812b992..7212926 100644 --- a/scripts/loop/shared/agent.mjs +++ b/scripts/loop/shared/agent.mjs @@ -125,7 +125,9 @@ export function summarize(events) { */ export const SESSION_HINT = 'session files land under --session-dir (uploaded as an artifact)'; -export function runPi({ prompt, cwd, sessionDir, model, timeoutMs = 3600000, log = () => {} }) { +export function runPi({ + prompt, cwd, sessionDir, model, timeoutMs = 3600000, writeLevel = 'report', log = () => {}, +}) { return new Promise((resolve) => { const args = [ '--mode', 'json', @@ -140,8 +142,24 @@ export function runPi({ prompt, cwd, sessionDir, model, timeoutMs = 3600000, log // to pi, overridable with LOOP_ENGINE_CMD (e.g. run the same contract locally through // another CLI or wrapper script). const engine = (process.env.LOOP_ENGINE_CMD || 'pi').split(/\s+/).filter(Boolean); + + // `env: process.env` hands the agent everything the host sees, including + // `GH_TOKEN` — which is `secrets.LOOP_GH_TOKEN || github.token`, so as soon + // as a PAT is configured the agent inherits whatever that PAT can do. Under + // the report boundary nothing the agent does is supposed to reach GitHub, + // and that must not rest on the prompt alone: hand it the job token instead, + // which the workflow caps at `read-only` via `permissions:`. Writes by the + // *host* (applyActions) still use the real GH_TOKEN in its own process. + const childEnv = { ...process.env }; + if (writeLevel !== 'auto' && process.env.GH_READ_TOKEN) { + childEnv.GH_TOKEN = process.env.GH_READ_TOKEN; + log('[loop] agent is given a read-only GitHub token (report boundary)'); + } else if (writeLevel === 'auto') { + log('[loop] agent is given the write-capable GitHub token (auto mode)'); + } + const child = spawn(engine[0], [...engine.slice(1), ...args], { - cwd, env: process.env, stdio: ['pipe', 'pipe', 'pipe'], + cwd, env: childEnv, stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; let stderr = '';