diff --git a/control-plane/internal/policy/policy.go b/control-plane/internal/policy/policy.go index d1f2d91..545001b 100644 --- a/control-plane/internal/policy/policy.go +++ b/control-plane/internal/policy/policy.go @@ -161,8 +161,7 @@ type hostAllowlistEvaluator struct { } func (e hostAllowlistEvaluator) Evaluate(req EvalRequest) string { - cmd, _ := req.Input["command"].(string) - host := extractHost(cmd) + host := extractHostFromInput(req.Input) if host == "" { return "skip" } @@ -370,6 +369,17 @@ func extractPackageToken(cmd string) string { var urlHostRE = regexp.MustCompile(`https?://([A-Za-z0-9.\-]+)`) +func extractHostFromInput(input map[string]any) string { + u, _ := input["url"].(string) + if u != "" { + if host := extractHost(u); host != "" { + return host + } + } + cmd, _ := input["command"].(string) + return extractHost(cmd) +} + func extractHost(cmd string) string { m := urlHostRE.FindStringSubmatch(cmd) if len(m) < 2 { @@ -863,4 +873,3 @@ func compilePathGlob(g string) (*regexp.Regexp, error) { b.WriteString("$") return regexp.Compile(b.String()) } - diff --git a/control-plane/internal/policy/policy_test.go b/control-plane/internal/policy/policy_test.go index 833e3a1..9a8a1fc 100644 --- a/control-plane/internal/policy/policy_test.go +++ b/control-plane/internal/policy/policy_test.go @@ -237,6 +237,28 @@ gates: on_miss: deny ` +const netEgressURLYAML = ` +version: 1 +mode: enforce +gates: + - id: rogue.net-egress + match: + any_of: + - tool: Bash + command_regex: '\b(curl|wget)\b' + - tool: WebFetch + any_url_regex: + - '^https?://' + - tool: WebSearch + any_url_regex: + - '^https?://' + evaluate: + - kind: host-allowlist + list: __INLINE__:api.anthropic.com,api.openai.com + on_hit: allow + on_miss: deny +` + const typosquatYAML = ` version: 1 mode: enforce @@ -481,6 +503,45 @@ func TestEvaluate_HostAllowlist_NoURLDoesNotMatch(t *testing.T) { } } +func TestEvaluate_HostAllowlist_WebFetchURLHitAllows(t *testing.T) { + p, err := Load(strings.NewReader(netEgressURLYAML)) + if err != nil { + t.Fatalf("load: %v", err) + } + v := p.Evaluate(EvalRequest{ + Tool: "WebFetch", + Input: map[string]any{"url": "https://api.openai.com/v1/responses"}, + }) + if v.Verdict != "allow" || v.RuleID != "rogue.net-egress" { + t.Fatalf("got %+v", v) + } +} + +func TestEvaluate_HostAllowlist_WebSearchURLMissDenies(t *testing.T) { + p, _ := Load(strings.NewReader(netEgressURLYAML)) + v := p.Evaluate(EvalRequest{ + Tool: "WebSearch", + Input: map[string]any{"url": "https://evil.biz/search?q=secrets"}, + }) + if v.Verdict != "deny" || v.RuleID != "rogue.net-egress" { + t.Fatalf("got %+v", v) + } +} + +func TestEvaluate_HostAllowlist_PrefersURLFieldOverCommand(t *testing.T) { + p, _ := Load(strings.NewReader(netEgressURLYAML)) + v := p.Evaluate(EvalRequest{ + Tool: "WebFetch", + Input: map[string]any{ + "url": "https://api.openai.com/v1/responses", + "command": "curl https://evil.biz/pwn", + }, + }) + if v.Verdict != "allow" { + t.Fatalf("got %+v", v) + } +} + func TestEvaluate_MultiEvaluator_SkipThenAlways(t *testing.T) { p, _ := Load(strings.NewReader(multiEvalYAML)) // "zzz" not in allowlist → on_miss=skip → fallthrough to always:deny