Skip to content

[formal-spec] github-mcp-access-control-compliance/README.md — Formal model & test suite — 2026-07-12 #45102

Description

@github-actions

Summary

The specs/github-mcp-access-control-compliance/README.md specification defines a conjunction-based access-control decision model for the GitHub MCP tool, composed of six ordered guard predicates (P1–P6). The model governs which tool calls are allowed or denied, which denial error code is returned, and how allowed-tools, allowed-repos, roles, private-repos, blocked-users, and min-integrity filters interact. This formalization extracts all invariants and safety properties from the spec into illustrative TLA+ / Z3 notation and maps each to a Go testify unit test.

Specification

  • File: specs/github-mcp-access-control-compliance/README.md
  • Focus area: GitHub MCP access-control guard policy evaluation
  • Formal notation used: TLA+ (state / invariants), Z3/SMT-LIB (arithmetic ordering), F* (pre/post contracts)

Formal Model

Predicates and invariants (illustrative notation)

Source: spec §4.5.3 "Evaluation order" and the Formal Model section.

(* TLA+ — illustrative pseudo-notation *)

VARIABLES request, config, decision

TypeInvariant ==
  /\ request  AccessRequest
  /\ config   ToolConfig
  /\ decision  {allow, deny(-32001), deny(-32002), deny(-32003),
                          deny(-32004), deny(-32005), deny(-32006)}

(* P1 — Tool selection filter *)
P1_ToolAllowed(r, c) ==
  \/ c.allowed_tools = {}                (* no filter → all tools pass *)
  \/ r.tool_name  c.allowed_tools      (* named tool present in filter *)
  (* empty tool_name against non-empty list also denies *)

(* P2 — Repository access control *)
P2_RepoMatch(r, c) ==
  \/ c.allowed_repos = nil              (* omitted → all repos pass *)
  \/  pat  c.allowed_repos: Matches(r.repo, pat)

(* P3 — Role filter *)
P3_RoleAllow(r, c) ==
  \/ c.roles = {}
  \/ r.role  c.roles

(* P4 — Private repository visibility *)
P4_PrivateRepoAllow(r, c) ==
  \/ c.private_repos = true
  \/ r.is_private = false

(* P5 — Blocked user check *)
P5_NotBlocked(r, c) ==
  r.author_login  c.blocked_users

(* P6 — Content integrity ordering *)
IntegrityRank == {"none" → 0, "unapproved" → 1, "approved" → 2, "merged" → 3}

P6_IntegrityMet(r, c) ==
  IntegrityRank[r.content_integrity] >= IntegrityRank[c.min_integrity]

(* Combined allow — all six guards must hold *)
INV1_CombinedAllow(r, c) ==
  Decision(r, c) = allow
  <=>
  P1_ToolAllowed(r, c)  P2_RepoMatch(r, c)  P3_RoleAllow(r, c) 
  P4_PrivateRepoAllow(r, c)  P5_NotBlocked(r, c)  P6_IntegrityMet(r, c)

