Skip to content

Add formal GitHub MCP access-control conformance model and predicate-mapped test suite#44790

Open
pelikhan with Copilot wants to merge 10 commits into
mainfrom
copilot/github-mcp-access-control-compliance
Open

Add formal GitHub MCP access-control conformance model and predicate-mapped test suite#44790
pelikhan with Copilot wants to merge 10 commits into
mainfrom
copilot/github-mcp-access-control-compliance

Conversation

Copilot AI commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

This PR formalizes GitHub MCP access-control compliance for the fixture spec by expressing the decision function as a conjunction of guard predicates and binding each normative predicate/invariant to executable tests. It adds explicit coverage for repo scope, role filtering, private-repo gating, tool allowlists, blocked-user safety, integrity ordering, and first-failing-guard error semantics.

  • Formal spec alignment

    • Extended specs/github-mcp-access-control-compliance/README.md with:
      • a compact formal model (AccessRequest × ToolConfig → Decision)
      • guard conjunction definition for the six access-control dimensions
      • first-failing-guard deny-code rule
      • behavioral coverage map linking predicates/invariants to TestFormal_* functions
    • Added generated test-suite reference to the new formal test file.
  • Executable formal conformance tests

    • Added pkg/workflow/github_mcp_access_control_formal_test.go with predicate-mapped tests:
      • P1/P2/P3: exact/wildcard/empty-repos behavior
      • P4/P5/P6/P7/P8: role, private-repo, blocked-user, tool-name, integrity checks
      • INV1/INV2: all-guards-required and first-failure error-code selection
      • safety properties for blocked-user denial and no-spurious-allow.
  • Minimal in-test model harness

    • Added a small deterministic evaluator used only by the formal tests to encode guard ordering and denial-code mapping, including integrity ordinal comparison and repository pattern matching.
if containsExact(cfg.BlockedUsers, req.UserLogin) {
    return formalDecision{errorCode: formalErrorBlockedUser}
}
if !formalRepositoryAllowed(cfg.Repos, req.Repository) {
    return formalDecision{errorCode: formalErrorRepoNotAllowed}
}
// ... role/private/tool/integrity checks in fixed order
return formalDecision{allow: true}

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Add formal model and test suite for access control compliance Add formal GitHub MCP access-control conformance model and predicate-mapped test suite Jul 10, 2026
Copilot AI requested a review from pelikhan July 10, 2026 16:54
@pelikhan pelikhan marked this pull request as ready for review July 10, 2026 18:25
Copilot AI review requested due to automatic review settings July 10, 2026 18:26

Copilot AI left a comment

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.

Pull request overview

Formalizes GitHub MCP access-control predicates and adds a test-only decision model.

Changes:

  • Documents guard predicates, invariants, and test mappings.
  • Adds formal access-decision tests and evaluator helpers.
  • Documents the new test suite.
Show a summary per file
File Description
specs/github-mcp-access-control-compliance/README.md Adds the formal model and coverage map.
pkg/workflow/github_mcp_access_control_formal_test.go Adds predicate tests and a test-only evaluator.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 12
  • Review effort level: Medium

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.

