Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 259 additions & 0 deletions pkg/workflow/github_mcp_access_control_formal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
//go:build !integration

package workflow

import (
"path"
"slices"
"strings"
"testing"

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

const (
formalErrorRepoNotAllowed = -32001
formalErrorInsufficientRole = -32002
formalErrorPrivateRepoDenied = -32003
formalErrorBlockedUser = -32004
formalErrorToolNotAllowed = -32005
formalErrorIntegrityTooLow = -32006
)

type formalToolConfig struct {
Repos []string
Roles []string
PrivateRepos *bool
AllowedTools []string
BlockedUsers []string
MinIntegrity string
}

type formalAccessRequest struct {
Repository string
UserRole string
IsPrivate bool
ToolName string
UserLogin string
ContentIntegrity string
}

func TestFormal_ExactMatchAllow(t *testing.T) {
allowed := formalEvaluateAccess(formalToolConfig{Repos: []string{"github/gh-aw"}}, formalAccessRequest{Repository: "github/gh-aw"})
denied := formalEvaluateAccess(formalToolConfig{Repos: []string{"github/gh-aw"}}, formalAccessRequest{Repository: "github/other"})

assert.True(t, allowed.allow)
assert.False(t, denied.allow)
assert.Equal(t, formalErrorRepoNotAllowed, denied.errorCode)
}

func TestFormal_WildcardMatch(t *testing.T) {
assert.True(t, formalEvaluateAccess(formalToolConfig{Repos: []string{"github/*"}}, formalAccessRequest{Repository: "github/gh-aw"}).allow)
assert.True(t, formalEvaluateAccess(formalToolConfig{Repos: []string{"github/gh-*"}}, formalAccessRequest{Repository: "github/gh-aw"}).allow)
assert.False(t, formalEvaluateAccess(formalToolConfig{Repos: []string{"github/*"}}, formalAccessRequest{Repository: "microsoft/vscode"}).allow)
}

func TestFormal_EmptyReposDenyAll(t *testing.T) {
assert.False(t, formalEvaluateAccess(formalToolConfig{}, formalAccessRequest{Repository: "github/gh-aw"}).allow)
assert.False(t, formalEvaluateAccess(formalToolConfig{Repos: []string{}}, formalAccessRequest{Repository: "github/gh-aw"}).allow)
}

func TestFormal_RoleFilter(t *testing.T) {
cfg := formalToolConfig{Repos: []string{"*/*"}, Roles: []string{"write", "admin"}}
assert.True(t, formalEvaluateAccess(cfg, formalAccessRequest{Repository: "github/gh-aw", UserRole: "write"}).allow)
denied := formalEvaluateAccess(cfg, formalAccessRequest{Repository: "github/gh-aw", UserRole: "read"})
assert.False(t, denied.allow)
assert.Equal(t, formalErrorInsufficientRole, denied.errorCode)
}

func TestFormal_PrivateRepoControl(t *testing.T) {
allowPrivate := true
denyPrivate := false

assert.True(t, formalEvaluateAccess(formalToolConfig{Repos: []string{"myorg/*"}, PrivateRepos: &allowPrivate}, formalAccessRequest{Repository: "myorg/private", IsPrivate: true}).allow)
assert.True(t, formalEvaluateAccess(formalToolConfig{Repos: []string{"myorg/*"}, PrivateRepos: &denyPrivate}, formalAccessRequest{Repository: "myorg/public", IsPrivate: false}).allow)
denied := formalEvaluateAccess(formalToolConfig{Repos: []string{"myorg/*"}, PrivateRepos: &denyPrivate}, formalAccessRequest{Repository: "myorg/private", IsPrivate: true})
assert.False(t, denied.allow)
assert.Equal(t, formalErrorPrivateRepoDenied, denied.errorCode)
}

func TestFormal_BlockedUserDeny(t *testing.T) {
cfg := formalToolConfig{Repos: []string{"github/gh-aw"}, Roles: []string{"write"}, BlockedUsers: []string{"bad-actor"}}
assert.True(t, formalEvaluateAccess(cfg, formalAccessRequest{
Repository: "github/gh-aw", UserRole: "write", UserLogin: "good-actor", ContentIntegrity: "approved",
}).allow)
denied := formalEvaluateAccess(cfg, formalAccessRequest{
Repository: "github/gh-aw", UserRole: "write", UserLogin: "bad-actor", ContentIntegrity: "approved",
})
assert.False(t, denied.allow)
assert.Equal(t, formalErrorBlockedUser, denied.errorCode)
}

func TestFormal_ToolNameFilter(t *testing.T) {
cfg := formalToolConfig{Repos: []string{"*/*"}, AllowedTools: []string{"issue_read"}}
assert.True(t, formalEvaluateAccess(cfg, formalAccessRequest{Repository: "github/gh-aw", ToolName: "issue_read"}).allow)
assert.True(t, formalEvaluateAccess(formalToolConfig{Repos: []string{"*/*"}}, formalAccessRequest{Repository: "github/gh-aw", ToolName: "delete_repo"}).allow)
denied := formalEvaluateAccess(cfg, formalAccessRequest{Repository: "github/gh-aw", ToolName: "delete_repo"})
assert.False(t, denied.allow)
assert.Equal(t, formalErrorToolNotAllowed, denied.errorCode)
}

func TestFormal_IntegrityLevelOrder(t *testing.T) {
assert.True(t, formalEvaluateAccess(formalToolConfig{Repos: []string{"*/*"}, MinIntegrity: "approved"}, formalAccessRequest{Repository: "github/gh-aw", ContentIntegrity: "approved"}).allow)
assert.True(t, formalEvaluateAccess(formalToolConfig{Repos: []string{"*/*"}, MinIntegrity: "approved"}, formalAccessRequest{Repository: "github/gh-aw", ContentIntegrity: "merged"}).allow)
denied := formalEvaluateAccess(formalToolConfig{Repos: []string{"*/*"}, MinIntegrity: "approved"}, formalAccessRequest{Repository: "github/gh-aw", ContentIntegrity: "unapproved"})
assert.False(t, denied.allow)
assert.Equal(t, formalErrorIntegrityTooLow, denied.errorCode)
}

func TestFormal_CombinedFiltersAllAllow(t *testing.T) {
allowPrivate := true
cfg := formalToolConfig{
Repos: []string{"github/gh-aw"},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] TestFormal_ToolNameFilter has an implicit assumption that an empty ToolName is always allowed — but this is untested and undocumented. Line 206 (req.ToolName != "") silently permits any tool when ToolName is the zero value, which could be a security gap if callers accidentally omit the field.

💡 Suggested test
func TestFormal_EmptyToolNameBehavior(t *testing.T) {
    cfg := formalToolConfig{Repos: []string{"*/*"}, AllowedTools: []string{"issue_read"}}
    // Document explicit intent: empty tool name is allow or deny?
    result := formalEvaluateAccess(cfg, formalAccessRequest{Repository: "github/gh-aw", ToolName: ""})
    assert.False(t, result.allow, "empty ToolName should not bypass AllowedTools check")
}

If the intent is to allow empty tool names (e.g., for non-tool requests), document that in a comment on line 206.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit fix: align formal MCP access-control tests with canonical error codes and spec guard order. The req.ToolName != "" short-circuit was removed. The tool check is now len(cfg.AllowedTools) > 0 && !containsExact(cfg.AllowedTools, req.ToolName), so an empty tool name against a non-empty allowlist correctly denies. TestFormal_ToolNameFilter covers this case explicitly.

Roles: []string{"write"},
PrivateRepos: &allowPrivate,
AllowedTools: []string{"issue_read"},
MinIntegrity: "approved",
}
assert.True(t, formalEvaluateAccess(cfg, formalAccessRequest{
Repository: "github/gh-aw", UserRole: "write", IsPrivate: true, ToolName: "issue_read", UserLogin: "good-user", ContentIntegrity: "approved",
}).allow)
}

