Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ADR-52325: White-Box Testing for Permission Management Spec-Compliance Predicates

**Date**: 2026-08-12
**Status**: Draft
**Deciders**: pelikhan, copilot-swe-agent

---

### Context

`specs/security-architecture-spec-validation.md` §12 Compliance Test Matrix Gap Analysis flagged three Permission Management test cases — T-PM-003 (strict mode gating), T-PM-005 (`workflow_run` repository validation), and T-PM-007 (token precedence) — as lacking dedicated test coverage. The implementation functions that encode these behaviors (`hasWorkflowRunTrigger`, `buildWorkflowRunRepoSafetyCondition`, `validateWorkflowRunBranches`, `getEffectiveGitHubToken`, `getEffectiveSafeOutputGitHubToken`) are intentionally unexported internals within `pkg/workflow`. Closing the spec coverage gap requires a test file that can call these unexported functions directly. The same white-box pattern is already used by existing `security_architecture_*_formal_test.go` files in this package.

### Decision

We will declare the formal spec-compliance test file (`security_architecture_pm_formal_test.go`) as `package workflow` (same-package / white-box test) rather than `package workflow_test` (external / black-box test). This allows the test file to call unexported functions directly without requiring any changes to production code or the addition of exported shims.

### Alternatives Considered

#### Alternative 1: Add Exported `*ForTest()` Wrapper Functions

Thin exported shims (e.g., `HasWorkflowRunTriggerForTest`) are added in a separate `_test.go` file within `package workflow`, making the internals callable from `package workflow_test`. This keeps the external-test boundary intact but requires writing and maintaining boilerplate wrapper functions for each unexported symbol under test. Rejected because it adds production-adjacent indirection without a correctness benefit over same-package membership, and the project has already established the white-box convention for this file family.

#### Alternative 2: Promote the Functions to Exported Status

Rename the unexported functions to uppercase exports so they become part of the package's public API. Rejected because these are deliberate implementation details — exporting them would make them an implicit contract that is harder to change later, and the motivation for testing them is compliance verification, not external use.

### Consequences

#### Positive
- No changes to production code are needed; the implementation is tested exactly as written.
- Follows the existing project convention for `security_architecture_*_formal_test.go` files, keeping this new file consistent with its siblings.
- Predicate tests directly encode the spec invariants against the real functions, avoiding any mismatch between a shim's behavior and the underlying implementation.

#### Negative
- The test file is tightly coupled to internal function signatures; renaming or extracting methods to a different struct will break these tests without any compiler warning at the call site.
- White-box tests carry a risk of circular reasoning: they verify properties of the same code they are derived from, so they will not catch cases where the implementation and the tests share the same misunderstanding of the spec.

#### Neutral
- The test file must be declared in `package workflow` to compile, placing it in the same namespace as production code.
- The `//go:build !integration` build tag ensures the file is excluded from integration builds, consistent with other unit tests in the package.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
265 changes: 265 additions & 0 deletions pkg/workflow/security_architecture_pm_formal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
//go:build !integration

package workflow

import (
"strings"
"testing"

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

// This file formalizes the §12 Compliance Test Matrix Gap Analysis for
// Permission Management (T-PM-003, T-PM-005, T-PM-007) described in
// specs/security-architecture-spec-validation.md. It derives predicates
// directly from the exported/unexported implementation in role_checks.go,
// agent_validation.go, github_token.go and compiler_mutators.go.

// P1: WorkflowRunRepoSafetyCondition
// The compiled if: guard must check repo-id equality and non-fork status
// only for workflow_run events, and must allow all other events unconditionally.
func TestFormalPM005_WorkflowRunRepoSafetyCondition(t *testing.T) {
c := NewCompiler()
condition := c.buildWorkflowRunRepoSafetyCondition()

assert.Equal(t,
"${{ github.event_name != 'workflow_run' || github.event.workflow_run.repository.id == github.repository_id && (!(github.event.workflow_run.repository.fork)) }}",
condition,
)
}

// P2: WorkflowRunRepoSafetyOnlyAppliesWhenTriggerPresent
// hasWorkflowRunTrigger must correctly detect map/string/absent "on:" forms.
func TestFormalPM005_WorkflowRunRepoSafetyOnlyWhenTriggerDeclared(t *testing.T) {
c := NewCompiler()

tests := []struct {
name string
frontmatter map[string]any
expected bool
}{
{
name: "nil frontmatter",
frontmatter: nil,
expected: false,
},
{
name: "no on section",
frontmatter: map[string]any{},
expected: false,
},
{
name: "map form with workflow_run",
frontmatter: map[string]any{
"on": map[string]any{"workflow_run": map[string]any{}},
},
expected: true,
},
{
name: "map form without workflow_run",
frontmatter: map[string]any{
"on": map[string]any{"push": map[string]any{}},
},
expected: false,
},
{
name: "string form matching workflow_run",
frontmatter: map[string]any{
"on": "workflow_run",
},
expected: true,
},
{
name: "string form not matching workflow_run",
frontmatter: map[string]any{
"on": "push",
},
expected: false,
},
{
name: "slice form containing workflow_run (currently unsupported)",
frontmatter: map[string]any{
"on": []string{"workflow_run", "push"},
},
expected: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, c.hasWorkflowRunTrigger(tt.frontmatter))
})
}
}

// P3: WorkflowRunRequiresNonEmptyWorkflowsField
func TestFormalPM_WorkflowRunRequiresNonEmptyWorkflows(t *testing.T) {
tests := []struct {
name string
value any
expected bool
}{
{"non-empty string", "my-workflow", true},
{"blank string", " ", false},
{"empty string", "", false},
{"non-empty []string", []string{"my-workflow"}, true},
{"[]string with only blanks", []string{" ", ""}, false},
{"empty []string", []string{}, false},
{"non-empty []any of strings", []any{"my-workflow"}, true},
{"[]any with only blanks", []any{" ", ""}, false},
{"empty []any", []any{}, false},
{"nil value", nil, false},
{"unsupported type", 42, false},
}

for _, tt := range tests {

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] P2 is missing a test case for the []string (slice-of-strings) on: form.

hasWorkflowRunTrigger handles map[string]any and string but not []string. If YAML parses on: [workflow_run, push] as []string, the current implementation returns false (silently misses the trigger). There is no test capturing this boundary, so the gap is undetected.

💡 Suggested test case to add to the table
{
    name: "slice form containing workflow_run",
    frontmatter: map[string]any{
        "on": []string{"workflow_run", "push"},
    },
    expected: true, // or false if the implementation intentionally omits this
},

If the expected value turns red, that signals a real gap in the production implementation worth fixing.

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

Addressed in 3ec481d: added the on: []string{"workflow_run", "push"} boundary case and documented current behavior as unsupported (expected false) to pin the implementation boundary.

t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, hasNonEmptyWorkflowRunWorkflows(tt.value))
})

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] P3 tests []string with blank-only entries but omits the equivalent []any blank-only case.

hasNonEmptyWorkflowRunWorkflows has a separate code path for []any. The test table covers []any for non-empty and empty-slice, but not []any{" ", ""} (blank strings). This asymmetry could hide a bug where the []any path skips whitespace trimming.

💡 Suggested additional row
{"[]any with only blanks", []any{"  ", ""}, false},

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

Addressed in 3ec481d: added the []any{" ", ""} blank-only case to cover the []any trimming path explicitly.

}
}

// Shared workflow_run "on:" fixtures used across the P4-P6 predicates below.
const (
workflowRunOnMissingBranches = "on:\n workflow_run:\n workflows: [\"ci\"]\n types: [completed]\n"
workflowRunOnWithBranches = "on:\n workflow_run:\n workflows: [\"ci\"]\n types: [completed]\n branches: [main]\n"
pushOnNoWorkflowRun = "on:\n push:\n branches: [main]\n"
)

// P4: WorkflowRunBranchRestrictionModeSensitive
// Missing branch restrictions must error in strict mode and warn otherwise.
func TestFormalPM003_StrictModeGatesMissingBranchRestriction(t *testing.T) {
workflowData := &WorkflowData{On: workflowRunOnMissingBranches}

t.Run("strict mode errors on missing branches", func(t *testing.T) {
c := NewCompiler()
c.SetStrictMode(true)
err := c.validateWorkflowRunBranches(workflowData, "workflow.md")
require.Error(t, err)
assert.Contains(t, err.Error(), "branch restrictions")
})

t.Run("non-strict mode warns on missing branches", func(t *testing.T) {
c := NewCompiler()
c.SetStrictMode(false)
c.ResetWarningCount()
err := c.validateWorkflowRunBranches(workflowData, "workflow.md")
require.NoError(t, err)
assert.Equal(t, 1, c.GetWarningCount())
})
Comment on lines +143 to +150

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.

Addressed in 3ec481d: the non-strict subtest now resets warning count and asserts GetWarningCount() == 1 after validation.

}