Comment on lines +15 to +19
formalErrorRepoNotAllowed = -32001
formalErrorInsufficientRole = -32002
formalErrorPrivateRepoDenied = -32003
formalErrorBlockedUser = -32004
formalErrorToolNotAllowed = -32005
Comment on lines +56 to +58
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_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)
Comment on lines +190 to +192
if containsExact(cfg.BlockedUsers, req.UserLogin) {
return formalDecision{errorCode: formalErrorBlockedUser}
}
| `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` |
|---|---|---|
| `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 |
Comment on lines +132 to +134
Formal conformance tests are implemented in:

`pkg/workflow/github_mcp_access_control_formal_test.go`

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.


Where:

- `P1_RepoMatch`: repository matches at least one configured `repos` pattern (`owner/repo`, `owner/*`, `owner/prefix-*`)
Comment on lines +133 to +138
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)
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions github-actions Bot left a comment

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.

Review: Guard Order / Predicate Numbering Mismatch

The PR adds a well-structured formal model and predicate-mapped test suite. Two blocking issues need to be resolved:

1. Guard evaluation order contradicts the formal spec

The formalEvaluateAccess evaluator checks BlockedUsers before RepoMatch, but the README formal model places P5_NotBlocked after P1_RepoMatch. Because the first-failing-guard rule determines the denial error code, multi-condition requests (blocked user + wrong repo) will produce -32004 from the code but the spec implies -32001. Either align the evaluator to match the spec order or update the spec to declare that blocked-user is evaluated first.

2. Predicate numbering inconsistency between formal model and coverage table

The formal model defines P1–P6, but the coverage table assigns P1–P8 with shifted numbering (e.g., P6_NotBlocked, P7_ToolAllowed). This breaks predicate→test traceability. Unify numbering across both sections.

Non-blocking observations
  • formalIntegrityRank returns -1 for unknown/empty integrity strings, which effectively ranks them below none (rank 0). An empty ContentIntegrity on a request where MinIntegrity is set will be denied, but the behavior for empty MinIntegrity is implicit. Worth a comment.
  • Pattern matching delegates to path.Match which accepts glob metacharacters like [a-z]. This is likely intentional but a comment would clarify scope.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • proxy.golang.org

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "proxy.golang.org"

See Network Configuration for more information.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 27.5 AIC · ⌖ 4.65 AIC · ⊞ 4.8K

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.

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.

@github-actions

Copy link
Copy Markdown
Contributor

Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (259 new lines across pkg/workflow/ and specs/) but does not have a linked Architecture Decision Record (ADR).

Draft ADR committed: docs/adr/44790-formalize-github-mcp-access-control-guard-conjunction.md — review and complete it before merging.

This PR cannot merge until an ADR is linked in the PR body.

What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff and PR description
  2. Complete the missing sections — in particular, resolve the [TODO: verify] item about whether containsExact already exists in the workflow package; refine alternatives if you evaluated other approaches not captured here
  3. Commit the finalized ADR to docs/adr/ on your branch (or accept the draft as-is)
  4. Reference the ADR in this PR body by adding a line such as:
ADR: [ADR-44790: Formalize GitHub MCP Access-Control Decision as a Guard Conjunction](docs/adr/44790-formalize-github-mcp-access-control-guard-conjunction.md)

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

Why ADRs Matter

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 0042-use-postgresql.md for PR 42).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · 54.4 AIC · ⌖ 13.3 AIC · ⊞ 8.5K ·
Comment /review to run again

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 94/100 — Excellent

Analyzed 12 test(s): 11 design, 1 implementation, 0 violation(s).

📊 Metrics (12 tests)
Metric Value
Analyzed 12 (Go: 12, JS: 0)
✅ Design 11 (91.7%)
⚠️ Implementation 1 (8.3%)
Edge/error coverage 11 (91.7%)
Duplicate clusters 0
Inflation No (formal model — production logic embedded in test file; no separate prod file)
🚨 Violations 0
Test File Classification Issues
TestFormal_ExactMatchAllow pkg/workflow/github_mcp_access_control_formal_test.go:41 design_test / behavioral_contract / high_value Assertion messages missing descriptive context
TestFormal_WildcardMatch ...:50 design_test / behavioral_contract / high_value Assertion messages missing descriptive context
TestFormal_EmptyReposDenyAll ...:56 design_test / behavioral_contract / high_value
TestFormal_RoleFilter ...:61 design_test / behavioral_contract / high_value
TestFormal_PrivateRepoControl ...:69 design_test / behavioral_contract / high_value
TestFormal_BlockedUserDeny ...:80 design_test / behavioral_contract / high_value
TestFormal_ToolNameFilter ...:92 design_test / behavioral_contract / high_value Assertion messages missing descriptive context
TestFormal_IntegrityLevelOrder ...:101 design_test / behavioral_contract / high_value
TestFormal_CombinedFiltersAllAllow ...:109 implementation_test / implementation_detail / low_value Happy-path only; no denial or edge assertion
TestFormal_ErrorCodeFirstFailingGuard ...:123 design_test / behavioral_contract / high_value
TestFormal_BlockedUserSafetyProperty ...:141 design_test / behavioral_contract / high_value
TestFormal_NoSpuriousAllowInvariant ...:160 design_test / behavioral_contract / high_value
⚠️ Flagged Tests (1)

TestFormal_CombinedFiltersAllAllow (pkg/workflow/github_mcp_access_control_formal_test.go:109) — implementation_test. This test exercises only the happy path (all filters pass) with no denial assertions. A complementary case that flips one field at a time to verify the combined filter still rejects would greatly increase its value. Consider merging it into TestFormal_NoSpuriousAllowInvariant or adding at least one denial sub-case.

Minor style note (multiple tests): Several assert.True / assert.False calls lack a descriptive failure message string (third argument). Without it, go test -v failure output only shows the line number, making CI triage harder. For example: assert.True(t, allowed.allow, "expected repo github/gh-aw to be allowed").

Verdict

Passed. 8.3% implementation tests (threshold: 30%). No guideline violations. The formal model approach — embedding the access-control predicate directly in the test file — is unusual but sound: it provides a specification-grade reference implementation that the production code can be validated against.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • awmgmcpg

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "awmgmcpg"

See Network Configuration for more information.

🧪 Test quality analysis by Test Quality Sentinel · 34.5 AIC · ⌖ 9.92 AIC · ⊞ 6.8K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

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.

Test Quality Sentinel: 94/100. 8.3% implementation tests (threshold: 30%). No violations.

@github-actions github-actions Bot left a comment

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.

Skills-Based Review 🧠

Applied /tdd and /grill-with-docs — requesting changes on correctness gaps in the formal model.

📋 Key Themes & Highlights

Key Themes

  1. Guard order inconsistency (correctness): formalEvaluateAccess checks BlockedUsers first, but the README formal model lists it as the 5th guard (P5_NotBlocked). The first-failing-guard semantics are the central claim of this PR — the implementation and spec must agree.

  2. Empty ToolName bypass (security): When ToolName == "", the AllowedTools guard is silently skipped (line 206). This is untested and undocumented; callers who omit the field bypass the tool allowlist.

  3. Unknown integrity values (correctness): formalIntegrityRank returns -1 for unrecognised levels, which causes formalErrorIntegrityTooLow to fire — but this behaviour is not tested or documented.

  4. Wildcard-owner pattern gap (completeness): Patterns like */gh-aw are silently rejected by formalRepositoryAllowed with no test declaring that as intentional.

  5. Predicate numbering drift (spec): The formal model block uses P1–P6, the coverage map uses P1–P8. The predicates don't share the same numbering, making traceability fragile.