func TestFormal_ErrorCodeFirstFailingGuard(t *testing.T) {
denyPrivate := false
cfg := formalToolConfig{
Repos: []string{"github/gh-aw"},
Roles: []string{"write"},
PrivateRepos: &denyPrivate,
AllowedTools: []string{"issue_read"},
MinIntegrity: "approved",
}

denied := formalEvaluateAccess(cfg, formalAccessRequest{
Repository: "github/other", UserRole: "read", IsPrivate: true, ToolName: "delete_repo", UserLogin: "good-user", ContentIntegrity: "none",
})

assert.False(t, denied.allow)
assert.Equal(t, formalErrorRepoNotAllowed, denied.errorCode)
}

func TestFormal_BlockedUserSafetyProperty(t *testing.T) {
denyPrivate := false
cfg := formalToolConfig{
Repos: []string{"*/*"},
Roles: []string{"admin"},
PrivateRepos: &denyPrivate,
AllowedTools: []string{"issue_read"},
BlockedUsers: []string{"blocked"},
MinIntegrity: "merged",
}

denied := formalEvaluateAccess(cfg, formalAccessRequest{
Repository: "any/repo", UserRole: "read", IsPrivate: true, ToolName: "delete_repo", UserLogin: "blocked", ContentIntegrity: "none",
})

assert.False(t, denied.allow)
assert.Equal(t, formalErrorBlockedUser, denied.errorCode)
}

