Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion app/cli/cmd/attestation_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func newAttestationAddCmd() *cobra.Command {
var artifactCASConn *grpc.ClientConn
var annotationsFlag []string
var noStrictValidation bool
var policyInputFromFileFlag []string

// OCI registry credentials can be passed as flags or environment variables
var registryServer, registryUsername, registryPassword string
Expand Down Expand Up @@ -91,6 +92,13 @@ func newAttestationAddCmd() *cobra.Command {
return err
}

// Parse and resolve the policy input files (column -> policy input).
// Done once here; the resolved local paths are reused across retries.
policyInputFiles, err := resolvePolicyInputFiles(policyInputFromFileFlag)
if err != nil {
return err
}

// In some cases, the attestation state is stored remotely. To control concurrency we use
// optimistic locking. We retry the operation if the state has changed since we last read it.
return runWithBackoffRetry(
Expand All @@ -110,7 +118,7 @@ func newAttestationAddCmd() *cobra.Command {
}
}
// TODO: take the material output and show render it
resp, err := a.Run(cmd.Context(), attestationID, name, rawValuePath, kind, annotations)
resp, err := a.Run(cmd.Context(), attestationID, name, rawValuePath, kind, annotations, policyInputFiles)
if err != nil {
return err
}
Expand Down Expand Up @@ -146,6 +154,7 @@ func newAttestationAddCmd() *cobra.Command {
flagAttestationID(cmd)
cmd.Flags().StringVar(&kind, "kind", "", fmt.Sprintf("kind of the material to be recorded: %q", schemaapi.ListAvailableMaterialKind()))
cmd.Flags().BoolVar(&noStrictValidation, "no-strict-validation", false, "skip strict schema validation for structured materials (SBOM_CYCLONEDX_JSON, OPENAPI_SPEC, ASYNCAPI_SPEC, OSSF_SCORECARD_JSON)")
cmd.Flags().StringArrayVar(&policyInputFromFileFlag, "policy-input-from-file", nil, "feed a policy input from a column of a CSV or JSON file, in the format <input>=<file>[:<column>] (e.g. ignored_paths=exception.csv:Path); <column> is a single top-level column/field name and defaults to the input name; repeatable. The file is also recorded as EVIDENCE.")

// Optional OCI registry credentials
cmd.Flags().StringVar(&registryServer, "registry-server", "", fmt.Sprintf("OCI repository server, ($%s)", registryServerEnvVarName))
Expand All @@ -167,6 +176,38 @@ func newAttestationAddCmd() *cobra.Command {
return cmd
}

// resolvePolicyInputFiles parses each --policy-input-from-file value and
// resolves its file reference to a local path (downloading URLs to a temporary
// file, mirroring how --value is handled).
func resolvePolicyInputFiles(raw []string) ([]*action.PolicyInputFromFile, error) {
if len(raw) == 0 {
return nil, nil
}

result := make([]*action.PolicyInputFromFile, 0, len(raw))
for _, r := range raw {
pif, err := action.ParsePolicyInputFromFile(r)
if err != nil {
return nil, err
}

path, err := resourceloader.GetPathForResource(pif.File)
if err != nil {
var uerr *resourceloader.UnrecognizedSchemeError
if errors.As(err, &uerr) {
path = pif.File
} else {
return nil, fmt.Errorf("loading policy input file: %w", err)
}
}
pif.File = path

result = append(result, pif)
}

return result, nil
}

// displayMaterialInfo prints the material information in a table format.
func displayMaterialInfo(status *action.AttestationStatusMaterial, policyEvaluations []*action.PolicyEvaluation) error {
if status == nil {
Expand Down
21 changes: 11 additions & 10 deletions app/cli/documentation/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -249,16 +249,17 @@ chainloop attestation add --value https://example.com/sbom.json
Options

```
--annotation strings additional annotation in the format of key=value
--attestation-id string Unique identifier of the in-progress attestation
-h, --help help for add
--kind string kind of the material to be recorded: ["ARTIFACT" "ASYNCAPI_SPEC" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CERTCC_DRANZER" "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" "OSSF_SCORECARD_JSON" "RADAMSA_CRASHES" "RADAMSA_REPORT" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "SYSINTERNALS_ACCESSCHK" "SYSINTERNALS_SIGCHECK" "TWISTCLI_SCAN_JSON" "YELP_DETECT_SECRETS_BASELINE" "ZAP_DAST_ZIP"]
--name string name of the material as shown in the contract
--no-strict-validation skip strict schema validation for structured materials (SBOM_CYCLONEDX_JSON, OPENAPI_SPEC, ASYNCAPI_SPEC, OSSF_SCORECARD_JSON)
--registry-password string registry password, ($CHAINLOOP_REGISTRY_PASSWORD)
--registry-server string OCI repository server, ($CHAINLOOP_REGISTRY_SERVER)
--registry-username string registry username, ($CHAINLOOP_REGISTRY_USERNAME)
--value string value to be recorded
--annotation strings additional annotation in the format of key=value
--attestation-id string Unique identifier of the in-progress attestation
-h, --help help for add
--kind string kind of the material to be recorded: ["ARTIFACT" "ASYNCAPI_SPEC" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CERTCC_DRANZER" "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" "OSSF_SCORECARD_JSON" "RADAMSA_CRASHES" "RADAMSA_REPORT" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "SYSINTERNALS_ACCESSCHK" "SYSINTERNALS_SIGCHECK" "TWISTCLI_SCAN_JSON" "YELP_DETECT_SECRETS_BASELINE" "ZAP_DAST_ZIP"]
--name string name of the material as shown in the contract
--no-strict-validation skip strict schema validation for structured materials (SBOM_CYCLONEDX_JSON, OPENAPI_SPEC, ASYNCAPI_SPEC, OSSF_SCORECARD_JSON)
--policy-input-from-file stringArray feed a policy input from a column of a CSV or JSON file, in the format <input>=<file>[:<column>] (e.g. ignored_paths=exception.csv:Path); <column> is a single top-level column/field name and defaults to the input name; repeatable. The file is also recorded as EVIDENCE.
--registry-password string registry password, ($CHAINLOOP_REGISTRY_PASSWORD)
--registry-server string OCI repository server, ($CHAINLOOP_REGISTRY_SERVER)
--registry-username string registry username, ($CHAINLOOP_REGISTRY_USERNAME)
--value string value to be recorded
```

Options inherited from parent commands
Expand Down
112 changes: 106 additions & 6 deletions app/cli/pkg/action/attestation_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ import (
"context"
"errors"
"fmt"
"strings"

pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
"github.com/chainloop-dev/chainloop/pkg/casclient"
"google.golang.org/grpc"
)
Expand Down Expand Up @@ -77,13 +80,20 @@ func NewAttestationAdd(cfg *AttestationAddOpts) (*AttestationAdd, error) {

var ErrAttestationNotInitialized = errors.New("attestation not yet initialized")

func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialName, materialValue, materialType string, annotations map[string]string) (*AttestationStatusMaterial, error) {
func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialName, materialValue, materialType string, annotations map[string]string, policyInputFiles []*PolicyInputFromFile) (*AttestationStatusMaterial, error) {
// initialize the crafter. If attestation-id is provided we assume the attestation is performed using remote state
crafter, err := newCrafter(&newCrafterStateOpts{enableRemoteState: (attestationID != ""), localStatePath: action.localStatePath}, action.CPConnection, action.opts...)
if err != nil {
return nil, fmt.Errorf("failed to load crafter: %w", err)
}

// Resolve runtime policy inputs from the provided files before adding the
// material, so a malformed file aborts the add early.
runtimeInputs, err := buildRuntimeInputs(policyInputFiles)
if err != nil {
return nil, err
}

if initialized, err := crafter.AlreadyInitialized(ctx, attestationID); err != nil {
return nil, fmt.Errorf("checking if attestation is already initialized: %w", err)
} else if !initialized {
Expand Down Expand Up @@ -120,10 +130,12 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
// 2. If materialName is not empty, check if the material is in the contract. If it is, add material from contract
// 2.1. If materialType is empty, try to guess the material kind with auto-detected kind and materialName
// 3. If materialType is not empty, add material contract free with materialType and materialName
addOpts := runtimeInputAddOpts(runtimeInputs)

var mt *api.Attestation_Material
switch {
case materialName == "" && materialType == "":
mt, err = crafter.AddMaterialContactFreeWithAutoDetectedKind(ctx, attestationID, "", materialValue, casBackend, annotations)
mt, err = crafter.AddMaterialContactFreeWithAutoDetectedKind(ctx, attestationID, "", materialValue, casBackend, annotations, addOpts...)
if err != nil {
return nil, fmt.Errorf("adding material: %w", err)
}
Expand All @@ -132,26 +144,32 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
switch {
// If the material is in the contract, add it from the contract
case crafter.IsMaterialInContract(materialName):
mt, err = crafter.AddMaterialFromContract(ctx, attestationID, materialName, materialValue, casBackend, annotations)
mt, err = crafter.AddMaterialFromContract(ctx, attestationID, materialName, materialValue, casBackend, annotations, addOpts...)
// If the material is not in the contract and the materialType is not provided, add material contract free with auto-detected kind, guessing the kind
case materialType == "":
mt, err = crafter.AddMaterialContactFreeWithAutoDetectedKind(ctx, attestationID, materialName, materialValue, casBackend, annotations)
mt, err = crafter.AddMaterialContactFreeWithAutoDetectedKind(ctx, attestationID, materialName, materialValue, casBackend, annotations, addOpts...)
if err != nil {
return nil, fmt.Errorf("adding material: %w", err)
}
action.Logger.Info().Str("kind", mt.MaterialType.String()).Msg("material kind detected")
// If the material is not in the contract and has a materialType, add material contract free with the provided materialType
default:
mt, err = crafter.AddMaterialContractFree(ctx, attestationID, materialType, materialName, materialValue, casBackend, annotations)
mt, err = crafter.AddMaterialContractFree(ctx, attestationID, materialType, materialName, materialValue, casBackend, annotations, addOpts...)
}
default:
mt, err = crafter.AddMaterialContractFree(ctx, attestationID, materialType, materialName, materialValue, casBackend, annotations)
mt, err = crafter.AddMaterialContractFree(ctx, attestationID, materialType, materialName, materialValue, casBackend, annotations, addOpts...)
}

if err != nil {
return nil, fmt.Errorf("adding material: %w", err)
}

// Record each source file as an EVIDENCE material, cross-linked to the
// evaluated material so the exemption set itself is attested.
if err := action.addPolicyInputEvidence(ctx, crafter, attestationID, mt.GetId(), policyInputFiles, casBackend); err != nil {
return nil, fmt.Errorf("recording policy input evidence: %w", err)
}

materialResult, err := attMaterialToAction(mt)
if err != nil {
return nil, fmt.Errorf("converting material to action: %w", err)
Expand All @@ -160,6 +178,88 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
return materialResult, nil
}

// runtimeInputAddOpts wraps the runtime inputs as crafter add options, or
// returns nil when there are none. Defined at package scope so it can name the
// crafter package type (the Run method shadows it with a local variable).
func runtimeInputAddOpts(runtimeInputs map[string]string) []crafter.AddOpt {
if len(runtimeInputs) == 0 {
return nil
}
return []crafter.AddOpt{crafter.WithRuntimeInputs(runtimeInputs)}
}

// buildRuntimeInputs reads each policy input file and returns a map of policy
// input name to its extracted values, ready to be merged onto contract
// arguments. Values are newline-joined, matching the engine's existing
// multi-value encoding (it splits inputs back on newlines and commas). As with
// contract-declared arguments, individual values must not embed those
// delimiters; path globs, the intended use, never do.
func buildRuntimeInputs(policyInputFiles []*PolicyInputFromFile) (map[string]string, error) {
if len(policyInputFiles) == 0 {
return nil, nil
}

runtimeInputs := make(map[string]string, len(policyInputFiles))
for _, pif := range policyInputFiles {
values, err := ExtractColumnValues(pif.File, pif.Column)
if err != nil {
return nil, fmt.Errorf("extracting %q from %q: %w", pif.Column, pif.File, err)
}
joined := strings.Join(values, "\n")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Newline join in buildRuntimeInputs is lossy for CSV/JSON values containing embedded newlines: the comment says "File paths never contain newlines" but the joined values are column data, not file paths.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/cli/pkg/action/attestation_add.go, line 206:

<comment>Newline join in buildRuntimeInputs is lossy for CSV/JSON values containing embedded newlines: the comment says "File paths never contain newlines" but the joined values are column data, not file paths.</comment>

<file context>
@@ -160,6 +178,60 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
+		if err != nil {
+			return nil, fmt.Errorf("extracting %q from %q: %w", pif.Column, pif.File, err)
+		}
+		joined := strings.Join(values, "\n")
+		if existing := runtimeInputs[pif.Input]; existing != "" {
+			runtimeInputs[pif.Input] = existing + "\n" + joined
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded. The newline join matches the engine's existing multi-value encoding (it splits inputs back on newlines and commas), so individual values must not embed those delimiters — the same constraint as contract with: arguments. Path globs, the intended use, never do. This is inherent to the input mechanism rather than specific to this code path.

🤖 Posted by Maximus bot (Claude Code) on behalf of @migmartri

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it — that boundary makes the original comment too broad here. Since the engine already re-splits multi-value inputs on newlines and commas, this isn’t a buildRuntimeInputs-specific lossiness bug; it’s the same delimiter constraint as contract with: arguments, and path globs fit that shape.

Thanks for the feedback! I've saved this as a new learning to improve future reviews.

if existing := runtimeInputs[pif.Input]; existing != "" {
runtimeInputs[pif.Input] = existing + "\n" + joined
} else {
runtimeInputs[pif.Input] = joined
}
}

return runtimeInputs, nil
}

// addPolicyInputEvidence adds each policy input file as an EVIDENCE material,
// linked back to the evaluated material via the chainloop.material.references
// annotation. The evidence material name is derived as "<material>-<input>";
// when the same input is fed by more than one file, a "-<n>" suffix keeps the
// names unique so no evidence record is silently overwritten.
func (action *AttestationAdd) addPolicyInputEvidence(ctx context.Context, c *crafter.Crafter, attestationID, materialName string, policyInputFiles []*PolicyInputFromFile, casBackend *casclient.CASBackend) error {
names := policyInputEvidenceNames(materialName, policyInputFiles)
for i, pif := range policyInputFiles {
annotations := map[string]string{
materials.AnnotationMaterialReferences: materialName,
}

if _, err := c.AddMaterialContractFree(ctx, attestationID, schemaapi.CraftingSchema_Material_EVIDENCE.String(), names[i], pif.File, casBackend, annotations); err != nil {
return fmt.Errorf("adding evidence material %q: %w", names[i], err)
}
}

return nil
}

// policyInputEvidenceNames returns the evidence material name for each policy
// input file, in order. Names are "<material>-<input>"; when the same input is
// fed by more than one file, a "-<n>" suffix keeps them unique so no evidence
// record is silently overwritten in the attestation.
func policyInputEvidenceNames(materialName string, policyInputFiles []*PolicyInputFromFile) []string {
inputCount := make(map[string]int, len(policyInputFiles))
for _, pif := range policyInputFiles {
inputCount[pif.Input]++
}

names := make([]string, len(policyInputFiles))
seen := make(map[string]int, len(policyInputFiles))
for i, pif := range policyInputFiles {
name := fmt.Sprintf("%s-%s", materialName, pif.Input)
if inputCount[pif.Input] > 1 {
seen[pif.Input]++
name = fmt.Sprintf("%s-%d", name, seen[pif.Input])
}
names[i] = name
}

return names
}

// GetPolicyEvaluations is a Wrapper around the getPolicyEvaluations
func (action *AttestationAdd) GetPolicyEvaluations(ctx context.Context, attestationID string) (map[string][]*PolicyEvaluation, error) {
crafter, err := newCrafter(&newCrafterStateOpts{enableRemoteState: (attestationID != ""), localStatePath: action.localStatePath}, action.CPConnection, action.opts...)
Expand Down
63 changes: 63 additions & 0 deletions app/cli/pkg/action/attestation_add_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// Copyright 2026 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 action

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestPolicyInputEvidenceNames(t *testing.T) {
testCases := []struct {
name string
materialName string
files []*PolicyInputFromFile
want []string
}{
{
name: "single input keeps the plain name",
materialName: "binaries",
files: []*PolicyInputFromFile{{Input: "ignored_paths"}},
want: []string{"binaries-ignored_paths"},
},
{
name: "distinct inputs are not suffixed",
materialName: "binaries",
files: []*PolicyInputFromFile{{Input: "ignored_paths"}, {Input: "paths"}},
want: []string{"binaries-ignored_paths", "binaries-paths"},
},
{
name: "same input fed by multiple files is disambiguated",
materialName: "binaries",
files: []*PolicyInputFromFile{{Input: "ignored_paths"}, {Input: "ignored_paths"}},
want: []string{"binaries-ignored_paths-1", "binaries-ignored_paths-2"},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := policyInputEvidenceNames(tc.materialName, tc.files)
assert.Equal(t, tc.want, got)
})
}
}

func TestBuildRuntimeInputsNil(t *testing.T) {
got, err := buildRuntimeInputs(nil)
assert.NoError(t, err)
assert.Nil(t, got)
}
Loading
Loading