Skip to content

Commit e15c8d6

Browse files
authored
feat(policies): inject per-evaluation project context for chainloop.* built-ins (#3094)
Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent dadda99 commit e15c8d6

11 files changed

Lines changed: 339 additions & 74 deletions

File tree

app/cli/cmd/policy_develop_eval.go

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ import (
2727

2828
func newPolicyDevelopEvalCmd() *cobra.Command {
2929
var (
30-
materialPath string
31-
kind string
32-
annotations []string
33-
policyPath string
34-
inputs []string
35-
allowedHostnames []string
30+
materialPath string
31+
kind string
32+
annotations []string
33+
policyPath string
34+
inputs []string
35+
allowedHostnames []string
36+
projectName string
37+
projectVersionName string
3638
)
3739

3840
cmd := &cobra.Command{
@@ -41,18 +43,20 @@ func newPolicyDevelopEvalCmd() *cobra.Command {
4143
Long: `Perform a full evaluation of the policy against the provided material type.
4244
The command checks if there is a path in the policy for the specified kind and
4345
evaluates the policy against the provided material or attestation.`,
44-
Example: `
46+
Example: `
4547
# Evaluate policy against a material file
4648
chainloop policy develop eval --policy policy.yaml --material sbom.json --kind SBOM_CYCLONEDX_JSON --annotation key1=value1,key2=value2 --input key3=value3`,
4749
RunE: func(_ *cobra.Command, _ []string) error {
4850
opts := &action.PolicyEvalOpts{
49-
MaterialPath: materialPath,
50-
Kind: kind,
51-
Annotations: parseKeyValue(annotations),
52-
PolicyPath: policyPath,
53-
Inputs: parseKeyValue(inputs),
54-
AllowedHostnames: allowedHostnames,
55-
Debug: flagDebug,
51+
MaterialPath: materialPath,
52+
Kind: kind,
53+
Annotations: parseKeyValue(annotations),
54+
PolicyPath: policyPath,
55+
Inputs: parseKeyValue(inputs),
56+
AllowedHostnames: allowedHostnames,
57+
Debug: flagDebug,
58+
ProjectName: projectName,
59+
ProjectVersionName: projectVersionName,
5660
}
5761

5862
policyEval, err := action.NewPolicyEval(opts, ActionOpts)
@@ -76,6 +80,8 @@ evaluates the policy against the provided material or attestation.`,
7680
cmd.Flags().StringVarP(&policyPath, "policy", "p", "policy.yaml", "Policy reference (./my-policy.yaml, https://my-domain.com/my-policy.yaml, chainloop://my-stored-policy)")
7781
cmd.Flags().StringArrayVar(&inputs, "input", []string{}, "Key-value pairs of policy inputs (key=value)")
7882
cmd.Flags().StringSliceVar(&allowedHostnames, "allowed-hostnames", []string{}, "Additional hostnames allowed for http.send requests in policies")
83+
cmd.Flags().StringVar(&projectName, "project", "", "Project name to use as engine context for chainloop.* built-ins")
84+
cmd.Flags().StringVar(&projectVersionName, "project-version", "", "Project version to use as engine context for chainloop.* built-ins")
7985

8086
return cmd
8187
}

app/cli/documentation/cli-reference.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2940,6 +2940,8 @@ Options
29402940
--kind string Kind of the material: ["ARTIFACT" "ASYNCAPI_SPEC" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CHAINLOOP_AI_AGENT_CONFIG" "CHAINLOOP_AI_CODING_SESSION" "CHAINLOOP_PR_INFO" "CHAINLOOP_RUNNER_CONTEXT" "CONTAINER_IMAGE" "CSAF_INFORMATIONAL_ADVISORY" "CSAF_SECURITY_ADVISORY" "CSAF_SECURITY_INCIDENT_RESPONSE" "CSAF_VEX" "EVIDENCE" "GHAS_CODE_SCAN" "GHAS_DEPENDENCY_SCAN" "GHAS_SECRET_SCAN" "GITLAB_SECURITY_REPORT" "GITLEAKS_JSON" "GRAPHQL_SPEC" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENAPI_SPEC" "OPENVEX" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "TWISTCLI_SCAN_JSON" "ZAP_DAST_ZIP"]
29412941
--material string Path to material or attestation file
29422942
-p, --policy string Policy reference (./my-policy.yaml, https://my-domain.com/my-policy.yaml, chainloop://my-stored-policy) (default "policy.yaml")
2943+
--project string Project name to use as engine context for chainloop.* built-ins
2944+
--project-version string Project version to use as engine context for chainloop.* built-ins
29432945
```
29442946

29452947
Options inherited from parent commands

app/cli/internal/policydevel/eval.go

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,17 @@ const (
3737
)
3838

3939
type EvalOptions struct {
40-
PolicyPath string
41-
MaterialKind string
42-
Annotations map[string]string
43-
MaterialPath string
44-
Inputs map[string]string
45-
AllowedHostnames []string
46-
Debug bool
47-
AttestationClient controlplanev1.AttestationServiceClient
48-
ControlPlaneConn *grpc.ClientConn
40+
PolicyPath string
41+
MaterialKind string
42+
Annotations map[string]string
43+
MaterialPath string
44+
Inputs map[string]string
45+
AllowedHostnames []string
46+
Debug bool
47+
AttestationClient controlplanev1.AttestationServiceClient
48+
ControlPlaneConn *grpc.ClientConn
49+
ProjectName string
50+
ProjectVersionName string
4951
}
5052

5153
type EvalResult struct {
@@ -80,7 +82,7 @@ func Evaluate(opts *EvalOptions, logger zerolog.Logger) (*EvalSummary, error) {
8082
material.Annotations = opts.Annotations
8183

8284
// 3. Verify material against policy
83-
summary, err := verifyMaterial(policies, material, opts.MaterialPath, opts.Debug, opts.AllowedHostnames, opts.AttestationClient, opts.ControlPlaneConn, &logger)
85+
summary, err := verifyMaterial(policies, material, opts.MaterialPath, opts.Debug, opts.AllowedHostnames, opts.AttestationClient, opts.ControlPlaneConn, opts.ProjectName, opts.ProjectVersionName, &logger)
8486
if err != nil {
8587
return nil, err
8688
}
@@ -108,7 +110,7 @@ func createPolicies(policyPath string, inputs map[string]string) (*v1.Policies,
108110
}, nil
109111
}
110112

111-
func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materialPath string, debug bool, allowedHostnames []string, attestationClient controlplanev1.AttestationServiceClient, grpcConn *grpc.ClientConn, logger *zerolog.Logger) (*EvalSummary, error) {
113+
func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materialPath string, debug bool, allowedHostnames []string, attestationClient controlplanev1.AttestationServiceClient, grpcConn *grpc.ClientConn, projectName, projectVersion string, logger *zerolog.Logger) (*EvalSummary, error) {
112114
var opts []policies.PolicyVerifierOption
113115
if len(allowedHostnames) > 0 {
114116
opts = append(opts, policies.WithAllowedHostnames(allowedHostnames...))
@@ -117,6 +119,9 @@ func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materi
117119
opts = append(opts, policies.WithIncludeRawData(debug))
118120
opts = append(opts, policies.WithEnablePrint(enablePrint))
119121
opts = append(opts, policies.WithGRPCConn(grpcConn))
122+
if projectName != "" || projectVersion != "" {
123+
opts = append(opts, policies.WithProjectContext(projectName, projectVersion))
124+
}
120125

121126
v := policies.NewPolicyVerifier(pol, attestationClient, logger, opts...)
122127
policyEvs, err := v.VerifyMaterial(context.Background(), material, materialPath)

app/cli/pkg/action/policy_develop_eval.go

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ import (
2222
)
2323

2424
type PolicyEvalOpts struct {
25-
MaterialPath string
26-
Kind string
27-
Annotations map[string]string
28-
PolicyPath string
29-
Inputs map[string]string
30-
AllowedHostnames []string
31-
Debug bool
25+
MaterialPath string
26+
Kind string
27+
Annotations map[string]string
28+
PolicyPath string
29+
Inputs map[string]string
30+
AllowedHostnames []string
31+
Debug bool
32+
ProjectName string
33+
ProjectVersionName string
3234
}
3335

3436
type PolicyEval struct {
@@ -50,15 +52,17 @@ func (action *PolicyEval) Run() (*policydevel.EvalSummary, error) {
5052
}
5153

5254
evalOpts := &policydevel.EvalOptions{
53-
PolicyPath: action.opts.PolicyPath,
54-
MaterialKind: action.opts.Kind,
55-
Annotations: action.opts.Annotations,
56-
MaterialPath: action.opts.MaterialPath,
57-
Inputs: action.opts.Inputs,
58-
AllowedHostnames: action.opts.AllowedHostnames,
59-
Debug: action.opts.Debug,
60-
AttestationClient: attClient,
61-
ControlPlaneConn: action.CPConnection,
55+
PolicyPath: action.opts.PolicyPath,
56+
MaterialKind: action.opts.Kind,
57+
Annotations: action.opts.Annotations,
58+
MaterialPath: action.opts.MaterialPath,
59+
Inputs: action.opts.Inputs,
60+
AllowedHostnames: action.opts.AllowedHostnames,
61+
Debug: action.opts.Debug,
62+
AttestationClient: attClient,
63+
ControlPlaneConn: action.CPConnection,
64+
ProjectName: action.opts.ProjectName,
65+
ProjectVersionName: action.opts.ProjectVersionName,
6266
}
6367

6468
// Evaluate policy

pkg/attestation/crafter/crafter.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,13 +694,16 @@ func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_M
694694
return i.MaterialName == m.Name
695695
})
696696

697+
projectName, projectVersion := c.projectContext()
698+
697699
pgv := policies.NewPolicyGroupVerifier(
698700
c.CraftingState.GetPolicyGroups(),
699701
c.CraftingState.GetPolicies(),
700702
c.attClient,
701703
c.Logger,
702704
policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...),
703705
policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()),
706+
policies.WithProjectContext(projectName, projectVersion),
704707
)
705708
policyGroupResults, err := pgv.VerifyMaterial(ctx, mt, value)
706709
if err != nil {
@@ -718,6 +721,7 @@ func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_M
718721
c.Logger,
719722
policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...),
720723
policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()),
724+
policies.WithProjectContext(projectName, projectVersion),
721725
)
722726
policyResults, err := pv.VerifyMaterial(ctx, mt, value)
723727
if err != nil {
@@ -745,6 +749,21 @@ func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_M
745749
return mt, nil
746750
}
747751

752+
// projectContext returns the project name and version from the workflow
753+
// metadata so policy verifiers can pass them to the engine. Either may be
754+
// empty (e.g. dry-run before workflow metadata is populated); built-ins
755+
// must degrade gracefully in that case.
756+
func (c *Crafter) projectContext() (string, string) {
757+
wf := c.CraftingState.GetAttestation().GetWorkflow()
758+
version := wf.GetVersion().GetVersion()
759+
if version == "" {
760+
// Fall back to the deprecated flat field for state written by older clients.
761+
//nolint:staticcheck // intentional fallback for backwards compatibility
762+
version = wf.GetProjectVersion()
763+
}
764+
return wf.GetProject(), version
765+
}
766+
748767
// policyEvalMatches returns true if two policy evaluations refer to the same policy
749768
// with the same arguments. It treats nil and empty maps as equivalent to handle
750769
// protojson round-trip serialization where empty maps are omitted.
@@ -755,11 +774,14 @@ func policyEvalMatches(a, b *api.PolicyEvaluation) bool {
755774
// EvaluateAttestationPolicies evaluates the attestation-level policies and stores them in the attestation state.
756775
// The phase parameter controls which policies are evaluated based on their attestation_phases spec field.
757776
func (c *Crafter) EvaluateAttestationPolicies(ctx context.Context, attestationID string, statement *intoto.Statement, phase policies.EvalPhase) error {
777+
projectName, projectVersion := c.projectContext()
778+
758779
// evaluate attestation-level policies
759780
pv := policies.NewPolicyVerifier(c.CraftingState.GetPolicies(), c.attClient, c.Logger,
760781
policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...),
761782
policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()),
762783
policies.WithEvalPhase(phase),
784+
policies.WithProjectContext(projectName, projectVersion),
763785
)
764786
policyEvaluations, err := pv.VerifyStatement(ctx, statement)
765787
if err != nil {
@@ -770,6 +792,7 @@ func (c *Crafter) EvaluateAttestationPolicies(ctx context.Context, attestationID
770792
policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...),
771793
policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()),
772794
policies.WithEvalPhase(phase),
795+
policies.WithProjectContext(projectName, projectVersion),
773796
)
774797
policyGroupResults, err := pgv.VerifyStatement(ctx, statement)
775798
if err != nil {

pkg/policies/engine/engine.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ type CommonEngineOptions struct {
3737
IncludeRawData bool
3838
EnablePrint bool
3939
ControlPlaneConnection *grpc.ClientConn
40+
// ProjectName / ProjectVersionName carry the project + version this engine
41+
// instance is evaluating policies for. They are surfaced to chainloop.* built-ins
42+
// via the per-evaluation context.Context (see builtins.WithProjectContext) so a
43+
// built-in like chainloop.findings can scope its query without the rego author
44+
// having to pass the values explicitly. Either may be empty (e.g. local dev
45+
// eval without flags) — built-ins must degrade gracefully in that case.
46+
ProjectName string
47+
ProjectVersionName string
4048
}
4149

4250
// Option is a unified functional option for configuring policy engines
@@ -106,6 +114,17 @@ func WithGRPCConn(conn *grpc.ClientConn) Option {
106114
}
107115
}
108116

117+
// WithProjectContext sets the project name and version that this engine
118+
// instance is evaluating policies for. The values are propagated to chainloop.*
119+
// built-ins through the per-evaluation context so they can scope queries
120+
// (e.g. chainloop.findings) without the rego author passing them explicitly.
121+
func WithProjectContext(name, version string) Option {
122+
return func(opts *Options) {
123+
opts.ProjectName = name
124+
opts.ProjectVersionName = version
125+
}
126+
}
127+
109128
// ApplyOptions applies options and returns the configured Options
110129
// This automatically appends BaseAllowedHostnames to any user-provided hostnames
111130
func ApplyOptions(opts ...Option) *Options {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright 2026 The Chainloop Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package builtins
16+
17+
import "context"
18+
19+
// ProjectContext carries the project + version a policy is being evaluated against.
20+
// It is attached to the per-evaluation context.Context by the rego engine so that
21+
// chainloop.* built-ins can scope their requests (e.g. chainloop.findings) without
22+
// requiring the rego author to pass project_name / project_version_name explicitly.
23+
//
24+
// Values may be empty when the engine has no project context (e.g. a local
25+
// `chainloop policy develop eval` without --project flags). Built-ins must
26+
// degrade gracefully in that case rather than erroring.
27+
type ProjectContext struct {
28+
Name string
29+
Version string
30+
}
31+
32+
type projectContextKey struct{}
33+
34+
// WithProjectContext returns a derived context carrying the given project context.
35+
func WithProjectContext(ctx context.Context, pc ProjectContext) context.Context {
36+
return context.WithValue(ctx, projectContextKey{}, pc)
37+
}
38+
39+
// ProjectContextFromContext returns the project context attached to ctx, or the
40+
// zero value if none was set. The bool reports whether a value was present.
41+
func ProjectContextFromContext(ctx context.Context) (ProjectContext, bool) {
42+
pc, ok := ctx.Value(projectContextKey{}).(ProjectContext)
43+
return pc, ok
44+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright 2026 The Chainloop Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package builtins
16+
17+
import (
18+
"context"
19+
"testing"
20+
21+
"github.com/stretchr/testify/assert"
22+
)
23+
24+
func TestProjectContext(t *testing.T) {
25+
tests := []struct {
26+
name string
27+
setup func() context.Context
28+
wantName string
29+
wantVersion string
30+
wantOK bool
31+
}{
32+
{
33+
name: "no project context attached",
34+
setup: context.Background,
35+
wantOK: false,
36+
},
37+
{
38+
name: "context with project + version",
39+
setup: func() context.Context {
40+
return WithProjectContext(context.Background(), ProjectContext{Name: "my-app", Version: "v1.2.3"})
41+
},
42+
wantName: "my-app",
43+
wantVersion: "v1.2.3",
44+
wantOK: true,
45+
},
46+
{
47+
name: "context with only project name",
48+
setup: func() context.Context {
49+
return WithProjectContext(context.Background(), ProjectContext{Name: "my-app"})
50+
},
51+
wantName: "my-app",
52+
wantOK: true,
53+
},
54+
}
55+
56+
for _, tt := range tests {
57+
t.Run(tt.name, func(t *testing.T) {
58+
pc, ok := ProjectContextFromContext(tt.setup())
59+
assert.Equal(t, tt.wantOK, ok)
60+
assert.Equal(t, tt.wantName, pc.Name)
61+
assert.Equal(t, tt.wantVersion, pc.Version)
62+
})
63+
}
64+
}

0 commit comments

Comments
 (0)