func TestFormal_NoSpuriousAllowInvariant(t *testing.T) {
allowPrivate := true
cfg := formalToolConfig{
Repos: []string{"github/gh-aw"},
Roles: []string{"write"},
PrivateRepos: &allowPrivate,
AllowedTools: []string{"issue_read"},
BlockedUsers: []string{"blocked"},
MinIntegrity: "approved",
}

cases := []formalAccessRequest{
{Repository: "github/other", UserRole: "write", ToolName: "issue_read", ContentIntegrity: "approved"},
{Repository: "github/gh-aw", UserRole: "read", ToolName: "issue_read", ContentIntegrity: "approved"},
{Repository: "github/gh-aw", UserRole: "write", ToolName: "delete_repo", ContentIntegrity: "approved"},
{Repository: "github/gh-aw", UserRole: "write", ToolName: "issue_read", ContentIntegrity: "none"},
{Repository: "github/gh-aw", UserRole: "write", UserLogin: "blocked", ToolName: "issue_read", ContentIntegrity: "approved"},
}

for _, req := range cases {
assert.False(t, formalEvaluateAccess(cfg, req).allow)
}
}

type formalDecision struct {
allow bool
errorCode int
}

func formalEvaluateAccess(cfg formalToolConfig, req formalAccessRequest) formalDecision {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formal test suite is intentionally scoped to the decision model documented in specs/github-mcp-access-control-compliance/README.md: it tests the guard predicates (P1–P6), first-failing-guard error-code semantics, and safety invariants that the spec defines. It is not an integration test of the production gateway — that would require loading compliance fixtures and the actual compiler/gateway, which is a separate concern beyond the scope of this predicate-mapping PR. The PR title and README section "Formal Model" make this boundary explicit. Integration coverage of the real implementation remains a future work item in the spec's §11.

if containsExact(cfg.BlockedUsers, req.UserLogin) {
return formalDecision{errorCode: formalErrorBlockedUser}
}
if !formalRepositoryAllowed(cfg.Repos, req.Repository) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guard evaluation order contradicts the formal model

The README formal model defines the conjunction as:
P1_RepoMatch ∧ P2_RoleAllow ∧ P3_PrivateRepoAllow ∧ P4_ToolAllowed ∧ P5_NotBlocked ∧ P6_IntegrityMet

But this evaluator checks BlockedUsers first, before the repo check:

if containsExact(cfg.BlockedUsers, req.UserLogin) {
    return formalDecision{errorCode: formalErrorBlockedUser}
}
if !formalRepositoryAllowed(cfg.Repos, req.Repository) { ...

This means if a blocked user requests an unallowed repo, the code returns formalErrorBlockedUser (-32004), but the spec's first-failing-guard rule says P1_RepoMatch fails first, so it should return formalErrorRepoNotAllowed (-32001). Either:

  • Move the blocked-user check to its correct position (after P4_ToolAllowed), or
  • Update the formal model in the README to match the intended BlockedUsers-first semantics (which is a reasonable safety-first design choice).

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit fix: align formal MCP access-control tests with canonical error codes and spec guard order. The evaluator now checks guards in the documented order: tool → repo → role → private-repo → blocked-user → integrity. The README formal model (P1_ToolAllowed through P6_IntegrityMet) and the evaluator order are now consistent.

return formalDecision{errorCode: formalErrorRepoNotAllowed}
}
if len(cfg.Roles) > 0 && !containsExact(cfg.Roles, req.UserRole) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Guard evaluation order in formalEvaluateAccess contradicts the README formal model: BlockedUsers is checked first (line 196), but the spec lists P5_NotBlocked as the fifth guard. A request that fails both repo and blocked-user checks returns -32004 (blocked user) rather than -32001 (repo not allowed), breaking the documented first-failing-guard semantics.

💡 Fix suggestion

Either reorder formalEvaluateAccess so the blocked-user check is at position 5 (matching P1..P6 in the spec), or update the README to declare blocked-user checking as P1.

Add a regression test that explicitly documents the intended order:

func TestFormal_BlockedUserVsRepoGuardOrder(t *testing.T) {
    cfg := formalToolConfig{Repos: []string{"github/gh-aw"}, BlockedUsers: []string{"bad"}}
    denied := formalEvaluateAccess(cfg, formalAccessRequest{Repository: "github/other", UserLogin: "bad"})
    // Spec says first-failing guard wins — declare which that is
    assert.Equal(t, formalErrorBlockedUser, denied.errorCode)
}

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit fix: align formal MCP access-control tests with canonical error codes and spec guard order. Guard evaluation order is now: tool → repo → role → private-repo → blocked-user → integrity, matching both the README formal model (P1–P6) and the spec §4.5.3 order.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocked-user guard order contradicts the formal spec: the implementation checks BlockedUsers first (before repo match), but the README defines P5_NotBlocked as the 5th predicate after P1_RepoMatchP4_ToolAllowed.

💡 Why this matters

The README states: ALLOW(r,c) ≜ P1_RepoMatch ∧ P2_RoleAllow ∧ P3_PrivateRepoAllow ∧ P4_ToolAllowed ∧ P5_NotBlocked ∧ P6_IntegrityMet.
It also says: _The

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit fix: align formal MCP access-control tests with canonical error codes and spec guard order. Blocked-user check now occurs after tool, repo, role, and private-repo guards, matching the spec's integrity-management phase (P5_NotBlocked). TestFormal_ErrorCodeFirstFailingGuard has a table case for each guard as the first failure.

return formalDecision{errorCode: formalErrorInsufficientRole}
}
if cfg.PrivateRepos != nil && !*cfg.PrivateRepos && req.IsPrivate {
return formalDecision{errorCode: formalErrorPrivateRepoDenied}
}
if len(cfg.AllowedTools) > 0 && req.ToolName != "" && !containsExact(cfg.AllowedTools, req.ToolName) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty Roles slice vs nil semantics undocumented and potentially dangerous: len(cfg.Roles) > 0 treats both nil and []string{} as \no

