// 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")
})
}
Summary
The
specs/github-mcp-access-control-compliance/README.mdspecification 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 howallowed-tools,allowed-repos,roles,private-repos,blocked-users, andmin-integrityfilters 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
specs/github-mcp-access-control-compliance/README.mdFormal Model
Predicates and invariants (illustrative notation)
Source: spec §4.5.3 "Evaluation order" and the Formal Model section.
Z3 / SMT-LIB — integrity ordering (spec §4.5.3 integrity management)
F* contract — validateGitHubGuardPolicy preconditions
Behavioral Coverage Map
P1_ToolAllowedTestFormal_ToolNameFilterallowed-toolsallows named tool, denies others; empty tool name denies against non-empty listP2_RepoMatch(exact)TestFormal_ExactMatchAllowowner/repopattern allows matching repo, denies non-matchP2_RepoMatch(wildcard)TestFormal_WildcardMatchowner/*and*/repowildcard patterns are accepted;*/*full wildcardP2_RepoMatch(omitted)TestFormal_OmittedReposAllowAllreposallows all repos; empty array is compile-time validation errorP3_RoleAllowTestFormal_RoleFilterP4_PrivateRepoAllowTestFormal_PrivateRepoControlprivate-repos: falseblocks private repos; public repos passP5_NotBlockedTestFormal_BlockedUserDenyP6_IntegrityMetTestFormal_IntegrityLevelOrderP6_IntegrityMet(unknown)TestFormal_UnknownContentIntegrityDeniedContentIntegrity(rank -1) is always below any valid thresholdP6_IntegrityMet(invalid cfg)TestFormal_InvalidMinIntegrityConfigDeniedMinIntegrityconfig fails validationINV1_CombinedAllowTestFormal_CombinedFiltersAllAllowINV2_ErrorCodeTestFormal_ErrorCodeFirstFailingGuardSAFETY_BlockedUserAlwaysDeniedTestFormal_BlockedUserSafetyProperty-32005when P1–P4 passSAFETY_NoSpuriousAllowTestFormal_NoSpuriousAllowInvariantPRECOND_BlockedUsersRequireMinIntegrityTestFormal_BlockedUsersRequireMinIntegrityGenerated Test Suite
📄 `pkg/workflow/github_mcp_access_control_formal_test.go`
Usage
pkg/workflow/github_mcp_access_control_formal_test.go.validateGitHubGuardPolicyasValidateGitHubGuardPolicyForTest(or add a test-export shim inpkg/workflow/export_test.go).GitHubToolConfig.PrivateReposfield exists (addPrivateRepos bool \yaml:"private-repos,omitempty"`` if absent).// stubinterfaces with real implementation once the runtime evaluator is built.go test ./pkg/workflow/ -run "TestFormal_"Context
specs/github-mcp-access-control-compliance/README.md