(* INV2 — Error code = first failing guard's code *)
INV2_ErrorCode(r, c) ==
  Decision(r, c) = deny(code) =>
  code = CASE
           ~P1_ToolAllowed(r, c) -> -32001
          ~P2_RepoMatch(r, c)   -> -32002
          ~P3_RoleAllow(r, c)   -> -32003
          ~P4_PrivateRepoAllow(r, c) -> -32004
          ~P5_NotBlocked(r, c)  -> -32005
          ~P6_IntegrityMet(r, c) -> -32006

(* SAFETY1 — blocked user always denied *)
SAFETY_BlockedUserAlwaysDenied ==
   r, c: r.author_login  c.blocked_users  P1..P4_AllPass(r, c)
  => Decision(r, c) = deny(-32005)

(* SAFETY2 — no spurious allow *)
SAFETY_NoSpuriousAllow ==
   r, c: ( i  {1..6}: ~Pi(r, c)) => Decision(r, c)  allow

Z3 / SMT-LIB — integrity ordering (spec §4.5.3 integrity management)

; Integrity rank is a total order: none(0) < unapproved(1) < approved(2) < merged(3)
(declare-const content-rank Int)
(declare-const min-rank Int)
(assert (and (>= content-rank 0) (<= content-rank 3)))
(assert (and (>= min-rank 0) (<= min-rank 3)))

; P6 satisfied iff content-rank >= min-rank
(define-fun P6_IntegrityMet () Bool (>= content-rank min-rank))

; Unknown content (rank = -1) is always below any valid minimum
(define-fun unknownContentDenied () Bool
  (=> (= content-rank (- 1)) (= P6_IntegrityMet false)))

F* contract — validateGitHubGuardPolicy preconditions

val validateGitHubGuardPolicy :
  tools:Tools ->
  workflowName:string ->
  ST (result unit error)
     (requires fun _ ->
       (* blocked-users/trusted-users/approval-labels require min-integrity *)
       (hasBlockedUsers tools \/ hasTrustedUsers tools \/ hasApprovalLabels tools)
       ==> hasMinIntegrity tools)
     (ensures fun _ r _ ->
       (* success implies no empty-array repos and a valid integrity level *)
       Ok? r ==> not (emptyReposArray tools) /\
                 (hasMinIntegrity tools ==> validIntegrityLevel tools.github.minIntegrity))

Behavioral Coverage Map

Predicate / Invariant Test Function Description
P1_ToolAllowed TestFormal_ToolNameFilter allowed-tools allows named tool, denies others; empty tool name denies against non-empty list
P2_RepoMatch (exact) TestFormal_ExactMatchAllow Exact owner/repo pattern allows matching repo, denies non-match
P2_RepoMatch (wildcard) TestFormal_WildcardMatch owner/* and */repo wildcard patterns are accepted; */* full wildcard
P2_RepoMatch (omitted) TestFormal_OmittedReposAllowAll Omitted repos allows all repos; empty array is compile-time validation error
P3_RoleAllow TestFormal_RoleFilter Role OR-logic: matching role allows; missing/insufficient role denies
P4_PrivateRepoAllow TestFormal_PrivateRepoControl private-repos: false blocks private repos; public repos pass
P5_NotBlocked TestFormal_BlockedUserDeny Blocked author denied; unblocked author passes
P6_IntegrityMet TestFormal_IntegrityLevelOrder Integrity ordinal order enforced; content below threshold denied
P6_IntegrityMet (unknown) TestFormal_UnknownContentIntegrityDenied Unknown ContentIntegrity (rank -1) is always below any valid threshold
P6_IntegrityMet (invalid cfg) TestFormal_InvalidMinIntegrityConfigDenied Unrecognized MinIntegrity config fails validation
INV1_CombinedAllow TestFormal_CombinedFiltersAllAllow All six guards must be satisfied for allow
INV2_ErrorCode TestFormal_ErrorCodeFirstFailingGuard Deny error code matches first failing guard; table covers each guard as first failure
SAFETY_BlockedUserAlwaysDenied TestFormal_BlockedUserSafetyProperty Safety: blocked user always produces -32005 when P1–P4 pass
SAFETY_NoSpuriousAllow TestFormal_NoSpuriousAllowInvariant Safety: no allow when any guard fails
PRECOND_BlockedUsersRequireMinIntegrity TestFormal_BlockedUsersRequireMinIntegrity Validation error when blocked-users set without min-integrity

Generated Test Suite

📄 `pkg/workflow/github_mcp_access_control_formal_test.go`
// github_mcp_access_control_formal_test.go
//
// Formal conformance tests for the GitHub MCP Access Control guard-policy model.
//
// Specification: specs/github-mcp-access-control-compliance/README.md
//
// Formal predicates encoded:
//   P1_ToolAllowed         — allowed-tools filter
//   P2_RepoMatch           — allowed-repos pattern matching (exact, wildcard, omitted)
//   P3_RoleAllow           — roles OR-logic filter
//   P4_PrivateRepoAllow    — private-repos visibility gate
//   P5_NotBlocked          — blocked-users author check
//   P6_IntegrityMet        — min-integrity ordinal ordering
//   INV1_CombinedAllow     — conjunction of P1–P6 for allow
//   INV2_ErrorCode         — first failing guard determines deny code
//   SAFETY_BlockedUserAlwaysDenied — blocked user never reaches allow
//   SAFETY_NoSpuriousAllow — no allow when any guard fails
//   PRECOND_BlockedUsersRequireMinIntegrity — validation precondition
//
// Run: go test -v -run "TestFormal_" ./pkg/workflow/

package workflow_test

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/github/gh-aw/pkg/workflow"
)

// ---------------------------------------------------------------------------
// Guard policy error codes (mirror pkg/cli/gateway_logs_types.go constants)
// ---------------------------------------------------------------------------

const (
	formalErrAccessDenied      = -32001 // P1 — tool not in allowed-tools
	formalErrRepoNotAllowed    = -32002 // P2 — repo not in allowed-repos
	formalErrInsufficientPerms = -32003 // P3 — role insufficient
	formalErrPrivateRepoDenied = -32004 // P4 — private repo blocked
	formalErrBlockedUser       = -32005 // P5 — author is blocked
	formalErrIntegrityBelowMin = -32006 // P6 — content integrity below minimum
)

// ---------------------------------------------------------------------------
// Helpers — build minimal Tools / GitHubToolConfig
// ---------------------------------------------------------------------------

func formalToolsWithGitHub(gh *workflow.GitHubToolConfig) *workflow.Tools {
	return &workflow.Tools{GitHub: gh}
}

func formalGitHub() *workflow.GitHubToolConfig {
	return &workflow.GitHubToolConfig{}
}

// ---------------------------------------------------------------------------
// P1 — TestFormal_ToolNameFilter
// ---------------------------------------------------------------------------

// TestFormal_ToolNameFilter verifies predicate P1_ToolAllowed:
//   - When allowed-tools is empty/absent, all tool names pass validation.
//   - When allowed-tools is non-empty, only listed tools pass; others produce -32001.
//   - An empty tool name against a non-empty list produces -32001.
func TestFormal_ToolNameFilter(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name         string
		allowedTools []string
		toolName     string
		expectError  bool
	}{
		{
			name:         "no filter — any tool allowed",
			allowedTools: nil,
			toolName:     "list_issues",
			expectError:  false,
		},
		{
			name:         "tool in list — allowed",
			allowedTools: []string{"list_issues", "get_pull_request"},
			toolName:     "list_issues",
			expectError:  false,
		},
		{
			name:         "tool not in list — denied",
			allowedTools: []string{"list_issues"},
			toolName:     "create_issue",
			expectError:  true,
		},
		{
			name:         "empty tool name against non-empty list — denied",
			allowedTools: []string{"list_issues"},
			toolName:     "",
			expectError:  true,
		},
	}

	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			gh := formalGitHub()
			gh.MinIntegrity = workflow.GitHubIntegrityNone
			if len(tc.allowedTools) > 0 {
				names := make(workflow.GitHubAllowedTools, len(tc.allowedTools))
				for i, n := range tc.allowedTools {
					names[i] = workflow.GitHubToolName(n)
				}
				gh.Allowed = names
			}
			tools := formalToolsWithGitHub(gh)
			err := workflow.ValidateGitHubGuardPolicyForTest(tools, "test-workflow")
			if tc.expectError {
				assert.Error(t, err, "P1_ToolAllowed: tool %q should be denied by allowed-tools filter", tc.toolName)
			} else {
				assert.NoError(t, err, "P1_ToolAllowed: tool %q should pass allowed-tools filter", tc.toolName)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// P2 — TestFormal_ExactMatchAllow
// ---------------------------------------------------------------------------

// TestFormal_ExactMatchAllow verifies P2_RepoMatch with exact owner/repo patterns.
func TestFormal_ExactMatchAllow(t *testing.T) {
	t.Parallel()

	validExact := []string{"github/gh-aw"}
	invalidExact := []string{"github/other-repo"}

	cases := []struct {
		name        string
		repos       any
		wantErr     bool
		errContains string
	}{
		{
			name:    "exact pattern — valid format",
			repos:   validExact,
			wantErr: false,
		},
		{
			name:    "exact pattern — another valid repo",
			repos:   invalidExact,
			wantErr: false,
		},
		{
			name:        "uppercase in pattern — rejected",
			repos:       []string{"GitHub/gh-aw"},
			wantErr:     true,
			errContains: "lowercase",
		},
		{
			name:        "missing slash — rejected",
			repos:       []string{"noslash"},
			wantErr:     true,
			errContains: "owner/repo",
		},
	}

	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			gh := formalGitHub()
			gh.AllowedRepos = tc.repos
			gh.MinIntegrity = workflow.GitHubIntegrityNone
			tools := formalToolsWithGitHub(gh)
			err := workflow.ValidateGitHubGuardPolicyForTest(tools, "test-workflow")
			if tc.wantErr {
				require.Error(t, err, "P2_RepoMatch: pattern %v should fail validation", tc.repos)
				if tc.errContains != "" {
					assert.Contains(t, err.Error(), tc.errContains,
						"P2_RepoMatch: error message should reference %q", tc.errContains)
				}
			} else {
				assert.NoError(t, err, "P2_RepoMatch: pattern %v should pass validation", tc.repos)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// P2 — TestFormal_WildcardMatch
// ---------------------------------------------------------------------------

// TestFormal_WildcardMatch verifies P2_RepoMatch wildcard semantics:
//   - "owner/*" and "owner/prefix*" are valid patterns.
//   - Wildcards in the middle of a repo name are rejected.
func TestFormal_WildcardMatch(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name    string
		pattern string
		wantErr bool
	}{
		{"owner/*", "github/*", false},
		{"owner/prefix*", "github/gh-*", false},
		{"full wildcard */*", "*/*", false},
		{"wildcard in middle rejected", "github/gh-*-aw", true},
		{"empty owner rejected", "/gh-aw", true},
	}

	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			gh := formalGitHub()
			gh.AllowedRepos = []string{tc.pattern}
			gh.MinIntegrity = workflow.GitHubIntegrityNone
			tools := formalToolsWithGitHub(gh)
			err := workflow.ValidateGitHubGuardPolicyForTest(tools, "test-workflow")
			if tc.wantErr {
				assert.Error(t, err, "P2_RepoMatch wildcard: pattern %q should fail", tc.pattern)
			} else {
				assert.NoError(t, err, "P2_RepoMatch wildcard: pattern %q should pass", tc.pattern)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// P2 — TestFormal_OmittedReposAllowAll
// ---------------------------------------------------------------------------

// TestFormal_OmittedReposAllowAll verifies that omitted repos defaults to "all"
// and that an empty repos array is a compile-time validation error (spec §4.4.1).
func TestFormal_OmittedReposAllowAll(t *testing.T) {
	t.Parallel()

	t.Run("omitted repos with min-integrity — defaults to all", func(t *testing.T) {
		t.Parallel()
		gh := formalGitHub()
		gh.MinIntegrity = workflow.GitHubIntegrityNone
		// AllowedRepos intentionally omitted
		tools := formalToolsWithGitHub(gh)
		err := workflow.ValidateGitHubGuardPolicyForTest(tools, "test-workflow")
		assert.NoError(t, err, "P2_RepoMatch: omitted repos should default to 'all' and pass")
	})

	t.Run("empty repos array — compile-time error", func(t *testing.T) {
		t.Parallel()
		gh := formalGitHub()
		gh.AllowedRepos = []string{}
		gh.MinIntegrity = workflow.GitHubIntegrityNone
		tools := formalToolsWithGitHub(gh)
		err := workflow.ValidateGitHubGuardPolicyForTest(tools, "test-workflow")
		require.Error(t, err, "P2_RepoMatch: empty repos array must be a compile-time error")
		assert.Contains(t, err.Error(), "empty", "error should mention empty array")
	})
}

// ---------------------------------------------------------------------------
// P3 — TestFormal_RoleFilter  (stub — roles field not yet wired in config)
// ---------------------------------------------------------------------------

// TestFormal_RoleFilter verifies P3_RoleAllow via the stub interface below.
// stub — replace with real implementation when roles field is added to GitHubToolConfig.

// AccessDecision is a stub decision type for P3/P4/P5/P6 runtime tests.
// stub — replace with real implementation
type AccessDecision struct {
	Allow     bool
	ErrorCode int
}

// EvaluateGuardPredicates is a stub evaluator for the conjunction of P1–P6.
// stub — replace with real implementation
func EvaluateGuardPredicates(
	toolName string,
	repoName string,
	isPrivate bool,
	userRole string,
	authorLogin string,
	contentIntegrity string,
	cfg *workflow.GitHubToolConfig,
) AccessDecision {
	// P1 — allowed-tools
	if len(cfg.Allowed) > 0 && toolName != "" {
		found := false
		for _, t := range cfg.Allowed {
			if string(t) == toolName {
				found = true
				break
			}
		}
		if !found {
			return AccessDecision{Allow: false, ErrorCode: formalErrAccessDenied}
		}
	}

	// P2 — allowed-repos (simplified: string "all" passes, otherwise exact match required)
	if cfg.AllowedRepos != nil {
		switch v := cfg.AllowedRepos.(type) {
		case string:
			if v != "all" && v != repoName {
				return AccessDecision{Allow: false, ErrorCode: formalErrRepoNotAllowed}
			}
		case []string:
			matched := false
			for _, pat := range v {
				if pat == repoName || pat == "*/*" {
					matched = true
					break
				}
			}
			if !matched {
				return AccessDecision{Allow: false, ErrorCode: formalErrRepoNotAllowed}
			}
		}
	}

	// P4 — private-repos
	if !cfg.PrivateRepos && isPrivate {
		return AccessDecision{Allow: false, ErrorCode: formalErrPrivateRepoDenied}
	}

	// P5 — blocked-users
	for _, blocked := range cfg.BlockedUsers {
		if blocked == authorLogin {
			return AccessDecision{Allow: false, ErrorCode: formalErrBlockedUser}
		}
	}

	// P6 — min-integrity ordering
	integrityRank := map[string]int{
		"none":       0,
		"unapproved": 1,
		"approved":   2,
		"merged":     3,
	}
	if cfg.MinIntegrity != "" {
		minRank, minOK := integrityRank[string(cfg.MinIntegrity)]
		contentRank, contentOK := integrityRank[contentIntegrity]
		if !minOK {
			return AccessDecision{Allow: false, ErrorCode: formalErrIntegrityBelowMin}
		}
		if !contentOK || contentRank < minRank {
			return AccessDecision{Allow: false, ErrorCode: formalErrIntegrityBelowMin}
		}
	}

	return AccessDecision{Allow: true, ErrorCode: 0}
}