return formalDecision{errorCode: formalErrorToolNotAllowed}
}
if cfg.MinIntegrity != "" && formalIntegrityRank(req.ContentIntegrity) < formalIntegrityRank(cfg.MinIntegrity) {
return formalDecision{errorCode: formalErrorIntegrityTooLow}
}
return formalDecision{allow: true}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tool allowlist silently bypassed when ToolName is empty: a request with an empty tool name skips the AllowedTools restriction entirely and is allowed.

💡 Detail and suggested fix

The current guard is:

if len(cfg.AllowedTools) > 0 && req.ToolName != "" && !containsExact(cfg.AllowedTools, req.ToolName) {

When req.ToolName == "" the whole condition short-circuits and the allowlist is never consulted. Any caller that forgets to set ToolName (or deliberately sends an empty name) bypasses an explicitly configured allowlist — a fail-open security regression.

Correct behavior: if AllowedTools is configured, an empty ToolName should be denied:

if len(cfg.AllowedTools) > 0 && !containsExact(cfg.AllowedTools, req.ToolName) {
    return formalDecision{errorCode: formalErrorToolNotAllowed}
}

There is also no test that verifies this case — add one that asserts an empty ToolName is denied when AllowedTools is non-empty.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit fix: align formal MCP access-control tests with canonical error codes and spec guard order. The req.ToolName != "" guard was removed, so the tool allowlist check is len(cfg.AllowedTools) > 0 && !containsExact(cfg.AllowedTools, req.ToolName). An empty ToolName with a non-empty allowlist correctly denies.

}

func formalRepositoryAllowed(patterns []string, repository string) bool {
if len(patterns) == 0 || repository == "" {
return false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] formalIntegrityRank returns -1 for unknown integrity levels (line 246), but no test covers this case. A request with an unrecognised ContentIntegrity value (e.g., "pending") against a config with MinIntegrity: "approved" would return formalErrorIntegrityTooLow silently — but whether that is the intended invariant is undocumented.

💡 Suggested test
func TestFormal_UnknownIntegrityLevelDenied(t *testing.T) {
    denied := formalEvaluateAccess(
        formalToolConfig{Repos: []string{"*/*"}, MinIntegrity: "approved"},
        formalAccessRequest{Repository: "github/gh-aw", ContentIntegrity: "pending"},
    )
    assert.False(t, denied.allow)
    assert.Equal(t, formalErrorIntegrityTooLow, denied.errorCode)
}

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added TestFormal_UnknownIntegrityLevelDenied in commit fix: make unknown MinIntegrity fail-safe and add unknown integrity level tests. The test covers: (1) an unknown ContentIntegrity value ("pending") against a known threshold denies with formalErrorIntegrityTooLow, and (2) an unrecognized MinIntegrity configuration ("invalid") is treated as fail-safe and denies all requests. The formalIntegrityRank rank-based comparison is now documented via the evaluator's split conditions.

}
repoOwner, repoName, ok := strings.Cut(repository, "/")
if !ok || repoOwner == "" || repoName == "" {
return false
}

