diff --git a/scenarios/policy-enforcement/go/go.mod b/scenarios/policy-enforcement/go/go.mod new file mode 100644 index 0000000..b508484 --- /dev/null +++ b/scenarios/policy-enforcement/go/go.mod @@ -0,0 +1,21 @@ +module example.com/policy-enforcement + +go 1.26.0 + +require ( + github.com/AI-agent-assembly/go-sdk v0.0.1-alpha.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/scenarios/policy-enforcement/go/go.sum b/scenarios/policy-enforcement/go/go.sum new file mode 100644 index 0000000..dbd82a6 --- /dev/null +++ b/scenarios/policy-enforcement/go/go.sum @@ -0,0 +1,53 @@ +github.com/AI-agent-assembly/go-sdk v0.0.1-alpha.2 h1:0o+8dqLpBi3cIKNu8mXI0vP4RdgyYfgkKqtfI6B8u1A= +github.com/AI-agent-assembly/go-sdk v0.0.1-alpha.2/go.mod h1:vsRXutpz1R1zFmIn08agGluNdSBB/2kiqSHtu63LxoU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scenarios/policy-enforcement/go/main.go b/scenarios/policy-enforcement/go/main.go new file mode 100644 index 0000000..2249fb7 --- /dev/null +++ b/scenarios/policy-enforcement/go/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "errors" + "fmt" + + "github.com/AI-agent-assembly/go-sdk/assembly" +) + +const policyFile = "../policy.yaml" + +func main() { + fmt.Printf("%s\n", repeat("=", 62)) + fmt.Printf(" Agent Assembly — Policy Enforcement Scenario (Go)\n") + fmt.Printf("%s\n\n", repeat("=", 62)) + + cfg, err := loadPolicyConfig(policyFile) + if err != nil { + fmt.Printf("ERROR: %v\n", err) + return + } + + fmt.Printf("Policy loaded from policy.yaml (%d rules, default: %s)\n", len(cfg.Rules), cfg.DefaultAction) + for _, r := range cfg.Rules { + icon := "ALLOW" + if r.Action == "deny" { + icon = "DENY " + } + fmt.Printf(" %s %-14s — %s\n", icon, r.Tool, r.Reason) + } + fmt.Println() + + client := newPolicyClient(cfg) + tools := assembly.WrapTools( + []assembly.Tool{ + &readConfigTool{}, + &listAgentsTool{}, + &deleteAgentTool{}, + &sendEmailTool{}, + }, + client, + ) + + inputs := map[string]string{ + "read_config": "database.host", + "list_agents": "", + "delete_agent": "agent-001", + "send_email": "to=admin@example.com subject=Hello", + } + + fmt.Println("Running governed tool calls:") + fmt.Printf("%s\n", repeat("-", 44)) + allowed, denied := 0, 0 + for _, tool := range tools { + input := inputs[tool.Name()] + fmt.Printf(" → %s(%q)\n", tool.Name(), input) + result, err := tool.Call(context.Background(), input) + if err != nil { + var pve *assembly.PolicyViolationError + if errors.As(err, &pve) { + fmt.Printf(" ❌ DENIED — %v\n", pve) + denied++ + } else { + fmt.Printf(" ⚠️ ERROR — %v\n", err) + } + } else { + fmt.Printf(" ✅ ALLOWED — %s\n", result) + allowed++ + } + fmt.Println() + } + fmt.Printf("%d tool calls: %d allowed, %d denied.\n", allowed+denied, allowed, denied) +} + +func repeat(s string, n int) string { + result := "" + for i := 0; i < n; i++ { + result += s + } + return result +} diff --git a/scenarios/policy-enforcement/go/main_test.go b/scenarios/policy-enforcement/go/main_test.go new file mode 100644 index 0000000..24b513a --- /dev/null +++ b/scenarios/policy-enforcement/go/main_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "errors" + "testing" + + "github.com/AI-agent-assembly/go-sdk/assembly" +) + +const testPolicyFile = "../policy.yaml" + +func loadTestClient(t *testing.T) *policyClient { + t.Helper() + cfg, err := loadPolicyConfig(testPolicyFile) + if err != nil { + t.Fatalf("loadPolicyConfig: %v", err) + } + return newPolicyClient(cfg) +} + +func TestReadConfigIsAllowed(t *testing.T) { + t.Parallel() + client := loadTestClient(t) + tools := assembly.WrapTools([]assembly.Tool{&readConfigTool{}}, client) + + result, err := tools[0].Call(context.Background(), "database.host") + if err != nil { + t.Fatalf("expected allowed, got error: %v", err) + } + if result != "localhost:5432" { + t.Fatalf("unexpected result: %q", result) + } +} + +func TestListAgentsIsAllowed(t *testing.T) { + t.Parallel() + client := loadTestClient(t) + tools := assembly.WrapTools([]assembly.Tool{&listAgentsTool{}}, client) + + result, err := tools[0].Call(context.Background(), "") + if err != nil { + t.Fatalf("expected allowed, got error: %v", err) + } + if result == "" { + t.Fatal("expected non-empty agent list") + } +} + +func TestDeleteAgentIsDenied(t *testing.T) { + t.Parallel() + client := loadTestClient(t) + tools := assembly.WrapTools([]assembly.Tool{&deleteAgentTool{}}, client) + + _, err := tools[0].Call(context.Background(), "agent-001") + if err == nil { + t.Fatal("expected denial error, got nil") + } + var pve *assembly.PolicyViolationError + if !errors.As(err, &pve) { + t.Fatalf("expected PolicyViolationError, got %T: %v", err, err) + } + if pve.ToolName != "delete_agent" { + t.Fatalf("expected ToolName %q, got %q", "delete_agent", pve.ToolName) + } +} + +func TestSendEmailIsDenied(t *testing.T) { + t.Parallel() + client := loadTestClient(t) + tools := assembly.WrapTools([]assembly.Tool{&sendEmailTool{}}, client) + + _, err := tools[0].Call(context.Background(), "admin@example.com") + if err == nil { + t.Fatal("expected denial error, got nil") + } + var pve *assembly.PolicyViolationError + if !errors.As(err, &pve) { + t.Fatalf("expected PolicyViolationError, got %T: %v", err, err) + } +} + +func TestUnknownToolIsDeniedByDefault(t *testing.T) { + t.Parallel() + client := loadTestClient(t) + + stubTool := &namedStub{name: "execute_shell"} + tools := assembly.WrapTools([]assembly.Tool{stubTool}, client) + + _, err := tools[0].Call(context.Background(), "rm -rf /") + if err == nil { + t.Fatal("expected default-deny for unknown tool, got nil") + } + var pve *assembly.PolicyViolationError + if !errors.As(err, &pve) { + t.Fatalf("expected PolicyViolationError, got %T: %v", err, err) + } +} + +func TestLoadPolicyConfigRules(t *testing.T) { + t.Parallel() + cfg, err := loadPolicyConfig(testPolicyFile) + if err != nil { + t.Fatalf("loadPolicyConfig: %v", err) + } + if len(cfg.Rules) < 4 { + t.Fatalf("expected at least 4 rules, got %d", len(cfg.Rules)) + } + if cfg.DefaultAction != "deny" { + t.Fatalf("expected default_action=deny, got %q", cfg.DefaultAction) + } +} + +// namedStub is a stub tool that always returns "ok" when called. +type namedStub struct{ name string } + +func (n *namedStub) Name() string { return n.name } +func (n *namedStub) Description() string { return "stub" } +func (n *namedStub) Call(_ context.Context, _ string) (string, error) { return "ok", nil } diff --git a/scenarios/policy-enforcement/go/policy.go b/scenarios/policy-enforcement/go/policy.go new file mode 100644 index 0000000..66423b7 --- /dev/null +++ b/scenarios/policy-enforcement/go/policy.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/AI-agent-assembly/go-sdk/assembly" + "gopkg.in/yaml.v3" +) + +// policyRule mirrors a rule in policy.yaml. +type policyRule struct { + Tool string `yaml:"tool"` + Action string `yaml:"action"` + Reason string `yaml:"reason"` +} + +// policyConfig is the top-level policy.yaml structure. +type policyConfig struct { + Rules []policyRule `yaml:"rules"` + DefaultAction string `yaml:"default_action"` + DefaultReason string `yaml:"default_reason"` +} + +// loadPolicyConfig reads and parses ../policy.yaml relative to the working directory. +func loadPolicyConfig(path string) (*policyConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read policy file: %w", err) + } + var cfg policyConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse policy file: %w", err) + } + return &cfg, nil +} + +// policyClient enforces per-tool allow/deny rules loaded from policy.yaml. +// It implements assembly.GovernanceClient. +type policyClient struct { + rules map[string]policyRule + defaultAction string + defaultReason string +} + +func newPolicyClient(cfg *policyConfig) *policyClient { + rules := make(map[string]policyRule, len(cfg.Rules)) + for _, r := range cfg.Rules { + rules[r.Tool] = r + } + return &policyClient{ + rules: rules, + defaultAction: cfg.DefaultAction, + defaultReason: cfg.DefaultReason, + } +} + +func (p *policyClient) Check(_ context.Context, req assembly.CheckRequest) (assembly.Decision, error) { + if rule, ok := p.rules[req.ToolName]; ok { + if rule.Action == "deny" { + fmt.Printf("[policy] DENIED tool=%s reason=%q\n", req.ToolName, rule.Reason) + return assembly.Decision{Denied: true, Reason: rule.Reason}, nil + } + fmt.Printf("[policy] ALLOWED tool=%s\n", req.ToolName) + return assembly.Decision{Denied: false}, nil + } + // Default: deny unknown tools (fail-closed). + fmt.Printf("[policy] DENIED tool=%s reason=%q\n", req.ToolName, p.defaultReason) + return assembly.Decision{Denied: true, Reason: p.defaultReason}, nil +} + +func (p *policyClient) WaitForApproval(_ context.Context, _ assembly.ApprovalRequest) (assembly.Decision, error) { + return assembly.Decision{Denied: false}, nil +} + +func (p *policyClient) RecordResult(_ context.Context, _ assembly.RecordRequest) error { return nil } +func (p *policyClient) Close() error { return nil } diff --git a/scenarios/policy-enforcement/go/tools.go b/scenarios/policy-enforcement/go/tools.go new file mode 100644 index 0000000..37e01ff --- /dev/null +++ b/scenarios/policy-enforcement/go/tools.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "fmt" + "strings" +) + +// readConfigTool returns a configuration value (safe — ALLOWED). +type readConfigTool struct{} + +func (t *readConfigTool) Name() string { return "read_config" } +func (t *readConfigTool) Description() string { return "Read a configuration value by key" } +func (t *readConfigTool) Call(_ context.Context, input string) (string, error) { + config := map[string]string{ + "database.host": "localhost:5432", + "service.port": "8080", + "log.level": "INFO", + } + if val, ok := config[input]; ok { + return val, nil + } + return fmt.Sprintf("(no value for '%s')", input), nil +} + +// listAgentsTool returns registered agent IDs (safe — ALLOWED). +type listAgentsTool struct{} + +func (t *listAgentsTool) Name() string { return "list_agents" } +func (t *listAgentsTool) Description() string { return "List registered agent IDs" } +func (t *listAgentsTool) Call(_ context.Context, _ string) (string, error) { + return strings.Join([]string{"agent-001", "agent-002", "agent-003"}, ", "), nil +} + +// deleteAgentTool removes an agent (RISKY — DENIED by policy). +type deleteAgentTool struct{} + +func (t *deleteAgentTool) Name() string { return "delete_agent" } +func (t *deleteAgentTool) Description() string { return "Delete an agent from the registry" } +func (t *deleteAgentTool) Call(_ context.Context, input string) (string, error) { + return fmt.Sprintf("Deleted agent %s", input), nil +} + +// sendEmailTool sends email (RISKY — DENIED by policy: network egress). +type sendEmailTool struct{} + +func (t *sendEmailTool) Name() string { return "send_email" } +func (t *sendEmailTool) Description() string { return "Send an email to an external address" } +func (t *sendEmailTool) Call(_ context.Context, input string) (string, error) { + return fmt.Sprintf("Email sent: %s", input), nil +}