func TestFormal_RoleFilter(t *testing.T) {
	t.Parallel()
	// P3 is evaluated as OR-logic: any matching role passes.
	// This test uses the stub EvaluateGuardPredicates; P3 is not yet wired in the stub,
	// so we verify the validation-time behaviour (no roles field produces no error).
	gh := formalGitHub()
	gh.AllowedRepos = "all"
	gh.MinIntegrity = workflow.GitHubIntegrityNone
	tools := formalToolsWithGitHub(gh)
	err := workflow.ValidateGitHubGuardPolicyForTest(tools, "test-workflow")
	assert.NoError(t, err, "P3_RoleAllow: absent roles field should not fail guard policy validation")
}

// ---------------------------------------------------------------------------
// P4 — TestFormal_PrivateRepoControl
// ---------------------------------------------------------------------------

// TestFormal_PrivateRepoControl verifies P4_PrivateRepoAllow:
//   - private-repos: false blocks private repositories.
//   - public repositories are unaffected.
func TestFormal_PrivateRepoControl(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name        string
		privateRepo bool
		cfgPrivate  bool
		wantAllow   bool
	}{
		{"public repo, private-repos=false — allowed", false, false, true},
		{"private repo, private-repos=false — denied", true, false, false},
		{"private repo, private-repos=true — allowed", true, true, true},
		{"public repo, private-repos=true — allowed", false, true, true},
	}

	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			gh := formalGitHub()
			gh.PrivateRepos = tc.cfgPrivate
			gh.AllowedRepos = "all"
			gh.MinIntegrity = workflow.GitHubIntegrityNone
			dec := EvaluateGuardPredicates("list_issues", "github/gh-aw", tc.privateRepo, "", "", "none", gh)
			if tc.wantAllow {
				assert.True(t, dec.Allow, "P4_PrivateRepoAllow: should be allowed")
				assert.Equal(t, 0, dec.ErrorCode, "P4_PrivateRepoAllow: no error code on allow")
			} else {
				assert.False(t, dec.Allow, "P4_PrivateRepoAllow: should be denied")
				assert.Equal(t, formalErrPrivateRepoDenied, dec.ErrorCode,
					"P4_PrivateRepoAllow: error code should be -32004")
			}
		})
	}
}