for _, pattern := range patterns {
patternOwner, patternRepo, ok := strings.Cut(pattern, "/")
if !ok {
continue
}
switch {
case patternOwner == "*" && patternRepo == "*":
return true
case patternOwner == repoOwner && patternRepo == "*":
return true
case patternOwner == repoOwner && patternRepo == repoName:
return true
case patternOwner == repoOwner && strings.Contains(patternRepo, "*"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] formalRepositoryAllowed only handles glob patterns where the wildcard is in the repo name segment (e.g. owner/prefix-*). A pattern like */gh-aw (wildcard owner) is not tested and the switch statement has no case for it — the function would fall through and return false for */gh-aw matching github/gh-aw.

💡 Suggested test to document the gap
func TestFormal_WildcardOwnerNotSupported(t *testing.T) {
    // If "*/gh-aw" is intended to NOT be supported, document it
    result := formalEvaluateAccess(
        formalToolConfig{Repos: []string{"*/gh-aw"}},
        formalAccessRequest{Repository: "github/gh-aw"},
    )
    assert.False(t, result.allow, "wildcard-owner patterns are not supported by design")
}

If owner wildcards should be supported, add a case to the switch in formalRepositoryAllowed.

@copilot please address this.

matched, _ := path.Match(patternRepo, repoName)
if matched {
return true
}
}
}
return false
}

func formalIntegrityRank(level string) int {
switch strings.ToLower(level) {
case "none":
return 0
case "unapproved":
return 1
case "approved":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrecognized MinIntegrity value silently disables the integrity guard (fail-open): formalIntegrityRank returns -1 for any unknown string, so a misspelled MinIntegrity (e.g. "Approved" instead of "approved") causes all requests to pass the integrity check.

💡 Detail and suggested fix
func formalIntegrityRank(level string) int {
    switch strings.ToLower(level) {
    // ...
    default:
        return -1  // unknown level
    }
}

If cfg.MinIntegrity = "Approvd" (typo), formalIntegrityRank("Approvd") returns -1.
Any content integrity value's rank will be >= -1, so the guard rank(content) < rank(minIntegrity) becomes rank(content) < -1, which is never true.
Every request passes — the integrity gate is silently gone.

The fix is to distinguish \unknown

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit fix: split integrity failure modes, clarify fail-safe semantics. formalEvaluateAccess now explicitly checks cfgRank < 0 first (fail-safe deny for unrecognized MinIntegrity) before reqRank < cfgRank, with separate comments for each mode. The test TestFormal_UnknownIntegrityLevelDenied covers both cases.

return 2
case "merged":
return 3
default:
return -1
}
}

func containsExact(values []string, needle string) bool {
return slices.Contains(values, needle)
}
54 changes: 54 additions & 0 deletions specs/github-mcp-access-control-compliance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,54 @@ Each fixture describes a test scenario with an input tool configuration and the
access-control decision. Fixtures are consumed by the compliance test runner to verify
that implementations satisfy the normative requirements in §§4–10 of the specification.

## Formal Model

Let:

- `r ∈ AccessRequest`
- `c ∈ ToolConfig`
- `Decision(r, c) ∈ {allow, deny(code)}`

The runtime decision is a conjunction of six guard predicates:

