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/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..5074ee30d --- /dev/null +++ b/pkg/policies/engine/rego/builtins/example.go @@ -0,0 +1,60 @@ +// +// 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(&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 + ), + types.Named("response", types.A).Description("the hello world message"), // Response as object + ), + }, getHelloImpl) +} + +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}))) +} 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..f44aa7b7b --- /dev/null +++ b/pkg/policies/engine/rego/builtins/example_test.go @@ -0,0 +1,74 @@ +// +// 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) { + require.NoError(t, RegisterHelloBuiltin()) + // 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/registry.go b/pkg/policies/engine/rego/builtins/registry.go new file mode 100644 index 000000000..373fcc82a --- /dev/null +++ b/pkg/policies/engine/rego/builtins/registry.go @@ -0,0 +1,36 @@ +// 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 ( + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/topdown" +) + +const ( + // NonRestrictiveBuiltin is used in builtin definition categories to mark a builtin as non-suitable for Chainloop's restrictive mode + NonRestrictiveBuiltin = "non-restrictive" +) + +// Register registers built-ins globally with OPA +// This should be called once during initialization +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 +} diff --git a/pkg/policies/engine/rego/rego.go b/pkg/policies/engine/rego/rego.go index c79354b65..d437ee067 100644 --- a/pkg/policies/engine/rego/rego.go +++ b/pkg/policies/engine/rego/rego.go @@ -20,8 +20,10 @@ import ( "context" "encoding/json" "fmt" + "slices" "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/rego" "github.com/open-policy-agent/opa/v1/topdown/print" @@ -301,6 +303,13 @@ 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) diff --git a/pkg/policies/engine/rego/rego_test.go b/pkg/policies/engine/rego/rego_test.go index 5518ad600..c4bca2fca 100644 --- a/pkg/policies/engine/rego/rego_test.go +++ b/pkg/policies/engine/rego/rego_test.go @@ -21,6 +21,10 @@ import ( "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/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" ) @@ -431,3 +435,101 @@ func TestRego_MatchesEvaluation(t *testing.T) { assert.False(t, matches) }) } + +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{ + 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 := 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{ + 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 test.dangerous_func is not available in restrictive mode + assert.Error(t, err) + assert.Contains(t, err.Error(), "undefined function") + }) +} + +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: 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 + +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 + r := NewEngine() + 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) + }) +} 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..4cc68f727 --- /dev/null +++ b/pkg/policies/engine/rego/testfiles/custom_builtin_permissive.rego @@ -0,0 +1,14 @@ +package test + +import rego.v1 + +result := { + "violations": violations, + "skipped": false, +} + +violations contains msg if { + response := chainloop.hello("world") + response.message != "Hello, world!" + msg := sprintf("unexpected message! %s", [response.message]) +}