// Package workflow_test contains formal-model-derived unit tests for
// specs/security-architecture-spec-summary.md (§ RS-05a, cross-referencing
// specs/security-architecture-spec.md §11.3, lines 960-967).
//
// Formal predicates encoded by this file (see issue body for full TLA+/F*/Z3
// notation):
//
// RS05a_CheckoutGate - all four sub-gates must hold before checkout
// RS05a_1_RepoScope - aw_context.repo must match current repo identity
// RS05a_2_ActorTrust - assertTrustedCheckoutRuntime(): no-fork + write-or-bot
// RS05a_3_ParseResilience - malformed aw_context JSON must not panic/propagate
// RS05a_4_RefIsolation - refs/pull/N/head fetch, array-based execution only
// RS05a_5_ItemNumberRequired - absent/falsy item_number always blocks checkout
//
// No production Go implementation of the RS-05a checkout gate currently
// exists in pkg/workflow/ (the runtime logic lives in shell/JS setup
// scripts). The stub interface below encodes the normative contract so the
// predicates are testable; replace with real implementation bindings once
// a Go-side gate exists.
package workflow_test
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stub — replace with real implementation
//
// awContext mirrors the fields of the workflow_dispatch aw_context input
// relevant to RS-05a (see actions/setup/js/aw_context.cjs for the real
// runtime shape).
type awContext struct {
ItemType string `json:"item_type"`
ItemNumber string `json:"item_number"`
Repo string `json:"repo,omitempty"`
Actor string `json:"actor,omitempty"`
}
// stub — replace with real implementation
type checkoutDecision struct {
Allowed bool
WarningEmitted bool
FetchRef string
FetchOrigin string
ArrayBased bool
}
// stub — replace with real implementation
//
// assertTrustedCheckoutRuntime encodes RS05a_2_ActorTrust: the runtime
// repository MUST NOT be a fork, and the actor MUST hold write-or-higher
// permission or be a verified bot/app actor.
func assertTrustedCheckoutRuntime(isFork bool, actorHasWriteOrHigher bool, isVerifiedBotOrApp bool) bool {
if isFork {
return false
}
return actorHasWriteOrHigher || isVerifiedBotOrApp
}
// stub — replace with real implementation
//
// parseAwContextSafely encodes RS05a_3_ParseResilience: malformed JSON must
// never panic and must be surfaced as a caught, non-fatal error.
func parseAwContextSafely(raw string) (ctx awContext, warning bool, err error) {
if jsonErr := json.Unmarshal([]byte(raw), &ctx); jsonErr != nil {
return awContext{}, true, jsonErr
}
return ctx, false, nil
}
// stub — replace with real implementation
//
// evaluateRS05aCheckoutGate encodes RS05a_CheckoutGate: the conjunction of
// all sub-gates that must hold before a workflow_dispatch PR checkout may
// proceed.
func evaluateRS05aCheckoutGate(ctx awContext, currentRepo string, isFork bool, actorHasWriteOrHigher bool, isVerifiedBotOrApp bool) checkoutDecision {
if ctx.ItemType != "pull_request" {
// Out of RS-05a's scope entirely; no gating applies here.
return checkoutDecision{Allowed: true}
}
// RS05a_5_ItemNumberRequired
if ctx.ItemNumber == "" || ctx.ItemNumber == "0" {
return checkoutDecision{Allowed: false}
}
// RS05a_1_RepoScope
if ctx.Repo != "" && ctx.Repo != currentRepo {
return checkoutDecision{Allowed: false, WarningEmitted: true}
}
// RS05a_2_ActorTrust
if !assertTrustedCheckoutRuntime(isFork, actorHasWriteOrHigher, isVerifiedBotOrApp) {
return checkoutDecision{Allowed: false}
}
// RS05a_4_RefIsolation
return checkoutDecision{
Allowed: true,
FetchRef: "refs/pull/" + ctx.ItemNumber + "/head",
FetchOrigin: currentRepo,
ArrayBased: true,
}
}
func TestFormalRS05a_CheckoutGateConjunctionAllPass(t *testing.T) {
ctx := awContext{ItemType: "pull_request", ItemNumber: "42", Repo: "github/gh-aw"}
decision := evaluateRS05aCheckoutGate(ctx, "github/gh-aw", false, true, false)
require.True(t, decision.Allowed, "RS05a_CheckoutGate: checkout MUST be allowed when all four sub-gates (repo scope, actor trust, parse resilience, item number) hold")
assert.Equal(t, "refs/pull/42/head", decision.FetchRef, "RS05a_4_RefIsolation: fetch ref MUST be refs/pull/<N>/head when checkout is allowed")
}
func TestFormalRS05a_RepoScopeMismatchBlocksCheckout(t *testing.T) {
ctx := awContext{ItemType: "pull_request", ItemNumber: "7", Repo: "other-org/other-repo"}
decision := evaluateRS05aCheckoutGate(ctx, "github/gh-aw", false, true, false)
assert.False(t, decision.Allowed, "RS05a_1_RepoScope: a mismatched aw_context.repo MUST cause checkout to be skipped; cross-repository PR checkout is NOT supported")
assert.True(t, decision.WarningEmitted, "RS05a_1_RepoScope: a repo mismatch MUST emit a warning")
}
func TestFormalRS05a_RepoScopeAbsentAllowsCheckoutContinuation(t *testing.T) {
ctx := awContext{ItemType: "pull_request", ItemNumber: "7"}
decision := evaluateRS05aCheckoutGate(ctx, "github/gh-aw", false, true, false)
assert.True(t, decision.Allowed, "RS05a_1_RepoScope: an absent aw_context.repo field MUST NOT itself block checkout; only an explicit mismatch does")
}
func TestFormalRS05a_ActorTrustForkRejected(t *testing.T) {
ctx := awContext{ItemType: "pull_request", ItemNumber: "7", Repo: "github/gh-aw"}
decision := evaluateRS05aCheckoutGate(ctx, "github/gh-aw", true /* isFork */, true, false)
assert.False(t, decision.Allowed, "RS05a_2_ActorTrust: a forked runtime repository MUST block checkout even with a write-permission actor")
}
func TestFormalRS05a_ActorTrustInsufficientPermissionRejected(t *testing.T) {
ctx := awContext{ItemType: "pull_request", ItemNumber: "7", Repo: "github/gh-aw"}
decision := evaluateRS05aCheckoutGate(ctx, "github/gh-aw", false, false /* no write */, false /* not bot */)
assert.False(t, decision.Allowed, "RS05a_2_ActorTrust: an actor without write-or-higher permission and not a verified bot/app MUST be rejected")
}
func TestFormalRS05a_ActorTrustVerifiedBotAllowed(t *testing.T) {
ctx := awContext{ItemType: "pull_request", ItemNumber: "7", Repo: "github/gh-aw"}
decision := evaluateRS05aCheckoutGate(ctx, "github/gh-aw", false, false, true /* verified bot */)
assert.True(t, decision.Allowed, "RS05a_2_ActorTrust: a verified bot/app actor MUST satisfy the trust gate even without explicit write permission")
}
func TestFormalRS05a_MalformedJSONSkipsCheckoutWithoutPanic(t *testing.T) {
malformed := `{"item_type": "pull_request", "item_number": ` // truncated JSON
require.NotPanics(t, func() {
_, warning, err := parseAwContextSafely(malformed)
assert.Error(t, err, "RS05a_3_ParseResilience: malformed aw_context JSON MUST be caught as a non-fatal error")
assert.True(t, warning, "RS05a_3_ParseResilience: a parse failure MUST emit a warning rather than propagating the raw error")
}, "RS05a_3_ParseResilience: parsing malformed aw_context JSON MUST NOT panic")
}
func TestFormalRS05a_RefIsolationUsesPullHeadRef(t *testing.T) {
ctx := awContext{ItemType: "pull_request", ItemNumber: "1234", Repo: "github/gh-aw"}
decision := evaluateRS05aCheckoutGate(ctx, "github/gh-aw", false, true, false)
require.True(t, decision.Allowed, "precondition: checkout must be allowed to inspect the fetch ref")
assert.Equal(t, "refs/pull/1234/head", decision.FetchRef, "RS05a_4_RefIsolation: the PR head MUST be fetched exclusively via refs/pull/N/head")
assert.Equal(t, "github/gh-aw", decision.FetchOrigin, "RS05a_4_RefIsolation: the fetch origin MUST be the current repository")
}
func TestFormalRS05a_RefIsolationRejectsShellInterpolation(t *testing.T) {
ctx := awContext{ItemType: "pull_request", ItemNumber: "55", Repo: "github/gh-aw"}
decision := evaluateRS05aCheckoutGate(ctx, "github/gh-aw", false, true, false)
require.True(t, decision.Allowed, "precondition: checkout must be allowed to inspect execution mode")
assert.True(t, decision.ArrayBased, "RS05a_4_RefIsolation: fetch execution MUST be array-based with no shell interpolation")
}
func TestFormalRS05a_MissingItemNumberBlocksCheckout(t *testing.T) {
ctx := awContext{ItemType: "pull_request", ItemNumber: "", Repo: "github/gh-aw"}
decision := evaluateRS05aCheckoutGate(ctx, "github/gh-aw", false, true, false)
assert.False(t, decision.Allowed, "RS05a_5_ItemNumberRequired: checkout MUST NOT be performed when aw_context.item_number is absent")
}
func TestFormalRS05a_ZeroItemNumberTreatedAsFalsy(t *testing.T) {
ctx := awContext{ItemType: "pull_request", ItemNumber: "0", Repo: "github/gh-aw"}
decision := evaluateRS05aCheckoutGate(ctx, "github/gh-aw", false, true, false)
assert.False(t, decision.Allowed, "RS05a_5_ItemNumberRequired: an item_number of \"0\" MUST be treated as falsy and block checkout")
}
func TestFormalRS05a_NonPullRequestItemTypeBypassesGate(t *testing.T) {
ctx := awContext{ItemType: "issue", ItemNumber: "9", Repo: "other-org/other-repo"}
decision := evaluateRS05aCheckoutGate(ctx, "github/gh-aw", true, false, false)
assert.True(t, decision.Allowed, "RS05a_CheckoutGate: non-pull_request item_type values are outside RS-05a's scope and MUST NOT be gated by this rule")
}
Summary
specs/security-architecture-spec-summary.md(v1.0.1) summarizes the GitHub Agentic Workflows Security Architecture Specification: a 7-layer defense-in-depth model with seven core security guarantees (SG-01..SG-07), a canonical job-pipeline topology, and Section 11 runtime-security requirements (RS-01..RS-15). This run extends the existing formal coverage (SG-01..SG-07, already implemented inpkg/workflow/security_architecture_sg_formal_test.go) by formalizing RS-05a — theworkflow_dispatch+aw_contextpull-request checkout validation gate — which prior notes flagged as having no dedicated Go test coverage.Specification
specs/security-architecture-spec-summary.md(cross-referencesspecs/security-architecture-spec.md§11.3)workflow_dispatchPR checkout gating (RS-05a)Formal Model
Predicates and invariants (illustrative notation)
Behavioral Coverage Map
RS05a_CheckoutGateTestFormalRS05a_CheckoutGateConjunctionAllPassRS05a_1_RepoScopeTestFormalRS05a_RepoScopeMismatchBlocksCheckoutaw_context.repomismatch skips checkout and emits a warningRS05a_1_RepoScopeTestFormalRS05a_RepoScopeAbsentAllowsCheckoutContinuationaw_context.repofield does not itself block checkout (falls through to other gates)RS05a_2_ActorTrustTestFormalRS05a_ActorTrustForkRejectedRS05a_2_ActorTrustTestFormalRS05a_ActorTrustInsufficientPermissionRejectedRS05a_2_ActorTrustTestFormalRS05a_ActorTrustVerifiedBotAllowedRS05a_3_ParseResilienceTestFormalRS05a_MalformedJSONSkipsCheckoutWithoutPanicaw_contextJSON is caught, produces a warning, and never propagates a raw parse errorRS05a_4_RefIsolationTestFormalRS05a_RefIsolationUsesPullHeadRefrefs/pull/<N>/headagainst the current repo's originRS05a_4_RefIsolationTestFormalRS05a_RefIsolationRejectsShellInterpolationRS05a_5_ItemNumberRequiredTestFormalRS05a_MissingItemNumberBlocksCheckoutitem_numberunconditionally blocks checkoutRS05a_5_ItemNumberRequiredTestFormalRS05a_ZeroItemNumberTreatedAsFalsyitem_number == "0"/ empty string is treated as falsy (edge case)RS05a_CheckoutGateTestFormalRS05a_NonPullRequestItemTypeBypassesGatepull_requestitem_typevalues are outside RS-05a's scope entirely (edge case)Generated Test Suite
📄 `pkg/workflow/security_architecture_rs05a_formal_test.go`
Usage
pkg/workflow/security_architecture_rs05a_formal_test.go.// stubawContext,checkoutDecision,assertTrustedCheckoutRuntime,parseAwContextSafely, andevaluateRS05aCheckoutGatedefinitions with bindings to the real Go-side implementation once RS-05a gating is ported from the shell/JS setup scripts, or wire the stub logic directly againstactions/setup/js/aw_context.cjsbehavior via a JS-test bridge if the gate remains JS-only.go test ./pkg/workflow/... -run 'TestFormalRS05a'Context
specs/security-architecture-spec-summary.md