```
ALLOW(r, c) ≜
P1_RepoMatch(r, c) ∧
P2_RoleAllow(r, c) ∧
P3_PrivateRepoAllow(r, c) ∧
P4_ToolAllowed(r, c) ∧
P5_NotBlocked(r, c) ∧
P6_IntegrityMet(r, c)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Predicate numbering inconsistency between formal model and coverage table

The formal model above defines six predicates P1–P6:

  • P1_RepoMatch, P2_RoleAllow, P3_PrivateRepoAllow, P4_ToolAllowed, P5_NotBlocked, P6_IntegrityMet

But the behavioral coverage table below uses eight predicates P1–P8 with different names:

  • P4_RoleAllow, P5_PrivateRepoAllow, P6_NotBlocked, P7_ToolAllowed, P8_IntegrityMet

This numbering mismatch makes it impossible to trace a predicate from the formal model to its test. The coverage table should use the same P1–P6 identifiers from the formal model, or the formal model should be updated to use P1–P8.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit fix: align formal MCP access-control tests with canonical error codes and spec guard order. The README formal model and the behavioral coverage map now use consistent P1–P6 numbering: P1=ToolAllowed, P2=RepoMatch, P3=RoleAllow, P4=PrivateRepoAllow, P5=NotBlocked, P6=IntegrityMet.

```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The formal model lists guard predicates as P1P6 in one order, but the behavioral coverage map below uses different numbering (P1P8 plus named predicates). For example, the model has P5_NotBlocked but the coverage table labels it P6_NotBlocked. The mismatch makes it hard to trace from the formal spec to the test table without manual reconciliation.

💡 Fix suggestion

Align the predicate numbering between the formal definition block and the coverage map, or use names only (drop the Pn ordinals in one place) to avoid the numbering drift.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit fix: align formal MCP access-control tests with canonical error codes and spec guard order. The README now defines P1–P6 in evaluation order (P1=ToolAllowed first) and the behavioral coverage table uses the same numbering. No more offset between the formal model and the test coverage map.


Where:

- `P1_RepoMatch`: repository matches at least one configured `repos` pattern (`owner/repo`, `owner/*`, `owner/prefix-*`)
- `P2_RoleAllow`: if `roles` is configured, user role matches one configured role (OR-logic)
- `P3_PrivateRepoAllow`: private repository access is denied when `private-repos: false`
- `P4_ToolAllowed`: if `allowed-tools` is configured, requested tool name must be present
- `P5_NotBlocked`: blocked users are denied unconditionally
- `P6_IntegrityMet`: integrity ordering is enforced as `none < unapproved < approved < merged`

The denial code is selected by first failing guard in evaluation order.

## Behavioral Coverage Map

| Predicate / Invariant | Test Function | Description |
|---|---|---|
| `P1_ExactMatch` | `TestFormal_ExactMatchAllow` | Exact `owner/repo` pattern allows matching repo, denies others |
| `P2_WildcardMatch` | `TestFormal_WildcardMatch` | Owner-wildcard `owner/*` and prefix-wildcard `owner/prefix-*` matching |
| `P3_EmptyReposDenyAll` | `TestFormal_EmptyReposDenyAll` | Absent or empty repos list denies every repository |
| `P4_RoleAllow` | `TestFormal_RoleFilter` | Role OR-logic: matching role allows, insufficient role denies |
| `P5_PrivateRepoAllow` | `TestFormal_PrivateRepoControl` | `private-repos: false` blocks private repos; public repos unaffected |
| `P6_NotBlocked` | `TestFormal_BlockedUserDeny` | Blocked user denied unconditionally regardless of other conditions |
| `P7_ToolAllowed` | `TestFormal_ToolNameFilter` | `allowed-tools` restricts callable tools; absent allows all |
| `P8_IntegrityMet` | `TestFormal_IntegrityLevelOrder` | Integrity ordinal order enforced; content below threshold denied |
| `INV1_CombinedAllow` | `TestFormal_CombinedFiltersAllAllow` | All conditions must be satisfied for allow |
| `INV2_ErrorCode` | `TestFormal_ErrorCodeFirstFailingGuard` | Deny error code matches first failing guard in evaluation order |
| `SAFETY_BlockedUserAlwaysDenied` | `TestFormal_BlockedUserSafetyProperty` | Safety: blocked user always produces `-32004` |
| `SAFETY_NoSpuriousAllow` | `TestFormal_NoSpuriousAllowInvariant` | Safety: no allow decision when any guard fails |

## Fixture Files

| Filename | Scenario | Spec Coverage |
Expand Down Expand Up @@ -78,3 +126,9 @@ To run all related tests:
```bash
go test -v -run "TestValidateGitHubGuardPolicy" ./pkg/workflow/
```

## Generated Test Suite

Formal conformance tests are implemented in:

`pkg/workflow/github_mcp_access_control_formal_test.go`
Comment on lines +153 to +155

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit fix: align formal MCP access-control tests with canonical error codes and spec guard order. The README now includes a dedicated TestFormal_* run command at line 137–141.

Loading