From 6341ad223da4f8d240445a4a891f123e2fea0153 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 13 Nov 2026 17:13:14 +0900 Subject: [PATCH 1/4] docs(g01): record exact-main offline recheck --- docs/evidence/g01-live-canary.md | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/evidence/g01-live-canary.md b/docs/evidence/g01-live-canary.md index c0662bc1..7a54ad96 100644 --- a/docs/evidence/g01-live-canary.md +++ b/docs/evidence/g01-live-canary.md @@ -958,3 +958,36 @@ These checks use synthetic HTTP/subprocess fixtures only. They do not prove live - The selected path remains Go `1.27.3`, `github.com/actions/scaleset v0.5.0`, the high-level listener and independent reconciliation. The current upstream latest release remains [v0.5.0](https://github.com/actions/scaleset/releases/tag/v0.5.0). - Independent review rejected removing `github.ref_protected`: pinning the reusable callee does not bind or authorize the mutable caller. The guard remains. Private branch protection is unavailable under the current repository plan; no workflow or repository policy was changed. See the [caller-protection review record](https://github.com/1XP-AI/gh-runnerd/issues/1#issuecomment-6616630311). - The live canary remains unrun. Issue #1 remains OPEN and Project #2 remains In progress. Outstanding gates include a supported caller-protection path, production provenance and trust-root authority, broker-only execution, conditional Scale Set cleanup, trusted runtime/isolation/resource qualification, and separate concrete authorization plus evidence for the Linux ARM64/JIT canary. No live provider, credential, runner, Scale Set, Docker or host operation was performed for this update. + +### Exact-main offline and release recheck — 2026-11-13 + +Read-only GitHub and local repository checks confirmed both `main` refs at +`2a71461447bfea7acd61b5c82a38c9f380fb19b9`. The only change since source CI +commit `853d176766db50c55bbf84575c429ed4924db835` is documentation; the G01 +experiment source is unchanged. The pinned path remains Go `1.27.3` and +`github.com/actions/scaleset v0.5.0`, using the high-level listener with +independent reconciliation. The official releases endpoint still reports +`v0.5.0` as latest (published 2026-10-23). + +Offline verification on the exact current checkout used the local Go `1.27.3` +toolchain, disabled module downloads, read-only module manifests, and external +volume `GOCACHE`, `GOMODCACHE` and `GOTMPDIR`. The tagged fixture checks also +set `TMPDIR` to the external volume. + +| Check | Result | +|---|---| +| G01 module: `go test -race -p=1 -count=1 -timeout=300s ./...` | PASS: root, `livecanary` and `liveworker` packages | +| G01 module: `go vet -p=1 ./...` | PASS | +| G01 tagged gate: `go test -race -p=1 -count=1 -timeout=180s -tags='g01_live g01_pair_fixture' -run '^TestTaggedControllerExecutionRequiresBrokerProvenanceBeforeInput$' ./cmd/g01-live` | PASS: refused before controller input was read | +| G02 module: `go test -race -p=1 -count=1 -timeout=180s -tags=g01_pair_fixture ./cmd/g01-broker` | PASS: loopback broker fixture | +| Public CI run [41720512574](https://github.com/1XP-AI/gh-runnerd/actions/runs/41720512574) | PASS on unchanged source SHA `853d176766db50c55bbf84575c429ed4924db835` | + +This recheck found no additional failure in the exercised offline boundaries. +It does not establish atomic workflow handoff, production provenance or +trust-root custody, broker-only authority, server-side conditional deletion, +trusted runtime/isolation/resource qualification, or live canary behavior. +The v0.5.0 adapter still lacks conditional Scale Set deletion, so cleanup +remains quarantined. Issue #1 remains OPEN and Project #2 remains In progress +until the external acceptance gates and separately authorized Linux ARM64/JIT +canary are satisfied. No live GitHub write, provider request, credential, +runner, Scale Set, Docker or host operation was performed for this recheck. From c3dadbbb52debc61b2307fb89dc8dece41e6432f Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 13 Nov 2026 17:51:29 +0900 Subject: [PATCH 2/4] test(g01): strengthen broker process fixture oracles --- docs/evidence/g01-live-canary.md | 37 ++ .../acquire_loss_command_fixture_test.go | 4 +- .../controller_process_broker_fixture_test.go | 396 ++++++++++++++++-- .../controller_v3_process_fixture_test.go | 6 +- .../livecanary/phase_command_fixture_test.go | 9 +- .../broker_process_handoff_fixture.go | 8 +- 6 files changed, 415 insertions(+), 45 deletions(-) diff --git a/docs/evidence/g01-live-canary.md b/docs/evidence/g01-live-canary.md index 7a54ad96..edfd2300 100644 --- a/docs/evidence/g01-live-canary.md +++ b/docs/evidence/g01-live-canary.md @@ -991,3 +991,40 @@ remains quarantined. Issue #1 remains OPEN and Project #2 remains In progress until the external acceptance gates and separately authorized Linux ARM64/JIT canary are satisfied. No live GitHub write, provider request, credential, runner, Scale Set, Docker or host operation was performed for this recheck. + +### Process-fixture oracle strengthening — 2026-11-13 + +Starting from local branch head `6341ad223da4f8d240445a4a891f123e2fea0153`, +the tagged separate-process fixtures had four oracle gaps: they accepted any +nonempty App JWT signature, recorded only request method and path, rewrote +workflow identity from mutable approval state on each GET, and checked attempt +and account-admission journals only after the process had finished. Four new +regressions reproduced these gaps before the fixture changes: a forged RS256 +signature was accepted, query parameters were omitted, changing run identity +made a second GET route unexpectedly, and a token POST was counted with no +account-ledger intent. + +The offline fixture now verifies RS256 using the public key paired with the +synthetic broker key; snapshots run ID, SHA and reusable-workflow pin on first +observation; and checks the exact attempt and journal inode identities plus +the matching read/management lifecycle-start event inside the HTTP dispatch +before counting a token POST. Its safe request trace now captures full request +URI, selected non-secret headers, authorization class, and body length/SHA-256. +It does not retain authorization values or request bodies. The two expected +HTTP client header profiles are asserted separately. + +| Check | Result | +|---|---| +| Forged-signature, stable-workflow, query-capture and missing-ledger RED regressions | FAIL as expected before fixture fixes | +| Focused G01 process-broker and v3 child tests with `-race` | PASS | +| Current-source controller/broker TEST helper preparation | PASS; helper artifacts were placed on `/Volumes/worktree` | +| Actual command group-policy success, organization-only and both rejection cases with `-race` | PASS | +| Actual acquire-loss command qualification with `-race` | PASS | +| G02 broker command: `go test -race -p=1 -count=1 -timeout=180s -tags=g01_pair_fixture ./cmd/g01-broker` | PASS | +| G01 tagged fixture: `go vet -p=1 -tags='g01_live g01_pair_fixture' ./livecanary` | PASS | +| `git diff --check` | PASS | + +These remain synthetic offline proofs. They do not establish live GitHub +workflow stability, Scale Set cleanup, or runner execution. Issue #1 remains +OPEN and Project #2 remains In progress; no live provider, runner, credential, +Docker or host operation was performed. diff --git a/experiments/g01-scaleset/livecanary/acquire_loss_command_fixture_test.go b/experiments/g01-scaleset/livecanary/acquire_loss_command_fixture_test.go index eb4ac9a4..80761adc 100644 --- a/experiments/g01-scaleset/livecanary/acquire_loss_command_fixture_test.go +++ b/experiments/g01-scaleset/livecanary/acquire_loss_command_fixture_test.go @@ -466,7 +466,7 @@ func newCommandQualificationFixture(t *testing.T, controller, broker, variant, p } verificationAuthorization := "Bearer " + f.base.api.credentials.VerificationToken f.transport = &processBrokerHTTP{ - t: t, attempt: f.attempt, approval: &f.base.a, + t: t, attempt: f.attempt, admissionDirectory: f.admission, approval: &f.base.a, expectedWorkflowAuthorizations: []string{"Bearer synthetic-observation-credential", verificationAuthorization, verificationAuthorization}, } server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -494,7 +494,7 @@ func newCommandQualificationFixture(t *testing.T, controller, broker, variant, p } f.workflowSeed, _ = hex.DecodeString("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") f.issuerSeed = bytes.Repeat([]byte{71}, ed25519.SeedSize) - f.management = experimentalManagement(t, f.base.api.credentials.VerificationToken) + f.management, f.transport.appPublicKey = experimentalManagement(t, f.base.api.credentials.VerificationToken) t.Cleanup(func() { clear(f.management); clear(f.workflowSeed); clear(f.issuerSeed) }) return f } diff --git a/experiments/g01-scaleset/livecanary/controller_process_broker_fixture_test.go b/experiments/g01-scaleset/livecanary/controller_process_broker_fixture_test.go index c047e1fa..96026e81 100644 --- a/experiments/g01-scaleset/livecanary/controller_process_broker_fixture_test.go +++ b/experiments/g01-scaleset/livecanary/controller_process_broker_fixture_test.go @@ -5,7 +5,12 @@ package livecanary import ( "bytes" "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "io" @@ -15,6 +20,8 @@ import ( "reflect" "strconv" "strings" + "sync" + "syscall" "testing" "time" @@ -26,7 +33,10 @@ type processBrokerHTTP struct { approval *Approval t *testing.T attempt string + admissionDirectory string + appPublicKey *rsa.PublicKey calls []string + traces []processBrokerRequestTrace expectedWorkflowAuthorizations []string workflowAuthorizationsChecked int mints int @@ -37,6 +47,50 @@ type processBrokerHTTP struct { managementRevocations int failPreflight bool groupFault bool + workflowSnapshotOnce sync.Once + workflowSnapshot processWorkflowRunSnapshot +} + +type processBrokerRequestTrace struct { + Method string + RequestURI string + AuthorizationClass string + Accept string + APIVersion string + ContentType string + AcceptEncoding string + UserAgent string + BodySHA256 string + BodyLength int +} + +type processWorkflowRunSnapshot struct { + RunID int64 + SHA string + Branch string + Event string + Status string + Path string + Attempt int + ReferencedWorkflow string +} + +func (f *processBrokerHTTP) workflow() processWorkflowRunSnapshot { + if f == nil { + return processWorkflowRunSnapshot{RunID: 7, SHA: strings.Repeat("b", 40), Branch: "main", Event: "workflow_dispatch", Status: "in_progress", Path: ".github/workflows/canary.yml", Attempt: 1} + } + f.workflowSnapshotOnce.Do(func() { + snapshot := processWorkflowRunSnapshot{RunID: 7, SHA: strings.Repeat("b", 40), Branch: "main", Event: "workflow_dispatch", Status: "in_progress", Path: ".github/workflows/canary.yml", Attempt: 1} + if f.approval != nil { + snapshot.RunID = f.approval.WorkflowRunID + snapshot.SHA = f.approval.WorkflowSHA + if f.approval.GroupPolicy != (handoff.GroupPolicy{}) { + snapshot.ReferencedWorkflow = f.approval.GroupPolicy.Workflow + } + } + f.workflowSnapshot = snapshot + }) + return f.workflowSnapshot } func TestProcessBrokerHTTPRejectsUnrecognizedAuthorization(t *testing.T) { @@ -85,6 +139,27 @@ func TestProcessBrokerHTTPRejectsUnrecognizedAuthorization(t *testing.T) { } } +func TestProcessAppJWTRejectsForgedSignature(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + now := time.Now() + claims, err := json.Marshal(struct { + Issuer string `json:"iss"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` + }{"11", now.Add(-time.Minute).Unix(), now.Add(9 * time.Minute).Unix()}) + if err != nil { + t.Fatal(err) + } + forgedSignature := bytes.Repeat([]byte{0x01}, key.Size()) + forged := "Bearer " + base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + base64.RawURLEncoding.EncodeToString(claims) + "." + base64.RawURLEncoding.EncodeToString(forgedSignature) + if processAppJWTMatches(forged, 11, now, &key.PublicKey) { + t.Fatal("App JWT with a forged nonempty signature was accepted") + } +} + func (f *processBrokerHTTP) validateAuthorization(r *http.Request) error { if f.approval == nil { return nil @@ -98,7 +173,7 @@ func (f *processBrokerHTTP) validateAuthorization(r *http.Request) error { f.workflowAuthorizationsChecked++ return nil case r.URL.Path == "/app" || r.URL.Path == "/orgs/fixture-org/installation" || r.URL.Path == "/app/installations/12/access_tokens": - if !processAppJWTMatches(authorization, f.approval.AppID, time.Now()) { + if !processAppJWTMatches(authorization, f.approval.AppID, time.Now(), f.appPublicKey) { return fmt.Errorf("unexpected synthetic App authority") } return nil @@ -149,11 +224,11 @@ func expectedProcessBrokerV3Trace(approval Approval, readGroupReject bool) []str trace := []string{workflowGet, "GET /app", "GET /orgs/fixture-org/installation", "POST /app/installations/12/access_tokens"} appendPreflight := func(rejectGroup bool) { if approval.ManagementScope != "organization-only" { - trace = append(trace, "GET /installation/repositories", "GET /repos/fixture-org/canary") + trace = append(trace, "GET /installation/repositories?per_page=100", "GET /repos/fixture-org/canary") } trace = append(trace, "GET /orgs/fixture-org/actions/runner-groups/3") if !rejectGroup { - trace = append(trace, "GET /orgs/fixture-org/actions/runner-groups/3/repositories") + trace = append(trace, "GET /orgs/fixture-org/actions/runner-groups/3/repositories?per_page=100") } } appendPreflight(readGroupReject) @@ -169,8 +244,44 @@ func expectedProcessBrokerV3Trace(approval Approval, readGroupReject bool) []str func assertProcessBrokerTrace(t *testing.T, transport *processBrokerHTTP, want []string) { t.Helper() - if !reflect.DeepEqual(transport.calls, want) { - t.Fatalf("broker request order differs: got=%v want=%v", transport.calls, want) + wantCalls := make([]string, 0, len(want)) + for _, expected := range want { + method, requestURI, found := strings.Cut(expected, " ") + path, _, _ := strings.Cut(requestURI, "?") + if !found { + t.Fatalf("invalid expected broker request %q", expected) + } + wantCalls = append(wantCalls, method+" "+path) + } + if !reflect.DeepEqual(transport.calls, wantCalls) { + t.Fatalf("broker request order differs: got=%v want=%v", transport.calls, wantCalls) + } + if len(transport.traces) != len(want) { + t.Fatalf("broker request trace count=%d, want %d", len(transport.traces), len(want)) + } + for index, expected := range want { + method, requestURI, found := strings.Cut(expected, " ") + trace := transport.traces[index] + if !found || trace.Method != method || trace.RequestURI != requestURI { + t.Fatalf("broker request target differs at %d: trace=%+v want=%q", index, trace, expected) + } + requestPath, _, _ := strings.Cut(trace.RequestURI, "?") + g02IdentityRequest := requestPath == "/app" || requestPath == "/orgs/fixture-org/installation" + g01Headers := trace.Accept == "application/vnd.github+json" && trace.APIVersion == "2022-11-28" && trace.ContentType == "application/json" && trace.AcceptEncoding == "identity" && trace.UserAgent == "gh-runnerd-g01-broker-evidence" + g02Headers := trace.Accept == "application/vnd.github+json" && trace.APIVersion == "2022-11-28" && trace.ContentType == "" && trace.AcceptEncoding == "gzip" && trace.UserAgent == "gh-runnerd-g02-evidence" + if (g02IdentityRequest && !g02Headers) || (!g02IdentityRequest && !g01Headers) { + t.Fatalf("broker request headers differ at %d: %+v", index, trace) + } + if trace.AuthorizationClass == "" || trace.BodySHA256 == "" { + t.Fatalf("broker request omitted safe authority/body evidence at %d: %+v", index, trace) + } + if method == http.MethodPost { + if !strings.Contains(requestURI, "/access_tokens") || trace.BodyLength == 0 { + t.Fatalf("unexpected POST trace at %d: %+v", index, trace) + } + } else if trace.BodyLength != 0 { + t.Fatalf("unexpected body on %s request at %d: %+v", method, index, trace) + } } workflowGets := 0 for _, call := range want { @@ -183,6 +294,141 @@ func assertProcessBrokerTrace(t *testing.T, transport *processBrokerHTTP, want [ } } +type processBrokerInode struct { + Device uint64 `json:"device"` + Inode uint64 `json:"inode"` +} + +func processBrokerFileInode(info os.FileInfo) (processBrokerInode, bool) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return processBrokerInode{}, false + } + return processBrokerInode{Device: uint64(stat.Dev), Inode: stat.Ino}, true +} + +func (f *processBrokerHTTP) requireMintIntent(body []byte) error { + var request struct { + RepositoryIDs []int64 `json:"repository_ids"` + Permissions map[string]string `json:"permissions"` + } + if json.Unmarshal(body, &request) != nil { + return fmt.Errorf("malformed token request") + } + permission := request.Permissions["organization_self_hosted_runners"] + if permission != "read" && permission != "write" { + return fmt.Errorf("invalid token permission") + } + organizationOnly := f.approval != nil && f.approval.ManagementScope == "organization-only" + wantPermissions := map[string]string{"organization_self_hosted_runners": permission, "metadata": "read"} + wantRepositories := []int64{42} + if organizationOnly { + wantPermissions = map[string]string{"organization_self_hosted_runners": permission} + wantRepositories = nil + } + if !reflect.DeepEqual(request.Permissions, wantPermissions) || !reflect.DeepEqual(request.RepositoryIDs, wantRepositories) { + return fmt.Errorf("token request scope mismatch") + } + + attemptInfo, err := os.Lstat(f.attempt) + if err != nil || !attemptInfo.IsDir() || attemptInfo.Mode().Perm() != 0700 { + return fmt.Errorf("attempt identity unavailable") + } + journalInfo, err := os.Lstat(filepath.Join(f.attempt, "broker.jsonl")) + if err != nil || !journalInfo.Mode().IsRegular() || journalInfo.Mode().Perm() != 0600 { + return fmt.Errorf("attempt journal unavailable") + } + wantAttempt, ok := processBrokerFileInode(attemptInfo) + if !ok { + return fmt.Errorf("attempt identity unsupported") + } + wantJournal, ok := processBrokerFileInode(journalInfo) + if !ok { + return fmt.Errorf("journal identity unsupported") + } + attemptRaw, err := os.ReadFile(filepath.Join(f.attempt, "broker.jsonl")) + if err != nil { + return fmt.Errorf("attempt intent unavailable") + } + wantAttemptPhase := "token_request_started" + wantAdmissionKind := "read_token_lifecycle_started" + if permission == "write" { + wantAttemptPhase = "management_token_request_started" + wantAdmissionKind = "management_token_lifecycle_started" + } + attemptIntent := false + for _, line := range bytes.Split(attemptRaw, []byte("\n")) { + var event struct { + Phase string `json:"phase"` + } + if len(line) != 0 && json.Unmarshal(line, &event) == nil && event.Phase == wantAttemptPhase { + attemptIntent = true + } + } + if !attemptIntent { + return fmt.Errorf("attempt intent missing before mint") + } + + admissionDirectory := f.admissionDirectory + if admissionDirectory == "" { + admissionDirectory = filepath.Join(filepath.Dir(f.attempt), "admission") + } + ledger, err := os.ReadFile(filepath.Join(admissionDirectory, "broker-admission.jsonl")) + if err != nil { + return fmt.Errorf("account admission intent unavailable") + } + var latestStarted processTokenLifecycleRecord + for _, line := range bytes.Split(ledger, []byte("\n")) { + var record processTokenLifecycleRecord + if len(line) == 0 { + continue + } + if json.Unmarshal(line, &record) != nil { + return fmt.Errorf("account admission record malformed") + } + if record.Kind == "read_token_lifecycle_started" || record.Kind == "management_token_lifecycle_started" { + latestStarted = record + } + } + var recordedAttempt, recordedJournal processBrokerInode + if latestStarted.Kind != wantAdmissionKind || latestStarted.Slot == "" || + json.Unmarshal(latestStarted.Attempt, &recordedAttempt) != nil || json.Unmarshal(latestStarted.Journal, &recordedJournal) != nil || + recordedAttempt != wantAttempt || recordedJournal != wantJournal { + return fmt.Errorf("matching account admission intent missing before mint") + } + return nil +} + +func processAuthorizationClass(f *processBrokerHTTP, r *http.Request) string { + authorization := r.Header.Get("Authorization") + if r.URL.Path == "/app" || r.URL.Path == "/orgs/fixture-org/installation" || r.URL.Path == "/app/installations/12/access_tokens" { + if f.approval != nil && processAppJWTMatches(authorization, f.approval.AppID, time.Now(), f.appPublicKey) { + return "app-jwt" + } + return "invalid-app-jwt" + } + if r.URL.Path == workflowRunFixturePath(f) { + return "workflow-authority" + } + switch authorization { + case "Bearer synthetic-process-read-installation-token": + return "installation-read" + case "Bearer synthetic-process-installation-token": + return "installation-management" + case "": + return "none" + default: + if f.approval != nil { + for _, expected := range f.expectedWorkflowAuthorizations { + if authorization == expected { + return "workflow-credential" + } + } + } + return "other" + } +} + type processTokenLifecycleRecord struct { Phase string `json:"phase"` Kind string `json:"kind"` @@ -289,11 +535,10 @@ func assertProcessBrokerTokenLifecycle(t *testing.T, attemptDirectory, admission } } -// The adapter's focused unit test verifies the RS256 signature against its -// synthetic key. Process fixtures additionally bind provider requests to the -// intended App identity and the broker's bounded ten-minute JWT lifetime. -func processAppJWTMatches(authorization string, appID int64, now time.Time) bool { - if appID < 1 || !strings.HasPrefix(authorization, "Bearer ") { +// Process fixtures verify the RS256 signature against the public half of the +// exact synthetic key used by the broker, along with App identity and lifetime. +func processAppJWTMatches(authorization string, appID int64, now time.Time, publicKey *rsa.PublicKey) bool { + if appID < 1 || publicKey == nil || !strings.HasPrefix(authorization, "Bearer ") { return false } parts := strings.Split(strings.TrimPrefix(authorization, "Bearer "), ".") @@ -319,15 +564,39 @@ func processAppJWTMatches(authorization string, appID int64, now time.Time) bool return false } nowUnix := now.Unix() - return header.Algorithm == "RS256" && header.Type == "JWT" && claims.Issuer == strconv.FormatInt(appID, 10) && - claims.IssuedAt <= nowUnix && claims.IssuedAt >= now.Add(-2*time.Minute).Unix() && - claims.ExpiresAt > nowUnix && claims.ExpiresAt-nowUnix <= 10*60 && claims.ExpiresAt-claims.IssuedAt == 10*60 + if header.Algorithm != "RS256" || header.Type != "JWT" || claims.Issuer != strconv.FormatInt(appID, 10) || + !(claims.IssuedAt <= nowUnix && claims.IssuedAt >= now.Add(-2*time.Minute).Unix() && + claims.ExpiresAt > nowUnix && claims.ExpiresAt-nowUnix <= 10*60 && claims.ExpiresAt-claims.IssuedAt == 10*60) { + return false + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + return rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], signature) == nil } func (f *processBrokerHTTP) RoundTrip(r *http.Request) (*http.Response, error) { if err := f.validateAuthorization(r); err != nil { return nil, err } + var body []byte + if r.Body != nil { + var err error + body, err = io.ReadAll(io.LimitReader(r.Body, 64<<10+1)) + if err != nil || len(body) > 64<<10 { + return nil, fmt.Errorf("request body unreadable") + } + } + if r.Method == http.MethodPost && r.URL.Path == "/app/installations/12/access_tokens" { + if err := f.requireMintIntent(body); err != nil { + return nil, err + } + } + digest := sha256.Sum256(body) + f.traces = append(f.traces, processBrokerRequestTrace{ + Method: r.Method, RequestURI: r.URL.RequestURI(), AuthorizationClass: processAuthorizationClass(f, r), + Accept: r.Header.Get("Accept"), APIVersion: r.Header.Get("X-GitHub-Api-Version"), ContentType: r.Header.Get("Content-Type"), + AcceptEncoding: r.Header.Get("Accept-Encoding"), UserAgent: r.Header.Get("User-Agent"), + BodySHA256: hex.EncodeToString(digest[:]), BodyLength: len(body), + }) f.calls = append(f.calls, r.Method+" "+r.URL.Path) repo := map[string]any{"id": 42, "full_name": "fixture-org/canary", "private": true, "fork": false, "owner": map[string]any{"id": 101, "login": "fixture-org"}} var payload any @@ -342,14 +611,10 @@ func (f *processBrokerHTTP) RoundTrip(r *http.Request) (*http.Response, error) { if e != nil || len(stored) == 0 { f.t.Error("observation preceded durable attempt") } - runID, sha := int64(7), strings.Repeat("b", 40) - if f.approval != nil { - runID = f.approval.WorkflowRunID - sha = f.approval.WorkflowSHA - } - run := map[string]any{"id": runID, "head_sha": sha, "head_branch": "main", "event": "workflow_dispatch", "status": "in_progress", "path": ".github/workflows/canary.yml", "run_attempt": 1, "repository": repo, "head_repository": repo} - if f.approval != nil && f.approval.GroupPolicy != (handoff.GroupPolicy{}) { - workflow := f.approval.GroupPolicy.Workflow + snapshot := f.workflow() + run := map[string]any{"id": snapshot.RunID, "head_sha": snapshot.SHA, "head_branch": snapshot.Branch, "event": snapshot.Event, "status": snapshot.Status, "path": snapshot.Path, "run_attempt": snapshot.Attempt, "repository": repo, "head_repository": repo} + if snapshot.ReferencedWorkflow != "" { + workflow := snapshot.ReferencedWorkflow at := strings.LastIndexByte(workflow, '@') if at <= 0 || at == len(workflow)-1 { f.t.Error("fixture reusable workflow pin is malformed") @@ -360,11 +625,6 @@ func (f *processBrokerHTTP) RoundTrip(r *http.Request) (*http.Response, error) { payload = run case "/app/installations/12/access_tokens": f.mints++ - stored, _ := os.ReadFile(filepath.Join(f.attempt, "broker.jsonl")) - if !bytes.Contains(stored, []byte("token_request_started")) { - f.t.Error("mint preceded durable intent") - } - body, _ := io.ReadAll(r.Body) var request struct { RepositoryIDs []int64 `json:"repository_ids"` Permissions map[string]string `json:"permissions"` @@ -422,8 +682,8 @@ func (f *processBrokerHTTP) RoundTrip(r *http.Request) (*http.Response, error) { payload = repo case "/orgs/fixture-org/actions/runner-groups/3": payload = map[string]any{"id": 3, "name": "fixture-group", "visibility": "selected", "default": false, "inherited": false, "allows_public_repositories": f.failPreflight} - if f.approval != nil && f.approval.GroupPolicy.Version == 2 { - expected := f.approval.GroupPolicy.Workflow + if snapshot := f.workflow(); snapshot.ReferencedWorkflow != "" { + expected := snapshot.ReferencedWorkflow if f.groupFault { expected = strings.Replace(expected, "dddd", "eeee", 1) } @@ -448,7 +708,7 @@ func TestProcessBrokerWorkflowRunFixtureProvidesInProgressStatus(t *testing.T) { t.Fatal(err) } transport := &processBrokerHTTP{t: t, attempt: attempt} - request, err := http.NewRequest(http.MethodGet, "https://api.github.com"+workflowRunFixturePath(transport), nil) + request, err := http.NewRequest(http.MethodGet, "https://api.github.com"+workflowRunFixturePath(transport)+"?per_page=100", nil) if err != nil { t.Fatal(err) } @@ -466,6 +726,72 @@ func TestProcessBrokerWorkflowRunFixtureProvidesInProgressStatus(t *testing.T) { if workflow.Status != "in_progress" { t.Fatalf("workflow fixture status = %q, want in_progress", workflow.Status) } + if len(transport.traces) != 1 || transport.traces[0].RequestURI != "/repos/fixture-org/canary/actions/runs/7?per_page=100" { + t.Fatalf("request trace omitted the query: %+v", transport.traces) + } +} + +func TestProcessBrokerWorkflowRunFixtureKeepsProviderIdentityStable(t *testing.T) { + attempt := t.TempDir() + if err := os.WriteFile(filepath.Join(attempt, "broker.jsonl"), []byte("attempt\n"), 0600); err != nil { + t.Fatal(err) + } + approval := &Approval{AppID: 11, WorkflowRunID: 7, WorkflowSHA: strings.Repeat("b", 40), Organization: "fixture-org", Repository: "canary", GroupPolicy: handoff.GroupPolicy{Version: 2, Workflow: "fixture-org/canary/.github/workflows/runner.yml@" + strings.Repeat("d", 40)}} + transport := &processBrokerHTTP{t: t, attempt: attempt, approval: approval, expectedWorkflowAuthorizations: []string{"Bearer synthetic-observation-token", "Bearer synthetic-observation-token"}} + path := workflowRunFixturePath(transport) + observe := func() (map[string]any, error) { + request, err := http.NewRequest(http.MethodGet, "https://api.github.com"+path, nil) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", "Bearer synthetic-observation-token") + response, err := transport.RoundTrip(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + var run map[string]any + err = json.NewDecoder(response.Body).Decode(&run) + return run, err + } + first, err := observe() + if err != nil { + t.Fatal(err) + } + approval.WorkflowRunID = 8 + approval.WorkflowSHA = strings.Repeat("c", 40) + approval.GroupPolicy.Workflow = "fixture-org/canary/.github/workflows/runner.yml@" + strings.Repeat("e", 40) + second, err := observe() + if err != nil { + t.Fatal(err) + } + if first["id"] != float64(7) || first["head_sha"] != strings.Repeat("b", 40) || first["referenced_workflows"] == nil || !reflect.DeepEqual(first, second) { + t.Fatalf("provider identity changed within one fixture: first=%v second=%v", first, second) + } +} + +func TestProcessBrokerHTTPRequiresAdmissionIntentBeforeMint(t *testing.T) { + parent := t.TempDir() + attempt := filepath.Join(parent, "attempt") + if err := os.Mkdir(attempt, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(attempt, "broker.jsonl"), []byte(`{"phase":"token_request_started"}`+"\n"), 0600); err != nil { + t.Fatal(err) + } + transport := &processBrokerHTTP{t: t, attempt: attempt} + body := `{"repository_ids":[42],"permissions":{"metadata":"read","organization_self_hosted_runners":"read"}}` + request, err := http.NewRequest(http.MethodPost, "https://api.github.com/app/installations/12/access_tokens", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + response, err := transport.RoundTrip(request) + if response != nil { + response.Body.Close() + } + if err == nil || transport.mints != 0 || len(transport.calls) != 0 { + t.Fatalf("token mint crossed dispatch without account-ledger intent: err=%v mints=%d calls=%v", err, transport.mints, transport.calls) + } } func TestControllerProcessHandoffActualBrokerSeparateProcess(t *testing.T) { @@ -513,10 +839,11 @@ func TestControllerProcessHandoffActualBrokerSeparateProcess(t *testing.T) { t.Fatal(e) } grant := enrollment.WorkflowGrantForFixture{Request: request, OrganizationID: 101, RepositoryID: a.RepositoryID, ExpiresAt: a.ExpiresAt} - transport := &processBrokerHTTP{t: t, attempt: files.StateDirectory, approval: &a, expectedWorkflowAuthorizations: []string{"Bearer synthetic-observation-token"}, failPreflight: preflightFail} + transport := &processBrokerHTTP{t: t, attempt: files.StateDirectory, admissionDirectory: filepath.Join(parent, "admission"), approval: &a, expectedWorkflowAuthorizations: []string{"Bearer synthetic-observation-token"}, failPreflight: preflightFail} ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - result, effects, e := enrollment.RunProcessHandoffForFixture(ctx, files, transport, grant) + options := enrollment.ProcessHandoffFixtureOptions{ObserveAppJWTKey: func(key *rsa.PublicKey) { transport.appPublicKey = key }} + result, effects, e := enrollment.RunProcessHandoffForFixtureWithOptions(ctx, files, transport, grant, options) wantSignatures := 1 if preflightFail { wantSignatures = 0 @@ -539,7 +866,7 @@ func TestControllerProcessHandoffActualBrokerSeparateProcess(t *testing.T) { } // A second actual entry must not mint or sign again, even after failure. mints := transport.mints - _, again, e := enrollment.RunProcessHandoffForFixture(ctx, files, transport, grant) + _, again, e := enrollment.RunProcessHandoffForFixtureWithOptions(ctx, files, transport, grant, options) if e == nil || transport.mints != mints || again.BindingSignatures != 0 { t.Fatal("permanent broker attempt was retried") } @@ -554,8 +881,5 @@ func TestControllerProcessHandoffActualBrokerSeparateProcess(t *testing.T) { } func workflowRunFixturePath(f *processBrokerHTTP) string { - if f.approval != nil { - return fmt.Sprintf("/repos/fixture-org/canary/actions/runs/%d", f.approval.WorkflowRunID) - } - return "/repos/fixture-org/canary/actions/runs/7" + return fmt.Sprintf("/repos/fixture-org/canary/actions/runs/%d", f.workflow().RunID) } diff --git a/experiments/g01-scaleset/livecanary/controller_v3_process_fixture_test.go b/experiments/g01-scaleset/livecanary/controller_v3_process_fixture_test.go index 014bead7..85c1e931 100644 --- a/experiments/g01-scaleset/livecanary/controller_v3_process_fixture_test.go +++ b/experiments/g01-scaleset/livecanary/controller_v3_process_fixture_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "crypto/ed25519" + "crypto/rsa" "encoding/json" "io" "net/http" @@ -171,7 +172,7 @@ func newControllerV3ProcessFixture(t *testing.T) controllerV3ProcessFixture { t.Fatal(err) } base := &processBrokerHTTP{ - t: t, attempt: attempt, approval: &approval, + t: t, attempt: attempt, admissionDirectory: filepath.Join(parent, "admission"), approval: &approval, expectedWorkflowAuthorizations: []string{ "Bearer synthetic-observation-token", "Bearer synthetic-process-verification-token", @@ -252,6 +253,9 @@ func TestControllerProcessHandoffV3ActualBrokerChild(t *testing.T) { ObserveHandoff: func(receipt, envelope []byte) { observedReceipt, observedBinding = receipt, envelope }, + ObserveAppJWTKey: func(key *rsa.PublicKey) { + fixture.transport.base.appPublicKey = key + }, } switch tc.name { case "changed-callee": diff --git a/experiments/g01-scaleset/livecanary/phase_command_fixture_test.go b/experiments/g01-scaleset/livecanary/phase_command_fixture_test.go index 146641ed..5969891f 100644 --- a/experiments/g01-scaleset/livecanary/phase_command_fixture_test.go +++ b/experiments/g01-scaleset/livecanary/phase_command_fixture_test.go @@ -56,7 +56,7 @@ func ownedExperimentalPipe(t *testing.T, data []byte) *os.File { t.Cleanup(func() { r.Close(); w.Close(); <-done }) return r } -func experimentalManagement(t *testing.T, verification string) []byte { +func experimentalManagement(t *testing.T, verification string) ([]byte, *rsa.PublicKey) { t.Helper() key, e := rsa.GenerateKey(rand.Reader, 2048) if e != nil { @@ -65,7 +65,7 @@ func experimentalManagement(t *testing.T, verification string) []byte { private := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) defer clear(private) raw, _ := json.Marshal(map[string]string{"pem": string(private), "verification_token": verification}) - return raw + return raw, &key.PublicKey } func writePhaseFixtureConfig(t *testing.T, state, admission, policy, harness string, api *SDKAPI, ca []byte) { t.Helper() @@ -313,7 +313,7 @@ func TestExperimentalActualCommandBrokerReceiver(t *testing.T) { brokerFiles := enrollment.BrokerFiles{ApprovalPath: approvalPath, StateDirectory: filepath.Join(parent, "attempt-"+mode), ControllerBinary: binPath, ControllerApproval: files.ControllerApprovalPath, ControllerStateDirectory: files.ControllerStateDirectory, WorkerApproval: files.WorkerApprovalPath, WorkerStateDirectory: files.WorkerStateDirectory} verificationAuthorization := "Bearer " + base.api.credentials.VerificationToken transport := &processBrokerHTTP{ - t: t, attempt: brokerFiles.StateDirectory, approval: &a, + t: t, attempt: brokerFiles.StateDirectory, admissionDirectory: admission, approval: &a, expectedWorkflowAuthorizations: []string{"Bearer synthetic-observation-credential", verificationAuthorization, verificationAuthorization}, groupFault: caseName == "group-policy-broker-wrong", } @@ -329,7 +329,8 @@ func TestExperimentalActualCommandBrokerReceiver(t *testing.T) { if caseName == "issuer-seed" { issuerSeed = bytes.Repeat([]byte{9}, 32) } - management := experimentalManagement(t, base.api.credentials.VerificationToken) + management, appPublicKey := experimentalManagement(t, base.api.credentials.VerificationToken) + transport.appPublicKey = appPublicKey defer clear(management) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() diff --git a/experiments/g02-auth/broker_process_handoff_fixture.go b/experiments/g02-auth/broker_process_handoff_fixture.go index f854cc72..a21e90fa 100644 --- a/experiments/g02-auth/broker_process_handoff_fixture.go +++ b/experiments/g02-auth/broker_process_handoff_fixture.go @@ -27,8 +27,9 @@ type ProcessHandoffEffectsForFixture struct { // ProcessHandoffFixtureOptions changes only the tagged offline fixture's // handoff observation. It cannot configure an ordinary broker build. type ProcessHandoffFixtureOptions struct { - ObserveHandoff func(receipt, envelope []byte) - MutateReceipt func([]byte) []byte + ObserveHandoff func(receipt, envelope []byte) + ObserveAppJWTKey func(*rsa.PublicKey) + MutateReceipt func([]byte) []byte } type processFixtureSigner struct { key ed25519.PrivateKey @@ -101,6 +102,9 @@ func RunProcessHandoffForFixtureWithOptions(ctx context.Context, files BrokerFil if e != nil { return BrokerResult{}, effects, errBroker } + if options.ObserveAppJWTKey != nil { + options.ObserveAppJWTKey(&key.PublicKey) + } private := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) defer clear(private) verificationToken := "" From a171e33d676b03b777513b56513a8e9d16de45cd Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 13 Nov 2026 18:05:50 +0900 Subject: [PATCH 3/4] test(g01): bind admission fixture to exact journal --- docs/evidence/g01-live-canary.md | 1 + .../controller_process_broker_fixture_test.go | 101 ++++++++++++++---- 2 files changed, 83 insertions(+), 19 deletions(-) diff --git a/docs/evidence/g01-live-canary.md b/docs/evidence/g01-live-canary.md index edfd2300..a9593042 100644 --- a/docs/evidence/g01-live-canary.md +++ b/docs/evidence/g01-live-canary.md @@ -1016,6 +1016,7 @@ HTTP client header profiles are asserted separately. | Check | Result | |---|---| | Forged-signature, stable-workflow, query-capture and missing-ledger RED regressions | FAIL as expected before fixture fixes | +| Admission-intent guard regression: missing record, wrong lifecycle, mismatched attempt inode and mismatched journal inode (`go test -race -p=1 -count=1 -tags=g01_pair_fixture -run '^TestProcessBrokerHTTPRequiresAdmissionIntentBeforeMint$' ./livecanary`) | PASS: all four refused before mint, request trace or transport dispatch | | Focused G01 process-broker and v3 child tests with `-race` | PASS | | Current-source controller/broker TEST helper preparation | PASS; helper artifacts were placed on `/Volumes/worktree` | | Actual command group-policy success, organization-only and both rejection cases with `-race` | PASS | diff --git a/experiments/g01-scaleset/livecanary/controller_process_broker_fixture_test.go b/experiments/g01-scaleset/livecanary/controller_process_broker_fixture_test.go index 96026e81..e31401ea 100644 --- a/experiments/g01-scaleset/livecanary/controller_process_broker_fixture_test.go +++ b/experiments/g01-scaleset/livecanary/controller_process_broker_fixture_test.go @@ -771,26 +771,89 @@ func TestProcessBrokerWorkflowRunFixtureKeepsProviderIdentityStable(t *testing.T } func TestProcessBrokerHTTPRequiresAdmissionIntentBeforeMint(t *testing.T) { - parent := t.TempDir() - attempt := filepath.Join(parent, "attempt") - if err := os.Mkdir(attempt, 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(attempt, "broker.jsonl"), []byte(`{"phase":"token_request_started"}`+"\n"), 0600); err != nil { - t.Fatal(err) - } - transport := &processBrokerHTTP{t: t, attempt: attempt} - body := `{"repository_ids":[42],"permissions":{"metadata":"read","organization_self_hosted_runners":"read"}}` - request, err := http.NewRequest(http.MethodPost, "https://api.github.com/app/installations/12/access_tokens", strings.NewReader(body)) - if err != nil { - t.Fatal(err) - } - response, err := transport.RoundTrip(request) - if response != nil { - response.Body.Close() + cases := []struct { + name string + omitLedgerRecord bool + kind string + wrongAttempt bool + wrongJournal bool + }{ + {name: "missing ledger record", kind: "read_token_lifecycle_started", omitLedgerRecord: true}, + {name: "wrong lifecycle", kind: "management_token_lifecycle_started"}, + {name: "different attempt inode", kind: "read_token_lifecycle_started", wrongAttempt: true}, + {name: "different journal inode", kind: "read_token_lifecycle_started", wrongJournal: true}, } - if err == nil || transport.mints != 0 || len(transport.calls) != 0 { - t.Fatalf("token mint crossed dispatch without account-ledger intent: err=%v mints=%d calls=%v", err, transport.mints, transport.calls) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + parent := t.TempDir() + attempt := filepath.Join(parent, "attempt") + if err := os.Mkdir(attempt, 0700); err != nil { + t.Fatal(err) + } + journal := filepath.Join(attempt, "broker.jsonl") + if err := os.WriteFile(journal, []byte(`{"phase":"token_request_started"}`+"\n"), 0600); err != nil { + t.Fatal(err) + } + admissionDirectory := filepath.Join(parent, "admission") + if err := os.Mkdir(admissionDirectory, 0700); err != nil { + t.Fatal(err) + } + + attemptInfo, err := os.Lstat(attempt) + if err != nil { + t.Fatal(err) + } + journalInfo, err := os.Lstat(journal) + if err != nil { + t.Fatal(err) + } + attemptIdentity, ok := processBrokerFileInode(attemptInfo) + if !ok { + t.Fatal("attempt identity unavailable") + } + journalIdentity, ok := processBrokerFileInode(journalInfo) + if !ok { + t.Fatal("journal identity unavailable") + } + if tc.wrongAttempt { + attemptIdentity.Inode++ + } + if tc.wrongJournal { + journalIdentity.Inode++ + } + if !tc.omitLedgerRecord { + record := processTokenLifecycleRecord{Kind: tc.kind, Slot: "fixture-read-slot"} + record.Attempt, err = json.Marshal(attemptIdentity) + if err != nil { + t.Fatal(err) + } + record.Journal, err = json.Marshal(journalIdentity) + if err != nil { + t.Fatal(err) + } + ledgerRecord, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(admissionDirectory, "broker-admission.jsonl"), append(ledgerRecord, '\n'), 0600); err != nil { + t.Fatal(err) + } + } + + transport := &processBrokerHTTP{t: t, attempt: attempt, admissionDirectory: admissionDirectory} + body := `{"repository_ids":[42],"permissions":{"metadata":"read","organization_self_hosted_runners":"read"}}` + request, err := http.NewRequest(http.MethodPost, "https://api.github.com/app/installations/12/access_tokens", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + response, err := transport.RoundTrip(request) + if response != nil { + response.Body.Close() + } + if err == nil || transport.mints != 0 || len(transport.calls) != 0 || len(transport.traces) != 0 { + t.Fatalf("token mint crossed dispatch without matching account-ledger identity: err=%v mints=%d calls=%v traces=%+v", err, transport.mints, transport.calls, transport.traces) + } + }) } } From 0dd54000ac3e8b83031b18b7cd15071bdc6892b6 Mon Sep 17 00:00:00 2001 From: JinWoo Lee Date: Fri, 13 Nov 2026 18:10:20 +0900 Subject: [PATCH 4/4] docs(g01): scope admission test evidence precisely --- docs/evidence/g01-live-canary.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/evidence/g01-live-canary.md b/docs/evidence/g01-live-canary.md index a9593042..26abde16 100644 --- a/docs/evidence/g01-live-canary.md +++ b/docs/evidence/g01-live-canary.md @@ -1016,7 +1016,7 @@ HTTP client header profiles are asserted separately. | Check | Result | |---|---| | Forged-signature, stable-workflow, query-capture and missing-ledger RED regressions | FAIL as expected before fixture fixes | -| Admission-intent guard regression: missing record, wrong lifecycle, mismatched attempt inode and mismatched journal inode (`go test -race -p=1 -count=1 -tags=g01_pair_fixture -run '^TestProcessBrokerHTTPRequiresAdmissionIntentBeforeMint$' ./livecanary`) | PASS: all four refused before mint, request trace or transport dispatch | +| Admission-intent guard regression: missing record, wrong lifecycle, mismatched attempt inode and mismatched journal inode (`go test -race -p=1 -count=1 -tags=g01_pair_fixture -run '^TestProcessBrokerHTTPRequiresAdmissionIntentBeforeMint$' ./livecanary`) | PASS: all four returned errors before fake route handling; mint, call and trace counts remained zero | | Focused G01 process-broker and v3 child tests with `-race` | PASS | | Current-source controller/broker TEST helper preparation | PASS; helper artifacts were placed on `/Volumes/worktree` | | Actual command group-policy success, organization-only and both rejection cases with `-race` | PASS |