Skip to content

Run local agents on a least-privilege GitHub identity instead of the maintainer's credential #659

Description

@bess-product-owner

Summary

Local agents run on the maintainer's own credential. Constrain them with a
dedicated GitHub identity holding least-privilege rights instead, so "an agent
may not do X" becomes a property the token cannot express rather than a command
pattern someone has to think of.

The problem

.claude/settings.json tries to express agent authority by pattern-matching
command strings. That language cannot express the property we actually want.
#620 is three consecutive rounds of proving it: Stage 4 found two git push
holes, the maintainer found two more by hand (git push origin refs/tags/v1.2.3,
git push origin HEAD — which pushes main when you are on main), and the
review's own conclusion was that prefix globs cannot express "no valid spelling
reaches a protected branch". The same shape broke main: #657 replaced the
blanket Bash(gh api) / Bash(gh api *) ask rules with ten enumerated forms,
and quality-check.sh's gate — which requires the blanket spelling precisely
because the dangerous markers sit at arbitrary argument positions — now fails on
a clean origin/main export.

The enumeration keeps leaking because it is carrying weight it cannot carry.

Measured starting state

Both agent accounts exist, but their tokens are as powerful as the maintainer's.
All three are classic PATs carrying repo (full repository control):

Identity Token Kind Scopes
johanzander gh auth (keyring) classic gist, project, read:org, repo, workflow
bess-product-owner BESS_PO_TOKEN classic project, repo
bess-agent BESS_AGENT_TOKEN classic repo, workflow, write:packages, delete:packages, notifications

Two things fall out of that table:

  1. gh-agent.sh only changes attribution, not authority. It swaps the token
    for a single gh call and exits. Every git push, gh pr create and
    gh api an agent makes still runs on the maintainer's credential.
  2. bess-agent can delete published GHCR packages (delete:packages) and
    rewrite .github/workflows/* (workflow). A token that can edit a workflow
    can grant itself anything, since the workflow runs with repo secrets.

The merge gate is prose, and now measured to be

Protect Main Branch (ruleset 17568935, enforcement: active) has an empty
bypass_actors list — so direct pushes to main are genuinely blocked for
everyone including the owner. Good. But its pull_request rule reads:

{"required_approving_review_count": 0, "require_last_push_approval": false, ...}

Zero approvals required. Any token with pull_requests: write can open a PR and
immediately merge it. "Never merge, ever" is currently enforced by nothing but
skill prose.

Proposed permission sets

Fine-grained PATs, one per role:

bess-developer — pushes branches, opens PRs, comments:

Permission Level Why
Contents write push feature branches
Pull requests write open PRs, comment
Issues write comment, label
Metadata read mandatory
Workflows none privilege-escalation vector — see above
Administration none cannot touch rulesets, settings, or delete the repo
Actions none cannot cancel, re-run or delete workflow runs
Packages none drops today's delete:packages

bess-product-owner — never pushes code:

Permission Level
Contents none
Issues write
Pull requests write
Projects write
Metadata read

What this makes redundant

  • The protected-ref half of fix: guard git push by shape so feature-branch pushes run unattended #620's enumeration — which that PR's own thread
    already proposed deleting, on the same reasoning generalised beyond git push.
  • The blanket-vs-enumeration argument over Bash(gh api …): a token without
    Administration or Actions gets 403 on the destructive calls regardless of
    argument order, so the ask list stops being the guard and becomes belt-and-braces.

What this does NOT cover

State these explicitly so the token is not mistaken for a general safety net:

  1. Local filesystem damage. rm -rf, the worktree destruction in fix: stop the fleet prune from destroying the worktrees it cannot remove #653. A
    GitHub token has no opinion on those; that stays the OS sandbox's job. Two
    separate surfaces.
  2. Spend. A perfectly scoped token will still burn $0.50–1 on a duplicate
    review round. That is what feat: enforce review-loop legality and derive fleet state from GitHub #638's and feat: give issues and PRs an explicit state machine, so the backlog loop can progress work #658's review-loop gates are for, and
    this issue does not resolve their disagreement.
  3. Correctness. Nothing here constrains what an agent writes, only what it
    can reach.

Cost to accept

An agent can no longer push changes to .github/workflows/*. #645 edits
issue-analyze.yml, so under this scheme that change is applied by the
maintainer by hand. This is a deliberate trade, not an oversight — but it does
change the workflow and should be agreed rather than discovered.

Open questions to verify before committing

  1. Do fine-grained PATs cover Projects v2 for a user-owned project?
    BESS_PO_TOKEN currently relies on classic project scope, and the backlog
    board (Project . #1) is user-owned. Verify against the live board, do not assume.
  2. How should the merge gate actually be enforced? Raising
    required_approving_review_count to 1 with require_last_push_approval: true
    would stop an agent merging its own work — but bess-reviewer is a separate
    identity and could approve, so confirm what that composition actually yields
    before relying on it.
  3. Does git push pick the token up? The credential must govern git, not
    just gh — setting GH_TOKEN for agent sessions rather than only inside
    gh-agent.sh.

Definition of Done

  • Fine-grained PATs minted for both roles with the tables above
  • BESS_AGENT_TOKEN / BESS_PO_TOKEN repointed; classic PATs revoked
  • GH_TOKEN wired for agent sessions so it governs git push too
  • One ordinary issue driven end to end through implement-issue on the new
    token, with whatever 403s it hits recorded — the real permission surface
    measured, not guessed
  • Merge gate decision made and applied (open question 2)
  • settings.json enumeration reduced to whatever is still load-bearing
    afterwards, and fix: guard git push by shape so feature-branch pushes run unattended #620 re-scoped against the result

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions