Skip to content
Merged
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
14 changes: 7 additions & 7 deletions docs/GUARD_RESPONSE_LABELING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -280,9 +280,9 @@ The GitHub guard uses an `AllowOnly` policy with two fields:

```json
{
"allowonly": {
"allow-only": {
"repos": "<scope>",
"integrity": "<level>"
"min-integrity": "<level>"
}
}
```
Expand All @@ -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 |
|-------|---------|
Expand All @@ -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"
}
}
```
Expand Down
6 changes: 3 additions & 3 deletions internal/cmd/flags_difc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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")
}
Comment on lines 155 to 160

Copilot AI Mar 8, 2026

Copy link

Choose a reason for hiding this comment

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

The new error messages in buildAllowOnlyPolicy say "allow-only integrity ...", but the flag/config field name is "min-integrity" (and the help text uses "min-integrity"). Updating these messages to consistently refer to "min-integrity" will make it clearer to users what value is missing/invalid.

See below for a potential fix:

		return nil, fmt.Errorf("min-integrity is required")
	}
	if !hasIntegrity {
		return nil, fmt.Errorf("min-integrity must be one of: none, unapproved, approved, merged")

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

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.

Fixed in 94cc639. Updated both error messages in buildAllowOnlyPolicy to use "min-integrity" consistently:

  • "min-integrity is required"
  • "min-integrity must be one of: none, unapproved, approved, merged"


var repos interface{}
Expand Down
10 changes: 9 additions & 1 deletion internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
34 changes: 32 additions & 2 deletions internal/cmd/stdout_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
},
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 13 additions & 13 deletions internal/guard/guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand All @@ -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) {
Expand All @@ -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",
},
Expand All @@ -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",
},
Expand All @@ -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",
},
Expand All @@ -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",
},
Expand All @@ -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")
})
}
18 changes: 11 additions & 7 deletions internal/guard/wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Expand All @@ -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"]
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion internal/server/label_agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down
8 changes: 7 additions & 1 deletion internal/server/unified.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion test/integration/auth_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 26 additions & 12 deletions test/integration/binary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
Expand All @@ -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()
Expand Down
Loading
Loading