Positive Highlights

  • ✅ Excellent use of a minimal deterministic harness that is entirely test-scoped — no production code changes needed.
  • ✅ Safety properties (SAFETY_BlockedUserAlwaysDenied, SAFETY_NoSpuriousAllow) are a strong pattern for access-control specs.
  • ✅ The behavioral coverage map is clear and a great addition to the spec README.
  • ✅ Guard predicate naming (P1_RepoMatch, P2_RoleAllow, ...) maps cleanly to the formal model.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 45.3 AIC · ⌖ 5.09 AIC · ⊞ 6.6K
Comment /matt to run again

if !formalRepositoryAllowed(cfg.Repos, req.Repository) {
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.

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.


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.

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.

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.

[/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.

@github-actions github-actions Bot left a comment

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.

REQUEST_CHANGES — 3 correctness/security bugs must be fixed before merge

The formal model and its test harness introduce real fail-open logic errors that undermine the stated security guarantees of the access-control spec.

Blocking findings

1. Tool allowlist bypassed on empty ToolName (line 208)AllowedTools is silently skipped when req.ToolName == "". Any caller that omits the tool name bypasses an explicitly configured allowlist.

2. Blocked-user guard order diverges from spec (line 196) — The implementation evaluates BlockedUsers before RepoMatch, but the README formal model places P5_NotBlocked after P1–P4. The error-code ordering test (TestFormal_ErrorCodeFirstFailingGuard) does not cover this case, so the contradiction is undetected.

3. Unrecognized MinIntegrity silently disables the integrity gate (line 248)formalIntegrityRank returns -1 for any unknown string. A misconfigured/typo'd MinIntegrity makes the guard rank < -1 permanently false, granting every request regardless of integrity.

🔎 Code quality review by PR Code Quality Reviewer · 34.6 AIC · ⌖ 4.77 AIC · ⊞ 5.4K
Comment /review to run again

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.

if !formalRepositoryAllowed(cfg.Repos, req.Repository) {
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.

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 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.

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

@github-actions

Copy link
Copy Markdown
Contributor

PR Triage

Field Value
Category test
Risk Low
Score 38/100
Action batch_review
Batch low-risk-polish

Score breakdown: Impact 18 · Urgency 8 · Quality 12

Formalizes MCP access-control conformance model + adds predicate-mapped tests. No CI results; CHANGES_REQUESTED review needs resolution. Low code risk.

Generated by 🔧 PR Triage Agent · 226.8 AIC · ⌖ 5.86 AIC · ⊞ 5.4K ·

@github-actions

Copy link
Copy Markdown
Contributor

🤖 PR Triage Report

Field Value
Category test
Risk 🟡 Medium
Priority Score 35/100 (Impact: 20, Urgency: 10, Quality: 5)
Recommended Action batch_review — batch: compliance-test

Rationale: Formalizes MCP access-control conformance model with predicate-mapped test suite (371 lines added, 3 files). No CI checks visible; changes requested. Pair with other compliance/test PRs for review.

Run §29115410581

Generated by 🔧 PR Triage Agent · 226.8 AIC · ⌖ 5.86 AIC · ⊞ 5.4K ·

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

pr-sous-chef

@copilot please run the pr-finisher skill, address unresolved review comments, refresh the branch if needed, and rerun checks once the branch is up to date.

Unresolved review feedback to address:

Generated by 👨‍🍳 PR Sous Chef · 10.4 AIC · ⌖ 4.92 AIC · ⊞ 7.5K ·
Comment /souschef to run again

… and spec guard order

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

pr-sous-chef

@copilot please run the pr-finisher skill, address unresolved review comments, refresh the branch if needed, and rerun checks once the branch is up to date....

Addressed all unresolved review threads. Changes pushed in commit fix: align formal MCP access-control tests with canonical error codes and spec guard order:

  • Error codes — constants now match pkg/cli/gateway_logs_types.go and the normative spec appendix B: -32001 tool/general, -32002 repo, -32003 role, -32004 private-repo, -32005 blocked-user, -32006 integrity.
  • Guard evaluation orderformalEvaluateAccess now evaluates in spec §4.5.3 order: tool → repo → role → private-repo → blocked-user → integrity. Blocked-user is now correctly placed within integrity management (not before the repo/role/visibility guards).
  • Repos semanticsformalRepositoryAllowed now returns true for nil patterns (omitted = allow all per §4.4.1). Empty slice is documented as an invalid-config compile-time error and treated as no-match at runtime. TestFormal_EmptyReposDenyAll replaced by TestFormal_OmittedReposAllowAll.
  • Wildcard patterns — removed unsupported prefix wildcard (github/gh-*); added */repo support and a test case for it; only owner/repo, owner/*, */repo, */* are modelled.
  • Empty allowlist logic — removed the req.ToolName != "" guard so an empty tool name against a non-empty allowlist correctly denies.
  • INV2 first-failing-guard testTestFormal_ErrorCodeFirstFailingGuard now has a table case for each guard as the first failure while later guards also fail.
  • TestFormal_BlockedUserSafetyProperty — redesigned so all earlier guards (tool, repo, role, private-repo) pass; blocked-user is the only failing guard.
  • README — formal model conjunction reordered to match evaluation order (P1=ToolAllowed … P6=IntegrityMet); pattern list corrected; coverage table IDs realigned; repos semantics corrected; SAFETY invariant shows -32005; error code table matches canonical codes; TestFormal_* run command added.

Local make fmt and make agent-report-progress both pass. CI re-trigger needed from a maintainer before merge.

Copilot AI requested a review from gh-aw-bot July 10, 2026 21:43
@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill, address any remaining review feedback, and rerun checks once the branch is up to date.

Generated by 👨‍🍳 PR Sous Chef · 12.5 AIC · ⌖ 5.63 AIC · ⊞ 7.5K ·
Comment /souschef to run again

Copilot AI and others added 4 commits July 10, 2026 22:42
…vel tests

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
…te coverage map

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
…te coverage map and run command

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the pr-finisher skill, address any remaining review feedback, and rerun checks once the branch is up to date.

...

All remaining review feedback has been addressed. Summary of changes since last run:

  • Fail-open integrity bug fixed (threads ykW, yij): formalEvaluateAccess now explicitly checks cfgRank < 0 first (unrecognized MinIntegrity → fail-safe deny) before reqRank < cfgRank, with separate comments for each failure mode.
  • New tests: TestFormal_UnknownContentIntegrityDenied (unknown content integrity rank -1 denies) and TestFormal_InvalidMinIntegrityConfigDenied (unrecognized config value is fail-safe).
  • Tests split into separate functions per the code review suggestion; "unknown-level" used instead of "pending" to unambiguously signal an invalid value.
  • README coverage map updated with two distinct rows, one per new test function; test run command updated to include new patterns.
  • Earlier threads (xmS, xmZ, yiQ, yib, yit, yiz, ykM, ykS): already fixed in the prior commit — guard order, predicate numbering, empty-ToolName bypass, and */repo wildcard all addressed; replies posted on each thread.

Local make fmt and all 14 formal tests pass. CI re-trigger needed from a maintainer before merge.

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

pr-sous-chef

@copilot please run the pr-finisher skill, address the remaining unresolved review feedback, and rerun checks once the branch is up to date.

Unresolved review feedback to address:

Generated by 👨‍🍳 PR Sous Chef · 13.9 AIC · ⌖ 4.91 AIC · ⊞ 7.5K ·
Comment /souschef to run again

Copilot AI and others added 2 commits July 10, 2026 23:09
- Add TestFormal_FixtureRunner that loads YAML fixture files from
  specs/github-mcp-access-control-compliance/ and drives each scenario
  through formalEvaluateAccess, binding the spec artifacts to the
  executable formal model

- Fix all fixture YAML error codes to match canonical values:
  - exact-match-allow: repo deny -32001 → -32002
  - wildcard-deny: repo deny -32001 → -32002; replace unsupported prefix
    wildcard scenario with */repo wildcard-owner scenarios (C+D)
  - empty-repos-block: absent repos = allow all per §4.4.1; empty repos
    deny -32001 → -32002; update description and spec refs
  - role-deny: role deny -32002 → -32003
  - private-repo-block: private deny -32003 → -32004
  - blocked-user-deny: blocked deny -32004 → -32005
  - tool-name-filter: tool deny -32005 → -32001; fix description
  - combined-filter-allow: -32001→-32002, -32002→-32003; fix scenario E
    logic (private-repos:false + is_private:true, code -32004)

- Update README to document the fixture runner run command and
  reorder scenarios C/D in combined-filter-allow for logical order

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
… runner

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

pr-sous-chef

@copilot please run the pr-finisher skill, address the remaining unresolved review feedback, and rerun checks once the branch is up to date....

Addressed. Changes pushed in commits fix: add YAML fixture runner test and correct all fixture error codes and fix: use require.NoErrorf for explicit printf-style format in fixture runner:

  • TestFormal_FixtureRunner added — loads every YAML fixture file from specs/github-mcp-access-control-compliance/ and drives each scenario through formalEvaluateAccess, binding the spec artifacts directly to the executable formal model (29 scenarios across 9 fixture files all pass).
  • Fixture error codes corrected across all 9 fixture files to match the canonical codes in pkg/cli/gateway_logs_types.go: exact-match-allow, wildcard-deny, empty-repos-block, role-deny, private-repo-block, blocked-user-deny, tool-name-filter, and combined-filter-allow.
  • Logically incorrect scenarios fixed: absent repos now correctly produces allow (spec §4.4.1); combined-filter-allow Scenario E changed to private-repos: false + is_private: true (→ -32004); unsupported prefix wildcard in wildcard-deny replaced with */repo wildcard-owner scenarios.
  • README updated with a dedicated TestFormal_FixtureRunner run command and description.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants