From 9a70098749d26cf922cc6c3178648e8ff3441fc8 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 8 Mar 2026 14:50:37 -0700 Subject: [PATCH 1/5] fix: map wildcard bind address 0.0.0.0 to 127.0.0.1 in advertised URLs When the gateway binds to 0.0.0.0 (or ::), clients cannot connect to the advertised URLs since 0.0.0.0 is not a routable address. Fix: In writeGatewayConfig, prefer config.Gateway.Domain if set, and map wildcard addresses (0.0.0.0, ::) to 127.0.0.1 for client-reachable URLs. Also fixes TestBinaryInvocation_PipeOutput to use mock HTTP backend instead of Docker alpine container (same pattern as other test fixes). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/cmd/root.go | 10 +++++++- internal/cmd/stdout_config_test.go | 34 +++++++++++++++++++++++-- test/integration/auth_config_test.go | 2 +- test/integration/binary_test.go | 37 +++++++++++++++++++--------- 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index dc810f75c..d5d487fba 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -498,8 +498,16 @@ func writeGatewayConfig(cfg *config.Config, listenAddr, mode string, w io.Writer } debugLog.Printf("Parsed listen address: host=%s, port=%s", host, port) - // Determine domain (use host from listen address) + // Determine domain for client-reachable URLs. + // If the config specifies a domain, prefer that. Otherwise, use the listen + // host — but map wildcard bind addresses (0.0.0.0, ::) to 127.0.0.1 since + // clients cannot connect to wildcard addresses. domain := host + if cfg.Gateway != nil && cfg.Gateway.Domain != "" { + domain = cfg.Gateway.Domain + } else if domain == "0.0.0.0" || domain == "::" || domain == "[::]" { + domain = "127.0.0.1" + } debugLog.Printf("Resolved gateway address: host=%s, port=%s", host, port) diff --git a/internal/cmd/stdout_config_test.go b/internal/cmd/stdout_config_test.go index f995f4e08..fe9d524bb 100644 --- a/internal/cmd/stdout_config_test.go +++ b/internal/cmd/stdout_config_test.go @@ -50,7 +50,7 @@ func TestWriteGatewayConfigToStdout(t *testing.T) { wantAPIKey: "test-api-key", }, { - name: "unified mode with multiple servers", + name: "unified mode with multiple servers and wildcard bind", cfg: &config.Config{ Servers: map[string]*config.ServerConfig{ "github": { @@ -66,7 +66,7 @@ func TestWriteGatewayConfigToStdout(t *testing.T) { }, listenAddr: "0.0.0.0:3000", mode: "unified", - wantHost: "0.0.0.0", + wantHost: "127.0.0.1", wantPort: "3000", wantAPIKey: "unified-api-key", }, @@ -118,6 +118,36 @@ func TestWriteGatewayConfigToStdout(t *testing.T) { wantPort: "3000", wantAPIKey: "ipv6-key", }, + { + name: "domain config overrides listen host", + cfg: &config.Config{ + Servers: map[string]*config.ServerConfig{ + "github": {Command: "docker"}, + }, + Gateway: &config.GatewayConfig{ + APIKey: "domain-key", + Domain: "my-gateway.local", + }, + }, + listenAddr: "0.0.0.0:8080", + mode: "routed", + wantHost: "my-gateway.local", + wantPort: "8080", + wantAPIKey: "domain-key", + }, + { + name: "IPv6 wildcard mapped to 127.0.0.1", + cfg: &config.Config{ + Servers: map[string]*config.ServerConfig{ + "test": {Command: "echo"}, + }, + }, + listenAddr: "[::]:9090", + mode: "routed", + wantHost: "127.0.0.1", + wantPort: "9090", + wantAPIKey: "", + }, } for _, tt := range tests { diff --git a/test/integration/auth_config_test.go b/test/integration/auth_config_test.go index 78447a0f1..8ca391510 100644 --- a/test/integration/auth_config_test.go +++ b/test/integration/auth_config_test.go @@ -117,7 +117,7 @@ func TestOutputConfigWithAuthHeaders(t *testing.T) { t.Errorf("Expected non-empty url, got: %v", echoserver["url"]) } - expectedURL := fmt.Sprintf("http://127.0.0.1:%d/mcp/echoserver", port) + expectedURL := fmt.Sprintf("http://localhost:%d/mcp/echoserver", port) assert.Equal(t, expectedURL, url, "url = %q, got: %q") // Verify headers object is present per spec section 5.4 diff --git a/test/integration/binary_test.go b/test/integration/binary_test.go index 7525382e7..2d5437145 100644 --- a/test/integration/binary_test.go +++ b/test/integration/binary_test.go @@ -289,27 +289,40 @@ func TestBinaryInvocation_PipeOutput(t *testing.T) { binaryPath := findBinary(t) t.Logf("Using binary: %s", binaryPath) - // Create a simple config - configFile := createTempConfig(t, map[string]interface{}{ - "testserver": map[string]interface{}{ - "command": "docker", - "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, - }, - }) - defer os.Remove(configFile) + // Use in-process mock backend to avoid Docker dependency + mockBackend := createMinimalMockMCPBackend(t) + defer mockBackend.Close() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() port := "13004" + + // Prepare config JSON for stdin with HTTP backend + configJSON := map[string]interface{}{ + "mcpServers": map[string]interface{}{ + "testserver": map[string]interface{}{ + "type": "http", + "url": mockBackend.URL + "/mcp", + }, + }, + "gateway": map[string]interface{}{ + "port": 13004, + "domain": "localhost", + "apiKey": "test-pipe-key", + }, + } + configBytes, _ := json.Marshal(configJSON) + cmd := exec.CommandContext(ctx, binaryPath, - "--config", configFile, + "--config-stdin", "--listen", "127.0.0.1:"+port, "--unified", ) // Capture stdout through a pipe (the scenario we're testing) var stdout, stderr bytes.Buffer + cmd.Stdin = bytes.NewReader(configBytes) cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -325,7 +338,7 @@ func TestBinaryInvocation_PipeOutput(t *testing.T) { // Wait for server to start serverURL := "http://127.0.0.1:" + port - if !waitForServer(t, serverURL+"/health", 5*time.Second) { + if !waitForServer(t, serverURL+"/health", 15*time.Second) { t.Logf("STDOUT: %s", stdout.String()) t.Logf("STDERR: %s", stderr.String()) t.Fatal("Server did not start in time") @@ -334,7 +347,7 @@ func TestBinaryInvocation_PipeOutput(t *testing.T) { t.Log("✓ Server started successfully with piped stdout") // Small delay to ensure stdout is written - time.Sleep(100 * time.Millisecond) + time.Sleep(200 * time.Millisecond) // Parse the JSON gateway configuration from stdout stdoutStr := stdout.String() From 0214cbacb936b4f8c38a49594611364ff285cc8f Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 8 Mar 2026 15:16:36 -0700 Subject: [PATCH 2/5] fix: normalize allow-only key to kebab-case consistently Change canonical JSON key from "allowonly" to "allow-only" (with dash) across all serialization, deserialization, map lookups, error messages, tests, and documentation. Accept legacy "allowonly" (no dash) for backward compatibility in: - GuardPolicy.UnmarshalJSON - parsePolicyMap in unified.go - buildStrictLabelAgentPayload in wasm.go Also fix integration test timeouts in difc_config_test.go by adding context.WithTimeout and missing apiKey in JSON stdin configs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/GUARD_RESPONSE_LABELING.md | 14 ++++----- internal/cmd/flags_difc.go | 8 ++--- internal/cmd/flags_difc_test.go | 2 +- internal/config/config_difc_test.go | 8 ++--- internal/config/guard_policy.go | 36 +++++++++++------------ internal/guard/guard_test.go | 26 ++++++++-------- internal/guard/wasm.go | 18 +++++++----- internal/server/label_agent_test.go | 2 +- internal/server/unified.go | 13 ++++++++ test/integration/difc_config_test.go | 44 +++++++++++++++++++++------- 10 files changed, 106 insertions(+), 65 deletions(-) diff --git a/docs/GUARD_RESPONSE_LABELING.md b/docs/GUARD_RESPONSE_LABELING.md index 0d771cab0..bae93591a 100644 --- a/docs/GUARD_RESPONSE_LABELING.md +++ b/docs/GUARD_RESPONSE_LABELING.md @@ -145,9 +145,9 @@ For WASM guards, the gateway: 1. Normalizes the policy payload (handles both raw JSON and Go map inputs) 2. Validates the policy structure via `buildStrictLabelAgentPayload()`: - - Requires a top-level `allowonly` key with `repos` and `integrity` fields + - Requires a top-level `allow-only` key with `repos` and `min-integrity` fields - `repos`: `"all"`, `"public"`, or an array of scoped repo strings - - `integrity`: one of `"none"`, `"unapproved"`, `"approved"`, `"merged"` + - `min-integrity`: one of `"none"`, `"unapproved"`, `"approved"`, `"merged"` - Rejects legacy `policy` envelope keys 3. Calls the WASM module's exported `label_agent` function 4. Parses the response via `parseLabelAgentResponse()`, which validates: @@ -280,9 +280,9 @@ The GitHub guard uses an `AllowOnly` policy with two fields: ```json { - "allowonly": { + "allow-only": { "repos": "", - "integrity": "" + "min-integrity": "" } } ``` @@ -303,7 +303,7 @@ Scoped array entries support three patterns (all lowercase): | `owner/repo` | Exact repo | `"acme/web-app"` | | `owner/prefix*` | Repos matching prefix | `"acme/api-*"` | -**`integrity`** sets the minimum trust level for content the agent may read: +**`min-integrity`** sets the minimum trust level for content the agent may read: | Value | Meaning | |-------|---------| @@ -318,9 +318,9 @@ Given this policy in the gateway config: ```json { - "allowonly": { + "allow-only": { "repos": ["acme/web-app", "acme/api-*"], - "integrity": "approved" + "min-integrity": "approved" } } ``` diff --git a/internal/cmd/flags_difc.go b/internal/cmd/flags_difc.go index 0e1a04314..80be4c8ae 100644 --- a/internal/cmd/flags_difc.go +++ b/internal/cmd/flags_difc.go @@ -43,7 +43,7 @@ func init() { cmd.Flags().StringVar(&sessionSecrecy, "session-secrecy", getDefaultSessionSecrecy(), "Comma-separated initial secrecy labels for agent sessions (requires --enable-config-extensions)") cmd.Flags().StringVar(&sessionIntegrity, "session-integrity", getDefaultSessionIntegrity(), "Comma-separated initial integrity labels for agent sessions (requires --enable-config-extensions)") cmd.Flags().StringVar(&difcSinkServerIDs, "difc-sink-server-ids", getDefaultDIFCSinkServerIDs(), "Comma-separated server IDs whose RPC JSONL logs should include agent secrecy/integrity tag snapshots") - cmd.Flags().StringVar(&guardPolicyJSON, "guard-policy-json", getDefaultGuardPolicyJSON(), "Guard policy JSON (e.g. {\"allowonly\":{\"repos\":\"public\",\"integrity\":\"none\"}})") + cmd.Flags().StringVar(&guardPolicyJSON, "guard-policy-json", getDefaultGuardPolicyJSON(), "Guard policy JSON (e.g. {\"allow-only\":{\"repos\":\"public\",\"min-integrity\":\"none\"}})") cmd.Flags().BoolVar(&allowOnlyPublic, "allowonly-scope-public", getDefaultAllowOnlyScopePublic(), "Use public AllowOnly scope") cmd.Flags().StringVar(&allowOnlyOwner, "allowonly-scope-owner", getDefaultAllowOnlyScopeOwner(), "AllowOnly owner scope value") cmd.Flags().StringVar(&allowOnlyRepo, "allowonly-scope-repo", getDefaultAllowOnlyScopeRepo(), "AllowOnly repo name (requires owner)") @@ -143,7 +143,7 @@ func buildAllowOnlyPolicy(public bool, owner, repo, minIntegrity string) (*confi scopeCount++ } if repo != "" && owner == "" { - return nil, fmt.Errorf("allowonly scope repo requires allowonly scope owner") + return nil, fmt.Errorf("allow-only scope repo requires allow-only scope owner") } if scopeCount == 0 && minIntegrity == "" { @@ -153,10 +153,10 @@ func buildAllowOnlyPolicy(public bool, owner, repo, minIntegrity string) (*confi return nil, fmt.Errorf("exactly one AllowOnly scope variant must be set (public or owner[/repo])") } if integrityInput == "" { - return nil, fmt.Errorf("allowonly integrity is required") + return nil, fmt.Errorf("allow-only integrity is required") } if !hasIntegrity { - return nil, fmt.Errorf("allowonly integrity must be one of: none, unapproved, approved, merged") + return nil, fmt.Errorf("allow-only integrity must be one of: none, unapproved, approved, merged") } var repos interface{} diff --git a/internal/cmd/flags_difc_test.go b/internal/cmd/flags_difc_test.go index f781b7a89..85618c9ca 100644 --- a/internal/cmd/flags_difc_test.go +++ b/internal/cmd/flags_difc_test.go @@ -487,7 +487,7 @@ func TestGetDefaultGuardPolicyInputs(t *testing.T) { } }() - os.Setenv("MCP_GATEWAY_GUARD_POLICY_JSON", `{"allowonly":{"repos":"public","min-integrity":"none"}}`) + os.Setenv("MCP_GATEWAY_GUARD_POLICY_JSON", `{"allow-only":{"repos":"public","min-integrity":"none"}}`) os.Setenv("MCP_GATEWAY_ALLOWONLY_SCOPE_PUBLIC", "1") os.Setenv("MCP_GATEWAY_ALLOWONLY_SCOPE_OWNER", "lpcox") os.Setenv("MCP_GATEWAY_ALLOWONLY_SCOPE_REPO", "gh-aw-mcpg") diff --git a/internal/config/config_difc_test.go b/internal/config/config_difc_test.go index 6a82fc4d8..0594eaf29 100644 --- a/internal/config/config_difc_test.go +++ b/internal/config/config_difc_test.go @@ -576,7 +576,7 @@ func TestGuardPolicy_StdinParsingAndConversion(t *testing.T) { "type": "wasm", "path": "/guard/github-guard-rust.wasm", "policy": { - "allowonly": { + "allow-only": { "repos": ["lpcox/github-guard"], "min-integrity": "unapproved" } @@ -629,7 +629,7 @@ func TestGuardPolicy_InvalidRejected(t *testing.T) { } func TestParseGuardPolicyJSON(t *testing.T) { - policy, err := ParseGuardPolicyJSON(`{"allowonly":{"repos":"public","min-integrity":"none"}}`) + policy, err := ParseGuardPolicyJSON(`{"allow-only":{"repos":"public","min-integrity":"none"}}`) require.NoError(t, err) require.NotNil(t, policy) @@ -641,7 +641,7 @@ func TestParseGuardPolicyJSON(t *testing.T) { func TestParseGuardPolicyJSON_UpdatedRepoRegex(t *testing.T) { t.Run("accepts underscore scopes", func(t *testing.T) { - policy, err := ParseGuardPolicyJSON(`{"allowonly":{"repos":["owner_name/repo_name","owner-name/*","owner_name/repo_prefix*"],"min-integrity":"unapproved"}}`) + policy, err := ParseGuardPolicyJSON(`{"allow-only":{"repos":["owner_name/repo_name","owner-name/*","owner_name/repo_prefix*"],"min-integrity":"unapproved"}}`) require.NoError(t, err) require.NotNil(t, policy) @@ -653,7 +653,7 @@ func TestParseGuardPolicyJSON_UpdatedRepoRegex(t *testing.T) { }) t.Run("rejects dot in repo scope", func(t *testing.T) { - _, err := ParseGuardPolicyJSON(`{"allowonly":{"repos":["owner/repo.name"],"min-integrity":"unapproved"}}`) + _, err := ParseGuardPolicyJSON(`{"allow-only":{"repos":["owner/repo.name"],"min-integrity":"unapproved"}}`) require.Error(t, err) assert.Contains(t, err.Error(), "invalid") }) diff --git a/internal/config/guard_policy.go b/internal/config/guard_policy.go index 908826994..d993c6046 100644 --- a/internal/config/guard_policy.go +++ b/internal/config/guard_policy.go @@ -30,7 +30,7 @@ var validMinIntegrityValues = map[string]struct{}{ // GuardPolicy represents the policy payload passed to guard label_agent. type GuardPolicy struct { - AllowOnly *AllowOnlyPolicy `toml:"AllowOnly" json:"allowonly,omitempty"` + AllowOnly *AllowOnlyPolicy `toml:"AllowOnly" json:"allow-only,omitempty"` } // AllowOnlyPolicy configures scope and minimum required integrity. @@ -55,7 +55,7 @@ func (p *GuardPolicy) UnmarshalJSON(data []byte) error { var allowOnlyRaw json.RawMessage for key, value := range raw { switch strings.ToLower(key) { - case "allowonly": + case "allow-only", "allowonly": allowOnlyRaw = value default: return fmt.Errorf("policy contains unsupported field %q", key) @@ -63,7 +63,7 @@ func (p *GuardPolicy) UnmarshalJSON(data []byte) error { } if len(allowOnlyRaw) == 0 { - return fmt.Errorf("policy must include allowonly") + return fmt.Errorf("policy must include allow-only") } var allowOnly AllowOnlyPolicy @@ -76,7 +76,7 @@ func (p *GuardPolicy) UnmarshalJSON(data []byte) error { func (p GuardPolicy) MarshalJSON() ([]byte, error) { type serializedPolicy struct { - AllowOnly *AllowOnlyPolicy `json:"allowonly,omitempty"` + AllowOnly *AllowOnlyPolicy `json:"allow-only,omitempty"` } return json.Marshal(serializedPolicy(p)) @@ -92,22 +92,22 @@ func (p *AllowOnlyPolicy) UnmarshalJSON(data []byte) error { switch strings.ToLower(key) { case "repos": if err := json.Unmarshal(value, &p.Repos); err != nil { - return fmt.Errorf("invalid allowonly.repos: %w", err) + return fmt.Errorf("invalid allow-only.repos: %w", err) } case "min-integrity", "integrity": if err := json.Unmarshal(value, &p.MinIntegrity); err != nil { - return fmt.Errorf("invalid allowonly.min-integrity: %w", err) + return fmt.Errorf("invalid allow-only.min-integrity: %w", err) } default: - return fmt.Errorf("allowonly contains unsupported field %q", key) + return fmt.Errorf("allow-only contains unsupported field %q", key) } } if p.Repos == nil { - return fmt.Errorf("allowonly must include repos") + return fmt.Errorf("allow-only must include repos") } if strings.TrimSpace(p.MinIntegrity) == "" { - return fmt.Errorf("allowonly must include min-integrity") + return fmt.Errorf("allow-only must include min-integrity") } return nil @@ -131,12 +131,12 @@ func ValidateGuardPolicy(policy *GuardPolicy) error { // NormalizeGuardPolicy validates and normalizes policy shape. func NormalizeGuardPolicy(policy *GuardPolicy) (*NormalizedGuardPolicy, error) { if policy == nil || policy.AllowOnly == nil { - return nil, fmt.Errorf("policy must include allowonly") + return nil, fmt.Errorf("policy must include allow-only") } integrity := strings.ToLower(strings.TrimSpace(policy.AllowOnly.MinIntegrity)) if _, ok := validMinIntegrityValues[integrity]; !ok { - return nil, fmt.Errorf("allowonly.min-integrity must be one of: none, unapproved, approved, merged") + return nil, fmt.Errorf("allow-only.min-integrity must be one of: none, unapproved, approved, merged") } normalized := &NormalizedGuardPolicy{MinIntegrity: integrity} @@ -145,7 +145,7 @@ func NormalizeGuardPolicy(policy *GuardPolicy) (*NormalizedGuardPolicy, error) { case string: scopeValue := strings.ToLower(strings.TrimSpace(scope)) if scopeValue != "all" && scopeValue != "public" { - return nil, fmt.Errorf("allowonly.repos string must be 'all' or 'public'") + return nil, fmt.Errorf("allow-only.repos string must be 'all' or 'public'") } normalized.ScopeKind = scopeValue return normalized, nil @@ -173,13 +173,13 @@ func NormalizeGuardPolicy(policy *GuardPolicy) (*NormalizedGuardPolicy, error) { return normalized, nil default: - return nil, fmt.Errorf("allowonly.repos must be 'all', 'public', or a non-empty array of repo scope strings") + return nil, fmt.Errorf("allow-only.repos must be 'all', 'public', or a non-empty array of repo scope strings") } } func normalizeAndValidateScopeArray(scopes []interface{}) ([]string, error) { if len(scopes) == 0 { - return nil, fmt.Errorf("allowonly.repos array must contain at least one scope") + return nil, fmt.Errorf("allow-only.repos array must contain at least one scope") } seen := make(map[string]struct{}, len(scopes)) @@ -188,20 +188,20 @@ func normalizeAndValidateScopeArray(scopes []interface{}) ([]string, error) { for _, scopeValue := range scopes { scopeString, ok := scopeValue.(string) if !ok { - return nil, fmt.Errorf("allowonly.repos array values must be strings") + return nil, fmt.Errorf("allow-only.repos array values must be strings") } scopeString = strings.TrimSpace(scopeString) if scopeString == "" { - return nil, fmt.Errorf("allowonly.repos scope entries must not be empty") + return nil, fmt.Errorf("allow-only.repos scope entries must not be empty") } if !isValidRepoScope(scopeString) { - return nil, fmt.Errorf("allowonly.repos scope %q is invalid; expected owner/*, owner/repo, or owner/re*", scopeString) + return nil, fmt.Errorf("allow-only.repos scope %q is invalid; expected owner/*, owner/repo, or owner/re*", scopeString) } if _, exists := seen[scopeString]; exists { - return nil, fmt.Errorf("allowonly.repos must not contain duplicates") + return nil, fmt.Errorf("allow-only.repos must not contain duplicates") } seen[scopeString] = struct{}{} normalized = append(normalized, scopeString) diff --git a/internal/guard/guard_test.go b/internal/guard/guard_test.go index 7fac90aa3..6ca3d6f2c 100644 --- a/internal/guard/guard_test.go +++ b/internal/guard/guard_test.go @@ -642,7 +642,7 @@ func TestRequestStateContext(t *testing.T) { func TestNormalizePolicyPayload(t *testing.T) { t.Run("accepts object policy", func(t *testing.T) { input := map[string]interface{}{ - "allowonly": map[string]interface{}{ + "allow-only": map[string]interface{}{ "repos": "public", "integrity": "none", }, @@ -654,13 +654,13 @@ func TestNormalizePolicyPayload(t *testing.T) { }) t.Run("parses stringified json policy to object", func(t *testing.T) { - input := `{"allowonly":{"repos":"public","integrity":"none"}}` + input := `{"allow-only":{"repos":"public","integrity":"none"}}` result, err := normalizePolicyPayload(input) require.NoError(t, err) resultMap, ok := result.(map[string]interface{}) require.True(t, ok) - require.NotNil(t, resultMap["allowonly"]) + require.NotNil(t, resultMap["allow-only"]) }) t.Run("rejects invalid policy string", func(t *testing.T) { @@ -670,9 +670,9 @@ func TestNormalizePolicyPayload(t *testing.T) { } func TestBuildStrictLabelAgentPayload(t *testing.T) { - t.Run("accepts top-level allowonly payload", func(t *testing.T) { + t.Run("accepts top-level allow-only payload", func(t *testing.T) { input := map[string]interface{}{ - "allowonly": map[string]interface{}{ + "allow-only": map[string]interface{}{ "repos": "public", "integrity": "none", }, @@ -681,14 +681,14 @@ func TestBuildStrictLabelAgentPayload(t *testing.T) { payload, err := buildStrictLabelAgentPayload(input) require.NoError(t, err) require.NotNil(t, payload) - assert.Contains(t, payload, "allowonly") + assert.Contains(t, payload, "allow-only") assert.NotContains(t, payload, "policy") }) t.Run("rejects legacy policy envelope", func(t *testing.T) { input := map[string]interface{}{ "policy": map[string]interface{}{ - "allowonly": map[string]interface{}{ + "allow-only": map[string]interface{}{ "repos": "public", "integrity": "none", }, @@ -700,19 +700,19 @@ func TestBuildStrictLabelAgentPayload(t *testing.T) { assert.Equal(t, "gateway policy adapter is outdated: remove legacy envelope key policy before calling label_agent", err.Error()) }) - t.Run("rejects missing top-level allowonly", func(t *testing.T) { + t.Run("rejects missing top-level allow-only", func(t *testing.T) { input := map[string]interface{}{ "something_else": map[string]interface{}{}, } _, err := buildStrictLabelAgentPayload(input) require.Error(t, err) - assert.Equal(t, "label_agent policy must use top-level allowonly object (received policy.allowonly)", err.Error()) + assert.Equal(t, "label_agent policy must use top-level allow-only object (received policy.allow-only)", err.Error()) }) t.Run("rejects invalid repos value", func(t *testing.T) { input := map[string]interface{}{ - "allowonly": map[string]interface{}{ + "allow-only": map[string]interface{}{ "repos": []interface{}{}, "integrity": "none", }, @@ -725,7 +725,7 @@ func TestBuildStrictLabelAgentPayload(t *testing.T) { t.Run("rejects invalid integrity value", func(t *testing.T) { input := map[string]interface{}{ - "allowonly": map[string]interface{}{ + "allow-only": map[string]interface{}{ "repos": "all", "integrity": "reader-contrib", }, @@ -748,11 +748,11 @@ func TestParseLabelAgentResponse(t *testing.T) { }) t.Run("non success fails closed", func(t *testing.T) { - payload := []byte(`{"success":false,"error":"missing field allowonly"}`) + payload := []byte(`{"success":false,"error":"missing field allow-only"}`) result, err := parseLabelAgentResponse(payload) require.Error(t, err) assert.Nil(t, result) - assert.Contains(t, err.Error(), "missing field allowonly") + assert.Contains(t, err.Error(), "missing field allow-only") }) } diff --git a/internal/guard/wasm.go b/internal/guard/wasm.go index 250d251c1..18858aa0e 100644 --- a/internal/guard/wasm.go +++ b/internal/guard/wasm.go @@ -316,13 +316,13 @@ func normalizePolicyPayload(policy interface{}) (interface{}, error) { func buildStrictLabelAgentPayload(policy interface{}) (map[string]interface{}, error) { if policy == nil { - return nil, fmt.Errorf("invalid guard policy transport shape: expected {\"allowonly\":{\"repos\":...,\"integrity\":...}}") + return nil, fmt.Errorf("invalid guard policy transport shape: expected {\"allow-only\":{\"repos\":...,\"min-integrity\":...}}") } if policyMap, ok := policy.(map[string]interface{}); ok { if nested, hasPolicy := policyMap["policy"]; hasPolicy { if nestedMap, nestedOK := nested.(map[string]interface{}); nestedOK { - if _, hasAllowOnly := nestedMap["allowonly"]; hasAllowOnly { + if _, hasAllowOnly := nestedMap["allow-only"]; hasAllowOnly { return nil, fmt.Errorf("gateway policy adapter is outdated: remove legacy envelope key policy before calling label_agent") } } @@ -343,18 +343,22 @@ func buildStrictLabelAgentPayload(policy interface{}) (map[string]interface{}, e return nil, fmt.Errorf("gateway policy adapter is outdated: remove legacy envelope key policy before calling label_agent") } - allowOnlyRaw, ok := payload["allowonly"] + allowOnlyRaw, ok := payload["allow-only"] if !ok { - return nil, fmt.Errorf("label_agent policy must use top-level allowonly object (received policy.allowonly)") + // Accept legacy "allowonly" form for backward compatibility + allowOnlyRaw, ok = payload["allowonly"] + } + if !ok { + return nil, fmt.Errorf("label_agent policy must use top-level allow-only object (received policy.allow-only)") } if len(payload) != 1 { - return nil, fmt.Errorf("invalid guard policy transport shape: expected {\"allowonly\":{\"repos\":...,\"min-integrity\":...}}") + return nil, fmt.Errorf("invalid guard policy transport shape: expected {\"allow-only\":{\"repos\":...,\"min-integrity\":...}}") } allowOnly, ok := allowOnlyRaw.(map[string]interface{}) if !ok { - return nil, fmt.Errorf("invalid guard policy transport shape: expected {\"allowonly\":{\"repos\":...,\"min-integrity\":...}}") + return nil, fmt.Errorf("invalid guard policy transport shape: expected {\"allow-only\":{\"repos\":...,\"min-integrity\":...}}") } reposRaw, hasRepos := allowOnly["repos"] @@ -363,7 +367,7 @@ func buildStrictLabelAgentPayload(policy interface{}) (map[string]interface{}, e integrityRaw, hasIntegrity = allowOnly["integrity"] } if !hasRepos || !hasIntegrity || len(allowOnly) != 2 { - return nil, fmt.Errorf("invalid guard policy transport shape: expected {\"allowonly\":{\"repos\":...,\"min-integrity\":...}}") + return nil, fmt.Errorf("invalid guard policy transport shape: expected {\"allow-only\":{\"repos\":...,\"min-integrity\":...}}") } if !isValidAllowOnlyRepos(reposRaw) { diff --git a/internal/server/label_agent_test.go b/internal/server/label_agent_test.go index e68cf4b9c..9c69018bc 100644 --- a/internal/server/label_agent_test.go +++ b/internal/server/label_agent_test.go @@ -261,7 +261,7 @@ func TestCallBackendTool_LabelAgentInitializationFromServerGuardPolicies(t *test URL: backend.URL, Guard: "test-guard", GuardPolicies: map[string]interface{}{ - "allowonly": map[string]interface{}{ + "allow-only": map[string]interface{}{ "repos": "public", "min-integrity": "none", }, diff --git a/internal/server/unified.go b/internal/server/unified.go index 7b37d2402..197ac02c2 100644 --- a/internal/server/unified.go +++ b/internal/server/unified.go @@ -1260,6 +1260,19 @@ func parsePolicyMap(raw map[string]interface{}) (*config.GuardPolicy, error) { return nil, nil } + if _, hasAllowOnly := raw["allow-only"]; hasAllowOnly { + policyBytes, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("failed to serialize server guard policy: %w", err) + } + policy, err := config.ParseGuardPolicyJSON(string(policyBytes)) + if err != nil { + return nil, fmt.Errorf("invalid server guard policy: %w", err) + } + return policy, nil + } + + // Accept legacy "allowonly" form for backward compatibility if _, hasAllowOnly := raw["allowonly"]; hasAllowOnly { policyBytes, err := json.Marshal(raw) if err != nil { diff --git a/test/integration/difc_config_test.go b/test/integration/difc_config_test.go index 300223f22..b79f53632 100644 --- a/test/integration/difc_config_test.go +++ b/test/integration/difc_config_test.go @@ -2,6 +2,7 @@ package integration import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -105,7 +106,7 @@ func TestDIFCEnvironmentVariables(t *testing.T) { t.Run(tt.name, func(t *testing.T) { port := 13200 + len(tt.name)%100 // Generate unique port per test - // Create minimal config + // Create minimal config with apiKey (required by JSON stdin schema) config := fmt.Sprintf(`{ "mcpServers": { "test": { @@ -114,11 +115,15 @@ func TestDIFCEnvironmentVariables(t *testing.T) { } }, "gateway": { - "port": %d + "port": %d, + "apiKey": "test-key" } }`, port) - cmd := exec.Command(binary, "--config-stdin") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binary, "--config-stdin") cmd.Stdin = strings.NewReader(config) // Set environment variables @@ -199,7 +204,10 @@ func TestDIFCConfigWithGuards(t *testing.T) { } }` - cmd := exec.Command(binary, "--config-stdin") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binary, "--config-stdin") cmd.Stdin = strings.NewReader(config) var stdout, stderr bytes.Buffer @@ -244,11 +252,15 @@ func TestDIFCSessionLabelsViaEnv(t *testing.T) { } }, "gateway": { - "port": 13301 + "port": 13301, + "apiKey": "test-key" } }` - cmd := exec.Command(binary, "--config-stdin") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binary, "--config-stdin") cmd.Stdin = strings.NewReader(config) cmd.Env = append(os.Environ(), "MCP_GATEWAY_CONFIG_EXTENSIONS=true", @@ -304,7 +316,10 @@ func TestDIFCModeFilterViaEnv(t *testing.T) { } }` - cmd := exec.Command(binary, "--config-stdin") + ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel2() + + cmd := exec.CommandContext(ctx2, binary, "--config-stdin") cmd.Stdin = strings.NewReader(config) cmd.Env = append(os.Environ(), "MCP_GATEWAY_ENABLE_DIFC=true", @@ -351,7 +366,10 @@ func TestDIFCModePropagateViaEnv(t *testing.T) { } }` - cmd := exec.Command(binary, "--config-stdin") + ctx3, cancel3 := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel3() + + cmd := exec.CommandContext(ctx3, binary, "--config-stdin") cmd.Stdin = strings.NewReader(config) cmd.Env = append(os.Environ(), "MCP_GATEWAY_ENABLE_DIFC=true", @@ -398,7 +416,10 @@ func TestSessionLabelsRequireConfigExtensions(t *testing.T) { } }` - cmd := exec.Command(binary, "--config-stdin") + ctx4, cancel4 := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel4() + + cmd := exec.CommandContext(ctx4, binary, "--config-stdin") cmd.Stdin = strings.NewReader(config) // Note: NOT setting MCP_GATEWAY_CONFIG_EXTENSIONS cmd.Env = append(os.Environ(), @@ -456,7 +477,10 @@ func TestFullDIFCConfigFromJSON(t *testing.T) { } }` - cmd := exec.Command(binary, "--config-stdin", "--enable-config-extensions") + ctx5, cancel5 := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel5() + + cmd := exec.CommandContext(ctx5, binary, "--config-stdin", "--enable-config-extensions") cmd.Stdin = strings.NewReader(config) cmd.Env = append(os.Environ(), "MCP_GATEWAY_ENABLE_DIFC=true", From 5644dd4ae4beaa6ac003b0b08cad21f244d3cbe8 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 8 Mar 2026 15:42:34 -0700 Subject: [PATCH 3/5] Update internal/server/unified.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/server/unified.go | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/internal/server/unified.go b/internal/server/unified.go index 27f628a3b..e13463460 100644 --- a/internal/server/unified.go +++ b/internal/server/unified.go @@ -1254,20 +1254,13 @@ func parsePolicyMap(raw map[string]interface{}) (*config.GuardPolicy, error) { return nil, nil } - if _, hasAllowOnly := raw["allow-only"]; hasAllowOnly { - policyBytes, err := json.Marshal(raw) - if err != nil { - return nil, fmt.Errorf("failed to serialize server guard policy: %w", err) - } - policy, err := config.ParseGuardPolicyJSON(string(policyBytes)) - if err != nil { - return nil, fmt.Errorf("invalid server guard policy: %w", err) - } - return policy, nil + hasAllowOnly := false + if _, ok := raw["allow-only"]; ok { + hasAllowOnly = true + } else if _, ok := raw["allowonly"]; ok { // Accept legacy "allowonly" form for backward compatibility + hasAllowOnly = true } - - // Accept legacy "allowonly" form for backward compatibility - if _, hasAllowOnly := raw["allowonly"]; hasAllowOnly { + if hasAllowOnly { policyBytes, err := json.Marshal(raw) if err != nil { return nil, fmt.Errorf("failed to serialize server guard policy: %w", err) From 1f3b7c6ab8d266e4e6a1a3d783af5f8430ab7372 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 8 Mar 2026 15:43:11 -0700 Subject: [PATCH 4/5] Update test/integration/binary_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/integration/binary_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/integration/binary_test.go b/test/integration/binary_test.go index 2d5437145..58f184be7 100644 --- a/test/integration/binary_test.go +++ b/test/integration/binary_test.go @@ -312,7 +312,8 @@ func TestBinaryInvocation_PipeOutput(t *testing.T) { "apiKey": "test-pipe-key", }, } - configBytes, _ := json.Marshal(configJSON) + configBytes, err := json.Marshal(configJSON) + require.NoError(t, err) cmd := exec.CommandContext(ctx, binaryPath, "--config-stdin", From 94cc639895f7573dca1646f2af12063285467cba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:46:53 +0000 Subject: [PATCH 5/5] fix: update buildAllowOnlyPolicy error messages to use "min-integrity" Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- internal/cmd/flags_difc.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/flags_difc.go b/internal/cmd/flags_difc.go index 80be4c8ae..519a27c9d 100644 --- a/internal/cmd/flags_difc.go +++ b/internal/cmd/flags_difc.go @@ -153,10 +153,10 @@ func buildAllowOnlyPolicy(public bool, owner, repo, minIntegrity string) (*confi return nil, fmt.Errorf("exactly one AllowOnly scope variant must be set (public or owner[/repo])") } if integrityInput == "" { - return nil, fmt.Errorf("allow-only integrity is required") + return nil, fmt.Errorf("min-integrity is required") } if !hasIntegrity { - return nil, fmt.Errorf("allow-only integrity must be one of: none, unapproved, approved, merged") + return nil, fmt.Errorf("min-integrity must be one of: none, unapproved, approved, merged") } var repos interface{}