Skip to content

Commit 91a9be3

Browse files
edgeheroclaude
andcommitted
chore(plan-exec): 2026-07-16-github-trigger-on-pluggable-app-free-auth — phase "Phase E: Specs, rules, docs" passed gates
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGgfho2J6YfRSQN1bSDhj6
1 parent c249bc9 commit 91a9be3

9 files changed

Lines changed: 103 additions & 33 deletions

File tree

.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,17 @@ PI_CONCURRENCY=3 # how many jobs run in parallel
2121
VALKEY_URL=redis://127.0.0.1:6379
2222
PI_JOB_IMAGE=pi-job:latest # the image you built: docker build -f image/Dockerfile -t pi-job:latest .
2323
# PI_JOBS_DIR= # where per-job /job inputs live (default: your OS temp dir)
24+
25+
# --- GitHub trigger (receiver + worker auth) ---
26+
# Webhook receiver
27+
WEBHOOK_SECRET=
28+
RECEIVER_PORT=3000
29+
RECEIVER_BIND=0.0.0.0
30+
# Worker GitHub auth: source is gh | pat | app (default gh)
31+
GITHUB_AUTH_SOURCE=gh
32+
# For GITHUB_AUTH_SOURCE=pat: a repo-scoped, short-expiry fine-grained PAT
33+
GITHUB_PAT=
34+
# For GITHUB_AUTH_SOURCE=app (optional; required for multi-tenant)
35+
GITHUB_APP_ID=
36+
GITHUB_APP_INSTALLATION_ID=
37+
GITHUB_APP_PRIVATE_KEY_PATH=