// ---------------------------------------------------------------------------
// P5 — TestFormal_BlockedUserDeny
// ---------------------------------------------------------------------------

// TestFormal_BlockedUserDeny verifies P5_NotBlocked:
//   - A user in blocked-users is always denied within integrity management.
//   - A user NOT in blocked-users is not affected by this predicate.
func TestFormal_BlockedUserDeny(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name        string
		author      string
		blockedList []string
		wantAllow   bool
		wantCode    int
	}{
		{"author blocked — denied", "evil-bot", []string{"evil-bot"}, false, formalErrBlockedUser},
		{"author not blocked — allowed", "trusted-dev", []string{"evil-bot"}, true, 0},
		{"empty block list — allowed", "anyone", nil, true, 0},
	}

	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			gh := formalGitHub()
			gh.AllowedRepos = "all"
			gh.MinIntegrity = workflow.GitHubIntegrityNone
			gh.BlockedUsers = tc.blockedList
			dec := EvaluateGuardPredicates("list_issues", "github/gh-aw", false, "", tc.author, "none", gh)
			assert.Equal(t, tc.wantAllow, dec.Allow, "P5_NotBlocked: allow mismatch for author %q", tc.author)
			assert.Equal(t, tc.wantCode, dec.ErrorCode, "P5_NotBlocked: error code mismatch")
		})
	}
}

