Skip to content

[formal-spec] security-architecture-spec-summary.md — Formal model & test suite — 2026-07-17 #46279

Description

@github-actions

Summary

specs/security-architecture-spec-summary.md is 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

  • File: specs/security-architecture-spec-summary.md
  • Focus area: Pre-activation RBAC pattern (PM-10a–PM-10d), Appendix G lock-file checklist, strict-mode compile guards
  • Formal notation used: TLA+ (state machine), F* (pre/post contracts), Z3/SMT-LIB (ordering constraints)

Formal Model

Predicates and invariants (illustrative notation)

PM10a — Pre-Activation Job Separation

PM10a_PreActivationSeparation 
   workflowData  CompiledWorkflows :
    workflowData.Roles  [] 
      PreActivationJobName  CompiledJobNames(workflowData) 
      ActivationJobName  CompiledJobNames(workflowData) 
      IndexOf(PreActivationJobName, CompiledJobNames(workflowData))
        < IndexOf(ActivationJobName, CompiledJobNames(workflowData))

Source: spec PM-10a — "role checks in a separate pre_activation job that precedes the activation job"

PM10b — Activated Output Gate

PM10b_ActivatedOutputGate 
   workflowData  CompiledWorkflows :
    workflowData.Roles  []"activated"  PreActivationJob(workflowData).Outputs 
      ActivationJob(workflowData).If  nil

Source: spec PM-10b — "pre_activation job MUST output an activated boolean that the activation job uses as an execution gate"

PM10c — Pre-Activation Read-Only Permissions

PM10c_PreActivationReadOnly 
   workflowData  CompiledWorkflows :
    LET perms == PreActivationJob(workflowData).Permissions
    IN perms  nil 
       scope  WritableScopes : perms[scope]  write

Source: spec PM-10c — "MUST run with read-only permissions...MUST NOT have write permissions of any kind"

PM10d — Required Roles Default

val extractRoles :
  frontmatter : map string any ->
  Tot (list string)
  (requires True)
  (ensures fun roles ->
    (not (Map.mem "roles" frontmatter)roles = ["admin"; "maintainer"; "write"])(Map.mem "roles" frontmatterroles = parseList (Map.sel frontmatter "roles")))

Source: spec PM-10d — "default value MUST be admin,maintainer,write when no roles field is specified"

AppG1 — Lock-File Action Pinning

AppG1_ActionPinning 
   step  AllCompiledSteps :
    step.uses  nil 
       sha  SHA256Strings : step.uses contains ("@" ++ sha)

Source: Appendix G "Lock File Validation Checklist — action pinning"

AppG2 — Fork Protection

AppG2_ForkProtection 
   workflow  CompiledWorkflows :
    workflow.On contains "pull_request_target" 
       step  workflow.ActivationSteps :
        step.id = "validate-fork"

Source: Appendix G "Lock File Validation Checklist — fork protection"

StrictMode_BlockWritePermissions

val validateDangerousPermissions :
  workflowData : WorkflowData ->
  permissions : Permissions ->
  Tot (option Error)
  (requires True)
  (ensures fun result ->
    HasWritePermissions(permissions)workflowData.StrictMode = trueSome? result)

Source: spec SG-07 / Appendix G "strict mode violations — write permissions"

Behavioral Coverage Map

Predicate / Invariant Test Function Description
PM10a_PreActivationSeparation TestFormalPM10a_PreActivationJobPrecedesActivation With roles configured, pre_activation job is compiled and precedes activation
PM10b_ActivatedOutputGate TestFormalPM10b_ActivatedOutputExistsAndGatesActivation pre_activation exposes activated output; activation job carries an if: condition
PM10c_PreActivationReadOnly TestFormalPM10c_PreActivationJobHasNoWritePermissions Compiled pre_activation job permissions contain no write-level scopes
PM10d_RequiredRolesDefault TestFormalPM10d_RequiredRolesDefaultToAdminMaintainerWrite extractRoles returns admin,maintainer,write when frontmatter has no roles field
PM10d_RequiredRolesDefault TestFormalPM10d_RequiredRolesCustomFieldHonoured extractRoles returns caller-supplied roles when frontmatter specifies roles: [triage]
AppG1_ActionPinning TestFormalAppG1_CompiledStepsUseSHAPins Every uses: step in a compiled workflow contains a full SHA pin
AppG2_ForkProtection TestFormalAppG2_PullRequestTargetContainsForkValidation Workflow compiled with pull_request_target includes fork-validation step
StrictMode_BlockWritePermissions TestFormalStrictMode_WritePermissionsRejected validateDangerousPermissions returns an error when any scope is set to write
PM10a + JobTopologyOrder TestFormalPM10a_PreActivationAbsentWhenNoRoles When roles is empty/omitted, pre_activation job is absent and topology remains valid
PM10c read-only (table) TestFormalPM10c_PreActivationPermissionsTableDriven Table-driven check covering contents:read, actions:read, and nil permission variants

Generated Test Suite

📄 `pkg/workflow/security_architecture_pm10_formal_test.go`
(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")
}

Usage

  1. Copy the test file to pkg/workflow/security_architecture_pm10_formal_test.go.
  2. Replace any // stub interfaces with real implementations (none needed — all tests call production code).
  3. Run: go test ./pkg/workflow/ -run FormalPM10\|FormalAppG\|FormalStrictMode -v

Context

  • Spec processed: specs/security-architecture-spec-summary.md
  • Formal notation: TLA+, F*, Z3/SMT-LIB
  • Prior runs: SG-01–SG-07 (security_architecture_sg_formal_test.go), P1–P10 (security_architecture_formal_test.go)
  • This run adds: PM-10a–PM-10d (pre-activation RBAC), AppG1–AppG2 (lock-file checklist), StrictMode (Appendix G violations)
  • Run: https://github.com/github/gh-aw/actions/runs/29593849784

Generated by 🔬 Daily Formal Spec Verifier · 132.6 AIC · ⌖ 10.1 AIC · ⊞ 7.1K ·

  • expires on Jul 24, 2026, 8:07 AM UTC-08:00

Metadata

Metadata

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions