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 3a16ccfbc..519a27c9d 100644 --- a/internal/cmd/flags_difc.go +++ b/internal/cmd/flags_difc.go @@ -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("min-integrity is required") } if !hasIntegrity { - return nil, fmt.Errorf("allowonly 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{} 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/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 52e8e10b4..e13463460 100644 --- a/internal/server/unified.go +++ b/internal/server/unified.go @@ -1254,7 +1254,13 @@ func parsePolicyMap(raw map[string]interface{}) (*config.GuardPolicy, error) { return nil, nil } - if _, hasAllowOnly := raw["allowonly"]; hasAllowOnly { + 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 + } + if hasAllowOnly { policyBytes, err := json.Marshal(raw) if err != nil { return nil, fmt.Errorf("failed to serialize server guard policy: %w", err) 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..58f184be7 100644 --- a/test/integration/binary_test.go +++ b/test/integration/binary_test.go @@ -289,27 +289,41 @@ 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, err := json.Marshal(configJSON) + require.NoError(t, err) + 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 +339,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 +348,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() diff --git a/test/integration/difc_config_test.go b/test/integration/difc_config_test.go index 718be56d1..938d54d85 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" @@ -119,7 +120,7 @@ func TestDIFCEnvironmentVariables(t *testing.T) { t.Run(tt.name, func(t *testing.T) { port := getFreePort(t) // Allocate a unique free port for each subtest - // Create minimal config + // Create minimal config with apiKey (required by JSON stdin schema) config := fmt.Sprintf(`{ "mcpServers": { "test": { @@ -128,11 +129,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 @@ -213,7 +218,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 @@ -258,11 +266,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", @@ -318,7 +330,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", @@ -365,7 +380,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", @@ -412,7 +430,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(), @@ -470,7 +491,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",