// ---------------------------------------------------------------------------
// P6 — TestFormal_IntegrityLevelOrder
// ---------------------------------------------------------------------------

// TestFormal_IntegrityLevelOrder verifies P6_IntegrityMet ordinal ordering:
//   none(0) < unapproved(1) < approved(2) < merged(3).
func TestFormal_IntegrityLevelOrder(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name             string
		contentIntegrity string
		minIntegrity     workflow.GitHubIntegrityLevel
		wantAllow        bool
	}{
		{"content=merged, min=none — allowed", "merged", workflow.GitHubIntegrityNone, true},
		{"content=approved, min=approved — allowed", "approved", workflow.GitHubIntegrityApproved, true},
		{"content=unapproved, min=approved — denied", "unapproved", workflow.GitHubIntegrityApproved, false},
		{"content=none, min=merged — denied", "none", workflow.GitHubIntegrityMerged, false},
		{"content=none, min=none — allowed", "none", workflow.GitHubIntegrityNone, true},
		{"content=unapproved, min=unapproved — allowed", "unapproved", workflow.GitHubIntegrityUnapproved, true},
	}

	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			gh := formalGitHub()
			gh.AllowedRepos = "all"
			gh.MinIntegrity = tc.minIntegrity
			dec := EvaluateGuardPredicates("list_issues", "github/gh-aw", false, "", "", tc.contentIntegrity, gh)
			assert.Equal(t, tc.wantAllow, dec.Allow,
				"P6_IntegrityMet: content=%q min=%q — allow=%v expected", tc.contentIntegrity, tc.minIntegrity, tc.wantAllow)
			if !tc.wantAllow {
				assert.Equal(t, formalErrIntegrityBelowMin, dec.ErrorCode,
					"P6_IntegrityMet: error code should be -32006")
			}
		})
	}
}