README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,19 @@ pi-dispatch run ── enqueue ──▶ Valkey + BullMQ ──▶ worker (on yo
7373

7474
Read [`SECURITY.md`](SECURITY.md) before you rely on it — it states plainly what is and is not defended.
7575

76-
## Advanced: GitHub automation *(in progress)*
76+
## Advanced: GitHub automation
7777

7878
pi-dispatch can also be triggered by GitHub — label an issue, and a container works it on a fresh clone,
79-
opens a PR, and comments back. This path needs a **GitHub App** and is **not yet built** in this
80-
repository; the local-folder path above is complete. When it lands:
79+
opens a PR, and comments back. A repo **webhook** drives it (set a `WEBHOOK_SECRET`), and the worker
80+
authenticates to GitHub via `GITHUB_AUTH_SOURCE`: `gh` (a `gh auth token`) or a repo-scoped fine-grained
81+
**PAT** by default. A GitHub **App is optional** — it buys stronger token scoping and is what you need
82+
for multi-tenant.
8183

8284
- Only a collaborator's label or `@pi` comment starts a job (the label *is* the approval step).
83-
- The agent gets a **1-hour, single-repo token** — and, honestly: that token *can* merge, because GitHub
84-
gates push and merge behind the same `contents: write` scope. **Branch protection on your default
85-
branch is the real control**, so the worker will refuse an unprotected repo. `SECURITY.md` has the
86-
detail.
85+
- The agent gets a **repo-scoped, short-lived token** — and, honestly: that token *can* merge, because
86+
GitHub gates push and merge behind the same `contents: write` scope. **Branch protection on your
87+
default branch is the real control**, so the worker **refuses** an unprotected repo. `SECURITY.md` has
88+
the detail.
8789
- A separate **admin panel** on `127.0.0.1` (never on the internet-facing receiver) will turn the queue
8890
on/off, show jobs, and set the model/budgets. It will not edit your persona or skills — those live in
8991
your project's `.pi/`, in git, reviewed.
@@ -99,8 +101,9 @@ minutes.
99101

100102
## Status
101103

102-
The local-folder path (image, worker, `pi-dispatch run` / `worker`) is built and works. The GitHub
103-
webhook path, the admin panel, and scheduled (cron) triggers are in progress. The design is specified in
104+
The local-folder path (image, worker, `pi-dispatch run` / `worker`) and the GitHub webhook path
105+
(receiver → queue → clone → PR) are built and work. The admin panel and scheduled (cron) triggers are in
106+
progress. The design is specified in
104107
[`specs/`](specs/) — start with [`specs/constitution.md`](specs/constitution.md) for the non-negotiables
105108
and [`specs/design.md`](specs/design.md) for the decisions and what was rejected.
106109

SECURITY.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,13 @@ Jobs are a **trigger × target** matrix, and the triggers do not share a threat
6464
- **Isolation.** One ephemeral container per job: `--cap-drop=ALL`, `--security-opt no-new-privileges`,
6565
memory/CPU/pids limits, non-root, `--rm`. Per-job rather than per-session, so state cannot leak
6666
between mutually-untrusting issue authors.
67-
- **Credential scope.** A GitHub App installation token minted per job: one repository, one hour. The
68-
expiry and the single-repo scope bound **where** an injected agent can act — not **how much**, within
69-
that repo. See *What is NOT defended*.
70-
- **CI integrity.** The token is minted **without** the `workflows` permission, which is a separate scope
71-
from `contents`. An injected agent therefore cannot rewrite `.github/workflows/` even though it can
72-
write code. This one holds.
67+
- **Credential scope.** A repo-scoped, short-lived token minted per job — a GitHub App installation
68+
token, or a single-owner fine-grained PAT. Its narrow scope and short expiry bound **where** and for
69+
**how long** an injected agent can act within that repo. See *What is NOT defended*.
70+
- **CI integrity.** The token is minimally-permissioned — `contents` and `pull-requests`, **not**
71+
`workflows`, which is a separate scope. For a fine-grained PAT this is an operator-set property. An
72+
injected agent therefore cannot rewrite `.github/workflows/` even though it can write code. This one
73+
holds.
7374
- **Branch protection is required.** The worker refuses to run against a repository whose default branch
7475
is unprotected, checked before any money is spent. This is the control that makes human review real
7576
rather than customary — without it, nothing technical stops a merge.
@@ -82,9 +83,9 @@ Jobs are a **trigger × target** matrix, and the triggers do not share a threat
8283
a pull request cannot change them. Baked guardrails are read from a path the project cannot influence.
8384
- **We never merge.** No code path in this project calls a merge API; grep is the test. Note carefully
8485
what that does and does not mean — see below.
85-
- **Spend.** A daily cap checked *before* tokens are spent, plus a per-job turn budget enforced by our
86-
runner (pi has no turn limit of its own). An agent that concluded "I can't fix this" is a success and
87-
is never blind-retried.
86+
- **Spend.** A daily cap checked *before* tokens are spent, a per-job turn budget enforced by our runner
87+
(pi has no turn limit of its own), and a 30-minute container wall-clock timeout. An agent that concluded
88+
"I can't fix this" is a success and is never blind-retried.
8889

8990
## What is NOT defended (v1)
9091

@@ -112,7 +113,7 @@ Stated openly rather than discovered later:
112113
removing the human who would have noticed.
113114
- **Network egress from the job container is unrestricted.** There is no allowlist proxy in v1. A job
114115
can reach the internet. If an agent is successfully induced to exfiltrate its environment, egress
115-
filtering will not stop it — the scoped token's one-hour expiry is what bounds the damage. Run this on
116+
filtering will not stop it — the token's short expiry and narrow scope are what bound the damage. Run this on
116117
hardware where that is acceptable, or put an egress policy on the Docker network yourself.
117118
- **The provider API key is broad.** Unlike the GitHub token it cannot be meaningfully scoped per job —
118119
the agent needs it to function. It is the one broad secret inside the container. **Set a spend limit

specs/constitution.md

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -256,16 +256,28 @@ that always fires is one nobody reads.
256256

257257
## CONST-TOKEN-SCOPED-PER-JOB
258258

259-
- **Statement**: Each job shall receive a freshly minted GitHub App installation token, scoped to one
260-
repository, expiring in one hour. No long-lived PAT shall enter a container.
261-
- **Why**: The one-hour expiry **is** the blast-radius bound for the case where an injected agent
262-
exfiltrates its environment — which is a *when*, not an *if*. A PAT makes one successful injection
263-
permanent and multi-repo. The provider API key is the acknowledged exception: it cannot be scoped
264-
because the agent cannot function without it, so it is bounded by a provider-side spend limit instead
265-
of by scope. That asymmetry is deliberate and documented rather than pretended away.
259+
- **Statement**: The container git credential shall be **repo-scoped** (reaching only the serviced
260+
repository), **minimally-permissioned** (contents + pull-requests only), **short-lived**,
261+
**host-held** and **env-injected** (never written to an agent-reachable file), and **not
262+
merge-capable in practice** (branch protection is the barrier — see `CONST-MERGE-NEVER-AUTOMATIC`).
263+
A freshly-minted GitHub App installation token satisfies these properties. A tightly-scoped,
264+
short-expiry **fine-grained** PAT satisfies them for a **single-owner** deployment. A **broad or
265+
long-lived classic PAT does not** and shall not enter a container. The App path remains strictly
266+
stronger on the token axis and is **mandatory for multi-tenant** deployments — a fine-grained PAT is
267+
per-account and cannot isolate mutually-distrusting owners.
268+
- **Why**: The credential's short **expiry** — not its capabilities — is the blast-radius bound for the
269+
case where an injected agent exfiltrates its environment, which is a *when*, not an *if*. A broad,
270+
long-lived classic PAT makes one successful injection permanent and multi-repo, which is why it is
271+
excluded. The provider API key is the acknowledged exception: it cannot be scoped because the agent
272+
cannot function without it, so it is bounded by a provider-side spend limit instead of by scope. That
273+
asymmetry is deliberate and documented rather than pretended away.
266274
- **Traces to**: `CONST-ISOLATION-CONTAINER-PER-JOB`, `INT-CONTAINER-RUNTIME-CONTRACT`
267-
- **Acceptance**: No container environment contains a credential valid beyond one hour or beyond one
268-
repository, except the provider key.
275+
- **Acceptance**: (a) **Code-checkable** — no container environment holds a credential that is
276+
broad-scope or long-lived; the App path scopes the token to exactly one repository; the token is
277+
env-injected and never written to `/workspace`, `.git/config`, argv, or logs; no acceptance clause
278+
mandates a specific expiry duration. (b) **Operator obligation** — a single-owner deployment must
279+
supply a repo-scoped, minimally-permissioned, short-expiry fine-grained PAT (or use the App); a broad
280+
or long-lived classic PAT is non-conformant. Multi-tenant deployments must use the App.
269281

270282
## CONST-PI-VERSION-PINNED
271283

@@ -291,3 +303,4 @@ that always fires is one nobody reads.
291303
|---|---|
292304
| 2026-07-15 | Initial. Extracted from `DESIGN.md` v0.1 (2026-07-14, local, uncommitted). That document recorded "50 claims adversarially verified: 48 confirmed, 2 refuted" — but verified **against documentation**. Source-verification at `earendil-works/pi @ 5e336cf` subsequently corrected ~7 points, two of them architecture-breaking. Hence the evidence convention above: source is authoritative, docs are a hint. |
293305
| 2026-07-15 | `CONST-NO-CONTEXT-FILES-MANDATORY` amended: it named only the CLI flag (`-nc`), but the runner uses the **SDK**, where the mechanism is `noContextFiles: true` on a caller-constructed `DefaultResourceLoader` — and it is **off by default**. The constraint therefore fails **open by omission**: there is no flag to forget, there is an entire object to forget to build. Statement and Evidence corrected; Acceptance unchanged (it was right; the named mechanism was wrong). This is the distinction the evidence convention exists to catch — the *requirement* was verified, the *mechanism* was assumed. |
306+
| 2026-07-17 | `CONST-TOKEN-SCOPED-PER-JOB` amended: Statement, Why, and Acceptance rewritten from a single-mechanism mandate (App installation token with one fixed expiry duration) to mechanism-neutral **required properties** — repo-scoped, minimally-permissioned, short-lived, host-held, env-injected, not merge-capable in practice. The App path satisfies them and stays **mandatory for multi-tenant**; a tightly-scoped short-expiry **fine-grained** PAT satisfies them for **single-owner**; a broad or long-lived classic PAT is excluded. The bound is the token's **expiry**, not a fixed duration — no acceptance clause mandates one. Provider-key exception preserved unchanged. |

specs/interfaces.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ Evidence convention as in `constitution.md`.
356356
reaps nothing. Chromium spawns many processes; zombies accumulate against `--pids-limit` until the
357357
job dies of something unrelated to its actual work.
358358
- Env **passed by the worker**: the configured provider's key variable(s), derived — not hardcoded
359-
(see below); `GITHUB_TOKEN` (scoped, 1h — GitHub-backed jobs only); `PI_JOB_ID`; `PI_PROVIDER`;
359+
(see below); `GITHUB_TOKEN` (scoped, short-lived — GitHub-backed jobs only); `PI_JOB_ID`; `PI_PROVIDER`;
360360
`PI_MODEL`; `PI_MAX_TURNS`; `PI_CODING_AGENT_DIR` (if not `$HOME/.pi/agent`)
361361
- Env **baked into the image**, because they are facts about the image and not choices a job makes:
362362
`PLAYWRIGHT_BROWSERS_PATH=/ms-playwright`, `PLAYWRIGHT_MCP_BROWSER=chromium`,

specs/open-questions.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Status values: `OPEN` (unanswered) · `WATCH` (not a question — a known-incomi
5353

5454
- **Status**: **ACCEPTED RISK***wants explicit ratification*
5555
- **Position**: v1 ships without an allowlist proxy. A job container can reach the internet. The bound
56-
on exfiltration is `CONST-TOKEN-SCOPED-PER-JOB`'s one-hour expiry, not network policy.
56+
on exfiltration is `CONST-TOKEN-SCOPED-PER-JOB`'s short-lived, minimally-permissioned credential, not network policy.
5757
- **Why it is a risk row and not a constraint**: the source design doc listed egress allowlisting as
5858
security "layer 4" while also saying v1 ships without it. **A constraint that ships unenforced is worse
5959
than an honest open risk** — it teaches readers that the constitution is aspirational, which corrodes
@@ -97,6 +97,25 @@ Status values: `OPEN` (unanswered) · `WATCH` (not a question — a known-incomi
9797
`earendil-works/pi @ 5e336cf → CHANGELOG.md:5-10` (`[Unreleased]`) · `→ sdk.ts:33-80`
9898
(`modelRuntime?: ModelRuntime` present **on main only**) · `→ sdk.ts:171` (async `ModelRuntime.create`)
9999

100+
## OQ-006 — Which GitHub auth mechanism is the default, and when is an App required?
101+
102+
- **Status**: **CLOSED — default `gh` / fine-grained PAT for single-owner; App for multi-tenant**
103+
- **Answer**: The default `GITHUB_AUTH_SOURCE` is `gh` (or a repo-scoped, short-expiry fine-grained PAT)
104+
for a single-owner deployment. The GitHub App path is optional, strictly stronger on the token axis
105+
(true per-repo scoping, shorter expiry), and **mandatory for multi-tenant** deployments — a
106+
fine-grained PAT is per-account and cannot isolate mutually-distrusting owners. A broad or long-lived
107+
classic PAT is non-conformant either way. This is the property set `CONST-TOKEN-SCOPED-PER-JOB`
108+
enumerates; the App is no longer a hard prerequisite for running GitHub jobs.
109+
- **Why it was an open question**: the original design assumed a GitHub App was mandatory. Research
110+
found no GitHub requirement forcing an App for a single-owner tool, and `@octokit/auth-app` shipped
111+
declared-but-unused — so the mechanism was undecided in practice while the docs implied App-only.
112+
- **What closed it**: this plan (the pluggable `makeGitHubAuth(pat|gh|app)` resolver) plus the E1
113+
amendment of `CONST-TOKEN-SCOPED-PER-JOB` from App-mandatory/one-hour to mechanism-neutral required
114+
properties. Recorded here so the decision is durable and greppable rather than buried in a closed PR.
115+
- **Related risk**: `OQ-004` (unrestricted egress) is the reason the credential mechanism matters — the
116+
token's short expiry, not network policy, is the exfiltration bound, so the mechanism must keep that
117+
expiry short and the scope narrow. `OQ-004` remains **ACCEPTED RISK** (unchanged by this entry).
118+
100119
---
101120

102121
## Retired from the source design document
@@ -135,3 +154,4 @@ adversarial passes did.
135154
| 2026-07-15 | Initial. Replaces `DESIGN.md` v0.1 §10. Collapsed from ~10 checklist items to 5 rows: source-verification at `earendil-works/pi @ 5e336cf` answered most of them. The register's value inverted in the process — from "holds ten unknowns" to "holds one known-incoming breaking change" (`OQ-005`). |
136155
| 2026-07-16 | `OQ-005` **retracted and re-corrected** to `WATCH — NOT IN THE PIN`. The 2026-07-15 "correction" below was itself wrong: it read `sdk.ts` at `5e336cf` (**HEAD**) to describe npm `0.80.7` (**the pin**), concluded `modelRuntime` had already landed, and declared the changelog unreliable. `ModelRuntime` does not exist in `0.80.7` — no `model-runtime` in its `dist/`, not exported from `dist/index.js`. The changelog said `[Unreleased]` and was exactly right. The runner was written against the phantom API, the image built cleanly, and every job would have died on a missing export; CI caught it on the first real container run. `constitution.md`'s evidence convention now requires verification against the **published artifact**, and `pinned-api.test.mjs` asserts `ModelRuntime` is absent so the real migration fails a test instead of a job. |
137156
| 2026-07-15 | ~~`OQ-005` corrected to **WATCH — PARTIALLY LANDED**~~**this entry was wrong; see above.** It claimed `modelRuntime` was already in `CreateAgentSessionOptions` at the pinned sha and that the changelog was not a reliable signal. Both false: the sha was HEAD, not the pin. Kept rather than deleted, because a spec that hides having been wrong teaches the next reader to trust it more than it deserves. |
157+
| 2026-07-17 | Added OQ-006 recording the GitHub-auth-mechanism decision (default gh/fine-grained PAT single-owner; App mandatory multi-tenant), closed by this plan + the E1 CONST-TOKEN-SCOPED-PER-JOB amendment. |

0 commit comments

Comments
 (0)