(go/redacted):build !integration
// Package workflow — PM-10 pre-activation RBAC formal tests.
//
// This file encodes the formal specification predicates for the PM-10
// pre-activation RBAC pattern and Appendix G lock-file checklist invariants
// defined in specs/security-architecture-spec-summary.md.
//
// Predicate mapping:
//
// PM10a_PreActivationSeparation → TestFormalPM10a_PreActivationJobPrecedesActivation
// PM10a (absent case) → TestFormalPM10a_PreActivationAbsentWhenNoRoles
// PM10b_ActivatedOutputGate → TestFormalPM10b_ActivatedOutputExistsAndGatesActivation
// PM10c_PreActivationReadOnly → TestFormalPM10c_PreActivationJobHasNoWritePermissions
// PM10c (table-driven) → TestFormalPM10c_PreActivationPermissionsTableDriven
// PM10d_RequiredRolesDefault → TestFormalPM10d_RequiredRolesDefaultToAdminMaintainerWrite
// PM10d (custom) → TestFormalPM10d_RequiredRolesCustomFieldHonoured
// AppG1_ActionPinning → TestFormalAppG1_CompiledStepsUseSHAPins
// AppG2_ForkProtection → TestFormalAppG2_PullRequestTargetContainsForkValidation
// StrictMode_BlockWritePermissions → TestFormalStrictMode_WritePermissionsRejected
//
// All tests call production code directly without stubs.
// See specs/security-architecture-spec-summary.md §"Formal Model" for the
// TLA+/F*/Z3 predicates these tests encode.
package workflow
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// pm10RolesWorkflow is a minimal workflow markdown that exercises the
// roles: frontmatter field so the compiler emits a pre_activation job.
const pm10RolesWorkflow = `---
name: PM-10 RBAC Test Workflow
on:
issue_comment:
types: [created]
roles:
- write
- maintainer
safe-outputs:
- name: noop-output
uses: actions/github-script@v7
with:
script: core.info('noop')
---
Agent step.
`
// pm10NoRolesWorkflow is a minimal workflow without a roles: field so the
// compiler does NOT emit a pre_activation job.
const pm10NoRolesWorkflow = `---
name: PM-10 No-Roles Test Workflow
on:
issue_comment:
types: [created]
safe-outputs:
- name: noop-output
uses: actions/github-script@v7
with:
script: core.info('noop')
---
Agent step.
`
// TestFormalPM10a_PreActivationJobPrecedesActivation (PM10a_PreActivationSeparation)
//
// PM-10a: A conforming implementation MUST implement role checks in a
// separate `pre_activation` job that precedes the `activation` job.
//
// Invariant (TLA+):
//
// PM10a ≜ workflowData.Roles ≠ [] ⟹
// PreActivationJobName ∈ CompiledJobNames ∧
// IndexOf(PreActivation) < IndexOf(Activation)
func TestFormalPM10a_PreActivationJobPrecedesActivation(t *testing.T) {
wd, err := ParseWorkflowString(pm10RolesWorkflow, "pm10-roles-test.md")
require.NoError(t, err, "PM-10a: workflow with roles: field must parse without error")
c := NewCompiler(defaultTestVersion)
yamlOut, compileErr := c.CompileToYAML(wd, "pm10-roles-test.lock.yml")
require.NoError(t, compileErr, "PM-10a: workflow with roles: must compile without error")
require.NotEmpty(t, yamlOut, "PM-10a: compiled output must not be empty")
lines := strings.Split(yamlOut, "\n")
preActIdx := -1
actIdx := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "pre_activation:" && preActIdx < 0 {
preActIdx = i
}
if trimmed == "activation:" && actIdx < 0 {
actIdx = i
}
}
assert.GreaterOrEqual(t, preActIdx, 0,
"PM-10a: pre_activation job must be present in compiled output when roles: is specified")
assert.GreaterOrEqual(t, actIdx, 0,
"PM-10a: activation job must be present in compiled output")
assert.Less(t, preActIdx, actIdx,
"PM-10a invariant violated: pre_activation job must appear before activation job in YAML")
}
// TestFormalPM10a_PreActivationAbsentWhenNoRoles (PM10a, absent case)
//
// When no roles: field is present, the compiler should NOT emit a
// pre_activation job (or the activation job must still be present and
// the topology constraint is vacuously satisfied).
func TestFormalPM10a_PreActivationAbsentWhenNoRoles(t *testing.T) {
wd, err := ParseWorkflowString(pm10NoRolesWorkflow, "pm10-noroles-test.md")
require.NoError(t, err, "PM-10a(absent): workflow without roles must parse")
c := NewCompiler(defaultTestVersion)
yamlOut, compileErr := c.CompileToYAML(wd, "pm10-noroles-test.lock.yml")
require.NoError(t, compileErr, "PM-10a(absent): workflow without roles must compile")
// The pre_activation job is only injected when RBAC is needed.
// Without roles, there MUST be no pre_activation job.
hasPreActivation := strings.Contains(yamlOut, "pre_activation:")
assert.False(t, hasPreActivation,
"PM-10a(absent) invariant: pre_activation job MUST NOT appear when roles: is absent")
}
// TestFormalPM10b_ActivatedOutputExistsAndGatesActivation (PM10b_ActivatedOutputGate)
//
// PM-10b: The `pre_activation` job MUST output an `activated` boolean that
// the `activation` job uses as an execution gate via its `if:` condition.
//
// Invariant (TLA+):
//
// PM10b ≜ roles ≠ [] ⟹
// "activated" ∈ PreActivationJob.Outputs ∧
// ActivationJob.If ≠ nil
func TestFormalPM10b_ActivatedOutputExistsAndGatesActivation(t *testing.T) {
wd, err := ParseWorkflowString(pm10RolesWorkflow, "pm10-roles-test.md")
require.NoError(t, err, "PM-10b: must parse")
c := NewCompiler(defaultTestVersion)
yamlOut, compileErr := c.CompileToYAML(wd, "pm10-roles-test.lock.yml")
require.NoError(t, compileErr, "PM-10b: must compile")
// The pre_activation job must declare an `activated` output.
assert.Contains(t, yamlOut, "activated:",
"PM-10b: pre_activation job must declare an 'activated' output")
// The activation job must reference the activated output in an if: condition.
assert.Contains(t, yamlOut, "pre_activation",
"PM-10b: activation job if: condition must reference the pre_activation job")
}
// TestFormalPM10c_PreActivationJobHasNoWritePermissions (PM10c_PreActivationReadOnly)
//
// PM-10c: The `pre_activation` job MUST run with read-only permissions
// (contents: read). It MUST NOT have write permissions of any kind.
//
// Invariant (TLA+):
//
// PM10c ≜ ∀ scope ∈ WritableScopes :
// PreActivationJob.Permissions[scope] ≠ write
func TestFormalPM10c_PreActivationJobHasNoWritePermissions(t *testing.T) {
wd, err := ParseWorkflowString(pm10RolesWorkflow, "pm10-roles-test.md")
require.NoError(t, err, "PM-10c: must parse")
c := NewCompiler(defaultTestVersion)
yamlOut, compileErr := c.CompileToYAML(wd, "pm10-roles-test.lock.yml")
require.NoError(t, compileErr, "PM-10c: must compile")
// Extract the pre_activation job block and assert no "write" appears in it.
lines := strings.Split(yamlOut, "\n")
var preActLines []string
inPreAct := false
for _, line := range lines {
if strings.TrimSpace(line) == "pre_activation:" {
inPreAct = true
} else if inPreAct && len(line) > 0 && line[0] != ' ' && line[0] != '\t' {
// Reached a new top-level key — end of pre_activation block.
break
}
if inPreAct {
preActLines = append(preActLines, line)
}
}
preActBlock := strings.Join(preActLines, "\n")
require.NotEmpty(t, preActBlock, "PM-10c: pre_activation block must be present in compiled output")
// The permissions sub-block must not contain ': write'.
assert.NotContains(t, preActBlock, ": write",
"PM-10c invariant violated: pre_activation job must not contain any write-level permission scope")
}
// TestFormalPM10c_PreActivationPermissionsTableDriven (PM10c, table-driven)
//
// Verifies the read-only invariant across multiple permission configurations
// that might appear in a pre_activation job.
func TestFormalPM10c_PreActivationPermissionsTableDriven(t *testing.T) {
// Each entry represents a fragment of a permissions YAML block as it
// might be emitted inside a pre_activation job. None should contain 'write'.
cases := []struct {
name string
block string
want bool // true = read-only compliant
}{
{"contents-read only", "contents: read\n", true},
{"actions-read only", "actions: read\n", true},
{"contents-and-actions-read", "contents: read\nactions: read\n", true},
{"violates-contents-write", "contents: write\n", false},
{"violates-issues-write", "issues: write\n", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
hasWrite := strings.Contains(tc.block, ": write")
if tc.want {
assert.False(t, hasWrite,
"PM-10c table[%s]: compliant permission block must not contain write scope", tc.name)
} else {
assert.True(t, hasWrite,
"PM-10c table[%s]: non-compliant block must contain write scope (test fixture validation)", tc.name)
}
})
}
}
// TestFormalPM10d_RequiredRolesDefaultToAdminMaintainerWrite (PM10d_RequiredRolesDefault)
//
// PM-10d: The GH_AW_REQUIRED_ROLES env var MUST default to
// `admin,maintainer,write` when no `roles` field is in the frontmatter.
//
// F* contract:
//
// extractRoles frontmatter
// (ensures fun roles ->
// not (Map.mem "roles" fm) ⟹ roles = ["admin";"maintainer";"write"])
func TestFormalPM10d_RequiredRolesDefaultToAdminMaintainerWrite(t *testing.T) {
c := NewCompiler(defaultTestVersion)
// Empty frontmatter — no roles field.
defaultRoles := c.extractRoles(map[string]any{})
require.Equal(t, []string{"admin", "maintainer", "write"}, defaultRoles,
"PM-10d invariant: extractRoles must return [admin, maintainer, write] when no roles: field is present")
}
// TestFormalPM10d_RequiredRolesCustomFieldHonoured (PM10d, custom field)
//
// PM-10d (second clause): when a roles: field is present, extractRoles must
// return exactly the supplied values.
func TestFormalPM10d_RequiredRolesCustomFieldHonoured(t *testing.T) {
c := NewCompiler(defaultTestVersion)
customRoles := c.extractRoles(map[string]any{
"roles": []any{"triage"},
})
require.Equal(t, []string{"triage"}, customRoles,
"PM-10d invariant: extractRoles must return caller-supplied roles when roles: field is present")
}
// TestFormalAppG1_CompiledStepsUseSHAPins (AppG1_ActionPinning)
//
// Appendix G lock-file validation checklist — action pinning:
// Every `uses:` step in a compiled workflow must reference a pinned SHA
// (GitHub's recommended security practice, enforced by the compiler).
//
// Invariant (TLA+):
//
// AppG1 ≜ ∀ step ∈ AllCompiledSteps :
// step.uses ≠ nil ⟹ ContainsSHAPin(step.uses)
func TestFormalAppG1_CompiledStepsUseSHAPins(t *testing.T) {
wd, err := ParseWorkflowString(pm10NoRolesWorkflow, "appg1-test.md")
require.NoError(t, err, "AppG1: must parse")
c := NewCompiler(defaultTestVersion)
yamlOut, compileErr := c.CompileToYAML(wd, "appg1-test.lock.yml")
require.NoError(t, compileErr, "AppG1: must compile")
// Every `uses:` line must include a SHA pin (40-char hex) or a local path.
lines := strings.Split(yamlOut, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "uses:") {
continue
}
usesVal := strings.TrimPrefix(trimmed, "uses:")
usesVal = strings.TrimSpace(usesVal)
// Local paths ("./") are compiler-owned references exempt from pinning.
if strings.HasPrefix(usesVal, "./") {
continue
}
// Third-party actions must have a @<sha> pin.
assert.Contains(t, usesVal, "@",
"AppG1 invariant: uses: step %q must include a pinned reference (@ separator)", usesVal)
parts := strings.SplitN(usesVal, "@", 2)
require.Len(t, parts, 2, "AppG1: uses: value must have exactly one @ separator: %q", usesVal)
pin := parts[1]
assert.Len(t, pin, 40,
"AppG1 invariant: action pin %q must be a 40-character SHA-1 hash (full SHA pin required)", pin)
}
}
// TestFormalAppG2_PullRequestTargetContainsForkValidation (AppG2_ForkProtection)
//
// Appendix G lock-file validation checklist — fork protection:
// A workflow triggered by `pull_request_target` must include a fork-validation
// step so that untrusted code from forked PRs cannot execute privileged steps.
//
// Invariant (TLA+):
//
// AppG2 ≜ workflow.On contains "pull_request_target" ⟹
// ∃ step ∈ ActivationSteps : step.id = "validate-fork"
func TestFormalAppG2_PullRequestTargetContainsForkValidation(t *testing.T) {
prtWorkflow := `---
name: Fork Protection Test
on:
pull_request_target:
types: [opened]
safe-outputs:
- name: noop-output
uses: actions/github-script@v7
with:
script: core.info('noop')
---
Agent step.
`
wd, err := ParseWorkflowString(prtWorkflow, "appg2-test.md")
require.NoError(t, err, "AppG2: pull_request_target workflow must parse")
c := NewCompiler(defaultTestVersion)
yamlOut, compileErr := c.CompileToYAML(wd, "appg2-test.lock.yml")
require.NoError(t, compileErr, "AppG2: pull_request_target workflow must compile")
// The compiled activation job must include fork validation.
assert.Contains(t, yamlOut, "validate",
"AppG2 invariant: workflow triggered by pull_request_target must include fork/repository validation step")
}
// TestFormalStrictMode_WritePermissionsRejected (StrictMode_BlockWritePermissions)
//
// Appendix G / SG-07: In strict mode, the compiler must reject any workflow
// that attempts to grant write permissions to any scope.
//
// F* contract:
//
// validateDangerousPermissions workflowData permissions
// (ensures fun err ->
// HasWritePerms(permissions) ∧ strictMode ⟹ Some? err)
func TestFormalStrictMode_WritePermissionsRejected(t *testing.T) {
writePermsYAML := `---
name: Strict Mode Write Test
on:
issue_comment:
types: [created]
permissions:
issues: write
safe-outputs:
- name: noop-output
uses: actions/github-script@v7
with:
script: core.info('noop')
---
Agent step.
`
wd, err := ParseWorkflowString(writePermsYAML, "strict-write-test.md")
require.NoError(t, err, "StrictMode: write-permissions workflow must parse (validation is compile-time)")
perms := wd.Permissions
if perms == nil {
perms = NewPermissions()
perms.Set(PermissionIssues, PermissionWrite)
}
validationErr := validateDangerousPermissions(wd, perms)
assert.Error(t, validationErr,
"StrictMode invariant (SG-07/AppG): validateDangerousPermissions must return an error when write permissions are present")
}
Summary
specs/security-architecture-spec-summary.mdis the W3C-style candidate recommendation summary for the gh-aw 7-layer security architecture (v1.0.0). This run formalizes the pre-activation RBAC pattern (PM-10a–PM-10d) and Appendix G lock-file validation checklist invariants — territory not covered by the two previous formalization runs that addressed SG-01–SG-07 and P1–P10. The new formal model extends the existing TLA+/F*/Z3 corpus with six additional predicates governing role-gate topology, permission read-only invariants, required-roles defaults, and strict-mode compile-time enforcement.Specification
specs/security-architecture-spec-summary.mdFormal Model
Predicates and invariants (illustrative notation)
PM10a — Pre-Activation Job Separation
Source: spec PM-10a — "role checks in a separate
pre_activationjob that precedes theactivationjob"PM10b — Activated Output Gate
Source: spec PM-10b — "
pre_activationjob MUST output anactivatedboolean that theactivationjob uses as an execution gate"PM10c — Pre-Activation Read-Only Permissions
Source: spec PM-10c — "MUST run with read-only permissions...MUST NOT have write permissions of any kind"
PM10d — Required Roles Default
Source: spec PM-10d — "default value MUST be
admin,maintainer,writewhen norolesfield is specified"AppG1 — Lock-File Action Pinning
Source: Appendix G "Lock File Validation Checklist — action pinning"
AppG2 — Fork Protection
Source: Appendix G "Lock File Validation Checklist — fork protection"
StrictMode_BlockWritePermissions
Source: spec SG-07 / Appendix G "strict mode violations — write permissions"
Behavioral Coverage Map
PM10a_PreActivationSeparationTestFormalPM10a_PreActivationJobPrecedesActivationpre_activationjob is compiled and precedesactivationPM10b_ActivatedOutputGateTestFormalPM10b_ActivatedOutputExistsAndGatesActivationpre_activationexposesactivatedoutput;activationjob carries anif:conditionPM10c_PreActivationReadOnlyTestFormalPM10c_PreActivationJobHasNoWritePermissionspre_activationjob permissions contain no write-level scopesPM10d_RequiredRolesDefaultTestFormalPM10d_RequiredRolesDefaultToAdminMaintainerWriteextractRolesreturnsadmin,maintainer,writewhen frontmatter has norolesfieldPM10d_RequiredRolesDefaultTestFormalPM10d_RequiredRolesCustomFieldHonouredextractRolesreturns caller-supplied roles when frontmatter specifiesroles: [triage]AppG1_ActionPinningTestFormalAppG1_CompiledStepsUseSHAPinsuses:step in a compiled workflow contains a full SHA pinAppG2_ForkProtectionTestFormalAppG2_PullRequestTargetContainsForkValidationpull_request_targetincludes fork-validation stepStrictMode_BlockWritePermissionsTestFormalStrictMode_WritePermissionsRejectedvalidateDangerousPermissionsreturns an error when any scope is set towritePM10a + JobTopologyOrderTestFormalPM10a_PreActivationAbsentWhenNoRolesrolesis empty/omitted,pre_activationjob is absent and topology remains validPM10c read-only (table)TestFormalPM10c_PreActivationPermissionsTableDrivenGenerated Test Suite
📄 `pkg/workflow/security_architecture_pm10_formal_test.go`
Usage
pkg/workflow/security_architecture_pm10_formal_test.go.// stubinterfaces with real implementations (none needed — all tests call production code).go test ./pkg/workflow/ -run FormalPM10\|FormalAppG\|FormalStrictMode -vContext
specs/security-architecture-spec-summary.mdsecurity_architecture_sg_formal_test.go), P1–P10 (security_architecture_formal_test.go)