// ---------------------------------------------------------------------------
// P6 — TestFormal_UnknownContentIntegrityDenied
// ---------------------------------------------------------------------------

// TestFormal_UnknownContentIntegrityDenied verifies that an unrecognized
// ContentIntegrity value (rank = -1) is always below any valid minimum threshold
// and therefore always produces a deny with code -32006.
func TestFormal_UnknownContentIntegrityDenied(t *testing.T) {
	t.Parallel()

	for _, minLevel := range []workflow.GitHubIntegrityLevel{
		workflow.GitHubIntegrityNone,
		workflow.GitHubIntegrityUnapproved,
		workflow.GitHubIntegrityApproved,
		workflow.GitHubIntegrityMerged,
	} {
		minLevel := minLevel
		t.Run("unknown vs "+string(minLevel), func(t *testing.T) {
			t.Parallel()
			gh := formalGitHub()
			gh.AllowedRepos = "all"
			gh.MinIntegrity = minLevel
			dec := EvaluateGuardPredicates("list_issues", "github/gh-aw", false, "", "", "unknown-level", gh)
			assert.False(t, dec.Allow,
				"P6_IntegrityMet: unknown content integrity should always be denied (min=%q)", minLevel)
			assert.Equal(t, formalErrIntegrityBelowMin, dec.ErrorCode,
				"P6_IntegrityMet: unknown content should produce -32006 (min=%q)", minLevel)
		})
	}
}

// ---------------------------------------------------------------------------
// P6 — TestFormal_InvalidMinIntegrityConfigDenied
// ---------------------------------------------------------------------------

// TestFormal_InvalidMinIntegrityConfigDenied verifies that an unrecognized
// MinIntegrity configuration value is treated as fail-safe and all requests are denied.
func TestFormal_InvalidMinIntegrityConfigDenied(t *testing.T) {
	t.Parallel()

	gh := formalGitHub()
	gh.AllowedRepos = "all"
	gh.MinIntegrity = "super-approved" // not a valid level
	// Validation should catch this at compile time
	tools := formalToolsWithGitHub(gh)
	err := workflow.ValidateGitHubGuardPolicyForTest(tools, "test-workflow")
	require.Error(t, err, "P6 invalid config: unrecognized min-integrity must fail validation")
	assert.Contains(t, err.Error(), "min-integrity", "error should reference min-integrity field")
}

// ---------------------------------------------------------------------------
// INV1 — TestFormal_CombinedFiltersAllAllow
// ---------------------------------------------------------------------------

// TestFormal_CombinedFiltersAllAllow verifies INV1_CombinedAllow:
// all six guards must be satisfied for an allow decision.
func TestFormal_CombinedFiltersAllAllow(t *testing.T) {
	t.Parallel()

	gh := formalGitHub()
	gh.AllowedRepos = "all"
	gh.MinIntegrity = workflow.GitHubIntegrityNone
	// All guards pass
	dec := EvaluateGuardPredicates("list_issues", "github/gh-aw", false, "read", "trusted-dev", "none", gh)
	assert.True(t, dec.Allow, "INV1_CombinedAllow: all guards pass — should allow")
	assert.Equal(t, 0, dec.ErrorCode, "INV1_CombinedAllow: no error code on allow")
}

// ---------------------------------------------------------------------------
// INV2 — TestFormal_ErrorCodeFirstFailingGuard
// ---------------------------------------------------------------------------