// P5: WorkflowRunBranchRestrictionSatisfiedNoOp
// Present branch restrictions never trigger the strict/warn path, regardless
// of strict mode.
func TestFormalPM_BranchRestrictionPresentIsNoOp(t *testing.T) {
workflowData := &WorkflowData{On: workflowRunOnWithBranches}

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] The non-strict sub-test only asserts require.NoError but never verifies that a warning was actually emitted.

The production path calls c.IncrementWarningCount() when branches are missing in non-strict mode. A test that only checks NoError would still pass if the warning emission were accidentally deleted — meaning the predicate under-specifies P4's non-strict behaviour.

💡 Suggested addition
t.Run("non-strict mode warns on missing branches", func(t *testing.T) {
    c := NewCompiler()
    c.SetStrictMode(false)
    err := c.validateWorkflowRunBranches(workflowData, "workflow.md")
    require.NoError(t, err)
    assert.Equal(t, 1, c.GetWarningCount(), "expected exactly one warning to be emitted")
})

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

Handled in 3ec481d: non-strict path now explicitly verifies one warning is emitted (ResetWarningCount + GetWarningCount()==1).

for _, strict := range []bool{true, false} {
c := NewCompiler()
c.SetStrictMode(strict)
c.ResetWarningCount()
err := c.validateWorkflowRunBranches(workflowData, "workflow.md")
require.NoError(t, err, "strict=%v", strict)
assert.Equal(t, 0, c.GetWarningCount(), "strict=%v", strict)
}
}

// P6: NoWorkflowRunTriggerIsNoOp
// Non-workflow_run triggers must bypass validation entirely, even in strict mode.
func TestFormalPM_NoWorkflowRunTriggerSkipsValidation(t *testing.T) {
// validateWorkflowRunBranches short-circuits using strings.Contains on
// workflowData.On, so this fixture intentionally avoids any incidental
// "workflow_run" substrings.
workflowData := &WorkflowData{On: pushOnNoWorkflowRun}

c := NewCompiler()
c.SetStrictMode(true)
err := c.validateWorkflowRunBranches(workflowData, "workflow.md")
require.NoError(t, err)
}

// P7: DefaultGitHubTokenPrecedence
// A custom token always wins; otherwise the 3-tier secret fallback chain is used.

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] P6 is described as "bypasses validation entirely" but the actual short-circuit in validateWorkflowRunBranches is a simple strings.Contains(workflowData.On, "workflow_run") string check at line 347, not a structural YAML parse.

The test fixture pushOnNoWorkflowRun does not exercise the edge where the literal string workflow_run appears in a comment or a different field. A more defensive fixture (or a comment noting the string-match assumption) would make the test specification more accurate.

💡 Suggested clarifying comment
// The skip is string-based (strings.Contains), so a YAML comment or value
// containing "workflow_run" could accidentally opt in. This fixture is
// intentionally clean to document the happy path only.
workflowData := &WorkflowData{On: pushOnNoWorkflowRun}

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

Addressed in 3ec481d: added a clarifying note in the P6 test that the skip is currently strings.Contains-based and the fixture intentionally avoids incidental workflow_run substrings.

func TestFormalPM007_DefaultGitHubTokenPrecedence(t *testing.T) {
fallback := getEffectiveGitHubToken("")
assert.NotEqual(t, -1, strings.Index(fallback, "GH_AW_GITHUB_MCP_SERVER_TOKEN"))
assert.NotEqual(t, -1, strings.Index(fallback, "GH_AW_GITHUB_TOKEN"))
assert.NotEqual(t, -1, strings.Index(fallback, "GITHUB_TOKEN"))
assert.Less(t, strings.Index(fallback, "GH_AW_GITHUB_MCP_SERVER_TOKEN"), strings.Index(fallback, "GH_AW_GITHUB_TOKEN"))
assert.Less(t, strings.Index(fallback, "GH_AW_GITHUB_TOKEN"), strings.Index(fallback, "GITHUB_TOKEN"))
}

// P8: SafeOutputGitHubTokenPrecedence
// A custom token always wins; otherwise the 2-tier safe-output fallback chain is used.
func TestFormalPM007_SafeOutputTokenPrecedence(t *testing.T) {
fallback := getEffectiveSafeOutputGitHubToken("")
assert.NotContains(t, fallback, "GH_AW_GITHUB_MCP_SERVER_TOKEN")
assert.NotEqual(t, -1, strings.Index(fallback, "GH_AW_GITHUB_TOKEN"))
assert.NotEqual(t, -1, strings.Index(fallback, "GITHUB_TOKEN"))
assert.Less(t, strings.Index(fallback, "GH_AW_GITHUB_TOKEN"), strings.Index(fallback, "GITHUB_TOKEN"))
}

