From 30e03edb89267c21b28355b00bb595e05e468a46 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 15 Nov 2025 03:40:29 +0100 Subject: [PATCH 01/12] custom builtins for rego Signed-off-by: Jose I. Paris --- CLAUDE.md | 95 ++++++++ pkg/policies/engine/rego/builtins/errors.go | 31 +++ pkg/policies/engine/rego/builtins/http.go | 195 ++++++++++++++++ .../engine/rego/builtins/http_test.go | 189 ++++++++++++++++ pkg/policies/engine/rego/builtins/registry.go | 179 +++++++++++++++ .../engine/rego/builtins/registry_test.go | 208 ++++++++++++++++++ pkg/policies/engine/rego/rego.go | 40 +++- pkg/policies/engine/rego/rego_test.go | 130 +++++++++++ .../testfiles/custom_builtin_permissive.rego | 21 ++ 9 files changed, 1085 insertions(+), 3 deletions(-) create mode 100644 pkg/policies/engine/rego/builtins/errors.go create mode 100644 pkg/policies/engine/rego/builtins/http.go create mode 100644 pkg/policies/engine/rego/builtins/http_test.go create mode 100644 pkg/policies/engine/rego/builtins/registry.go create mode 100644 pkg/policies/engine/rego/builtins/registry_test.go create mode 100644 pkg/policies/engine/rego/testfiles/custom_builtin_permissive.rego diff --git a/CLAUDE.md b/CLAUDE.md index bd0777e60..bd582b5a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -246,6 +246,101 @@ Workflow Contracts define the structure and requirements for CI/CD attestations. - **Authentication**: JWT tokens generated by Control Plane, validated by CAS - **Streaming**: Uses gRPC bytestream protocol for efficient file transfers +### Policy Engine Extension + +The OPA/Rego policy engine supports custom built-in functions written in Go. + +**Architecture** (`pkg/policies/engine/rego/builtins/`): +- **Registry**: Manages custom built-in function registration +- **Security Levels**: Functions tagged as `SecurityLevelRestrictive` (production-safe) or `SecurityLevelPermissive` (development-only) +- **Operating Modes**: Restrictive mode (server-side) only allows restrictive functions; Permissive mode (CLI local dev) allows all functions + +**Adding Custom Built-ins**: + +1. **Create Built-in Implementation** (e.g., `pkg/policies/engine/rego/builtins/myfeature.go`): +```go +package builtins + +import ( + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/topdown" + "github.com/open-policy-agent/opa/types" +) + +const myFuncName = "chainloop.my_function" + +func RegisterMyBuiltins() error { + return Register(&BuiltinDef{ + Name: myFuncName, + Decl: &ast.Builtin{ + Name: myFuncName, + Decl: types.NewFunction( + types.Args(types.Named("input", types.S)), + types.Named("result", types.S), + ), + }, + Impl: myFunctionImpl, + SecurityLevel: SecurityLevelRestrictive, // or SecurityLevelPermissive + Description: "Description of what this function does", + }) +} + +func myFunctionImpl(bctx topdown.BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { + // Extract arguments + input, ok := operands[0].Value.(ast.String) + if !ok { + return fmt.Errorf("input must be a string") + } + + // Implement logic + result := processInput(string(input)) + + // Return result + return iter(ast.StringTerm(result)) +} + +func init() { + if err := RegisterMyBuiltins(); err != nil { + panic(fmt.Sprintf("failed to register built-ins: %v", err)) + } +} +``` + +2. **Use in Policies** (`*.rego`): +```rego +package example +import rego.v1 + +result := { + "violations": violations, + "skipped": false +} + +violations contains msg if { + output := chainloop.my_function(input.value) + output != "expected" + msg := "Function returned unexpected value" +} +``` + +3. **Testing**: +- Add unit tests in `pkg/policies/engine/rego/builtins/*_test.go` +- Add integration tests in `pkg/policies/engine/rego/rego_test.go` +- Test both restrictive and permissive modes +- Mock external dependencies (HTTP clients, databases, etc.) + +**Guidelines**: +- Use `chainloop.*` namespace for all custom built-ins +- Restrictive functions must be read-only, deterministic, and not make external calls +- Permissive functions can call external services but should use dependency injection +- Always implement proper error handling and return meaningful error messages +- Use context from `BuiltinContext` for timeout/cancellation support +- Document function signatures and behavior in the `Description` field + +**Example Use Cases**: +- **Restrictive**: Data transformations, format parsing, cryptographic verification with embedded keys +- **Permissive**: External API calls, database queries, dynamic credential fetching + ## Commit Guidelines All commits must meet these criteria: diff --git a/pkg/policies/engine/rego/builtins/errors.go b/pkg/policies/engine/rego/builtins/errors.go new file mode 100644 index 000000000..03897d06f --- /dev/null +++ b/pkg/policies/engine/rego/builtins/errors.go @@ -0,0 +1,31 @@ +// Copyright 2024-2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builtins + +import "errors" + +var ( + // ErrNilBuiltinDef is returned when a nil BuiltinDef is provided + ErrNilBuiltinDef = errors.New("built-in definition cannot be nil") + + // ErrEmptyBuiltinName is returned when a built-in name is empty + ErrEmptyBuiltinName = errors.New("built-in name cannot be empty") + + // ErrNilBuiltinDecl is returned when a built-in declaration is nil + ErrNilBuiltinDecl = errors.New("built-in declaration cannot be nil") + + // ErrNilBuiltinImpl is returned when a built-in implementation is nil + ErrNilBuiltinImpl = errors.New("built-in implementation cannot be nil") +) diff --git a/pkg/policies/engine/rego/builtins/http.go b/pkg/policies/engine/rego/builtins/http.go new file mode 100644 index 000000000..9c1454ea5 --- /dev/null +++ b/pkg/policies/engine/rego/builtins/http.go @@ -0,0 +1,195 @@ +// Copyright 2024-2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builtins + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/topdown" + "github.com/open-policy-agent/opa/v1/types" +) + +const ( + // httpWithAuthBuiltinName is the name of the chainloop.http_with_auth built-in + //nolint:gosec // False positive: this is a function name, not a credential + httpWithAuthBuiltinName = "chainloop.http_with_auth" + + // Default timeout for HTTP requests + defaultHTTPTimeout = 30 * time.Second +) + +// HTTPClient interface allows for dependency injection and testing +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// httpClientProvider is a function that returns an HTTP client +// This allows for lazy initialization and dependency injection +type httpClientProvider func() HTTPClient + +var ( + // defaultHTTPClient is the default HTTP client provider + defaultHTTPClient httpClientProvider = func() HTTPClient { + return &http.Client{ + Timeout: defaultHTTPTimeout, + } + } +) + +// SetHTTPClient sets a custom HTTP client provider for testing +func SetHTTPClient(provider httpClientProvider) { + defaultHTTPClient = provider +} + +// ResetHTTPClient resets the HTTP client to the default +func ResetHTTPClient() { + defaultHTTPClient = func() HTTPClient { + return &http.Client{ + Timeout: defaultHTTPTimeout, + } + } +} + +// RegisterHTTPBuiltins registers all HTTP-related custom built-in functions +func RegisterHTTPBuiltins() error { + return Register(&BuiltinDef{ + Name: httpWithAuthBuiltinName, + Decl: &ast.Builtin{ + Name: httpWithAuthBuiltinName, + Decl: types.NewFunction( + types.Args( + types.Named("url", types.S), // URL to fetch + types.Named("headers", types.NewObject(nil, types.NewDynamicProperty(types.S, types.S))), // Headers object + ), + types.Named("response", types.A), // Response as object + ), + }, + Impl: httpWithAuthImpl, + SecurityLevel: SecurityLevelPermissive, // Only available in permissive mode + Description: "Makes an HTTP GET request with custom authentication headers. " + + "Returns response body parsed as JSON. Only available in permissive mode for local development.", + }) +} + +// httpWithAuthImpl implements the chainloop.http_with_auth built-in function +func httpWithAuthImpl(bctx topdown.BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { + // Extract URL + urlStr, ok := operands[0].Value.(ast.String) + if !ok { + return fmt.Errorf("url must be a string") + } + + // Extract headers + headersObj, ok := operands[1].Value.(ast.Object) + if !ok { + return fmt.Errorf("headers must be an object") + } + + // Convert AST object to map + headers := make(map[string]string) + err := headersObj.Iter(func(k, v *ast.Term) error { + keyStr, ok := k.Value.(ast.String) + if !ok { + return fmt.Errorf("header key must be a string") + } + valStr, ok := v.Value.(ast.String) + if !ok { + return fmt.Errorf("header value must be a string") + } + headers[string(keyStr)] = string(valStr) + return nil + }) + if err != nil { + return err + } + + // Create HTTP request with context + ctx := bctx.Context + if ctx == nil { + ctx = context.Background() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, string(urlStr), nil) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + // Add custom headers + for key, value := range headers { + req.Header.Set(key, value) + } + + // Execute request using the configured client + client := defaultHTTPClient() + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to execute HTTP request: %w", err) + } + defer resp.Body.Close() + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Parse response body as JSON + var jsonData interface{} + if err := json.Unmarshal(body, &jsonData); err != nil { + // If not valid JSON, return as string + result := map[string]interface{}{ + "status": resp.StatusCode, + "status_text": resp.Status, + "body": string(body), + "headers": flattenHeaders(resp.Header), + } + return iter(ast.NewTerm(ast.MustInterfaceToValue(result))) + } + + // Return structured response + result := map[string]interface{}{ + "status": resp.StatusCode, + "status_text": resp.Status, + "body": jsonData, + "headers": flattenHeaders(resp.Header), + } + + return iter(ast.NewTerm(ast.MustInterfaceToValue(result))) +} + +// flattenHeaders converts http.Header to a simple map[string]string +// taking the first value for each header +func flattenHeaders(headers http.Header) map[string]string { + result := make(map[string]string) + for key, values := range headers { + if len(values) > 0 { + result[key] = values[0] + } + } + return result +} + +// init registers HTTP built-ins on package initialization +func init() { + if err := RegisterHTTPBuiltins(); err != nil { + panic(fmt.Sprintf("failed to register HTTP built-ins: %v", err)) + } +} diff --git a/pkg/policies/engine/rego/builtins/http_test.go b/pkg/policies/engine/rego/builtins/http_test.go new file mode 100644 index 000000000..34c2054ec --- /dev/null +++ b/pkg/policies/engine/rego/builtins/http_test.go @@ -0,0 +1,189 @@ +// Copyright 2024-2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builtins + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/open-policy-agent/opa/v1/rego" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockHTTPClient is a mock HTTP client for testing +type mockHTTPClient struct { + response *http.Response + err error +} + +func (m *mockHTTPClient) Do(_ *http.Request) (*http.Response, error) { + if m.err != nil { + return nil, m.err + } + return m.response, nil +} + +func TestHTTPWithAuth(t *testing.T) { + // Reset to default after tests + defer ResetHTTPClient() + + tests := []struct { + name string + policy string + mockResponse *http.Response + mockErr error + expectedStatus int + expectError bool + }{ + { + name: "successful JSON response", + policy: `package test +import rego.v1 + +result := chainloop.http_with_auth("https://api.example.com/data", { + "Authorization": "Bearer token123", + "X-Custom-Header": "value" +})`, + mockResponse: &http.Response{ + StatusCode: 200, + Status: "200 OK", + Body: io.NopCloser(bytes.NewBufferString(`{"key": "value", "count": 42}`)), + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, + expectedStatus: 200, + expectError: false, + }, + { + name: "non-JSON response", + policy: `package test +import rego.v1 + +result := chainloop.http_with_auth("https://api.example.com/text", {"Authorization": "Bearer token"})`, + mockResponse: &http.Response{ + StatusCode: 200, + Status: "200 OK", + Body: io.NopCloser(bytes.NewBufferString(`plain text response`)), + Header: http.Header{ + "Content-Type": []string{"text/plain"}, + }, + }, + expectedStatus: 200, + expectError: false, + }, + { + name: "error response", + policy: `package test +import rego.v1 + +result := chainloop.http_with_auth("https://api.example.com/error", {"Authorization": "Bearer token"})`, + mockResponse: &http.Response{ + StatusCode: 404, + Status: "404 Not Found", + Body: io.NopCloser(bytes.NewBufferString(`{"error": "not found"}`)), + Header: http.Header{}, + }, + expectedStatus: 404, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up mock HTTP client + SetHTTPClient(func() HTTPClient { + return &mockHTTPClient{ + response: tt.mockResponse, + err: tt.mockErr, + } + }) + + // Create registry with HTTP built-ins + registry := NewRegistry() + require.NoError(t, RegisterHTTPBuiltins()) + + // Get the built-in and add it to a new registry + httpBuiltin, ok := Get(httpWithAuthBuiltinName) + require.True(t, ok) + require.NoError(t, registry.Register(httpBuiltin)) + + // Register globally (permissive mode) + require.NoError(t, registry.RegisterGlobal(true)) + + // Prepare rego evaluation + ctx := context.Background() + r := rego.New( + rego.Query("data.test.result"), + rego.Module("test.rego", tt.policy), + ) + rs, err := r.Eval(ctx) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + require.Len(t, rs, 1) + require.Len(t, rs[0].Expressions, 1) + + result, ok := rs[0].Expressions[0].Value.(map[string]interface{}) + require.True(t, ok) + + // The status is returned as a number, convert it appropriately + statusVal := result["status"] + var status int + switch v := statusVal.(type) { + case json.Number: + i, err := v.Int64() + require.NoError(t, err) + status = int(i) + case float64: + status = int(v) + case int: + status = v + case int64: + status = int(v) + default: + require.Fail(t, "unexpected status type", "got type: %T", statusVal) + } + + assert.Equal(t, tt.expectedStatus, status) + }) + } +} + +func TestHTTPBuiltinRegistration(t *testing.T) { + t.Run("HTTP built-in is registered", func(t *testing.T) { + def, ok := Get(httpWithAuthBuiltinName) + assert.True(t, ok) + assert.NotNil(t, def) + assert.Equal(t, httpWithAuthBuiltinName, def.Name) + assert.Equal(t, SecurityLevelPermissive, def.SecurityLevel) + }) + + t.Run("HTTP built-in has correct signature", func(t *testing.T) { + def, ok := Get(httpWithAuthBuiltinName) + require.True(t, ok) + require.NotNil(t, def.Decl) + assert.Equal(t, httpWithAuthBuiltinName, def.Decl.Name) + }) +} diff --git a/pkg/policies/engine/rego/builtins/registry.go b/pkg/policies/engine/rego/builtins/registry.go new file mode 100644 index 000000000..5946a9e62 --- /dev/null +++ b/pkg/policies/engine/rego/builtins/registry.go @@ -0,0 +1,179 @@ +// Copyright 2024-2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builtins + +import ( + "sync" + + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/topdown" +) + +// SecurityLevel defines when a built-in function is allowed to execute +type SecurityLevel int + +const ( + // SecurityLevelPermissive functions are only available in permissive (development) mode + // These functions may make external calls, modify state, or perform operations + // that are not suitable for production policy evaluation + SecurityLevelPermissive SecurityLevel = iota + + // SecurityLevelRestrictive functions are safe to use in restrictive (production) mode + // These functions should be read-only, deterministic, and not make external calls + // or access sensitive resources + SecurityLevelRestrictive +) + +// BuiltinDef defines a custom built-in function that can be registered with OPA +type BuiltinDef struct { + // Name is the fully qualified name of the built-in (e.g., "chainloop.http_with_auth") + Name string + + // Decl is the built-in declaration that defines the function signature + Decl *ast.Builtin + + // Impl is the actual function implementation + Impl topdown.BuiltinFunc + + // SecurityLevel defines when this function is allowed to execute + SecurityLevel SecurityLevel + + // Description provides documentation for the function + Description string +} + +// Registry manages custom built-in functions for the OPA policy engine +type Registry struct { + mu sync.RWMutex + builtins map[string]*BuiltinDef +} + +// NewRegistry creates a new built-in function registry +func NewRegistry() *Registry { + return &Registry{ + builtins: make(map[string]*BuiltinDef), + } +} + +// Register adds a built-in function to the registry +func (r *Registry) Register(def *BuiltinDef) error { + if def == nil { + return ErrNilBuiltinDef + } + if def.Name == "" { + return ErrEmptyBuiltinName + } + if def.Decl == nil { + return ErrNilBuiltinDecl + } + if def.Impl == nil { + return ErrNilBuiltinImpl + } + + r.mu.Lock() + defer r.mu.Unlock() + + r.builtins[def.Name] = def + return nil +} + +// GetByMode returns all built-in functions that are allowed in the specified security level +// Functions with SecurityLevelRestrictive are available in both modes +func (r *Registry) GetByMode(isPermissive bool) []*BuiltinDef { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []*BuiltinDef + for _, def := range r.builtins { + // Restrictive functions are always available + if def.SecurityLevel == SecurityLevelRestrictive { + result = append(result, def) + continue + } + + // Permissive functions only available in permissive mode + if isPermissive && def.SecurityLevel == SecurityLevelPermissive { + result = append(result, def) + } + } + + return result +} + +// Get returns a built-in function by name +func (r *Registry) Get(name string) (*BuiltinDef, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + def, ok := r.builtins[name] + return def, ok +} + +// All returns all registered built-in functions +func (r *Registry) All() []*BuiltinDef { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*BuiltinDef, 0, len(r.builtins)) + for _, def := range r.builtins { + result = append(result, def) + } + + return result +} + +// RegisterGlobal registers built-ins globally with OPA based on security mode +// This should be called once during initialization +func (r *Registry) RegisterGlobal(isPermissive bool) error { + defs := r.GetByMode(isPermissive) + + for _, def := range defs { + // Register the built-in declaration with AST + ast.RegisterBuiltin(def.Decl) + + // Register the implementation with topdown + topdown.RegisterBuiltinFunc(def.Name, def.Impl) + } + + return nil +} + +// Global registry instance for default built-ins +var globalRegistry = NewRegistry() + +// Register adds a built-in to the global registry +func Register(def *BuiltinDef) error { + return globalRegistry.Register(def) +} + +// GetByMode returns built-ins from the global registry by mode +func GetByMode(isPermissive bool) []*BuiltinDef { + return globalRegistry.GetByMode(isPermissive) +} + +// Get returns a built-in from the global registry by name +func Get(name string) (*BuiltinDef, bool) { + return globalRegistry.Get(name) +} + +// All returns all built-ins from the global registry +func All() []*BuiltinDef { + return globalRegistry.All() +} + +// RegisterGlobalBuiltins registers global registry built-ins with OPA +func RegisterGlobalBuiltins(isPermissive bool) error { + return globalRegistry.RegisterGlobal(isPermissive) +} diff --git a/pkg/policies/engine/rego/builtins/registry_test.go b/pkg/policies/engine/rego/builtins/registry_test.go new file mode 100644 index 000000000..bbd6561e0 --- /dev/null +++ b/pkg/policies/engine/rego/builtins/registry_test.go @@ -0,0 +1,208 @@ +// Copyright 2024-2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builtins + +import ( + "testing" + + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/topdown" + "github.com/open-policy-agent/opa/v1/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistry_Register(t *testing.T) { + tests := []struct { + name string + def *BuiltinDef + wantErr error + }{ + { + name: "valid built-in", + def: &BuiltinDef{ + Name: "test.func", + Decl: &ast.Builtin{ + Name: "test.func", + Decl: types.NewFunction(types.Args(types.S), types.S), + }, + Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, + SecurityLevel: SecurityLevelRestrictive, + Description: "Test function", + }, + wantErr: nil, + }, + { + name: "nil built-in", + def: nil, + wantErr: ErrNilBuiltinDef, + }, + { + name: "empty name", + def: &BuiltinDef{ + Name: "", + Decl: &ast.Builtin{}, + Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, + }, + wantErr: ErrEmptyBuiltinName, + }, + { + name: "nil decl", + def: &BuiltinDef{ + Name: "test.func", + Decl: nil, + Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, + }, + wantErr: ErrNilBuiltinDecl, + }, + { + name: "nil impl", + def: &BuiltinDef{ + Name: "test.func", + Decl: &ast.Builtin{}, + Impl: nil, + }, + wantErr: ErrNilBuiltinImpl, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRegistry() + err := r.Register(tt.def) + + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestRegistry_GetByMode(t *testing.T) { + restrictiveDef := &BuiltinDef{ + Name: "test.restrictive", + Decl: &ast.Builtin{ + Name: "test.restrictive", + Decl: types.NewFunction(types.Args(types.S), types.S), + }, + Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, + SecurityLevel: SecurityLevelRestrictive, + Description: "Restrictive function", + } + + permissiveDef := &BuiltinDef{ + Name: "test.permissive", + Decl: &ast.Builtin{ + Name: "test.permissive", + Decl: types.NewFunction(types.Args(types.S), types.S), + }, + Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, + SecurityLevel: SecurityLevelPermissive, + Description: "Permissive function", + } + + t.Run("restrictive mode - only restrictive functions", func(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(restrictiveDef)) + require.NoError(t, r.Register(permissiveDef)) + + defs := r.GetByMode(false) // restrictive mode + assert.Len(t, defs, 1) + assert.Equal(t, "test.restrictive", defs[0].Name) + }) + + t.Run("permissive mode - all functions", func(t *testing.T) { + r := NewRegistry() + require.NoError(t, r.Register(restrictiveDef)) + require.NoError(t, r.Register(permissiveDef)) + + defs := r.GetByMode(true) // permissive mode + assert.Len(t, defs, 2) + + names := make([]string, len(defs)) + for i, def := range defs { + names[i] = def.Name + } + assert.Contains(t, names, "test.restrictive") + assert.Contains(t, names, "test.permissive") + }) +} + +func TestRegistry_Get(t *testing.T) { + r := NewRegistry() + def := &BuiltinDef{ + Name: "test.func", + Decl: &ast.Builtin{ + Name: "test.func", + Decl: types.NewFunction(types.Args(types.S), types.S), + }, + Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, + SecurityLevel: SecurityLevelRestrictive, + Description: "Test function", + } + + require.NoError(t, r.Register(def)) + + t.Run("existing function", func(t *testing.T) { + got, ok := r.Get("test.func") + assert.True(t, ok) + assert.Equal(t, def, got) + }) + + t.Run("non-existing function", func(t *testing.T) { + got, ok := r.Get("test.nonexistent") + assert.False(t, ok) + assert.Nil(t, got) + }) +} + +func TestRegistry_RegisterGlobal(t *testing.T) { + r := NewRegistry() + + def1 := &BuiltinDef{ + Name: "test.restrictive_global", + Decl: &ast.Builtin{ + Name: "test.restrictive_global", + Decl: types.NewFunction(types.Args(types.S), types.S), + }, + Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, + SecurityLevel: SecurityLevelRestrictive, + } + + def2 := &BuiltinDef{ + Name: "test.permissive_global", + Decl: &ast.Builtin{ + Name: "test.permissive_global", + Decl: types.NewFunction(types.Args(types.S), types.S), + }, + Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, + SecurityLevel: SecurityLevelPermissive, + } + + require.NoError(t, r.Register(def1)) + require.NoError(t, r.Register(def2)) + + t.Run("restrictive mode", func(t *testing.T) { + err := r.RegisterGlobal(false) + assert.NoError(t, err) + }) + + t.Run("permissive mode", func(t *testing.T) { + err := r.RegisterGlobal(true) + assert.NoError(t, err) + }) +} diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index c79354b65..42b8cbd25 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -22,8 +22,9 @@ import ( "fmt" "github.com/chainloop-dev/chainloop/pkg/policies/engine" - "github.com/open-policy-agent/opa/ast" - "github.com/open-policy-agent/opa/rego" + "github.com/chainloop-dev/chainloop/pkg/policies/engine/rego/builtins" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/rego" "github.com/open-policy-agent/opa/v1/topdown/print" "golang.org/x/exp/maps" ) @@ -40,6 +41,8 @@ type Engine struct { includeRawData bool // enablePrint determines whether to enable print statements in rego policies enablePrint bool + // builtinRegistry is the registry of custom built-in functions + builtinRegistry *builtins.Registry } type EngineOption func(*newEngineOptions) @@ -68,11 +71,18 @@ func WithEnablePrint(enable bool) EngineOption { } } +func WithBuiltinRegistry(registry *builtins.Registry) EngineOption { + return func(e *newEngineOptions) { + e.builtinRegistry = registry + } +} + type newEngineOptions struct { operatingMode EnvironmentMode allowedNetworkDomains []string includeRawData bool enablePrint bool + builtinRegistry *builtins.Registry } // NewEngine creates a new policy engine with the given options @@ -93,13 +103,30 @@ func NewEngine(opts ...EngineOption) *Engine { "www.cisa.gov", } - return &Engine{ + // Use global registry if none provided + registry := options.builtinRegistry + if registry == nil { + registry = builtins.NewRegistry() + // Register all built-ins from the global registry + for _, def := range builtins.All() { + _ = registry.Register(def) + } + } + + engine := &Engine{ operatingMode: options.operatingMode, // append base allowed network domains to the user provided ones allowedNetworkDomains: append(baseAllowedNetworkDomains, options.allowedNetworkDomains...), includeRawData: options.includeRawData, enablePrint: options.enablePrint, + builtinRegistry: registry, } + + // Register ALL custom built-ins globally with OPA (both restrictive and permissive) + // The Capabilities() method will filter them based on operating mode + _ = registry.RegisterGlobal(true) // true = register all + + return engine } // EnvironmentMode defines the mode of running the policy engine @@ -306,6 +333,13 @@ func (r *Engine) Capabilities() *ast.Capabilities { delete(localBuiltIns, notAllowed.Name) } + // Remove permissive-only custom built-ins + for _, def := range r.builtinRegistry.All() { + if def.SecurityLevel == builtins.SecurityLevelPermissive { + delete(localBuiltIns, def.Name) + } + } + // Convert map to slice enabledBuiltin = make([]*ast.Builtin, 0, len(localBuiltIns)) for _, builtin := range localBuiltIns { diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index 5518ad600..136778666 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -16,11 +16,18 @@ package rego import ( + "bytes" "context" + "io" + "net/http" "os" "testing" "github.com/chainloop-dev/chainloop/pkg/policies/engine" + "github.com/chainloop-dev/chainloop/pkg/policies/engine/rego/builtins" + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/topdown" + "github.com/open-policy-agent/opa/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -431,3 +438,126 @@ func TestRego_MatchesEvaluation(t *testing.T) { assert.False(t, matches) }) } + +func TestRego_CustomBuiltinsPermissiveMode(t *testing.T) { + // Set up mock HTTP client + defer builtins.ResetHTTPClient() + builtins.SetHTTPClient(func() builtins.HTTPClient { + return &mockHTTPClient{ + response: &http.Response{ + StatusCode: 200, + Status: "200 OK", + Body: io.NopCloser(bytes.NewBufferString(`{"allowed": true}`)), + Header: http.Header{}, + }, + } + }) + + regoContent, err := os.ReadFile("testfiles/custom_builtin_permissive.rego") + require.NoError(t, err) + + // Create engine in permissive mode + r := NewEngine(WithOperatingMode(EnvironmentModePermissive)) + policy := &engine.Policy{ + Name: "custom builtin test", + Source: regoContent, + } + + t.Run("custom builtin works in permissive mode", func(t *testing.T) { + result, err := r.Verify(context.TODO(), policy, []byte(`{"kind": "test"}`), nil) + require.NoError(t, err) + assert.False(t, result.Skipped) + assert.Len(t, result.Violations, 0) + }) +} + +func TestRego_CustomBuiltinsRestrictiveMode(t *testing.T) { + regoContent := []byte(`package test +import rego.v1 + +result := { + "violations": violations, + "skipped": false +} + +violations contains msg if { + # Try to use a permissive-only built-in + response := chainloop.http_with_auth("https://example.com", {"Authorization": "Bearer token"}) + response.status != 200 + msg := "Request failed" +}`) + + // Create engine in restrictive mode (default) + r := NewEngine() + policy := &engine.Policy{ + Name: "custom builtin test", + Source: regoContent, + } + + t.Run("permissive builtin fails in restrictive mode", func(t *testing.T) { + _, err := r.Verify(context.TODO(), policy, []byte(`{"kind": "test"}`), nil) + // Should fail because chainloop.http_with_auth is not available in restrictive mode + assert.Error(t, err) + assert.Contains(t, err.Error(), "undefined function") + }) +} + +func TestRego_CustomBuiltinRegistry(t *testing.T) { + // Create a custom restrictive built-in for testing + testBuiltin := &builtins.BuiltinDef{ + Name: "test.restrictive_func", + Decl: &ast.Builtin{ + Name: "test.restrictive_func", + Decl: types.NewFunction(types.Args(types.S), types.S), + }, + Impl: func(_ topdown.BuiltinContext, _ []*ast.Term, iter func(*ast.Term) error) error { + return iter(ast.StringTerm("test_value")) + }, + SecurityLevel: builtins.SecurityLevelRestrictive, + Description: "Test restrictive function", + } + + registry := builtins.NewRegistry() + require.NoError(t, registry.Register(testBuiltin)) + + regoContent := []byte(`package test +import rego.v1 + +result := { + "violations": violations, + "skipped": false +} + +violations contains msg if { + val := test.restrictive_func("input") + val != "test_value" + msg := "Value mismatch" +}`) + + t.Run("custom restrictive builtin works in restrictive mode", func(t *testing.T) { + // Create engine with custom registry + r := NewEngine(WithBuiltinRegistry(registry)) + policy := &engine.Policy{ + Name: "test", + Source: regoContent, + } + + result, err := r.Verify(context.TODO(), policy, []byte(`{"kind": "test"}`), nil) + require.NoError(t, err) + assert.False(t, result.Skipped) + assert.Len(t, result.Violations, 0) + }) +} + +// mockHTTPClient is a mock HTTP client for testing +type mockHTTPClient struct { + response *http.Response + err error +} + +func (m *mockHTTPClient) Do(_ *http.Request) (*http.Response, error) { + if m.err != nil { + return nil, m.err + } + return m.response, nil +} diff --git a/pkg/policies/engine/rego/testfiles/custom_builtin_permissive.rego b/pkg/policies/engine/rego/testfiles/custom_builtin_permissive.rego new file mode 100644 index 000000000..67730ec2d --- /dev/null +++ b/pkg/policies/engine/rego/testfiles/custom_builtin_permissive.rego @@ -0,0 +1,21 @@ +package test + +import rego.v1 + +result := { + "violations": violations, + "skipped": false, +} + +violations contains msg if { + # Use the custom HTTP built-in + response := chainloop.http_with_auth("https://api.example.com/check", {"Authorization": "Bearer token123"}) + response.status != 200 + msg := "API check failed" +} + +violations contains msg if { + response := chainloop.http_with_auth("https://api.example.com/check", {"Authorization": "Bearer token123"}) + response.body.allowed != true + msg := "API returned not allowed" +} From e5d44041d2efb0802dba3311ef21f7864e68ee2c Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 15 Nov 2025 12:42:52 +0100 Subject: [PATCH 02/12] add hello builtin Signed-off-by: Jose I. Paris --- app/cli/internal/policydevel/eval.go | 3 - pkg/policies/engine/rego/builtins/example.go | 71 +++++++ .../engine/rego/builtins/example_test.go | 86 ++++++++ pkg/policies/engine/rego/builtins/http.go | 195 ------------------ .../engine/rego/builtins/http_test.go | 189 ----------------- 5 files changed, 157 insertions(+), 387 deletions(-) create mode 100644 pkg/policies/engine/rego/builtins/example.go create mode 100644 pkg/policies/engine/rego/builtins/example_test.go delete mode 100644 pkg/policies/engine/rego/builtins/http.go delete mode 100644 pkg/policies/engine/rego/builtins/http_test.go diff --git a/app/cli/internal/policydevel/eval.go b/app/cli/internal/policydevel/eval.go index 48c5dee89..53d0dc2d7 100644 --- a/app/cli/internal/policydevel/eval.go +++ b/app/cli/internal/policydevel/eval.go @@ -165,9 +165,6 @@ func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materi } func craftMaterial(materialPath, materialKind string, logger *zerolog.Logger) (*v12.Attestation_Material, error) { - if fileNotExists(materialPath) { - return nil, fmt.Errorf("%s: does not exists", materialPath) - } backend := &casclient.CASBackend{ Name: "backend", MaxSize: 0, diff --git a/pkg/policies/engine/rego/builtins/example.go b/pkg/policies/engine/rego/builtins/example.go new file mode 100644 index 000000000..a47517b1f --- /dev/null +++ b/pkg/policies/engine/rego/builtins/example.go @@ -0,0 +1,71 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builtins + +import ( + "errors" + "fmt" + + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/topdown" + "github.com/open-policy-agent/opa/v1/types" +) + +const helloBuiltinName = "chainloop.hello" + +func RegisterHelloBuiltin() error { + return Register(&BuiltinDef{ + Name: helloBuiltinName, + Decl: &ast.Builtin{ + Name: helloBuiltinName, + Decl: types.NewFunction( + types.Args( + types.Named("name", types.S), // Digest to fetch + ), + types.Named("response", types.A), // Response as object + ), + }, + Impl: getHelloImpl, + SecurityLevel: SecurityLevelRestrictive, // Always available + Description: "Discovers artifact graph data by calling the Referrer chainloop service", + }) +} + +type helloResponse struct { + Message string `json:"message"` +} + +func getHelloImpl(_ topdown.BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { + if len(operands) < 1 { + return errors.New("need one operand") + } + + name, ok := operands[0].Value.(ast.String) + if !ok { + return errors.New("digest must be a string") + } + + message := fmt.Sprintf("Hello, %s!", string(name)) + + // call the iterator with the output value + return iter(ast.NewTerm(ast.MustInterfaceToValue(helloResponse{message}))) +} + +func init() { + if err := RegisterHelloBuiltin(); err != nil { + panic(fmt.Sprintf("failed to register Hello builtin: %v", err)) + } +} diff --git a/pkg/policies/engine/rego/builtins/example_test.go b/pkg/policies/engine/rego/builtins/example_test.go new file mode 100644 index 000000000..9e355571c --- /dev/null +++ b/pkg/policies/engine/rego/builtins/example_test.go @@ -0,0 +1,86 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builtins + +import ( + "context" + "testing" + + "github.com/open-policy-agent/opa/v1/rego" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHelloBuiltin(t *testing.T) { + tests := []struct { + name string + policy string + mockErr error + expectedMessage string + expectError bool + }{ + { + name: "successful render", + policy: `package test +import rego.v1 + +result := chainloop.hello("world")`, + expectedMessage: "Hello, world!", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + // Create registry with HTTP built-ins + registry := NewRegistry() + require.NoError(t, RegisterHelloBuiltin()) + + // Get the built-in and add it to a new registry + helloBuiltin, ok := Get(helloBuiltinName) + require.True(t, ok) + require.NoError(t, registry.Register(helloBuiltin)) + + // Register globally (permissive mode) + require.NoError(t, registry.RegisterGlobal(true)) + + // Prepare rego evaluation + ctx := context.Background() + r := rego.New( + rego.Query("data.test.result"), + rego.Module("test.rego", tt.policy), + ) + rs, err := r.Eval(ctx) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + require.Len(t, rs, 1) + require.Len(t, rs[0].Expressions, 1) + + result, ok := rs[0].Expressions[0].Value.(map[string]interface{}) + require.True(t, ok) + + // The status is returned as a number, convert it appropriately + msgVal := result["message"] + assert.Equal(t, tt.expectedMessage, msgVal) + }) + } +} diff --git a/pkg/policies/engine/rego/builtins/http.go b/pkg/policies/engine/rego/builtins/http.go deleted file mode 100644 index 9c1454ea5..000000000 --- a/pkg/policies/engine/rego/builtins/http.go +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright 2024-2025 The Chainloop Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package builtins - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/open-policy-agent/opa/v1/ast" - "github.com/open-policy-agent/opa/v1/topdown" - "github.com/open-policy-agent/opa/v1/types" -) - -const ( - // httpWithAuthBuiltinName is the name of the chainloop.http_with_auth built-in - //nolint:gosec // False positive: this is a function name, not a credential - httpWithAuthBuiltinName = "chainloop.http_with_auth" - - // Default timeout for HTTP requests - defaultHTTPTimeout = 30 * time.Second -) - -// HTTPClient interface allows for dependency injection and testing -type HTTPClient interface { - Do(req *http.Request) (*http.Response, error) -} - -// httpClientProvider is a function that returns an HTTP client -// This allows for lazy initialization and dependency injection -type httpClientProvider func() HTTPClient - -var ( - // defaultHTTPClient is the default HTTP client provider - defaultHTTPClient httpClientProvider = func() HTTPClient { - return &http.Client{ - Timeout: defaultHTTPTimeout, - } - } -) - -// SetHTTPClient sets a custom HTTP client provider for testing -func SetHTTPClient(provider httpClientProvider) { - defaultHTTPClient = provider -} - -// ResetHTTPClient resets the HTTP client to the default -func ResetHTTPClient() { - defaultHTTPClient = func() HTTPClient { - return &http.Client{ - Timeout: defaultHTTPTimeout, - } - } -} - -// RegisterHTTPBuiltins registers all HTTP-related custom built-in functions -func RegisterHTTPBuiltins() error { - return Register(&BuiltinDef{ - Name: httpWithAuthBuiltinName, - Decl: &ast.Builtin{ - Name: httpWithAuthBuiltinName, - Decl: types.NewFunction( - types.Args( - types.Named("url", types.S), // URL to fetch - types.Named("headers", types.NewObject(nil, types.NewDynamicProperty(types.S, types.S))), // Headers object - ), - types.Named("response", types.A), // Response as object - ), - }, - Impl: httpWithAuthImpl, - SecurityLevel: SecurityLevelPermissive, // Only available in permissive mode - Description: "Makes an HTTP GET request with custom authentication headers. " + - "Returns response body parsed as JSON. Only available in permissive mode for local development.", - }) -} - -// httpWithAuthImpl implements the chainloop.http_with_auth built-in function -func httpWithAuthImpl(bctx topdown.BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { - // Extract URL - urlStr, ok := operands[0].Value.(ast.String) - if !ok { - return fmt.Errorf("url must be a string") - } - - // Extract headers - headersObj, ok := operands[1].Value.(ast.Object) - if !ok { - return fmt.Errorf("headers must be an object") - } - - // Convert AST object to map - headers := make(map[string]string) - err := headersObj.Iter(func(k, v *ast.Term) error { - keyStr, ok := k.Value.(ast.String) - if !ok { - return fmt.Errorf("header key must be a string") - } - valStr, ok := v.Value.(ast.String) - if !ok { - return fmt.Errorf("header value must be a string") - } - headers[string(keyStr)] = string(valStr) - return nil - }) - if err != nil { - return err - } - - // Create HTTP request with context - ctx := bctx.Context - if ctx == nil { - ctx = context.Background() - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, string(urlStr), nil) - if err != nil { - return fmt.Errorf("failed to create HTTP request: %w", err) - } - - // Add custom headers - for key, value := range headers { - req.Header.Set(key, value) - } - - // Execute request using the configured client - client := defaultHTTPClient() - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to execute HTTP request: %w", err) - } - defer resp.Body.Close() - - // Read response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - // Parse response body as JSON - var jsonData interface{} - if err := json.Unmarshal(body, &jsonData); err != nil { - // If not valid JSON, return as string - result := map[string]interface{}{ - "status": resp.StatusCode, - "status_text": resp.Status, - "body": string(body), - "headers": flattenHeaders(resp.Header), - } - return iter(ast.NewTerm(ast.MustInterfaceToValue(result))) - } - - // Return structured response - result := map[string]interface{}{ - "status": resp.StatusCode, - "status_text": resp.Status, - "body": jsonData, - "headers": flattenHeaders(resp.Header), - } - - return iter(ast.NewTerm(ast.MustInterfaceToValue(result))) -} - -// flattenHeaders converts http.Header to a simple map[string]string -// taking the first value for each header -func flattenHeaders(headers http.Header) map[string]string { - result := make(map[string]string) - for key, values := range headers { - if len(values) > 0 { - result[key] = values[0] - } - } - return result -} - -// init registers HTTP built-ins on package initialization -func init() { - if err := RegisterHTTPBuiltins(); err != nil { - panic(fmt.Sprintf("failed to register HTTP built-ins: %v", err)) - } -} diff --git a/pkg/policies/engine/rego/builtins/http_test.go b/pkg/policies/engine/rego/builtins/http_test.go deleted file mode 100644 index 34c2054ec..000000000 --- a/pkg/policies/engine/rego/builtins/http_test.go +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright 2024-2025 The Chainloop Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package builtins - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "testing" - - "github.com/open-policy-agent/opa/v1/rego" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// mockHTTPClient is a mock HTTP client for testing -type mockHTTPClient struct { - response *http.Response - err error -} - -func (m *mockHTTPClient) Do(_ *http.Request) (*http.Response, error) { - if m.err != nil { - return nil, m.err - } - return m.response, nil -} - -func TestHTTPWithAuth(t *testing.T) { - // Reset to default after tests - defer ResetHTTPClient() - - tests := []struct { - name string - policy string - mockResponse *http.Response - mockErr error - expectedStatus int - expectError bool - }{ - { - name: "successful JSON response", - policy: `package test -import rego.v1 - -result := chainloop.http_with_auth("https://api.example.com/data", { - "Authorization": "Bearer token123", - "X-Custom-Header": "value" -})`, - mockResponse: &http.Response{ - StatusCode: 200, - Status: "200 OK", - Body: io.NopCloser(bytes.NewBufferString(`{"key": "value", "count": 42}`)), - Header: http.Header{ - "Content-Type": []string{"application/json"}, - }, - }, - expectedStatus: 200, - expectError: false, - }, - { - name: "non-JSON response", - policy: `package test -import rego.v1 - -result := chainloop.http_with_auth("https://api.example.com/text", {"Authorization": "Bearer token"})`, - mockResponse: &http.Response{ - StatusCode: 200, - Status: "200 OK", - Body: io.NopCloser(bytes.NewBufferString(`plain text response`)), - Header: http.Header{ - "Content-Type": []string{"text/plain"}, - }, - }, - expectedStatus: 200, - expectError: false, - }, - { - name: "error response", - policy: `package test -import rego.v1 - -result := chainloop.http_with_auth("https://api.example.com/error", {"Authorization": "Bearer token"})`, - mockResponse: &http.Response{ - StatusCode: 404, - Status: "404 Not Found", - Body: io.NopCloser(bytes.NewBufferString(`{"error": "not found"}`)), - Header: http.Header{}, - }, - expectedStatus: 404, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Set up mock HTTP client - SetHTTPClient(func() HTTPClient { - return &mockHTTPClient{ - response: tt.mockResponse, - err: tt.mockErr, - } - }) - - // Create registry with HTTP built-ins - registry := NewRegistry() - require.NoError(t, RegisterHTTPBuiltins()) - - // Get the built-in and add it to a new registry - httpBuiltin, ok := Get(httpWithAuthBuiltinName) - require.True(t, ok) - require.NoError(t, registry.Register(httpBuiltin)) - - // Register globally (permissive mode) - require.NoError(t, registry.RegisterGlobal(true)) - - // Prepare rego evaluation - ctx := context.Background() - r := rego.New( - rego.Query("data.test.result"), - rego.Module("test.rego", tt.policy), - ) - rs, err := r.Eval(ctx) - - if tt.expectError { - assert.Error(t, err) - return - } - - require.NoError(t, err) - require.Len(t, rs, 1) - require.Len(t, rs[0].Expressions, 1) - - result, ok := rs[0].Expressions[0].Value.(map[string]interface{}) - require.True(t, ok) - - // The status is returned as a number, convert it appropriately - statusVal := result["status"] - var status int - switch v := statusVal.(type) { - case json.Number: - i, err := v.Int64() - require.NoError(t, err) - status = int(i) - case float64: - status = int(v) - case int: - status = v - case int64: - status = int(v) - default: - require.Fail(t, "unexpected status type", "got type: %T", statusVal) - } - - assert.Equal(t, tt.expectedStatus, status) - }) - } -} - -func TestHTTPBuiltinRegistration(t *testing.T) { - t.Run("HTTP built-in is registered", func(t *testing.T) { - def, ok := Get(httpWithAuthBuiltinName) - assert.True(t, ok) - assert.NotNil(t, def) - assert.Equal(t, httpWithAuthBuiltinName, def.Name) - assert.Equal(t, SecurityLevelPermissive, def.SecurityLevel) - }) - - t.Run("HTTP built-in has correct signature", func(t *testing.T) { - def, ok := Get(httpWithAuthBuiltinName) - require.True(t, ok) - require.NotNil(t, def.Decl) - assert.Equal(t, httpWithAuthBuiltinName, def.Decl.Name) - }) -} From 89755e505506dc9d067824a08dc56b8d837c9b12 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 15 Nov 2025 19:04:00 +0100 Subject: [PATCH 03/12] add example and some tests Signed-off-by: Jose I. Paris --- app/cli/internal/policydevel/lint.go | 4 +-- pkg/policies/engine/rego/builtins/example.go | 10 +++--- .../engine/rego/builtins/example_test.go | 2 +- pkg/policies/engine/rego/builtins/registry.go | 4 +-- .../engine/rego/builtins/registry_test.go | 6 ++-- pkg/policies/engine/rego/rego.go | 6 ++-- pkg/policies/engine/rego/rego_test.go | 32 +------------------ .../testfiles/custom_builtin_permissive.rego | 13 ++------ 8 files changed, 20 insertions(+), 57 deletions(-) diff --git a/app/cli/internal/policydevel/lint.go b/app/cli/internal/policydevel/lint.go index 76b57e854..b1a9cfcb9 100644 --- a/app/cli/internal/policydevel/lint.go +++ b/app/cli/internal/policydevel/lint.go @@ -28,8 +28,8 @@ import ( v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/unmarshal" "github.com/chainloop-dev/chainloop/pkg/resourceloader" - opaAst "github.com/open-policy-agent/opa/v1/ast" - "github.com/open-policy-agent/opa/v1/format" + opaAst "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/format" "github.com/styrainc/regal/pkg/config" "github.com/styrainc/regal/pkg/linter" "github.com/styrainc/regal/pkg/report" diff --git a/pkg/policies/engine/rego/builtins/example.go b/pkg/policies/engine/rego/builtins/example.go index a47517b1f..19a3447d2 100644 --- a/pkg/policies/engine/rego/builtins/example.go +++ b/pkg/policies/engine/rego/builtins/example.go @@ -19,9 +19,9 @@ import ( "errors" "fmt" - "github.com/open-policy-agent/opa/v1/ast" - "github.com/open-policy-agent/opa/v1/topdown" - "github.com/open-policy-agent/opa/v1/types" + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/topdown" + "github.com/open-policy-agent/opa/types" ) const helloBuiltinName = "chainloop.hello" @@ -39,8 +39,8 @@ func RegisterHelloBuiltin() error { ), }, Impl: getHelloImpl, - SecurityLevel: SecurityLevelRestrictive, // Always available - Description: "Discovers artifact graph data by calling the Referrer chainloop service", + SecurityLevel: SecurityLevelPermissive, // Only available in permissive mode + Description: "Example builtin", }) } diff --git a/pkg/policies/engine/rego/builtins/example_test.go b/pkg/policies/engine/rego/builtins/example_test.go index 9e355571c..456826d0c 100644 --- a/pkg/policies/engine/rego/builtins/example_test.go +++ b/pkg/policies/engine/rego/builtins/example_test.go @@ -19,7 +19,7 @@ import ( "context" "testing" - "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/rego" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/policies/engine/rego/builtins/registry.go b/pkg/policies/engine/rego/builtins/registry.go index 5946a9e62..1fe8afbba 100644 --- a/pkg/policies/engine/rego/builtins/registry.go +++ b/pkg/policies/engine/rego/builtins/registry.go @@ -17,8 +17,8 @@ package builtins import ( "sync" - "github.com/open-policy-agent/opa/v1/ast" - "github.com/open-policy-agent/opa/v1/topdown" + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/topdown" ) // SecurityLevel defines when a built-in function is allowed to execute diff --git a/pkg/policies/engine/rego/builtins/registry_test.go b/pkg/policies/engine/rego/builtins/registry_test.go index bbd6561e0..c3e8bb63e 100644 --- a/pkg/policies/engine/rego/builtins/registry_test.go +++ b/pkg/policies/engine/rego/builtins/registry_test.go @@ -17,9 +17,9 @@ package builtins import ( "testing" - "github.com/open-policy-agent/opa/v1/ast" - "github.com/open-policy-agent/opa/v1/topdown" - "github.com/open-policy-agent/opa/v1/types" + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/topdown" + "github.com/open-policy-agent/opa/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index 42b8cbd25..3cfb1fe3e 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -23,9 +23,9 @@ import ( "github.com/chainloop-dev/chainloop/pkg/policies/engine" "github.com/chainloop-dev/chainloop/pkg/policies/engine/rego/builtins" - "github.com/open-policy-agent/opa/v1/ast" - "github.com/open-policy-agent/opa/v1/rego" - "github.com/open-policy-agent/opa/v1/topdown/print" + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/rego" + "github.com/open-policy-agent/opa/topdown/print" "golang.org/x/exp/maps" ) diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index 136778666..e7dc29aa2 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -16,10 +16,7 @@ package rego import ( - "bytes" "context" - "io" - "net/http" "os" "testing" @@ -440,19 +437,6 @@ func TestRego_MatchesEvaluation(t *testing.T) { } func TestRego_CustomBuiltinsPermissiveMode(t *testing.T) { - // Set up mock HTTP client - defer builtins.ResetHTTPClient() - builtins.SetHTTPClient(func() builtins.HTTPClient { - return &mockHTTPClient{ - response: &http.Response{ - StatusCode: 200, - Status: "200 OK", - Body: io.NopCloser(bytes.NewBufferString(`{"allowed": true}`)), - Header: http.Header{}, - }, - } - }) - regoContent, err := os.ReadFile("testfiles/custom_builtin_permissive.rego") require.NoError(t, err) @@ -482,8 +466,7 @@ result := { violations contains msg if { # Try to use a permissive-only built-in - response := chainloop.http_with_auth("https://example.com", {"Authorization": "Bearer token"}) - response.status != 200 + response := chainloop.hello("world") msg := "Request failed" }`) @@ -548,16 +531,3 @@ violations contains msg if { assert.Len(t, result.Violations, 0) }) } - -// mockHTTPClient is a mock HTTP client for testing -type mockHTTPClient struct { - response *http.Response - err error -} - -func (m *mockHTTPClient) Do(_ *http.Request) (*http.Response, error) { - if m.err != nil { - return nil, m.err - } - return m.response, nil -} diff --git a/pkg/policies/engine/rego/testfiles/custom_builtin_permissive.rego b/pkg/policies/engine/rego/testfiles/custom_builtin_permissive.rego index 67730ec2d..4cc68f727 100644 --- a/pkg/policies/engine/rego/testfiles/custom_builtin_permissive.rego +++ b/pkg/policies/engine/rego/testfiles/custom_builtin_permissive.rego @@ -8,14 +8,7 @@ result := { } violations contains msg if { - # Use the custom HTTP built-in - response := chainloop.http_with_auth("https://api.example.com/check", {"Authorization": "Bearer token123"}) - response.status != 200 - msg := "API check failed" -} - -violations contains msg if { - response := chainloop.http_with_auth("https://api.example.com/check", {"Authorization": "Bearer token123"}) - response.body.allowed != true - msg := "API returned not allowed" + response := chainloop.hello("world") + response.message != "Hello, world!" + msg := sprintf("unexpected message! %s", [response.message]) } From 965cefffa02752c3f93171c8f6bfae2a0f56d0a9 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 15 Nov 2025 19:12:28 +0100 Subject: [PATCH 04/12] fix claude Signed-off-by: Jose I. Paris --- CLAUDE.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd582b5a4..098655546 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -323,24 +323,14 @@ violations contains msg if { } ``` -3. **Testing**: -- Add unit tests in `pkg/policies/engine/rego/builtins/*_test.go` -- Add integration tests in `pkg/policies/engine/rego/rego_test.go` -- Test both restrictive and permissive modes -- Mock external dependencies (HTTP clients, databases, etc.) - **Guidelines**: - Use `chainloop.*` namespace for all custom built-ins - Restrictive functions must be read-only, deterministic, and not make external calls -- Permissive functions can call external services but should use dependency injection +- Permissive functions can call external services - Always implement proper error handling and return meaningful error messages - Use context from `BuiltinContext` for timeout/cancellation support - Document function signatures and behavior in the `Description` field -**Example Use Cases**: -- **Restrictive**: Data transformations, format parsing, cryptographic verification with embedded keys -- **Permissive**: External API calls, database queries, dynamic credential fetching - ## Commit Guidelines All commits must meet these criteria: From 633a3587de7add8ad896dd315a20a2473ed8913f Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 15 Nov 2025 19:16:01 +0100 Subject: [PATCH 05/12] undo import change Signed-off-by: Jose I. Paris --- app/cli/internal/policydevel/lint.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/cli/internal/policydevel/lint.go b/app/cli/internal/policydevel/lint.go index b1a9cfcb9..76b57e854 100644 --- a/app/cli/internal/policydevel/lint.go +++ b/app/cli/internal/policydevel/lint.go @@ -28,8 +28,8 @@ import ( v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/unmarshal" "github.com/chainloop-dev/chainloop/pkg/resourceloader" - opaAst "github.com/open-policy-agent/opa/ast" - "github.com/open-policy-agent/opa/format" + opaAst "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/format" "github.com/styrainc/regal/pkg/config" "github.com/styrainc/regal/pkg/linter" "github.com/styrainc/regal/pkg/report" From aa08f4724151a5446a917e6d4d517e1d80e78493 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 15 Nov 2025 19:30:43 +0100 Subject: [PATCH 06/12] undo change Signed-off-by: Jose I. Paris --- pkg/policies/engine/rego/rego.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index 3cfb1fe3e..858308b13 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -25,7 +25,7 @@ import ( "github.com/chainloop-dev/chainloop/pkg/policies/engine/rego/builtins" "github.com/open-policy-agent/opa/ast" "github.com/open-policy-agent/opa/rego" - "github.com/open-policy-agent/opa/topdown/print" + "github.com/open-policy-agent/opa/v1/topdown/print" "golang.org/x/exp/maps" ) From f8baa9af9437cb20b049caf4a9755e8a2eca9031 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 15 Nov 2025 19:57:12 +0100 Subject: [PATCH 07/12] upgrade to opa/v1 Signed-off-by: Jose I. Paris --- pkg/policies/engine/rego/builtins/example.go | 6 +++--- pkg/policies/engine/rego/builtins/example_test.go | 2 +- pkg/policies/engine/rego/builtins/registry.go | 4 ++-- pkg/policies/engine/rego/builtins/registry_test.go | 6 +++--- pkg/policies/engine/rego/rego_test.go | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/policies/engine/rego/builtins/example.go b/pkg/policies/engine/rego/builtins/example.go index 19a3447d2..dc4b1bb86 100644 --- a/pkg/policies/engine/rego/builtins/example.go +++ b/pkg/policies/engine/rego/builtins/example.go @@ -19,9 +19,9 @@ import ( "errors" "fmt" - "github.com/open-policy-agent/opa/ast" - "github.com/open-policy-agent/opa/topdown" - "github.com/open-policy-agent/opa/types" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/topdown" + "github.com/open-policy-agent/opa/v1/types" ) const helloBuiltinName = "chainloop.hello" diff --git a/pkg/policies/engine/rego/builtins/example_test.go b/pkg/policies/engine/rego/builtins/example_test.go index 456826d0c..9e355571c 100644 --- a/pkg/policies/engine/rego/builtins/example_test.go +++ b/pkg/policies/engine/rego/builtins/example_test.go @@ -19,7 +19,7 @@ import ( "context" "testing" - "github.com/open-policy-agent/opa/rego" + "github.com/open-policy-agent/opa/v1/rego" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/policies/engine/rego/builtins/registry.go b/pkg/policies/engine/rego/builtins/registry.go index 1fe8afbba..5946a9e62 100644 --- a/pkg/policies/engine/rego/builtins/registry.go +++ b/pkg/policies/engine/rego/builtins/registry.go @@ -17,8 +17,8 @@ package builtins import ( "sync" - "github.com/open-policy-agent/opa/ast" - "github.com/open-policy-agent/opa/topdown" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/topdown" ) // SecurityLevel defines when a built-in function is allowed to execute diff --git a/pkg/policies/engine/rego/builtins/registry_test.go b/pkg/policies/engine/rego/builtins/registry_test.go index c3e8bb63e..bbd6561e0 100644 --- a/pkg/policies/engine/rego/builtins/registry_test.go +++ b/pkg/policies/engine/rego/builtins/registry_test.go @@ -17,9 +17,9 @@ package builtins import ( "testing" - "github.com/open-policy-agent/opa/ast" - "github.com/open-policy-agent/opa/topdown" - "github.com/open-policy-agent/opa/types" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/topdown" + "github.com/open-policy-agent/opa/v1/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index e7dc29aa2..19b726cf8 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -22,9 +22,9 @@ import ( "github.com/chainloop-dev/chainloop/pkg/policies/engine" "github.com/chainloop-dev/chainloop/pkg/policies/engine/rego/builtins" - "github.com/open-policy-agent/opa/ast" - "github.com/open-policy-agent/opa/topdown" - "github.com/open-policy-agent/opa/types" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/topdown" + "github.com/open-policy-agent/opa/v1/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) From c865b4dbab408b5c99a7f0d4819e290067444d65 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 15 Nov 2025 20:12:26 +0100 Subject: [PATCH 08/12] lint Signed-off-by: Jose I. Paris --- pkg/policies/engine/rego/builtins/example_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/policies/engine/rego/builtins/example_test.go b/pkg/policies/engine/rego/builtins/example_test.go index 9e355571c..fae163477 100644 --- a/pkg/policies/engine/rego/builtins/example_test.go +++ b/pkg/policies/engine/rego/builtins/example_test.go @@ -45,7 +45,6 @@ result := chainloop.hello("world")`, for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create registry with HTTP built-ins registry := NewRegistry() require.NoError(t, RegisterHelloBuiltin()) From 479a24224ef958d40600efb74e0a651bc63e38fb Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sun, 16 Nov 2025 10:19:20 +0100 Subject: [PATCH 09/12] fix coyright notice Signed-off-by: Jose I. Paris --- pkg/policies/engine/rego/builtins/errors.go | 2 +- pkg/policies/engine/rego/builtins/registry.go | 2 +- pkg/policies/engine/rego/builtins/registry_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/policies/engine/rego/builtins/errors.go b/pkg/policies/engine/rego/builtins/errors.go index 03897d06f..2573d371a 100644 --- a/pkg/policies/engine/rego/builtins/errors.go +++ b/pkg/policies/engine/rego/builtins/errors.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/policies/engine/rego/builtins/registry.go b/pkg/policies/engine/rego/builtins/registry.go index 5946a9e62..f5637ccde 100644 --- a/pkg/policies/engine/rego/builtins/registry.go +++ b/pkg/policies/engine/rego/builtins/registry.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/pkg/policies/engine/rego/builtins/registry_test.go b/pkg/policies/engine/rego/builtins/registry_test.go index bbd6561e0..d3a80dabe 100644 --- a/pkg/policies/engine/rego/builtins/registry_test.go +++ b/pkg/policies/engine/rego/builtins/registry_test.go @@ -1,4 +1,4 @@ -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. From a720e2a60645d2d32f8538884a437adb8d44e45f Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sun, 16 Nov 2025 11:34:17 +0100 Subject: [PATCH 10/12] refactor to use opa registry directly Signed-off-by: Jose I. Paris --- pkg/policies/engine/rego/builtins/errors.go | 31 --- pkg/policies/engine/rego/builtins/example.go | 29 +-- .../engine/rego/builtins/example_test.go | 11 - pkg/policies/engine/rego/builtins/registry.go | 156 +------------ .../engine/rego/builtins/registry_test.go | 208 ------------------ pkg/policies/engine/rego/rego.go | 43 +--- pkg/policies/engine/rego/rego_test.go | 40 ++-- 7 files changed, 47 insertions(+), 471 deletions(-) delete mode 100644 pkg/policies/engine/rego/builtins/errors.go delete mode 100644 pkg/policies/engine/rego/builtins/registry_test.go diff --git a/pkg/policies/engine/rego/builtins/errors.go b/pkg/policies/engine/rego/builtins/errors.go deleted file mode 100644 index 2573d371a..000000000 --- a/pkg/policies/engine/rego/builtins/errors.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2025 The Chainloop Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package builtins - -import "errors" - -var ( - // ErrNilBuiltinDef is returned when a nil BuiltinDef is provided - ErrNilBuiltinDef = errors.New("built-in definition cannot be nil") - - // ErrEmptyBuiltinName is returned when a built-in name is empty - ErrEmptyBuiltinName = errors.New("built-in name cannot be empty") - - // ErrNilBuiltinDecl is returned when a built-in declaration is nil - ErrNilBuiltinDecl = errors.New("built-in declaration cannot be nil") - - // ErrNilBuiltinImpl is returned when a built-in implementation is nil - ErrNilBuiltinImpl = errors.New("built-in implementation cannot be nil") -) diff --git a/pkg/policies/engine/rego/builtins/example.go b/pkg/policies/engine/rego/builtins/example.go index dc4b1bb86..5074ee30d 100644 --- a/pkg/policies/engine/rego/builtins/example.go +++ b/pkg/policies/engine/rego/builtins/example.go @@ -27,21 +27,16 @@ import ( const helloBuiltinName = "chainloop.hello" func RegisterHelloBuiltin() error { - return Register(&BuiltinDef{ - Name: helloBuiltinName, - Decl: &ast.Builtin{ - Name: helloBuiltinName, - Decl: types.NewFunction( - types.Args( - types.Named("name", types.S), // Digest to fetch - ), - types.Named("response", types.A), // Response as object + return Register(&ast.Builtin{ + Name: helloBuiltinName, + Description: "Example builtin", + Decl: types.NewFunction( + types.Args( + types.Named("name", types.S).Description("Name of the person to greet"), // Digest to fetch ), - }, - Impl: getHelloImpl, - SecurityLevel: SecurityLevelPermissive, // Only available in permissive mode - Description: "Example builtin", - }) + types.Named("response", types.A).Description("the hello world message"), // Response as object + ), + }, getHelloImpl) } type helloResponse struct { @@ -63,9 +58,3 @@ func getHelloImpl(_ topdown.BuiltinContext, operands []*ast.Term, iter func(*ast // call the iterator with the output value return iter(ast.NewTerm(ast.MustInterfaceToValue(helloResponse{message}))) } - -func init() { - if err := RegisterHelloBuiltin(); err != nil { - panic(fmt.Sprintf("failed to register Hello builtin: %v", err)) - } -} diff --git a/pkg/policies/engine/rego/builtins/example_test.go b/pkg/policies/engine/rego/builtins/example_test.go index fae163477..f44aa7b7b 100644 --- a/pkg/policies/engine/rego/builtins/example_test.go +++ b/pkg/policies/engine/rego/builtins/example_test.go @@ -45,18 +45,7 @@ result := chainloop.hello("world")`, for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create registry with HTTP built-ins - registry := NewRegistry() require.NoError(t, RegisterHelloBuiltin()) - - // Get the built-in and add it to a new registry - helloBuiltin, ok := Get(helloBuiltinName) - require.True(t, ok) - require.NoError(t, registry.Register(helloBuiltin)) - - // Register globally (permissive mode) - require.NoError(t, registry.RegisterGlobal(true)) - // Prepare rego evaluation ctx := context.Background() r := rego.New( diff --git a/pkg/policies/engine/rego/builtins/registry.go b/pkg/policies/engine/rego/builtins/registry.go index f5637ccde..4b732d916 100644 --- a/pkg/policies/engine/rego/builtins/registry.go +++ b/pkg/policies/engine/rego/builtins/registry.go @@ -15,8 +15,6 @@ package builtins import ( - "sync" - "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/topdown" ) @@ -25,155 +23,17 @@ import ( type SecurityLevel int const ( - // SecurityLevelPermissive functions are only available in permissive (development) mode - // These functions may make external calls, modify state, or perform operations - // that are not suitable for production policy evaluation - SecurityLevelPermissive SecurityLevel = iota - - // SecurityLevelRestrictive functions are safe to use in restrictive (production) mode - // These functions should be read-only, deterministic, and not make external calls - // or access sensitive resources - SecurityLevelRestrictive + // NonRestrictiveBuiltin is used in builtin definition categories to mark a builtin as non-suitable for Chainloop's restrictive mode + NonRestrictiveBuiltin = "non-restrictive" ) -// BuiltinDef defines a custom built-in function that can be registered with OPA -type BuiltinDef struct { - // Name is the fully qualified name of the built-in (e.g., "chainloop.http_with_auth") - Name string - - // Decl is the built-in declaration that defines the function signature - Decl *ast.Builtin - - // Impl is the actual function implementation - Impl topdown.BuiltinFunc - - // SecurityLevel defines when this function is allowed to execute - SecurityLevel SecurityLevel - - // Description provides documentation for the function - Description string -} - -// Registry manages custom built-in functions for the OPA policy engine -type Registry struct { - mu sync.RWMutex - builtins map[string]*BuiltinDef -} - -// NewRegistry creates a new built-in function registry -func NewRegistry() *Registry { - return &Registry{ - builtins: make(map[string]*BuiltinDef), - } -} - -// Register adds a built-in function to the registry -func (r *Registry) Register(def *BuiltinDef) error { - if def == nil { - return ErrNilBuiltinDef - } - if def.Name == "" { - return ErrEmptyBuiltinName - } - if def.Decl == nil { - return ErrNilBuiltinDecl - } - if def.Impl == nil { - return ErrNilBuiltinImpl - } - - r.mu.Lock() - defer r.mu.Unlock() - - r.builtins[def.Name] = def - return nil -} - -// GetByMode returns all built-in functions that are allowed in the specified security level -// Functions with SecurityLevelRestrictive are available in both modes -func (r *Registry) GetByMode(isPermissive bool) []*BuiltinDef { - r.mu.RLock() - defer r.mu.RUnlock() - - var result []*BuiltinDef - for _, def := range r.builtins { - // Restrictive functions are always available - if def.SecurityLevel == SecurityLevelRestrictive { - result = append(result, def) - continue - } - - // Permissive functions only available in permissive mode - if isPermissive && def.SecurityLevel == SecurityLevelPermissive { - result = append(result, def) - } - } - - return result -} - -// Get returns a built-in function by name -func (r *Registry) Get(name string) (*BuiltinDef, bool) { - r.mu.RLock() - defer r.mu.RUnlock() - - def, ok := r.builtins[name] - return def, ok -} - -// All returns all registered built-in functions -func (r *Registry) All() []*BuiltinDef { - r.mu.RLock() - defer r.mu.RUnlock() - - result := make([]*BuiltinDef, 0, len(r.builtins)) - for _, def := range r.builtins { - result = append(result, def) - } - - return result -} - -// RegisterGlobal registers built-ins globally with OPA based on security mode +// Register registers built-ins globally with OPA // This should be called once during initialization -func (r *Registry) RegisterGlobal(isPermissive bool) error { - defs := r.GetByMode(isPermissive) - - for _, def := range defs { - // Register the built-in declaration with AST - ast.RegisterBuiltin(def.Decl) - - // Register the implementation with topdown - topdown.RegisterBuiltinFunc(def.Name, def.Impl) - } +func Register(def *ast.Builtin, builtinFunc topdown.BuiltinFunc) error { + // Register the built-in declaration with AST + ast.RegisterBuiltin(def) + // Register the implementation with topdown + topdown.RegisterBuiltinFunc(def.Name, builtinFunc) return nil } - -// Global registry instance for default built-ins -var globalRegistry = NewRegistry() - -// Register adds a built-in to the global registry -func Register(def *BuiltinDef) error { - return globalRegistry.Register(def) -} - -// GetByMode returns built-ins from the global registry by mode -func GetByMode(isPermissive bool) []*BuiltinDef { - return globalRegistry.GetByMode(isPermissive) -} - -// Get returns a built-in from the global registry by name -func Get(name string) (*BuiltinDef, bool) { - return globalRegistry.Get(name) -} - -// All returns all built-ins from the global registry -func All() []*BuiltinDef { - return globalRegistry.All() -} - -// RegisterGlobalBuiltins registers global registry built-ins with OPA -func RegisterGlobalBuiltins(isPermissive bool) error { - return globalRegistry.RegisterGlobal(isPermissive) -} diff --git a/pkg/policies/engine/rego/builtins/registry_test.go b/pkg/policies/engine/rego/builtins/registry_test.go deleted file mode 100644 index d3a80dabe..000000000 --- a/pkg/policies/engine/rego/builtins/registry_test.go +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright 2025 The Chainloop Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package builtins - -import ( - "testing" - - "github.com/open-policy-agent/opa/v1/ast" - "github.com/open-policy-agent/opa/v1/topdown" - "github.com/open-policy-agent/opa/v1/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRegistry_Register(t *testing.T) { - tests := []struct { - name string - def *BuiltinDef - wantErr error - }{ - { - name: "valid built-in", - def: &BuiltinDef{ - Name: "test.func", - Decl: &ast.Builtin{ - Name: "test.func", - Decl: types.NewFunction(types.Args(types.S), types.S), - }, - Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, - SecurityLevel: SecurityLevelRestrictive, - Description: "Test function", - }, - wantErr: nil, - }, - { - name: "nil built-in", - def: nil, - wantErr: ErrNilBuiltinDef, - }, - { - name: "empty name", - def: &BuiltinDef{ - Name: "", - Decl: &ast.Builtin{}, - Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, - }, - wantErr: ErrEmptyBuiltinName, - }, - { - name: "nil decl", - def: &BuiltinDef{ - Name: "test.func", - Decl: nil, - Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, - }, - wantErr: ErrNilBuiltinDecl, - }, - { - name: "nil impl", - def: &BuiltinDef{ - Name: "test.func", - Decl: &ast.Builtin{}, - Impl: nil, - }, - wantErr: ErrNilBuiltinImpl, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := NewRegistry() - err := r.Register(tt.def) - - if tt.wantErr != nil { - assert.ErrorIs(t, err, tt.wantErr) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestRegistry_GetByMode(t *testing.T) { - restrictiveDef := &BuiltinDef{ - Name: "test.restrictive", - Decl: &ast.Builtin{ - Name: "test.restrictive", - Decl: types.NewFunction(types.Args(types.S), types.S), - }, - Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, - SecurityLevel: SecurityLevelRestrictive, - Description: "Restrictive function", - } - - permissiveDef := &BuiltinDef{ - Name: "test.permissive", - Decl: &ast.Builtin{ - Name: "test.permissive", - Decl: types.NewFunction(types.Args(types.S), types.S), - }, - Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, - SecurityLevel: SecurityLevelPermissive, - Description: "Permissive function", - } - - t.Run("restrictive mode - only restrictive functions", func(t *testing.T) { - r := NewRegistry() - require.NoError(t, r.Register(restrictiveDef)) - require.NoError(t, r.Register(permissiveDef)) - - defs := r.GetByMode(false) // restrictive mode - assert.Len(t, defs, 1) - assert.Equal(t, "test.restrictive", defs[0].Name) - }) - - t.Run("permissive mode - all functions", func(t *testing.T) { - r := NewRegistry() - require.NoError(t, r.Register(restrictiveDef)) - require.NoError(t, r.Register(permissiveDef)) - - defs := r.GetByMode(true) // permissive mode - assert.Len(t, defs, 2) - - names := make([]string, len(defs)) - for i, def := range defs { - names[i] = def.Name - } - assert.Contains(t, names, "test.restrictive") - assert.Contains(t, names, "test.permissive") - }) -} - -func TestRegistry_Get(t *testing.T) { - r := NewRegistry() - def := &BuiltinDef{ - Name: "test.func", - Decl: &ast.Builtin{ - Name: "test.func", - Decl: types.NewFunction(types.Args(types.S), types.S), - }, - Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, - SecurityLevel: SecurityLevelRestrictive, - Description: "Test function", - } - - require.NoError(t, r.Register(def)) - - t.Run("existing function", func(t *testing.T) { - got, ok := r.Get("test.func") - assert.True(t, ok) - assert.Equal(t, def, got) - }) - - t.Run("non-existing function", func(t *testing.T) { - got, ok := r.Get("test.nonexistent") - assert.False(t, ok) - assert.Nil(t, got) - }) -} - -func TestRegistry_RegisterGlobal(t *testing.T) { - r := NewRegistry() - - def1 := &BuiltinDef{ - Name: "test.restrictive_global", - Decl: &ast.Builtin{ - Name: "test.restrictive_global", - Decl: types.NewFunction(types.Args(types.S), types.S), - }, - Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, - SecurityLevel: SecurityLevelRestrictive, - } - - def2 := &BuiltinDef{ - Name: "test.permissive_global", - Decl: &ast.Builtin{ - Name: "test.permissive_global", - Decl: types.NewFunction(types.Args(types.S), types.S), - }, - Impl: func(topdown.BuiltinContext, []*ast.Term, func(*ast.Term) error) error { return nil }, - SecurityLevel: SecurityLevelPermissive, - } - - require.NoError(t, r.Register(def1)) - require.NoError(t, r.Register(def2)) - - t.Run("restrictive mode", func(t *testing.T) { - err := r.RegisterGlobal(false) - assert.NoError(t, err) - }) - - t.Run("permissive mode", func(t *testing.T) { - err := r.RegisterGlobal(true) - assert.NoError(t, err) - }) -} diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index 858308b13..d437ee067 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -20,6 +20,7 @@ import ( "context" "encoding/json" "fmt" + "slices" "github.com/chainloop-dev/chainloop/pkg/policies/engine" "github.com/chainloop-dev/chainloop/pkg/policies/engine/rego/builtins" @@ -41,8 +42,6 @@ type Engine struct { includeRawData bool // enablePrint determines whether to enable print statements in rego policies enablePrint bool - // builtinRegistry is the registry of custom built-in functions - builtinRegistry *builtins.Registry } type EngineOption func(*newEngineOptions) @@ -71,18 +70,11 @@ func WithEnablePrint(enable bool) EngineOption { } } -func WithBuiltinRegistry(registry *builtins.Registry) EngineOption { - return func(e *newEngineOptions) { - e.builtinRegistry = registry - } -} - type newEngineOptions struct { operatingMode EnvironmentMode allowedNetworkDomains []string includeRawData bool enablePrint bool - builtinRegistry *builtins.Registry } // NewEngine creates a new policy engine with the given options @@ -103,30 +95,13 @@ func NewEngine(opts ...EngineOption) *Engine { "www.cisa.gov", } - // Use global registry if none provided - registry := options.builtinRegistry - if registry == nil { - registry = builtins.NewRegistry() - // Register all built-ins from the global registry - for _, def := range builtins.All() { - _ = registry.Register(def) - } - } - - engine := &Engine{ + return &Engine{ operatingMode: options.operatingMode, // append base allowed network domains to the user provided ones allowedNetworkDomains: append(baseAllowedNetworkDomains, options.allowedNetworkDomains...), includeRawData: options.includeRawData, enablePrint: options.enablePrint, - builtinRegistry: registry, } - - // Register ALL custom built-ins globally with OPA (both restrictive and permissive) - // The Capabilities() method will filter them based on operating mode - _ = registry.RegisterGlobal(true) // true = register all - - return engine } // EnvironmentMode defines the mode of running the policy engine @@ -328,18 +303,18 @@ func (r *Engine) Capabilities() *ast.Capabilities { localBuiltIns := make(map[string]*ast.Builtin, len(ast.BuiltinMap)) maps.Copy(localBuiltIns, ast.BuiltinMap) + // remove custom builtins self-declared non-restrictive + for k, builtin := range localBuiltIns { + if slices.Contains(builtin.Categories, builtins.NonRestrictiveBuiltin) { + delete(localBuiltIns, k) + } + } + // Remove not allowed builtins for _, notAllowed := range builtinFuncNotAllowed { delete(localBuiltIns, notAllowed.Name) } - // Remove permissive-only custom built-ins - for _, def := range r.builtinRegistry.All() { - if def.SecurityLevel == builtins.SecurityLevelPermissive { - delete(localBuiltIns, def.Name) - } - } - // Convert map to slice enabledBuiltin = make([]*ast.Builtin, 0, len(localBuiltIns)) for _, builtin := range localBuiltIns { diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index 19b726cf8..0503b7d53 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -440,6 +440,9 @@ func TestRego_CustomBuiltinsPermissiveMode(t *testing.T) { regoContent, err := os.ReadFile("testfiles/custom_builtin_permissive.rego") require.NoError(t, err) + // register builtin function + require.NoError(t, builtins.RegisterHelloBuiltin()) + // Create engine in permissive mode r := NewEngine(WithOperatingMode(EnvironmentModePermissive)) policy := &engine.Policy{ @@ -466,10 +469,18 @@ result := { violations contains msg if { # Try to use a permissive-only built-in - response := chainloop.hello("world") + response := test.dangerous_func("world") msg := "Request failed" }`) + require.NoError(t, builtins.Register(&ast.Builtin{ + Name: "test.dangerous_func", + Categories: []string{builtins.NonRestrictiveBuiltin}, + Decl: types.NewFunction(types.Args(types.S), types.S), + }, func(_ topdown.BuiltinContext, _ []*ast.Term, iter func(*ast.Term) error) error { + return iter(ast.StringTerm("test_value")) + })) + // Create engine in restrictive mode (default) r := NewEngine() policy := &engine.Policy{ @@ -485,23 +496,14 @@ violations contains msg if { }) } -func TestRego_CustomBuiltinRegistry(t *testing.T) { - // Create a custom restrictive built-in for testing - testBuiltin := &builtins.BuiltinDef{ +func TestRego_CustomBuiltin(t *testing.T) { + // Create a custom built-in for testing + require.NoError(t, builtins.Register(&ast.Builtin{ Name: "test.restrictive_func", - Decl: &ast.Builtin{ - Name: "test.restrictive_func", - Decl: types.NewFunction(types.Args(types.S), types.S), - }, - Impl: func(_ topdown.BuiltinContext, _ []*ast.Term, iter func(*ast.Term) error) error { - return iter(ast.StringTerm("test_value")) - }, - SecurityLevel: builtins.SecurityLevelRestrictive, - Description: "Test restrictive function", - } - - registry := builtins.NewRegistry() - require.NoError(t, registry.Register(testBuiltin)) + Decl: types.NewFunction(types.Args(types.S), types.S), + }, func(_ topdown.BuiltinContext, _ []*ast.Term, iter func(*ast.Term) error) error { + return iter(ast.StringTerm("test_value")) + })) regoContent := []byte(`package test import rego.v1 @@ -518,8 +520,8 @@ violations contains msg if { }`) t.Run("custom restrictive builtin works in restrictive mode", func(t *testing.T) { - // Create engine with custom registry - r := NewEngine(WithBuiltinRegistry(registry)) + // Create engine + r := NewEngine() policy := &engine.Policy{ Name: "test", Source: regoContent, From 520022dead6534ec07ac63eea6b47f556a0fbe40 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sun, 16 Nov 2025 11:53:12 +0100 Subject: [PATCH 11/12] add skill Signed-off-by: Jose I. Paris --- .../skills/custom-builtin-functions/SKILL.md | 79 +++++++++++++++++ CLAUDE.md | 85 ------------------- pkg/policies/engine/rego/builtins/registry.go | 3 - 3 files changed, 79 insertions(+), 88 deletions(-) create mode 100644 .claude/skills/custom-builtin-functions/SKILL.md diff --git a/.claude/skills/custom-builtin-functions/SKILL.md b/.claude/skills/custom-builtin-functions/SKILL.md new file mode 100644 index 000000000..d909d5c90 --- /dev/null +++ b/.claude/skills/custom-builtin-functions/SKILL.md @@ -0,0 +1,79 @@ +--- +name: custom-builtin-functions +description: Create a custom builtin function to be used in the Rego policy engine +--- + +### Policy Engine Extension + +The OPA/Rego policy engine supports custom built-in functions written in Go. + +**Adding Custom Built-ins**: + +1. **Create Built-in Implementation** (e.g., `pkg/policies/engine/rego/builtins/myfeature.go`): +```go +package builtins + +import ( + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/topdown" + "github.com/open-policy-agent/opa/types" +) + +const myFuncName = "chainloop.my_function" + +func RegisterMyBuiltins() error { + return Register(&ast.Builtin{ + Name: myFuncName, + Description: "Description of what this function does", + Decl: types.NewFunction( + types.Args(types.Named("input", types.S).Description("this is the input")), + types.Named("result", types.S).Description("this is the result"), + ), + }, myFunctionImpl) +} + +func myFunctionImpl(bctx topdown.BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { + // Extract arguments + input, ok := operands[0].Value.(ast.String) + if !ok { + return fmt.Errorf("input must be a string") + } + + // Implement logic + result := processInput(string(input)) + + // Return result + return iter(ast.StringTerm(result)) +} + +// Autoregisters on package load +func init() { + if err := RegisterMyBuiltins(); err != nil { + panic(fmt.Sprintf("failed to register built-ins: %v", err)) + } +} +``` + +2. **Use in Policies** (`*.rego`): +```rego +package example +import rego.v1 + +result := { + "violations": violations, + "skipped": false +} + +violations contains msg if { + output := chainloop.my_function(input.value) + output != "expected" + msg := "Function returned unexpected value" +} +``` + +**Guidelines**: +- Use `chainloop.*` namespace for all custom built-ins +- Functions that call third party services should be marked as non-restrictive by adding the `NonRestrictiveBuiltin` category to the builtin definition +- Always implement proper error handling and return meaningful error messages +- Use context from `BuiltinContext` for timeout/cancellation support +- Document function signatures and behavior in the `Description` field and parameter definitions diff --git a/CLAUDE.md b/CLAUDE.md index 098655546..bd0777e60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -246,91 +246,6 @@ Workflow Contracts define the structure and requirements for CI/CD attestations. - **Authentication**: JWT tokens generated by Control Plane, validated by CAS - **Streaming**: Uses gRPC bytestream protocol for efficient file transfers -### Policy Engine Extension - -The OPA/Rego policy engine supports custom built-in functions written in Go. - -**Architecture** (`pkg/policies/engine/rego/builtins/`): -- **Registry**: Manages custom built-in function registration -- **Security Levels**: Functions tagged as `SecurityLevelRestrictive` (production-safe) or `SecurityLevelPermissive` (development-only) -- **Operating Modes**: Restrictive mode (server-side) only allows restrictive functions; Permissive mode (CLI local dev) allows all functions - -**Adding Custom Built-ins**: - -1. **Create Built-in Implementation** (e.g., `pkg/policies/engine/rego/builtins/myfeature.go`): -```go -package builtins - -import ( - "github.com/open-policy-agent/opa/ast" - "github.com/open-policy-agent/opa/topdown" - "github.com/open-policy-agent/opa/types" -) - -const myFuncName = "chainloop.my_function" - -func RegisterMyBuiltins() error { - return Register(&BuiltinDef{ - Name: myFuncName, - Decl: &ast.Builtin{ - Name: myFuncName, - Decl: types.NewFunction( - types.Args(types.Named("input", types.S)), - types.Named("result", types.S), - ), - }, - Impl: myFunctionImpl, - SecurityLevel: SecurityLevelRestrictive, // or SecurityLevelPermissive - Description: "Description of what this function does", - }) -} - -func myFunctionImpl(bctx topdown.BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { - // Extract arguments - input, ok := operands[0].Value.(ast.String) - if !ok { - return fmt.Errorf("input must be a string") - } - - // Implement logic - result := processInput(string(input)) - - // Return result - return iter(ast.StringTerm(result)) -} - -func init() { - if err := RegisterMyBuiltins(); err != nil { - panic(fmt.Sprintf("failed to register built-ins: %v", err)) - } -} -``` - -2. **Use in Policies** (`*.rego`): -```rego -package example -import rego.v1 - -result := { - "violations": violations, - "skipped": false -} - -violations contains msg if { - output := chainloop.my_function(input.value) - output != "expected" - msg := "Function returned unexpected value" -} -``` - -**Guidelines**: -- Use `chainloop.*` namespace for all custom built-ins -- Restrictive functions must be read-only, deterministic, and not make external calls -- Permissive functions can call external services -- Always implement proper error handling and return meaningful error messages -- Use context from `BuiltinContext` for timeout/cancellation support -- Document function signatures and behavior in the `Description` field - ## Commit Guidelines All commits must meet these criteria: diff --git a/pkg/policies/engine/rego/builtins/registry.go b/pkg/policies/engine/rego/builtins/registry.go index 4b732d916..373fcc82a 100644 --- a/pkg/policies/engine/rego/builtins/registry.go +++ b/pkg/policies/engine/rego/builtins/registry.go @@ -19,9 +19,6 @@ import ( "github.com/open-policy-agent/opa/v1/topdown" ) -// SecurityLevel defines when a built-in function is allowed to execute -type SecurityLevel int - const ( // NonRestrictiveBuiltin is used in builtin definition categories to mark a builtin as non-suitable for Chainloop's restrictive mode NonRestrictiveBuiltin = "non-restrictive" From eb94f8b77378753bb1cecb2669f3dc6e52a9d435 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sun, 16 Nov 2025 12:42:54 +0100 Subject: [PATCH 12/12] fix comment Signed-off-by: Jose I. Paris --- pkg/policies/engine/rego/rego_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index 0503b7d53..c4bca2fca 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -490,7 +490,7 @@ violations contains msg if { t.Run("permissive builtin fails in restrictive mode", func(t *testing.T) { _, err := r.Verify(context.TODO(), policy, []byte(`{"kind": "test"}`), nil) - // Should fail because chainloop.http_with_auth is not available in restrictive mode + // Should fail because test.dangerous_func is not available in restrictive mode assert.Error(t, err) assert.Contains(t, err.Error(), "undefined function") })