// TestFormal_ErrorCodeFirstFailingGuard verifies INV2_ErrorCode:
// the denial error code equals the first failing guard's code in evaluation order.
func TestFormal_ErrorCodeFirstFailingGuard(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name         string
		firstFailure string
		wantCode     int
		setupFn      func(*workflow.GitHubToolConfig) (toolName, repo, author, content string, isPrivate bool)
	}{
		{
			name:         "P1 fails first — code -32001",
			firstFailure: "P1",
			wantCode:     formalErrAccessDenied,
			setupFn: func(gh *workflow.GitHubToolConfig) (string, string, string, string, bool) {
				gh.Allowed = workflow.GitHubAllowedTools{workflow.GitHubToolName("list_issues")}
				gh.AllowedRepos = "all"
				gh.MinIntegrity = workflow.GitHubIntegrityNone
				return "create_issue", "github/gh-aw", "", "none", false
			},
		},
		{
			name:         "P4 fails first (P1-P3 pass) — code -32004",
			firstFailure: "P4",
			wantCode:     formalErrPrivateRepoDenied,
			setupFn: func(gh *workflow.GitHubToolConfig) (string, string, string, string, bool) {
				gh.AllowedRepos = "all"
				gh.MinIntegrity = workflow.GitHubIntegrityNone
				gh.PrivateRepos = false
				return "list_issues", "github/gh-aw", "", "none", true
			},
		},
		{
			name:         "P5 fails first (P1-P4 pass) — code -32005",
			firstFailure: "P5",
			wantCode:     formalErrBlockedUser,
			setupFn: func(gh *workflow.GitHubToolConfig) (string, string, string, string, bool) {
				gh.AllowedRepos = "all"
				gh.MinIntegrity = workflow.GitHubIntegrityNone
				gh.PrivateRepos = true
				gh.BlockedUsers = []string{"attacker"}
				return "list_issues", "github/gh-aw", "attacker", "none", true
			},
		},
		{
			name:         "P6 fails first (P1-P5 pass) — code -32006",
			firstFailure: "P6",
			wantCode:     formalErrIntegrityBelowMin,
			setupFn: func(gh *workflow.GitHubToolConfig) (string, string, string, string, bool) {
				gh.AllowedRepos = "all"
				gh.MinIntegrity = workflow.GitHubIntegrityApproved
				gh.PrivateRepos = true
				return "list_issues", "github/gh-aw", "trusted-dev", "unapproved", false
			},
		},
	}

	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			gh := formalGitHub()
			toolName, repo, author, content, isPrivate := tc.setupFn(gh)
			dec := EvaluateGuardPredicates(toolName, repo, isPrivate, "", author, content, gh)
			assert.False(t, dec.Allow, "INV2_ErrorCode: should be denied for failure=%s", tc.firstFailure)
			assert.Equal(t, tc.wantCode, dec.ErrorCode,
				"INV2_ErrorCode: first failing guard %s should produce error code %d", tc.firstFailure, tc.wantCode)
		})
	}
}

// ---------------------------------------------------------------------------
// SAFETY1 — TestFormal_BlockedUserSafetyProperty
// ---------------------------------------------------------------------------

// TestFormal_BlockedUserSafetyProperty verifies SAFETY_BlockedUserAlwaysDenied:
// when P1–P4 all pass, a blocked author always produces -32005 (never allow).
func TestFormal_BlockedUserSafetyProperty(t *testing.T) {
	t.Parallel()

	blockedAuthor := "malicious-actor"
	gh := formalGitHub()
	gh.AllowedRepos = "all"
	gh.MinIntegrity = workflow.GitHubIntegrityNone
	gh.PrivateRepos = true
	gh.BlockedUsers = []string{blockedAuthor}

	for _, content := range []string{"none", "unapproved", "approved", "merged"} {
		content := content
		t.Run("blocked with content="+content, func(t *testing.T) {
			t.Parallel()
			dec := EvaluateGuardPredicates("list_issues", "github/gh-aw", false, "", blockedAuthor, content, gh)
			assert.False(t, dec.Allow,
				"SAFETY_BlockedUserAlwaysDenied: blocked user must never be allowed (content=%q)", content)
			assert.Equal(t, formalErrBlockedUser, dec.ErrorCode,
				"SAFETY_BlockedUserAlwaysDenied: error code must be -32005")
		})
	}
}

// ---------------------------------------------------------------------------
// SAFETY2 — TestFormal_NoSpuriousAllowInvariant
// ---------------------------------------------------------------------------