// P9: TokenChainsAreDistinctByJobRole
// The tool-token chain includes the MCP-server secret; the safe-output chain
// deliberately excludes it.
func TestFormalPM007_TokenChainsDifferByRole(t *testing.T) {
toolChain := getEffectiveGitHubToken("")
safeOutputChain := getEffectiveSafeOutputGitHubToken("")

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.

Duplicate test coverage (P7/P8/P9 — getEffectiveGitHubToken / getEffectiveSafeOutputGitHubToken)

github_token_test.go already covers both getEffectiveGitHubToken and getEffectiveSafeOutputGitHubToken with custom-token and empty-string cases (table-driven). P9's assertion that the chains differ by role is genuinely new — consider extracting only that into a dedicated test and removing the overlapping P7/P8 single-call assertions.

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

No code change for this suggestion. I kept P7/P8 in this formal PM file intentionally to preserve complete in-file specification of the documented PM token-precedence predicates, while P9 still captures the role-specific chain distinction.

assert.Contains(t, toolChain, "GH_AW_GITHUB_MCP_SERVER_TOKEN")
assert.NotContains(t, safeOutputChain, "GH_AW_GITHUB_MCP_SERVER_TOKEN")

// Both chains share the common tail of the fallback chain.
assert.Contains(t, toolChain, "GH_AW_GITHUB_TOKEN")
assert.Contains(t, toolChain, "GITHUB_TOKEN")
assert.Contains(t, safeOutputChain, "GH_AW_GITHUB_TOKEN")
assert.Contains(t, safeOutputChain, "GITHUB_TOKEN")
}

// P10: StrictModeIsPerCompilerInstance
// SetStrictMode toggles the strictMode field deterministically and does not
// leak across compiler instances.
func TestFormalPM003_SetStrictModeIsIdempotentSetter(t *testing.T) {
c1 := NewCompiler()
c2 := NewCompiler()

c1.SetStrictMode(true)
c2.SetStrictMode(false)
assert.False(t, c2.strictMode)

// Idempotent: setting the same value repeatedly is a no-op.
c1.SetStrictMode(true)
assert.True(t, c1.strictMode)

// Toggling flips the field.
c1.SetStrictMode(false)
assert.False(t, c1.strictMode)
}

// P11: BashRestrictionWildcardSafe
// Boundary matrix for HasBashExplicitRestriction: nil/wildcard/false/empty-list.

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.

Duplicate test coverage (P10 — TestFormalPM003_SetStrictModeIsIdempotentSetter)

TestCompilerSetStrictMode in compiler_mutators_test.go already tests SetStrictMode(true) and verifies c.strictMode. This new test adds toggle and idempotency coverage which is useful, but the single-instance baseline is redundant. Consider removing the first assertion (c1.SetStrictMode(true) / assert.True) and keeping only the new cross-instance isolation and toggle cases to avoid duplication.

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

No code change for this suggestion. I kept the explicit baseline assertion in P10 intentionally so the formal PM predicate test remains self-contained and readable without relying on a separate file for the initial setter expectation.

func TestFormalPM_BashExplicitRestrictionBoundary(t *testing.T) {
tests := []struct {
name string
tools map[string]any
expected bool
}{
{"nil tools", nil, false},
{"no bash key", map[string]any{}, false},
{"bash true (no restriction)", map[string]any{"bash": true}, false},
{"bash false (explicit restriction)", map[string]any{"bash": false}, true},
{"bash nil value", map[string]any{"bash": nil}, false},
{"bash wildcard list", map[string]any{"bash": []any{"*"}}, false},
{"bash mixed list with wildcard (no restriction)", map[string]any{"bash": []any{"ls", "*", "cat"}}, false},
{"bash empty list (explicit restriction)", map[string]any{"bash": []any{}}, true},
{"bash named list (explicit restriction)", map[string]any{"bash": []any{"ls"}}, true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, HasBashExplicitRestriction(tt.tools))
})
}
}

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.

Duplicate and incomplete test coverage (P11 — TestFormalPM_BashExplicitRestrictionBoundary)

This table-driven test re-tests HasBashExplicitRestriction cases already individually covered in agent_validation_formal_test.go. More critically, the existing file also tests the mixed-wildcard-with-names case (["ls", "*", "cat"]false) which is absent from this table, creating an accidental coverage gap.

Consider either removing P11 (keeping agent_validation_formal_test.go canonical) or replacing it with a row for the missing mixed-wildcard case to add genuinely new coverage.

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

Addressed in 3ec481d: the P11 boundary table now includes the mixed wildcard case ([]any{"ls","*","cat"} -> no explicit restriction).

Loading