Follow-up to #2277 (github-push), raised while clearing hydra's builder-write-access-hardening task 1.1.
What is actually blocked
github-push gives a pushing forge credential a compliant home. It does not give it an origin. Today the value must be a fine-grained PAT that a human creates at github.com, because GitHub has no API that creates a PAT.
I checked whether Doriath closes this, since it is the fleet's credential-custody service. It does not, and it says so in its own specs. Verified against doriath at 12ab3f9cf, with a positive control on each absence claim (grep -rn 'openssl_[a-z_]*(' lib/ returns 71 call sites across 7 files, so the method reaches the crypto code):
openssl_sign appears zero times. The only *_sign calls are openssl_csr_sign in CertificateAuthorityService (X.509 issuance from a CSR, against Doriath's own internal CA — github.com does not trust it).
JWSBuilder is absent from the app. JwtAuthService imports JWSVerifier only: it verifies inbound RS256 assertions and returns bin2hex(random_bytes(32)). It cannot produce a signed token.
- No outbound call to any credential provider exists.
grep -rn 'IClientService\|newClient()' lib/ returns exactly two egress sites — HIBP and the SIEM webhook.
- The specs state the boundary outright: "Doriath stores static, zero-knowledge secrets (ADR-003) and cannot mint dynamic credentials like Vault; a lease bounds the access-grant lifetime … it is not credential generation" (
openspec/specs/machine-secret-leases/spec.md:10). "Rotation" only flags for a human — markRotated() compares keyUpdatedAt and returns requiresRotation: true if nobody changed it.
Doriath provides custody, and it does that well: ciphertext-only storage, JWT-Bearer machine auth, leases with TTL/renew/revoke. That is the right division of labour. It is simply not an origin.
The one machine-mintable shape
POST /app/installations/{installation_id}/access_tokens — confirmed against the API docs — accepts a repositories array and a permissions object, and returns a token that expires in one hour. {"repositories": ["hydra"], "permissions": {"contents": "write"}} is task 1.1's requirement exactly, and it omits workflows by construction rather than by trusting whoever clicked the checkboxes.
⚠️ Omitting repositories/permissions inherits the full installation grant, so the narrowing must be sent explicitly on every mint. That is a fail-open default and the implementation must not rely on the caller remembering.
Correcting a claim I inherited
The blocker was recorded as "the broker cannot do an RSA signing operation". That is not the blocker. openssl_sign() is a PHP builtin and the broker is PHP; generic-jwt's own comment ("the app generates and signs the JWT itself — the broker cannot sign") describes a policy, not a missing primitive. The real blockers are:
- No GitHub App exists for ConductionNL's own use.
GET /orgs/ConductionNL/installations returns 14 installations, all third-party (atlassian, codecov, cursor, …). There is nothing to mint from.
- Creating one cannot be done headlessly. There is no REST endpoint. Even the manifest flow requires POSTing an HTML form in a browser to
https://github.com/organizations/ConductionNL/settings/apps/new, a human clicking Create GitHub App, and a redirect carrying a code valid for one hour — only then is POST /app-manifests/{code}/conversions server-to-server.
So the human step is not eliminated; it moves from per credential, per repo, per expiry to once, for the organisation. That is the whole win, and it is a large one.
What this issue asks for
Until then
The credential is a human action, and nothing available to an agent substitutes for it. For the record, the only forge credential an agent can reach on the dev host is the gh CLI OAuth token, whose scopes are gist, read:org, repo, workflow — account-wide and carrying workflow write. It is the exact credential task 1.1 forbids and must not be stored as github-push.
Follow-up to #2277 (
github-push), raised while clearinghydra'sbuilder-write-access-hardeningtask 1.1.What is actually blocked
github-pushgives a pushing forge credential a compliant home. It does not give it an origin. Today the value must be a fine-grained PAT that a human creates at github.com, because GitHub has no API that creates a PAT.I checked whether Doriath closes this, since it is the fleet's credential-custody service. It does not, and it says so in its own specs. Verified against doriath at
12ab3f9cf, with a positive control on each absence claim (grep -rn 'openssl_[a-z_]*(' lib/returns 71 call sites across 7 files, so the method reaches the crypto code):openssl_signappears zero times. The only*_signcalls areopenssl_csr_signinCertificateAuthorityService(X.509 issuance from a CSR, against Doriath's own internal CA — github.com does not trust it).JWSBuilderis absent from the app.JwtAuthServiceimportsJWSVerifieronly: it verifies inbound RS256 assertions and returnsbin2hex(random_bytes(32)). It cannot produce a signed token.grep -rn 'IClientService\|newClient()' lib/returns exactly two egress sites — HIBP and the SIEM webhook.openspec/specs/machine-secret-leases/spec.md:10). "Rotation" only flags for a human —markRotated()compareskeyUpdatedAtand returnsrequiresRotation: trueif nobody changed it.Doriath provides custody, and it does that well: ciphertext-only storage, JWT-Bearer machine auth, leases with TTL/renew/revoke. That is the right division of labour. It is simply not an origin.
The one machine-mintable shape
POST /app/installations/{installation_id}/access_tokens— confirmed against the API docs — accepts arepositoriesarray and apermissionsobject, and returns a token that expires in one hour.{"repositories": ["hydra"], "permissions": {"contents": "write"}}is task 1.1's requirement exactly, and it omitsworkflowsby construction rather than by trusting whoever clicked the checkboxes.repositories/permissionsinherits the full installation grant, so the narrowing must be sent explicitly on every mint. That is a fail-open default and the implementation must not rely on the caller remembering.Correcting a claim I inherited
The blocker was recorded as "the broker cannot do an RSA signing operation". That is not the blocker.
openssl_sign()is a PHP builtin and the broker is PHP;generic-jwt's own comment ("the app generates and signs the JWT itself — the broker cannot sign") describes a policy, not a missing primitive. The real blockers are:GET /orgs/ConductionNL/installationsreturns 14 installations, all third-party (atlassian, codecov, cursor, …). There is nothing to mint from.https://github.com/organizations/ConductionNL/settings/apps/new, a human clicking Create GitHub App, and a redirect carrying acodevalid for one hour — only then isPOST /app-manifests/{code}/conversionsserver-to-server.So the human step is not eliminated; it moves from per credential, per repo, per expiry to once, for the organisation. That is the whole win, and it is a large one.
What this issue asks for
Contents: Read and write, noWorkflows), installed on selected repositories. Human, once.rubenvdlindeis an org admin.openssl_sign, 10-minuteexp,iss= app id), exchanges it atPOST /app/installations/{id}/access_tokensalways sendingrepositoriesandpermissionsexplicitly, and returns the 1-hour token toresolveInjectable().PUT /repos/{o}/{r}/contents/.github/workflows/probe.ymland require the refusal. A201means the grant is wrong.Until then
The credential is a human action, and nothing available to an agent substitutes for it. For the record, the only forge credential an agent can reach on the dev host is the
ghCLI OAuth token, whose scopes aregist, read:org, repo, workflow— account-wide and carrying workflow write. It is the exact credential task 1.1 forbids and must not be stored asgithub-push.