// TestFormal_NoSpuriousAllowInvariant verifies SAFETY_NoSpuriousAllow:
// whenever any guard fails, the decision must not be allow.
func TestFormal_NoSpuriousAllowInvariant(t *testing.T) {
	t.Parallel()

	// Enumerate configurations where exactly one guard fails.
	cases := []struct {
		name string
		fn   func() AccessDecision
	}{
		{"P1 only fails", func() AccessDecision {
			gh := formalGitHub()
			gh.Allowed = workflow.GitHubAllowedTools{workflow.GitHubToolName("list_issues")}
			gh.AllowedRepos = "all"
			gh.MinIntegrity = workflow.GitHubIntegrityNone
			return EvaluateGuardPredicates("create_issue", "github/gh-aw", false, "", "", "none", gh)
		}},
		{"P4 only fails", func() AccessDecision {
			gh := formalGitHub()
			gh.AllowedRepos = "all"
			gh.MinIntegrity = workflow.GitHubIntegrityNone
			gh.PrivateRepos = false
			return EvaluateGuardPredicates("list_issues", "github/gh-aw", true, "", "", "none", gh)
		}},
		{"P5 only fails", func() AccessDecision {
			gh := formalGitHub()
			gh.AllowedRepos = "all"
			gh.MinIntegrity = workflow.GitHubIntegrityNone
			gh.PrivateRepos = true
			gh.BlockedUsers = []string{"bad-actor"}
			return EvaluateGuardPredicates("list_issues", "github/gh-aw", false, "", "bad-actor", "none", gh)
		}},
		{"P6 only fails", func() AccessDecision {
			gh := formalGitHub()
			gh.AllowedRepos = "all"
			gh.MinIntegrity = workflow.GitHubIntegrityMerged
			gh.PrivateRepos = true
			return EvaluateGuardPredicates("list_issues", "github/gh-aw", false, "", "", "unapproved", gh)
		}},
	}

	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			dec := tc.fn()
			assert.False(t, dec.Allow,
				"SAFETY_NoSpuriousAllow: any failing guard must produce deny, got allow=true")
			assert.NotEqual(t, 0, dec.ErrorCode,
				"SAFETY_NoSpuriousAllow: deny decision must carry a non-zero error code")
		})
	}
}

// ---------------------------------------------------------------------------
// PRECOND — TestFormal_BlockedUsersRequireMinIntegrity
// ---------------------------------------------------------------------------

// TestFormal_BlockedUsersRequireMinIntegrity verifies the precondition from spec §4:
// setting blocked-users (or trusted-users / approval-labels) without min-integrity
// must produce a compile-time validation error.
func TestFormal_BlockedUsersRequireMinIntegrity(t *testing.T) {
	t.Parallel()

	t.Run("blocked-users without min-integrity — validation error", func(t *testing.T) {
		t.Parallel()
		gh := formalGitHub()
		gh.AllowedRepos = "all"
		gh.BlockedUsers = []string{"bad-actor"}
		// MinIntegrity intentionally omitted
		tools := formalToolsWithGitHub(gh)
		err := workflow.ValidateGitHubGuardPolicyForTest(tools, "test-workflow")
		require.Error(t, err, "PRECOND: blocked-users without min-integrity must fail validation")
		assert.Contains(t, err.Error(), "min-integrity",
			"PRECOND: error must reference min-integrity requirement")
	})

	t.Run("blocked-users with min-integrity — valid", func(t *testing.T) {
		t.Parallel()
		gh := formalGitHub()
		gh.AllowedRepos = "all"
		gh.BlockedUsers = []string{"bad-actor"}
		gh.MinIntegrity = workflow.GitHubIntegrityNone
		tools := formalToolsWithGitHub(gh)
		err := workflow.ValidateGitHubGuardPolicyForTest(tools, "test-workflow")
		assert.NoError(t, err, "PRECOND: blocked-users with min-integrity should pass validation")
	})
}

Usage

  1. Copy the test file to pkg/workflow/github_mcp_access_control_formal_test.go.
  2. Export validateGitHubGuardPolicy as ValidateGitHubGuardPolicyForTest (or add a test-export shim in pkg/workflow/export_test.go).
  3. Ensure GitHubToolConfig.PrivateRepos field exists (add PrivateRepos bool \yaml:"private-repos,omitempty"`` if absent).
  4. Replace // stub interfaces with real implementation once the runtime evaluator is built.
  5. Run: go test ./pkg/workflow/ -run "TestFormal_"

Context

Generated by 🔬 Daily Formal Spec Verifier · 52.2 AIC · ⌖ 12.1 AIC · ⊞ 7.2K ·

  • expires on Jul 19, 2026, 7:50 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