From 525bcd55d8a3125d3e79bb1dd270cc42969376d7 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 09:47:01 +0100 Subject: [PATCH 01/18] feat(cli): auto-collect AI agent configuration during attestation init Add a Collector interface to the crafter package that enables pluggable auto-discovery of evidence during attestation init. Refactor existing PR metadata collection into PRMetadataCollector and introduce a new AIAgentConfigCollector that discovers Claude agent config files (CLAUDE.md, .claude/*, .mcp.json) and attaches them as EVIDENCE material. Ref: PFM-4919 Signed-off-by: Jose Ignacio Paris Signed-off-by: Jose I. Paris --- app/cli/pkg/action/attestation_init.go | 11 +- internal/aiagentconfig/aiagentconfig.go | 74 ++++++++++ internal/aiagentconfig/builder.go | 83 ++++++++++++ internal/aiagentconfig/builder_test.go | 117 ++++++++++++++++ internal/aiagentconfig/discover.go | 66 +++++++++ internal/aiagentconfig/discover_test.go | 127 ++++++++++++++++++ pkg/attestation/crafter/collector.go | 32 +++++ .../crafter/collector_aiagentconfig.go | 90 +++++++++++++ .../crafter/collector_prmetadata.go | 91 +++++++++++++ pkg/attestation/crafter/crafter.go | 96 ++++--------- 10 files changed, 713 insertions(+), 74 deletions(-) create mode 100644 internal/aiagentconfig/aiagentconfig.go create mode 100644 internal/aiagentconfig/builder.go create mode 100644 internal/aiagentconfig/builder_test.go create mode 100644 internal/aiagentconfig/discover.go create mode 100644 internal/aiagentconfig/discover_test.go create mode 100644 pkg/attestation/crafter/collector.go create mode 100644 pkg/attestation/crafter/collector_aiagentconfig.go create mode 100644 pkg/attestation/crafter/collector_prmetadata.go diff --git a/app/cli/pkg/action/attestation_init.go b/app/cli/pkg/action/attestation_init.go index 587821bfa..580554f20 100644 --- a/app/cli/pkg/action/attestation_init.go +++ b/app/cli/pkg/action/attestation_init.go @@ -300,11 +300,12 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun return "", err } - // Auto-collect PR/MR metadata if in PR/MR context - if err := action.c.AutoCollectPRMetadata(ctx, attestationID, discoveredRunner, casBackend); err != nil { - action.Logger.Warn().Err(err).Msg("failed to auto-collect PR/MR metadata") - // Don't fail the init - this is best-effort - } + // Register and run auto-discovery collectors + action.c.RegisterCollectors( + crafter.NewPRMetadataCollector(discoveredRunner), + crafter.NewAIAgentConfigCollector(), + ) + action.c.RunCollectors(ctx, attestationID, casBackend) // Evaluate attestation-level policies at init phase attClient := pb.NewAttestationServiceClient(action.CPConnection) diff --git a/internal/aiagentconfig/aiagentconfig.go b/internal/aiagentconfig/aiagentconfig.go new file mode 100644 index 000000000..1e19241aa --- /dev/null +++ b/internal/aiagentconfig/aiagentconfig.go @@ -0,0 +1,74 @@ +// +// 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 aiagentconfig + +const ( + // EvidenceID is the identifier for the AI agent config evidence + EvidenceID = "CHAINLOOP_AI_AGENT_CONFIG" + // EvidenceSchemaURL is the URL to the JSON schema for AI agent config + EvidenceSchemaURL = "https://schemas.chainloop.dev/aiagentconfig/1.0/ai-agent-config.schema.json" +) + +// Agent identifies the AI agent provider +type Agent struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +// GitContext holds optional git information at capture time +type GitContext struct { + Repository string `json:"repository,omitempty"` + Branch string `json:"branch,omitempty"` + CommitSHA string `json:"commit_sha,omitempty"` +} + +// ConfigFile represents a single discovered configuration file +type ConfigFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Base64Content string `json:"base64_content"` +} + +// Data is the payload inside the evidence envelope +type Data struct { + SchemaVersion string `json:"schema_version"` + Agent Agent `json:"agent"` + ConfigHash string `json:"config_hash"` + CapturedAt string `json:"captured_at"` + GitContext *GitContext `json:"git_context,omitempty"` + ConfigFiles []ConfigFile `json:"config_files"` + // Future fields for richer analysis + Permissions any `json:"permissions,omitempty"` + MCPServers any `json:"mcp_servers,omitempty"` + Subagents any `json:"subagents,omitempty"` +} + +// Evidence is the full envelope matching the custom evidence format +type Evidence struct { + ID string `json:"chainloop.material.evidence.id"` + Schema string `json:"schema"` + Data Data `json:"data"` +} + +// NewEvidence creates a new Evidence instance with the standard envelope +func NewEvidence(data Data) *Evidence { + return &Evidence{ + ID: EvidenceID, + Schema: EvidenceSchemaURL, + Data: data, + } +} diff --git a/internal/aiagentconfig/builder.go b/internal/aiagentconfig/builder.go new file mode 100644 index 000000000..69386b44d --- /dev/null +++ b/internal/aiagentconfig/builder.go @@ -0,0 +1,83 @@ +// +// 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 aiagentconfig + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// BuildEvidence reads discovered files and constructs the evidence payload. +// rootDir is the base directory, filePaths are relative to rootDir. +// gitCtx may be nil if not in a git repository. +func BuildEvidence(rootDir string, filePaths []string, gitCtx *GitContext) (*Evidence, error) { + configFiles := make([]ConfigFile, 0, len(filePaths)) + hashes := make([]string, 0, len(filePaths)) + + for _, relPath := range filePaths { + absPath := filepath.Join(rootDir, relPath) + + content, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", relPath, err) + } + + info, err := os.Stat(absPath) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", relPath, err) + } + + hash := sha256.Sum256(content) + hexHash := hex.EncodeToString(hash[:]) + hashes = append(hashes, hexHash) + + configFiles = append(configFiles, ConfigFile{ + Path: relPath, + SHA256: hexHash, + Size: info.Size(), + Base64Content: base64.StdEncoding.EncodeToString(content), + }) + } + + data := Data{ + SchemaVersion: "v1alpha", + Agent: Agent{Name: "claude"}, + ConfigHash: computeCombinedHash(hashes), + CapturedAt: time.Now().UTC().Format(time.RFC3339), + GitContext: gitCtx, + ConfigFiles: configFiles, + } + + return NewEvidence(data), nil +} + +// computeCombinedHash sorts individual hashes, concatenates them, and hashes the result. +func computeCombinedHash(hashes []string) string { + sorted := make([]string, len(hashes)) + copy(sorted, hashes) + sort.Strings(sorted) + + combined := sha256.Sum256([]byte(strings.Join(sorted, ""))) + + return hex.EncodeToString(combined[:]) +} diff --git a/internal/aiagentconfig/builder_test.go b/internal/aiagentconfig/builder_test.go new file mode 100644 index 000000000..8491bf8db --- /dev/null +++ b/internal/aiagentconfig/builder_test.go @@ -0,0 +1,117 @@ +// +// 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 aiagentconfig + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildEvidence(t *testing.T) { + rootDir := t.TempDir() + + // Create test files + file1Content := []byte("# Project Rules\nAlways use Go.") + file2Content := []byte(`{"allow": ["read"]}`) + + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), file1Content, 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(rootDir, ".claude"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(rootDir, ".claude", "settings.json"), file2Content, 0o600)) + + gitCtx := &GitContext{ + Repository: "https://github.com/org/repo", + CommitSHA: "abc123", + } + + evidence, err := BuildEvidence(rootDir, []string{"CLAUDE.md", ".claude/settings.json"}, gitCtx) + require.NoError(t, err) + + // Verify envelope + assert.Equal(t, EvidenceID, evidence.ID) + assert.Equal(t, EvidenceSchemaURL, evidence.Schema) + + // Verify data + assert.Equal(t, "v1alpha", evidence.Data.SchemaVersion) + assert.Equal(t, "claude", evidence.Data.Agent.Name) + assert.NotEmpty(t, evidence.Data.CapturedAt) + assert.NotEmpty(t, evidence.Data.ConfigHash) + + // Verify git context + require.NotNil(t, evidence.Data.GitContext) + assert.Equal(t, "https://github.com/org/repo", evidence.Data.GitContext.Repository) + assert.Equal(t, "abc123", evidence.Data.GitContext.CommitSHA) + + // Verify config files + require.Len(t, evidence.Data.ConfigFiles, 2) + + cf1 := evidence.Data.ConfigFiles[0] + assert.Equal(t, "CLAUDE.md", cf1.Path) + assert.Equal(t, int64(len(file1Content)), cf1.Size) + hash1 := sha256.Sum256(file1Content) + assert.Equal(t, hex.EncodeToString(hash1[:]), cf1.SHA256) + assert.Equal(t, base64.StdEncoding.EncodeToString(file1Content), cf1.Base64Content) + + cf2 := evidence.Data.ConfigFiles[1] + assert.Equal(t, ".claude/settings.json", cf2.Path) + hash2 := sha256.Sum256(file2Content) + assert.Equal(t, hex.EncodeToString(hash2[:]), cf2.SHA256) + + // Verify config hash is deterministic + hashes := []string{hex.EncodeToString(hash1[:]), hex.EncodeToString(hash2[:])} + sort.Strings(hashes) + combined := sha256.Sum256([]byte(strings.Join(hashes, ""))) + assert.Equal(t, hex.EncodeToString(combined[:]), evidence.Data.ConfigHash) +} + +func TestBuildEvidenceWithoutGitContext(t *testing.T) { + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600)) + + evidence, err := BuildEvidence(rootDir, []string{"CLAUDE.md"}, nil) + require.NoError(t, err) + + assert.Nil(t, evidence.Data.GitContext) + assert.Len(t, evidence.Data.ConfigFiles, 1) +} + +func TestBuildEvidenceJSONFormat(t *testing.T) { + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600)) + + evidence, err := BuildEvidence(rootDir, []string{"CLAUDE.md"}, nil) + require.NoError(t, err) + + // Verify it marshals to valid JSON with the correct envelope field + jsonData, err := json.Marshal(evidence) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, json.Unmarshal(jsonData, &raw)) + + assert.Equal(t, EvidenceID, raw["chainloop.material.evidence.id"]) + assert.Equal(t, EvidenceSchemaURL, raw["schema"]) + assert.NotNil(t, raw["data"]) +} diff --git a/internal/aiagentconfig/discover.go b/internal/aiagentconfig/discover.go new file mode 100644 index 000000000..80b6fd621 --- /dev/null +++ b/internal/aiagentconfig/discover.go @@ -0,0 +1,66 @@ +// +// 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 aiagentconfig + +import ( + "path/filepath" + "sort" +) + +// claudePatterns are glob patterns for Claude agent configuration files, +// evaluated relative to a root directory. +var claudePatterns = []string{ + "CLAUDE.md", + ".claude/CLAUDE.md", + ".claude/settings.json", + ".mcp.json", + ".claude/rules/*.md", + ".claude/agents/*.md", + ".claude/commands/*.md", + ".claude/skills/*/SKILL.md", +} + +// Discover searches rootDir for AI agent configuration files. +// It only looks in rootDir and its subdirectories, never in parent directories. +// Returns deduplicated relative paths sorted alphabetically. +func Discover(rootDir string) ([]string, error) { + seen := make(map[string]struct{}) + var results []string + + for _, pattern := range claudePatterns { + absPattern := filepath.Join(rootDir, pattern) + matches, err := filepath.Glob(absPattern) + if err != nil { + return nil, err + } + + for _, match := range matches { + rel, err := filepath.Rel(rootDir, match) + if err != nil { + return nil, err + } + + if _, ok := seen[rel]; !ok { + seen[rel] = struct{}{} + results = append(results, rel) + } + } + } + + sort.Strings(results) + + return results, nil +} diff --git a/internal/aiagentconfig/discover_test.go b/internal/aiagentconfig/discover_test.go new file mode 100644 index 000000000..1c02198e4 --- /dev/null +++ b/internal/aiagentconfig/discover_test.go @@ -0,0 +1,127 @@ +// +// 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 aiagentconfig + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscover(t *testing.T) { + tests := []struct { + name string + files []string + expected []string + }{ + { + name: "no config files", + files: []string{"main.go", "README.md"}, + expected: nil, + }, + { + name: "top-level CLAUDE.md only", + files: []string{"CLAUDE.md"}, + expected: []string{"CLAUDE.md"}, + }, + { + name: "all claude patterns", + files: []string{ + "CLAUDE.md", + ".claude/CLAUDE.md", + ".claude/settings.json", + ".mcp.json", + ".claude/rules/coding.md", + ".claude/rules/testing.md", + ".claude/agents/reviewer.md", + ".claude/commands/deploy.md", + ".claude/skills/search/SKILL.md", + }, + expected: []string{ + ".claude/CLAUDE.md", + ".claude/agents/reviewer.md", + ".claude/commands/deploy.md", + ".claude/rules/coding.md", + ".claude/rules/testing.md", + ".claude/settings.json", + ".claude/skills/search/SKILL.md", + ".mcp.json", + "CLAUDE.md", + }, + }, + { + name: "non-matching files are ignored", + files: []string{ + "CLAUDE.md", + ".claude/rules/coding.md", + ".claude/rules/coding.txt", // wrong extension for rules pattern + ".claude/other/something.md", // not in a known pattern + "some/nested/CLAUDE.md", // nested too deep + }, + expected: []string{ + ".claude/rules/coding.md", + "CLAUDE.md", + }, + }, + { + name: "results are sorted and deduplicated", + files: []string{ + ".mcp.json", + "CLAUDE.md", + ".claude/settings.json", + }, + expected: []string{ + ".claude/settings.json", + ".mcp.json", + "CLAUDE.md", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootDir := t.TempDir() + + for _, f := range tt.files { + absPath := filepath.Join(rootDir, f) + require.NoError(t, os.MkdirAll(filepath.Dir(absPath), 0o755)) + require.NoError(t, os.WriteFile(absPath, []byte("test content"), 0o600)) + } + + results, err := Discover(rootDir) + require.NoError(t, err) + assert.Equal(t, tt.expected, results) + }) + } +} + +func TestDiscoverNeverTraversesParent(t *testing.T) { + parentDir := t.TempDir() + + // Create a CLAUDE.md in the parent + require.NoError(t, os.WriteFile(filepath.Join(parentDir, "CLAUDE.md"), []byte("parent"), 0o600)) + + // Create a subdirectory to search from + childDir := filepath.Join(parentDir, "subproject") + require.NoError(t, os.MkdirAll(childDir, 0o755)) + + results, err := Discover(childDir) + require.NoError(t, err) + assert.Empty(t, results, "should not find files in parent directory") +} diff --git a/pkg/attestation/crafter/collector.go b/pkg/attestation/crafter/collector.go new file mode 100644 index 000000000..7945e5502 --- /dev/null +++ b/pkg/attestation/crafter/collector.go @@ -0,0 +1,32 @@ +// +// 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 crafter + +import ( + "context" + + "github.com/chainloop-dev/chainloop/pkg/casclient" +) + +// Collector auto-discovers and attaches evidence during attestation init. +// Each collector runs best-effort: failures are logged but never fail the attestation. +type Collector interface { + // ID returns a unique identifier for this collector (used in logs). + ID() string + // Collect discovers data and adds materials to the attestation. + // Returning nil means nothing was collected (no-op is expected). + Collect(ctx context.Context, crafter *Crafter, attestationID string, casBackend *casclient.CASBackend) error +} diff --git a/pkg/attestation/crafter/collector_aiagentconfig.go b/pkg/attestation/crafter/collector_aiagentconfig.go new file mode 100644 index 000000000..8644f6ac5 --- /dev/null +++ b/pkg/attestation/crafter/collector_aiagentconfig.go @@ -0,0 +1,90 @@ +// +// 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 crafter + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/chainloop-dev/chainloop/internal/aiagentconfig" + "github.com/chainloop-dev/chainloop/pkg/casclient" +) + +// AIAgentConfigCollector discovers AI agent configuration files and attaches them as evidence. +type AIAgentConfigCollector struct{} + +// NewAIAgentConfigCollector creates a new AI agent config collector. +func NewAIAgentConfigCollector() *AIAgentConfigCollector { + return &AIAgentConfigCollector{} +} + +func (c *AIAgentConfigCollector) ID() string { return "ai-agent-config" } + +func (c *AIAgentConfigCollector) Collect(ctx context.Context, cr *Crafter, attestationID string, casBackend *casclient.CASBackend) error { + files, err := aiagentconfig.Discover(cr.WorkingDir()) + if err != nil { + return fmt.Errorf("discovering AI agent config files: %w", err) + } + + if len(files) == 0 { + cr.Logger.Debug().Msg("no AI agent config files found, skipping") + return nil + } + + cr.Logger.Info().Int("files", len(files)).Msg("discovered AI agent config files") + + var gitCtx *aiagentconfig.GitContext + if head := cr.CraftingState.GetAttestation().GetHead(); head != nil { + gitCtx = &aiagentconfig.GitContext{ + CommitSHA: head.GetHash(), + } + if remotes := head.GetRemotes(); len(remotes) > 0 { + gitCtx.Repository = remotes[0].GetUrl() + } + } + + evidence, err := aiagentconfig.BuildEvidence(cr.WorkingDir(), files, gitCtx) + if err != nil { + return fmt.Errorf("building AI agent config evidence: %w", err) + } + + jsonData, err := json.Marshal(evidence) + if err != nil { + return fmt.Errorf("marshaling AI agent config: %w", err) + } + + tmpFile, err := os.CreateTemp("", "ai-agent-config-*.json") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(jsonData); err != nil { + tmpFile.Close() + return fmt.Errorf("writing temp file: %w", err) + } + tmpFile.Close() + + if _, err := cr.AddMaterialContractFree(ctx, attestationID, "EVIDENCE", "ai-agent-config-claude", tmpFile.Name(), casBackend, nil); err != nil { + return fmt.Errorf("adding AI agent config material: %w", err) + } + + cr.Logger.Info().Msg("successfully collected AI agent configuration evidence") + + return nil +} diff --git a/pkg/attestation/crafter/collector_prmetadata.go b/pkg/attestation/crafter/collector_prmetadata.go new file mode 100644 index 000000000..bbded3471 --- /dev/null +++ b/pkg/attestation/crafter/collector_prmetadata.go @@ -0,0 +1,91 @@ +// +// 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 crafter + +import ( + "context" + "encoding/json" + "fmt" + "os" + + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/internal/prinfo" + "github.com/chainloop-dev/chainloop/pkg/casclient" +) + +// PRMetadataCollector collects pull/merge request metadata from the CI environment. +type PRMetadataCollector struct { + runner SupportedRunner +} + +// NewPRMetadataCollector creates a collector that detects PR/MR context from the given runner. +func NewPRMetadataCollector(runner SupportedRunner) *PRMetadataCollector { + return &PRMetadataCollector{runner: runner} +} + +func (c *PRMetadataCollector) ID() string { return "pr-metadata" } + +func (c *PRMetadataCollector) Collect(ctx context.Context, cr *Crafter, attestationID string, casBackend *casclient.CASBackend) error { + isPR, metadata, err := DetectPRContext(c.runner) + if err != nil { + return fmt.Errorf("detecting PR/MR context: %w", err) + } + + if !isPR { + cr.Logger.Debug().Msg("not in PR/MR context, skipping metadata collection") + return nil + } + + cr.Logger.Info().Str("platform", metadata.Platform).Str("number", metadata.Number).Msg("detected PR/MR context") + + evidenceData := prinfo.NewEvidence(prinfo.Data{ + Platform: metadata.Platform, + Type: metadata.Type, + Number: metadata.Number, + Title: metadata.Title, + Description: metadata.Description, + SourceBranch: metadata.SourceBranch, + TargetBranch: metadata.TargetBranch, + URL: metadata.URL, + Author: metadata.Author, + }) + + jsonData, err := json.Marshal(evidenceData) + if err != nil { + return fmt.Errorf("marshaling PR/MR metadata: %w", err) + } + + materialName := fmt.Sprintf("pr-metadata-%s", metadata.Number) + tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s.json", materialName)) + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(jsonData); err != nil { + tmpFile.Close() + return fmt.Errorf("writing temp file: %w", err) + } + tmpFile.Close() + + if _, err := cr.AddMaterialContractFree(ctx, attestationID, schemaapi.CraftingSchema_Material_CHAINLOOP_PR_INFO.String(), materialName, tmpFile.Name(), casBackend, nil); err != nil { + return fmt.Errorf("adding PR/MR metadata material: %w", err) + } + + cr.Logger.Info().Msg("successfully collected and attested PR/MR metadata") + + return nil +} diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 5918e0648..99e62dc61 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -17,7 +17,6 @@ package crafter import ( "context" - "encoding/json" "errors" "fmt" "net/url" @@ -31,7 +30,6 @@ import ( v1 "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/internal/ociauth" - "github.com/chainloop-dev/chainloop/internal/prinfo" 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/attestation/crafter/runners/commitverification" @@ -76,6 +74,9 @@ type Crafter struct { // noStrictValidation skips strict schema validation noStrictValidation bool + + // collectors are auto-discovery collectors that run during attestation init + collectors []Collector } type VersionedCraftingState struct { @@ -128,6 +129,30 @@ func WithNoStrictValidation(noStrictValidation bool) NewOpt { } } +// WorkingDir returns the working directory used for file discovery. +func (c *Crafter) WorkingDir() string { + return c.workingDir +} + +// RegisterCollectors appends collectors to be run during attestation init. +func (c *Crafter) RegisterCollectors(collectors ...Collector) { + c.collectors = append(c.collectors, collectors...) +} + +// RunCollectors executes all registered collectors best-effort. +// Failures are logged but never propagated. +func (c *Crafter) RunCollectors(ctx context.Context, attestationID string, casBackend *casclient.CASBackend) { + if err := c.LoadCraftingState(ctx, attestationID); err != nil { + c.Logger.Warn().Err(err).Msg("failed to reload crafting state before running collectors") + } + + for _, collector := range c.collectors { + if err := collector.Collect(ctx, c, attestationID, casBackend); err != nil { + c.Logger.Warn().Err(err).Str("collector", collector.ID()).Msg("collector failed") + } + } +} + // Create a completely new crafter func NewCrafter(stateManager StateManager, attClient v1.AttestationServiceClient, opts ...NewOpt) (*Crafter, error) { noopLogger := zerolog.Nop() @@ -501,73 +526,6 @@ func (c *Crafter) ResolveEnvVars(ctx context.Context, attestationID string) erro return nil } -// AutoCollectPRMetadata automatically collects PR/MR metadata if running in a PR/MR context -func (c *Crafter) AutoCollectPRMetadata(ctx context.Context, attestationID string, runner SupportedRunner, casBackend *casclient.CASBackend) error { - if err := c.requireStateLoaded(); err != nil { - return fmt.Errorf("crafting state not loaded before inspecting PR/MR metadata: %w", err) - } - - if err := c.LoadCraftingState(ctx, attestationID); err != nil { - c.Logger.Warn().Err(err).Msg("failed to reload crafting state") - } - - // Detect if we're in a PR/MR context - isPR, metadata, err := DetectPRContext(runner) - if err != nil { - return fmt.Errorf("failed to detect PR/MR context: %w", err) - } - - // If not in PR/MR context, nothing to do - if !isPR { - c.Logger.Debug().Msg("not in PR/MR context, skipping metadata collection") - return nil - } - - c.Logger.Info().Str("platform", metadata.Platform).Str("number", metadata.Number).Msg("detected PR/MR context") - - // Create the material - evidenceData := prinfo.NewEvidence(prinfo.Data{ - Platform: metadata.Platform, - Type: metadata.Type, - Number: metadata.Number, - Title: metadata.Title, - Description: metadata.Description, - SourceBranch: metadata.SourceBranch, - TargetBranch: metadata.TargetBranch, - URL: metadata.URL, - Author: metadata.Author, - }) - - // Marshal to JSON - jsonData, err := json.Marshal(evidenceData) - if err != nil { - return fmt.Errorf("failed to marshal PR/MR metadata: %w", err) - } - - // Create a temporary file for the metadata - materialName := fmt.Sprintf("pr-metadata-%s", metadata.Number) - tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s.json", materialName)) - if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) - } - defer os.Remove(tmpFile.Name()) - - // Write the JSON data to the temp file - if _, err := tmpFile.Write(jsonData); err != nil { - tmpFile.Close() - return fmt.Errorf("failed to write metadata to temp file: %w", err) - } - tmpFile.Close() - - // Add the material using the crafter with explicit CHAINLOOP_PR_INFO type - if _, err := c.AddMaterialContractFree(ctx, attestationID, schemaapi.CraftingSchema_Material_CHAINLOOP_PR_INFO.String(), materialName, tmpFile.Name(), casBackend, nil); err != nil { - return fmt.Errorf("failed to add PR/MR metadata material: %w", err) - } - - c.Logger.Info().Msg("successfully collected and attested PR/MR metadata") - return nil -} - func (c *Crafter) resolveRunnerInfo() { c.CraftingState.Attestation.RunnerEnvironment = &api.RunnerEnvironment{ Environment: c.Runner.Environment().String(), From a3fa7c68455c3aa7af2d7385926590e98fd5a64e Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 10:32:06 +0100 Subject: [PATCH 02/18] fix: address PR review feedback for AI agent config collector - Reject symlinks before reading config files to prevent arbitrary file upload - Return early in RunCollectors when crafting state reload fails - Include file path in config hash to detect renames/moves - Fix temp file pattern to preserve .json suffix Signed-off-by: Jose Ignacio Paris Signed-off-by: Jose I. Paris --- internal/aiagentconfig/builder.go | 14 ++++++++----- internal/aiagentconfig/builder_test.go | 21 +++++++++++++++++-- .../crafter/collector_prmetadata.go | 2 +- pkg/attestation/crafter/crafter.go | 1 + 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/internal/aiagentconfig/builder.go b/internal/aiagentconfig/builder.go index 69386b44d..f0862ae87 100644 --- a/internal/aiagentconfig/builder.go +++ b/internal/aiagentconfig/builder.go @@ -37,19 +37,23 @@ func BuildEvidence(rootDir string, filePaths []string, gitCtx *GitContext) (*Evi for _, relPath := range filePaths { absPath := filepath.Join(rootDir, relPath) - content, err := os.ReadFile(absPath) + // Reject symlinks to prevent uploading arbitrary files outside the repo + info, err := os.Lstat(absPath) if err != nil { - return nil, fmt.Errorf("reading %s: %w", relPath, err) + return nil, fmt.Errorf("stat %s: %w", relPath, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("reading %s: symlinks are not supported", relPath) } - info, err := os.Stat(absPath) + content, err := os.ReadFile(absPath) if err != nil { - return nil, fmt.Errorf("stat %s: %w", relPath, err) + return nil, fmt.Errorf("reading %s: %w", relPath, err) } hash := sha256.Sum256(content) hexHash := hex.EncodeToString(hash[:]) - hashes = append(hashes, hexHash) + hashes = append(hashes, fmt.Sprintf("%s:%s", relPath, hexHash)) configFiles = append(configFiles, ConfigFile{ Path: relPath, diff --git a/internal/aiagentconfig/builder_test.go b/internal/aiagentconfig/builder_test.go index 8491bf8db..b271cf890 100644 --- a/internal/aiagentconfig/builder_test.go +++ b/internal/aiagentconfig/builder_test.go @@ -20,6 +20,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "fmt" "os" "path/filepath" "sort" @@ -79,8 +80,11 @@ func TestBuildEvidence(t *testing.T) { hash2 := sha256.Sum256(file2Content) assert.Equal(t, hex.EncodeToString(hash2[:]), cf2.SHA256) - // Verify config hash is deterministic - hashes := []string{hex.EncodeToString(hash1[:]), hex.EncodeToString(hash2[:])} + // Verify config hash is deterministic (includes path:hash for rename detection) + hashes := []string{ + fmt.Sprintf("CLAUDE.md:%s", hex.EncodeToString(hash1[:])), + fmt.Sprintf(".claude/settings.json:%s", hex.EncodeToString(hash2[:])), + } sort.Strings(hashes) combined := sha256.Sum256([]byte(strings.Join(hashes, ""))) assert.Equal(t, hex.EncodeToString(combined[:]), evidence.Data.ConfigHash) @@ -115,3 +119,16 @@ func TestBuildEvidenceJSONFormat(t *testing.T) { assert.Equal(t, EvidenceSchemaURL, raw["schema"]) assert.NotNil(t, raw["data"]) } + +func TestBuildEvidenceRejectsSymlinks(t *testing.T) { + rootDir := t.TempDir() + + // Create a real file and a symlink to it + realFile := filepath.Join(rootDir, "real.txt") + require.NoError(t, os.WriteFile(realFile, []byte("secret"), 0o600)) + require.NoError(t, os.Symlink(realFile, filepath.Join(rootDir, "CLAUDE.md"))) + + _, err := BuildEvidence(rootDir, []string{"CLAUDE.md"}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "symlinks are not supported") +} diff --git a/pkg/attestation/crafter/collector_prmetadata.go b/pkg/attestation/crafter/collector_prmetadata.go index bbded3471..f683a2116 100644 --- a/pkg/attestation/crafter/collector_prmetadata.go +++ b/pkg/attestation/crafter/collector_prmetadata.go @@ -69,7 +69,7 @@ func (c *PRMetadataCollector) Collect(ctx context.Context, cr *Crafter, attestat } materialName := fmt.Sprintf("pr-metadata-%s", metadata.Number) - tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s.json", materialName)) + tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-*.json", materialName)) if err != nil { return fmt.Errorf("creating temp file: %w", err) } diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 99e62dc61..d4c9080ea 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -144,6 +144,7 @@ func (c *Crafter) RegisterCollectors(collectors ...Collector) { func (c *Crafter) RunCollectors(ctx context.Context, attestationID string, casBackend *casclient.CASBackend) { if err := c.LoadCraftingState(ctx, attestationID); err != nil { c.Logger.Warn().Err(err).Msg("failed to reload crafting state before running collectors") + return } for _, collector := range c.collectors { From 9bfdaa6104dddc1636e279a275cabaa056271f65 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 11:02:25 +0100 Subject: [PATCH 03/18] fix: validate resolved paths stay within root dir to block symlinked directories The previous symlink check only caught leaf file symlinks. A symlinked parent directory (e.g., .claude/ -> /outside/dir) could still allow reading arbitrary files. Now uses filepath.EvalSymlinks to resolve the full path and verifies it remains under rootDir. Signed-off-by: Jose Ignacio Paris Signed-off-by: Jose I. Paris --- internal/aiagentconfig/builder.go | 38 ++++++++++++++++++++++---- internal/aiagentconfig/builder_test.go | 38 ++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/internal/aiagentconfig/builder.go b/internal/aiagentconfig/builder.go index f0862ae87..956577f2c 100644 --- a/internal/aiagentconfig/builder.go +++ b/internal/aiagentconfig/builder.go @@ -31,26 +31,39 @@ import ( // rootDir is the base directory, filePaths are relative to rootDir. // gitCtx may be nil if not in a git repository. func BuildEvidence(rootDir string, filePaths []string, gitCtx *GitContext) (*Evidence, error) { + // Resolve rootDir to its real path so symlink comparisons are reliable + realRoot, err := filepath.EvalSymlinks(rootDir) + if err != nil { + return nil, fmt.Errorf("resolving root dir: %w", err) + } + configFiles := make([]ConfigFile, 0, len(filePaths)) hashes := make([]string, 0, len(filePaths)) for _, relPath := range filePaths { absPath := filepath.Join(rootDir, relPath) - // Reject symlinks to prevent uploading arbitrary files outside the repo - info, err := os.Lstat(absPath) + // Resolve the full path through any symlinks (covers both symlinked + // files and symlinked parent directories like .claude/) and verify + // the resolved path stays within rootDir. + realPath, err := filepath.EvalSymlinks(absPath) if err != nil { - return nil, fmt.Errorf("stat %s: %w", relPath, err) + return nil, fmt.Errorf("resolving %s: %w", relPath, err) } - if info.Mode()&os.ModeSymlink != 0 { - return nil, fmt.Errorf("reading %s: symlinks are not supported", relPath) + if err := ensureInsideDir(realPath, realRoot); err != nil { + return nil, fmt.Errorf("reading %s: %w", relPath, err) } - content, err := os.ReadFile(absPath) + content, err := os.ReadFile(realPath) if err != nil { return nil, fmt.Errorf("reading %s: %w", relPath, err) } + info, err := os.Stat(realPath) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", relPath, err) + } + hash := sha256.Sum256(content) hexHash := hex.EncodeToString(hash[:]) hashes = append(hashes, fmt.Sprintf("%s:%s", relPath, hexHash)) @@ -85,3 +98,16 @@ func computeCombinedHash(hashes []string) string { return hex.EncodeToString(combined[:]) } + +// ensureInsideDir verifies that filePath is inside dir. Both paths must be +// already resolved (no symlinks). Returns an error if the file escapes. +func ensureInsideDir(filePath, dir string) error { + cleanDir := filepath.Clean(dir) + string(filepath.Separator) + cleanFile := filepath.Clean(filePath) + + if !strings.HasPrefix(cleanFile, cleanDir) { + return fmt.Errorf("path escapes root directory via symlink") + } + + return nil +} diff --git a/internal/aiagentconfig/builder_test.go b/internal/aiagentconfig/builder_test.go index b271cf890..1dabccebd 100644 --- a/internal/aiagentconfig/builder_test.go +++ b/internal/aiagentconfig/builder_test.go @@ -120,15 +120,41 @@ func TestBuildEvidenceJSONFormat(t *testing.T) { assert.NotNil(t, raw["data"]) } -func TestBuildEvidenceRejectsSymlinks(t *testing.T) { +func TestBuildEvidenceRejectsSymlinksEscapingRoot(t *testing.T) { rootDir := t.TempDir() + outsideDir := t.TempDir() - // Create a real file and a symlink to it - realFile := filepath.Join(rootDir, "real.txt") - require.NoError(t, os.WriteFile(realFile, []byte("secret"), 0o600)) - require.NoError(t, os.Symlink(realFile, filepath.Join(rootDir, "CLAUDE.md"))) + // Create a file outside rootDir and a symlink pointing to it + require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0o600)) + require.NoError(t, os.Symlink(filepath.Join(outsideDir, "secret.txt"), filepath.Join(rootDir, "CLAUDE.md"))) _, err := BuildEvidence(rootDir, []string{"CLAUDE.md"}, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "symlinks are not supported") + assert.Contains(t, err.Error(), "path escapes root directory via symlink") +} + +func TestBuildEvidenceRejectsSymlinkedParentDir(t *testing.T) { + rootDir := t.TempDir() + outsideDir := t.TempDir() + + // Create a .claude directory outside rootDir with a config file + outsideClaude := filepath.Join(outsideDir, "claude-data") + require.NoError(t, os.MkdirAll(outsideClaude, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(outsideClaude, "settings.json"), []byte(`{"secret": true}`), 0o600)) + + // Symlink .claude -> outside directory + require.NoError(t, os.Symlink(outsideClaude, filepath.Join(rootDir, ".claude"))) + + _, err := BuildEvidence(rootDir, []string{".claude/settings.json"}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "path escapes root directory via symlink") +} + +func TestBuildEvidenceAllowsRegularFilesInRoot(t *testing.T) { + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600)) + + evidence, err := BuildEvidence(rootDir, []string{"CLAUDE.md"}, nil) + require.NoError(t, err) + assert.Len(t, evidence.Data.ConfigFiles, 1) } From 23d835c2e95b03a28274128bc9961006091ed64c Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 11:56:10 +0100 Subject: [PATCH 04/18] feat(cli): add dedicated CHAINLOOP_AI_AGENT_CONFIG material type with JSON schema validation Replace the generic EVIDENCE material type with a dedicated CHAINLOOP_AI_AGENT_CONFIG type for AI agent configuration data. This adds JSON schema validation when users manually add materials via `chainloop attestation add`, matching the CHAINLOOP_PR_INFO pattern. Signed-off-by: Jose I. Paris --- .../workflowcontract/v1/crafting_schema.ts | 7 ++ ...on.v1.Attestation.Material.jsonschema.json | 6 +- ...tation.v1.Attestation.Material.schema.json | 6 +- ...tation.v1.PolicyEvaluation.jsonschema.json | 3 +- ...ttestation.v1.PolicyEvaluation.schema.json | 3 +- ...v1.CraftingSchema.Material.jsonschema.json | 3 +- ...act.v1.CraftingSchema.Material.schema.json | 3 +- ...ct.v1.PolicyGroup.Material.jsonschema.json | 3 +- ...ntract.v1.PolicyGroup.Material.schema.json | 3 +- ...flowcontract.v1.PolicySpec.jsonschema.json | 3 +- ...workflowcontract.v1.PolicySpec.schema.json | 3 +- ...owcontract.v1.PolicySpecV2.jsonschema.json | 3 +- ...rkflowcontract.v1.PolicySpecV2.schema.json | 3 +- .../workflowcontract/v1/crafting_schema.pb.go | 13 ++- .../workflowcontract/v1/crafting_schema.proto | 2 + .../v1/crafting_schema_validations.go | 3 +- .../ai-agent-config-1.0.schema.json | 103 ++++++++++++++++++ internal/schemavalidators/schemavalidators.go | 62 ++++++++--- .../crafter/collector_aiagentconfig.go | 3 +- .../materials/chainloop_ai_agent_config.go | 84 ++++++++++++++ .../crafter/materials/materials.go | 4 +- 21 files changed, 289 insertions(+), 34 deletions(-) create mode 100644 internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-1.0.schema.json create mode 100644 pkg/attestation/crafter/materials/chainloop_ai_agent_config.go diff --git a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts index 6c6eb8aab..eb9266e35 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -253,6 +253,8 @@ export enum CraftingSchema_Material_MaterialType { CHAINLOOP_PR_INFO = 26, /** GITLEAKS_JSON - Gitleaks json report https://github.com/gitleaks/gitleaks/blob/master/README.md#reporting */ GITLEAKS_JSON = 27, + /** CHAINLOOP_AI_AGENT_CONFIG - AI agent configuration collected automatically during attestation */ + CHAINLOOP_AI_AGENT_CONFIG = 28, UNRECOGNIZED = -1, } @@ -342,6 +344,9 @@ export function craftingSchema_Material_MaterialTypeFromJSON(object: any): Craft case 27: case "GITLEAKS_JSON": return CraftingSchema_Material_MaterialType.GITLEAKS_JSON; + case 28: + case "CHAINLOOP_AI_AGENT_CONFIG": + return CraftingSchema_Material_MaterialType.CHAINLOOP_AI_AGENT_CONFIG; case -1: case "UNRECOGNIZED": default: @@ -407,6 +412,8 @@ export function craftingSchema_Material_MaterialTypeToJSON(object: CraftingSchem return "CHAINLOOP_PR_INFO"; case CraftingSchema_Material_MaterialType.GITLEAKS_JSON: return "GITLEAKS_JSON"; + case CraftingSchema_Material_MaterialType.CHAINLOOP_AI_AGENT_CONFIG: + return "CHAINLOOP_AI_AGENT_CONFIG"; case CraftingSchema_Material_MaterialType.UNRECOGNIZED: default: return "UNRECOGNIZED"; diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.jsonschema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.jsonschema.json index 803edb704..b90174e57 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.jsonschema.json @@ -44,7 +44,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" @@ -124,7 +125,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.schema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.schema.json index 104655bc2..b8d0018ff 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.schema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.schema.json @@ -44,7 +44,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" @@ -124,7 +125,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json index 8ec851969..9bbd3e3db 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json @@ -141,7 +141,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json index 0f83e0878..f1ae2bc9d 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json @@ -141,7 +141,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.jsonschema.json index e3983734e..08b6a62bf 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.jsonschema.json @@ -61,7 +61,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.schema.json index 247a7e924..67f66d436 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.schema.json @@ -61,7 +61,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json index 79f1c285c..438d0f353 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json @@ -49,7 +49,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json index 86e13ccef..47fff7fed 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json @@ -49,7 +49,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.jsonschema.json index 167026049..8b4c4b97f 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.jsonschema.json @@ -63,7 +63,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.schema.json index 005ba560c..17484e2d3 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.schema.json @@ -63,7 +63,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json index 7749248d9..3d2db99b8 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json @@ -84,7 +84,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json index f1d5f5fa2..3f8383f01 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json @@ -84,7 +84,8 @@ "SLSA_PROVENANCE", "CHAINLOOP_RUNNER_CONTEXT", "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON" + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index 06cfb540b..ccadb1565 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -206,6 +206,8 @@ const ( CraftingSchema_Material_CHAINLOOP_PR_INFO CraftingSchema_Material_MaterialType = 26 // Gitleaks json report https://github.com/gitleaks/gitleaks/blob/master/README.md#reporting CraftingSchema_Material_GITLEAKS_JSON CraftingSchema_Material_MaterialType = 27 + // AI agent configuration collected automatically during attestation + CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG CraftingSchema_Material_MaterialType = 28 ) // Enum value maps for CraftingSchema_Material_MaterialType. @@ -239,6 +241,7 @@ var ( 25: "CHAINLOOP_RUNNER_CONTEXT", 26: "CHAINLOOP_PR_INFO", 27: "GITLEAKS_JSON", + 28: "CHAINLOOP_AI_AGENT_CONFIG", } CraftingSchema_Material_MaterialType_value = map[string]int32{ "MATERIAL_TYPE_UNSPECIFIED": 0, @@ -269,6 +272,7 @@ var ( "CHAINLOOP_RUNNER_CONTEXT": 25, "CHAINLOOP_PR_INFO": 26, "GITLEAKS_JSON": 27, + "CHAINLOOP_AI_AGENT_CONFIG": 28, } ) @@ -1903,7 +1907,7 @@ var File_workflowcontract_v1_crafting_schema_proto protoreflect.FileDescriptor const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\n" + - ")workflowcontract/v1/crafting_schema.proto\x12\x13workflowcontract.v1\x1a\x1bbuf/validate/validate.proto\"\xaa\x0e\n" + + ")workflowcontract/v1/crafting_schema.proto\x12\x13workflowcontract.v1\x1a\x1bbuf/validate/validate.proto\"\xc9\x0e\n" + "\x0eCraftingSchema\x122\n" + "\x0eschema_version\x18\x01 \x01(\tB\v\xbaH\x06r\x04\n" + "\x02v1\x18\x01R\rschemaVersion\x12N\n" + @@ -1925,7 +1929,7 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x0eCIRCLECI_BUILD\x10\x05\x12\x13\n" + "\x0fDAGGER_PIPELINE\x10\x06\x12\x15\n" + "\x11TEAMCITY_PIPELINE\x10\a\x12\x13\n" + - "\x0fTEKTON_PIPELINE\x10\b:\x02\x18\x01\x1a\x8c\b\n" + + "\x0fTEKTON_PIPELINE\x10\b:\x02\x18\x01\x1a\xab\b\n" + "\bMaterial\x12[\n" + "\x04type\x18\x01 \x01(\x0e29.workflowcontract.v1.CraftingSchema.Material.MaterialTypeB\f\xbaH\a\x82\x01\x04\x10\x01 \x00\x18\x01R\x04type\x12\x99\x01\n" + "\x04name\x18\x02 \x01(\tB\x84\x01\xbaH\x7f\xba\x01|\n" + @@ -1934,7 +1938,7 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x06output\x18\x04 \x01(\bB\x02\x18\x01R\x06output\x12E\n" + "\vannotations\x18\x05 \x03(\v2\x1f.workflowcontract.v1.AnnotationB\x02\x18\x01R\vannotations\x12\x1f\n" + "\vskip_upload\x18\x06 \x01(\bR\n" + - "skipUpload\"\xde\x04\n" + + "skipUpload\"\xfd\x04\n" + "\fMaterialType\x12\x1d\n" + "\x19MATERIAL_TYPE_UNSPECIFIED\x10\x00\x12\n" + "\n" + @@ -1967,7 +1971,8 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x0fSLSA_PROVENANCE\x10\x18\x12\x1c\n" + "\x18CHAINLOOP_RUNNER_CONTEXT\x10\x19\x12\x15\n" + "\x11CHAINLOOP_PR_INFO\x10\x1a\x12\x11\n" + - "\rGITLEAKS_JSON\x10\x1b:\x02\x18\x01:\x02\x18\x01\"\xfb\x01\n" + + "\rGITLEAKS_JSON\x10\x1b\x12\x1d\n" + + "\x19CHAINLOOP_AI_AGENT_CONFIG\x10\x1c:\x02\x18\x01:\x02\x18\x01\"\xfb\x01\n" + "\x10CraftingSchemaV2\x128\n" + "\vapi_version\x18\x01 \x01(\tB\x17\xbaH\x14r\x12\n" + "\x10chainloop.dev/v1R\n" + diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto index 6e2e1f25e..5f0f4563d 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -149,6 +149,8 @@ message CraftingSchema { CHAINLOOP_PR_INFO = 26; // Gitleaks json report https://github.com/gitleaks/gitleaks/blob/master/README.md#reporting GITLEAKS_JSON = 27; + // AI agent configuration collected automatically during attestation + CHAINLOOP_AI_AGENT_CONFIG = 28; } } } diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go b/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go index e667ba898..138f55189 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-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. @@ -49,6 +49,7 @@ var CraftingMaterialInValidationOrder = []CraftingSchema_Material_MaterialType{ CraftingSchema_Material_ZAP_DAST_ZIP, CraftingSchema_Material_SLSA_PROVENANCE, CraftingSchema_Material_CHAINLOOP_RUNNER_CONTEXT, + CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG, CraftingSchema_Material_ATTESTATION, CraftingSchema_Material_CONTAINER_IMAGE, CraftingSchema_Material_ARTIFACT, diff --git a/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-1.0.schema.json b/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-1.0.schema.json new file mode 100644 index 000000000..fa1cf1868 --- /dev/null +++ b/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-1.0.schema.json @@ -0,0 +1,103 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://schemas.chainloop.dev/aiagentconfig/1.0/ai-agent-config.schema.json", + "type": "object", + "title": "AI Agent Configuration", + "description": "Schema for AI agent configuration data collected during attestation", + "required": [ + "schema_version", + "agent", + "config_hash", + "captured_at", + "config_files" + ], + "properties": { + "schema_version": { + "type": "string", + "description": "Schema version identifier" + }, + "agent": { + "type": "object", + "description": "AI agent provider information", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "description": "Agent provider name" + }, + "version": { + "type": "string", + "description": "Agent version" + } + }, + "additionalProperties": false + }, + "config_hash": { + "type": "string", + "description": "SHA-256 hash of combined config for drift detection" + }, + "captured_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of config snapshot" + }, + "git_context": { + "type": "object", + "description": "Repository, branch, commit SHA at capture time", + "properties": { + "repository": { + "type": "string", + "description": "Repository URL" + }, + "branch": { + "type": "string", + "description": "Branch name" + }, + "commit_sha": { + "type": "string", + "description": "Commit SHA" + } + }, + "additionalProperties": false + }, + "config_files": { + "type": "array", + "description": "Array of discovered configuration files", + "minItems": 1, + "items": { + "type": "object", + "required": ["path", "sha256", "size", "base64_content"], + "properties": { + "path": { + "type": "string", + "description": "Relative file path" + }, + "sha256": { + "type": "string", + "description": "SHA-256 digest of the file" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes" + }, + "base64_content": { + "type": "string", + "description": "Base64-encoded file content" + } + }, + "additionalProperties": false + } + }, + "permissions": { + "description": "Parsed allow/deny rules from agent settings" + }, + "mcp_servers": { + "description": "MCP server names, URLs, and commands" + }, + "subagents": { + "description": "Subagent names, allowed tools, model overrides" + } + }, + "additionalProperties": false +} diff --git a/internal/schemavalidators/schemavalidators.go b/internal/schemavalidators/schemavalidators.go index badf5a589..9d93bb02a 100644 --- a/internal/schemavalidators/schemavalidators.go +++ b/internal/schemavalidators/schemavalidators.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-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. @@ -40,6 +40,9 @@ type RunnerContextVersion string // PRInfoVersion represents the version of PR/MR Info schema. type PRInfoVersion string +// AIAgentConfigVersion represents the version of AI Agent Config schema. +type AIAgentConfigVersion string + const ( // RunnerContextVersion0_1 represents Runner Context version 0.1 schema. RunnerContextVersion0_1 RunnerContextVersion = "0.1" @@ -53,6 +56,8 @@ const ( CSAFVersion2_0 CSAFVersion = "2.0" // CSAFVersion2_1 represents CSAF version 2.0 schema. CSAFVersion2_1 CSAFVersion = "2.1" + // AIAgentConfigVersion1_0 represents AI Agent Config version 1.0 schema. + AIAgentConfigVersion1_0 AIAgentConfigVersion = "1.0" ) var ( @@ -87,6 +92,10 @@ var ( // PR/MR Info schemas //go:embed internal_schemas/prinfo/pr-info-1.0.schema.json prInfoSpecVersion1_0 string + + // AI Agent Config schemas + //go:embed internal_schemas/aiagentconfig/ai-agent-config-1.0.schema.json + aiAgentConfigSpecVersion1_0 string ) // schemaURLMapping maps the schema URL to the schema content. This is used to compile the schema validators @@ -94,24 +103,26 @@ var ( // The keys are the URLs of the schemas and the values are the schema content that can be found in the embedded // files. var schemaURLMapping = map[string]string{ - "http://cyclonedx.org/schema/jsf-0.82.schema.json": jsfSpecVersion0_82, - "http://cyclonedx.org/schema/spdx.schema.json": spdxSpec, - "http://cyclonedx.org/schema/bom-1.5.schema.json": bomSpecVersion1_5, - "http://cyclonedx.org/schema/bom-1.6.schema.json": bomSpecVersion1_6, - "https://docs.oasis-open.org/csaf/csaf/v2.0/csaf_json_schema.json": casfSpecVersion2_0, - "https://docs.oasis-open.org/csaf/csaf/v2.1/csaf_json_schema.json": casfSpecVersion2_1, - "https://www.first.org/cvss/cvss-v2.0.json": cvssSpecVersion2_0, - "https://www.first.org/cvss/cvss-v3.0.json": cvssSpecVersion3_0, - "https://www.first.org/cvss/cvss-v3.1.json": cvssSpecVersion3_1, - "https://www.first.org/cvss/cvss-v4.0.json": cvssSpecVersion4_0, - "https://chainloop.dev/schemas/runner-context-response-0.1.schema.json": runnerContextSpecVersion0_1, - "https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json": prInfoSpecVersion1_0, + "http://cyclonedx.org/schema/jsf-0.82.schema.json": jsfSpecVersion0_82, + "http://cyclonedx.org/schema/spdx.schema.json": spdxSpec, + "http://cyclonedx.org/schema/bom-1.5.schema.json": bomSpecVersion1_5, + "http://cyclonedx.org/schema/bom-1.6.schema.json": bomSpecVersion1_6, + "https://docs.oasis-open.org/csaf/csaf/v2.0/csaf_json_schema.json": casfSpecVersion2_0, + "https://docs.oasis-open.org/csaf/csaf/v2.1/csaf_json_schema.json": casfSpecVersion2_1, + "https://www.first.org/cvss/cvss-v2.0.json": cvssSpecVersion2_0, + "https://www.first.org/cvss/cvss-v3.0.json": cvssSpecVersion3_0, + "https://www.first.org/cvss/cvss-v3.1.json": cvssSpecVersion3_1, + "https://www.first.org/cvss/cvss-v4.0.json": cvssSpecVersion4_0, + "https://chainloop.dev/schemas/runner-context-response-0.1.schema.json": runnerContextSpecVersion0_1, + "https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json": prInfoSpecVersion1_0, + "https://schemas.chainloop.dev/aiagentconfig/1.0/ai-agent-config.schema.json": aiAgentConfigSpecVersion1_0, } var compiledCycloneDxSchemas map[CycloneDXVersion]*jsonschema.Schema var compiledCSAFSchemas map[CSAFVersion]*jsonschema.Schema var compiledRunnerContextSchemas map[RunnerContextVersion]*jsonschema.Schema var compiledPRInfoSchemas map[PRInfoVersion]*jsonschema.Schema +var compiledAIAgentConfigSchemas map[AIAgentConfigVersion]*jsonschema.Schema func init() { compiler := jsonschema.NewCompiler() @@ -132,6 +143,9 @@ func init() { compiledPRInfoSchemas = make(map[PRInfoVersion]*jsonschema.Schema) compiledPRInfoSchemas[PRInfoVersion1_0] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json") + + compiledAIAgentConfigSchemas = make(map[AIAgentConfigVersion]*jsonschema.Schema) + compiledAIAgentConfigSchemas[AIAgentConfigVersion1_0] = compiler.MustCompile("https://schemas.chainloop.dev/aiagentconfig/1.0/ai-agent-config.schema.json") } // ValidateCycloneDX validates the given object against the specified CycloneDX schema version. @@ -245,6 +259,28 @@ func ValidatePRInfo(data interface{}, version PRInfoVersion) error { return nil } +// ValidateAIAgentConfig validates the AI agent config schema. +func ValidateAIAgentConfig(data any, version AIAgentConfigVersion) error { + if version == "" { + version = AIAgentConfigVersion1_0 + } + + schema, ok := compiledAIAgentConfigSchemas[version] + if !ok { + return errors.New("invalid AI agent config schema version") + } + + if err := schema.Validate(data); err != nil { + var invalidJSONTypeError jsonschema.InvalidJSONTypeError + if errors.As(err, &invalidJSONTypeError) { + return ErrInvalidJSONPayload + } + return err + } + + return nil +} + // errorIsJSONFormat checks if the error is a JSON format error. func errorIsJSONFormat(err error) error { var invalidJSONTypeError jsonschema.InvalidJSONTypeError diff --git a/pkg/attestation/crafter/collector_aiagentconfig.go b/pkg/attestation/crafter/collector_aiagentconfig.go index 8644f6ac5..92d3a536f 100644 --- a/pkg/attestation/crafter/collector_aiagentconfig.go +++ b/pkg/attestation/crafter/collector_aiagentconfig.go @@ -21,6 +21,7 @@ import ( "fmt" "os" + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/internal/aiagentconfig" "github.com/chainloop-dev/chainloop/pkg/casclient" ) @@ -80,7 +81,7 @@ func (c *AIAgentConfigCollector) Collect(ctx context.Context, cr *Crafter, attes } tmpFile.Close() - if _, err := cr.AddMaterialContractFree(ctx, attestationID, "EVIDENCE", "ai-agent-config-claude", tmpFile.Name(), casBackend, nil); err != nil { + if _, err := cr.AddMaterialContractFree(ctx, attestationID, schemaapi.CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG.String(), "ai-agent-config-claude", tmpFile.Name(), casBackend, nil); err != nil { return fmt.Errorf("adding AI agent config material: %w", err) } diff --git a/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go b/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go new file mode 100644 index 000000000..fb119088c --- /dev/null +++ b/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go @@ -0,0 +1,84 @@ +// +// 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 materials + +import ( + "context" + "encoding/json" + "fmt" + "os" + + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/internal/aiagentconfig" + "github.com/chainloop-dev/chainloop/internal/schemavalidators" + api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" + "github.com/chainloop-dev/chainloop/pkg/casclient" + + "github.com/rs/zerolog" +) + +type ChainloopAIAgentConfigCrafter struct { + *crafterCommon + backend *casclient.CASBackend +} + +// NewChainloopAIAgentConfigCrafter generates a new CHAINLOOP_AI_AGENT_CONFIG material. +// This material type contains AI agent configuration data collected during attestation. +func NewChainloopAIAgentConfigCrafter(schema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger) (*ChainloopAIAgentConfigCrafter, error) { + if schema.Type != schemaapi.CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG { + return nil, fmt.Errorf("material type is not chainloop_ai_agent_config") + } + + craftCommon := &crafterCommon{logger: l, input: schema} + return &ChainloopAIAgentConfigCrafter{backend: backend, crafterCommon: craftCommon}, nil +} + +// Craft validates the AI agent config against the JSON schema, calculates the digest, +// uploads it and returns the material definition. +func (c *ChainloopAIAgentConfigCrafter) Craft(ctx context.Context, artifactPath string) (*api.Attestation_Material, error) { + f, err := os.ReadFile(artifactPath) + if err != nil { + return nil, fmt.Errorf("can't open the file: %w", err) + } + + var v aiagentconfig.Evidence + if err := json.Unmarshal(f, &v); err != nil { + c.logger.Debug().Err(err).Msg("error decoding file") + return nil, fmt.Errorf("invalid JSON format: %w", err) + } + + dataBytes, err := json.Marshal(v.Data) + if err != nil { + return nil, fmt.Errorf("failed to marshal data for validation: %w", err) + } + + var rawData any + if err := json.Unmarshal(dataBytes, &rawData); err != nil { + return nil, fmt.Errorf("failed to unmarshal data for validation: %w", err) + } + + if err := schemavalidators.ValidateAIAgentConfig(rawData, schemavalidators.AIAgentConfigVersion1_0); err != nil { + c.logger.Debug().Err(err).Msg("schema validation failed") + return nil, fmt.Errorf("AI agent config validation failed: %w", err) + } + + material, err := uploadAndCraft(ctx, c.input, c.backend, artifactPath, c.logger) + if err != nil { + return nil, err + } + + return material, nil +} diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go index a8c92a090..9cfa0c927 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-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. @@ -283,6 +283,8 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia crafter, err = NewChainloopPRInfoCrafter(materialSchema, casBackend, logger) case schemaapi.CraftingSchema_Material_GITLEAKS_JSON: crafter, err = NewGitleaksReportCrafter(materialSchema, casBackend, logger) + case schemaapi.CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG: + crafter, err = NewChainloopAIAgentConfigCrafter(materialSchema, casBackend, logger) default: return nil, fmt.Errorf("material of type %q not supported yet", materialSchema.Type) } From 0601fdb31a3db35e01b87aad6aed522c3ac5415f Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 12:06:45 +0100 Subject: [PATCH 05/18] fix: use semantic version 0.1 for AI agent config schema Change schema version from 1.0 to 0.1 to reflect the experimental nature of the feature using semantic versioning without patch. Signed-off-by: Jose I. Paris --- internal/aiagentconfig/aiagentconfig.go | 2 +- ...0.schema.json => ai-agent-config-0.1.schema.json} | 2 +- internal/schemavalidators/schemavalidators.go | 12 ++++++------ .../crafter/materials/chainloop_ai_agent_config.go | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) rename internal/schemavalidators/internal_schemas/aiagentconfig/{ai-agent-config-1.0.schema.json => ai-agent-config-0.1.schema.json} (97%) diff --git a/internal/aiagentconfig/aiagentconfig.go b/internal/aiagentconfig/aiagentconfig.go index 1e19241aa..f2220c4e2 100644 --- a/internal/aiagentconfig/aiagentconfig.go +++ b/internal/aiagentconfig/aiagentconfig.go @@ -19,7 +19,7 @@ const ( // EvidenceID is the identifier for the AI agent config evidence EvidenceID = "CHAINLOOP_AI_AGENT_CONFIG" // EvidenceSchemaURL is the URL to the JSON schema for AI agent config - EvidenceSchemaURL = "https://schemas.chainloop.dev/aiagentconfig/1.0/ai-agent-config.schema.json" + EvidenceSchemaURL = "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json" ) // Agent identifies the AI agent provider diff --git a/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-1.0.schema.json b/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json similarity index 97% rename from internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-1.0.schema.json rename to internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json index fa1cf1868..21e1ada4f 100644 --- a/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-1.0.schema.json +++ b/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://schemas.chainloop.dev/aiagentconfig/1.0/ai-agent-config.schema.json", + "$id": "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json", "type": "object", "title": "AI Agent Configuration", "description": "Schema for AI agent configuration data collected during attestation", diff --git a/internal/schemavalidators/schemavalidators.go b/internal/schemavalidators/schemavalidators.go index 9d93bb02a..594de4207 100644 --- a/internal/schemavalidators/schemavalidators.go +++ b/internal/schemavalidators/schemavalidators.go @@ -56,8 +56,8 @@ const ( CSAFVersion2_0 CSAFVersion = "2.0" // CSAFVersion2_1 represents CSAF version 2.0 schema. CSAFVersion2_1 CSAFVersion = "2.1" - // AIAgentConfigVersion1_0 represents AI Agent Config version 1.0 schema. - AIAgentConfigVersion1_0 AIAgentConfigVersion = "1.0" + // AIAgentConfigVersion0_1 represents AI Agent Config version 0.1 schema. + AIAgentConfigVersion0_1 AIAgentConfigVersion = "0.1" ) var ( @@ -94,7 +94,7 @@ var ( prInfoSpecVersion1_0 string // AI Agent Config schemas - //go:embed internal_schemas/aiagentconfig/ai-agent-config-1.0.schema.json + //go:embed internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json aiAgentConfigSpecVersion1_0 string ) @@ -115,7 +115,7 @@ var schemaURLMapping = map[string]string{ "https://www.first.org/cvss/cvss-v4.0.json": cvssSpecVersion4_0, "https://chainloop.dev/schemas/runner-context-response-0.1.schema.json": runnerContextSpecVersion0_1, "https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json": prInfoSpecVersion1_0, - "https://schemas.chainloop.dev/aiagentconfig/1.0/ai-agent-config.schema.json": aiAgentConfigSpecVersion1_0, + "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json": aiAgentConfigSpecVersion1_0, } var compiledCycloneDxSchemas map[CycloneDXVersion]*jsonschema.Schema @@ -145,7 +145,7 @@ func init() { compiledPRInfoSchemas[PRInfoVersion1_0] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json") compiledAIAgentConfigSchemas = make(map[AIAgentConfigVersion]*jsonschema.Schema) - compiledAIAgentConfigSchemas[AIAgentConfigVersion1_0] = compiler.MustCompile("https://schemas.chainloop.dev/aiagentconfig/1.0/ai-agent-config.schema.json") + compiledAIAgentConfigSchemas[AIAgentConfigVersion0_1] = compiler.MustCompile("https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json") } // ValidateCycloneDX validates the given object against the specified CycloneDX schema version. @@ -262,7 +262,7 @@ func ValidatePRInfo(data interface{}, version PRInfoVersion) error { // ValidateAIAgentConfig validates the AI agent config schema. func ValidateAIAgentConfig(data any, version AIAgentConfigVersion) error { if version == "" { - version = AIAgentConfigVersion1_0 + version = AIAgentConfigVersion0_1 } schema, ok := compiledAIAgentConfigSchemas[version] diff --git a/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go b/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go index fb119088c..d9dc676f7 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go @@ -70,7 +70,7 @@ func (c *ChainloopAIAgentConfigCrafter) Craft(ctx context.Context, artifactPath return nil, fmt.Errorf("failed to unmarshal data for validation: %w", err) } - if err := schemavalidators.ValidateAIAgentConfig(rawData, schemavalidators.AIAgentConfigVersion1_0); err != nil { + if err := schemavalidators.ValidateAIAgentConfig(rawData, schemavalidators.AIAgentConfigVersion0_1); err != nil { c.logger.Debug().Err(err).Msg("schema validation failed") return nil, fmt.Errorf("AI agent config validation failed: %w", err) } From 02a8fd22d349ebf95164786a06691234d2ae594d Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 12:10:25 +0100 Subject: [PATCH 06/18] rename variable Signed-off-by: Jose I. Paris --- internal/schemavalidators/schemavalidators.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/schemavalidators/schemavalidators.go b/internal/schemavalidators/schemavalidators.go index 594de4207..d500d7711 100644 --- a/internal/schemavalidators/schemavalidators.go +++ b/internal/schemavalidators/schemavalidators.go @@ -95,7 +95,7 @@ var ( // AI Agent Config schemas //go:embed internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json - aiAgentConfigSpecVersion1_0 string + aiAgentConfigSpecVersion0_1 string ) // schemaURLMapping maps the schema URL to the schema content. This is used to compile the schema validators @@ -115,7 +115,7 @@ var schemaURLMapping = map[string]string{ "https://www.first.org/cvss/cvss-v4.0.json": cvssSpecVersion4_0, "https://chainloop.dev/schemas/runner-context-response-0.1.schema.json": runnerContextSpecVersion0_1, "https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json": prInfoSpecVersion1_0, - "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json": aiAgentConfigSpecVersion1_0, + "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json": aiAgentConfigSpecVersion0_1, } var compiledCycloneDxSchemas map[CycloneDXVersion]*jsonschema.Schema From 1a87b0dd6289909594c9a5dbfb293b476eb643e0 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 12:14:29 +0100 Subject: [PATCH 07/18] feat(cli): add --collectors flag to attestation init for opt-in collectors PR metadata collection remains always-on. Other collectors like AI agent config require explicit opt-in via --collectors flag (e.g. --collectors aiconfig). Signed-off-by: Jose I. Paris --- app/cli/cmd/attestation_init.go | 3 +++ app/cli/pkg/action/attestation_init.go | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/cli/cmd/attestation_init.go b/app/cli/cmd/attestation_init.go index bc8afa43d..d28d6e8e9 100644 --- a/app/cli/cmd/attestation_init.go +++ b/app/cli/cmd/attestation_init.go @@ -36,6 +36,7 @@ func newAttestationInitCmd() *cobra.Command { projectVersionRelease bool existingVersion bool newWorkflowcontract string + collectors []string ) cmd := &cobra.Command{ @@ -105,6 +106,7 @@ func newAttestationInitCmd() *cobra.Command { NewWorkflowContractRef: newWorkflowcontract, ProjectVersionMarkAsReleased: projectVersionRelease, RequireExistingVersion: existingVersion, + Collectors: collectors, }) return err @@ -164,6 +166,7 @@ func newAttestationInitCmd() *cobra.Command { cmd.Flags().StringVar(&projectVersion, "version", "", "project version, i.e 0.1.0") cmd.Flags().BoolVar(&projectVersionRelease, "release", false, "promote the provided version as a release") cmd.Flags().BoolVar(&existingVersion, "existing-version", false, "return an error if the version doesn't exist in the project") + cmd.Flags().StringSliceVar(&collectors, "collectors", nil, "comma-separated list of additional collectors to enable (e.g. aiconfig)") return cmd } diff --git a/app/cli/pkg/action/attestation_init.go b/app/cli/pkg/action/attestation_init.go index 580554f20..6cb021a5a 100644 --- a/app/cli/pkg/action/attestation_init.go +++ b/app/cli/pkg/action/attestation_init.go @@ -95,6 +95,8 @@ type AttestationInitRunOpts struct { RequireExistingVersion bool WorkflowName string NewWorkflowContractRef string + // Collectors is a list of additional collector names to enable (e.g. "aiconfig") + Collectors []string } func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRunOpts) (string, error) { @@ -301,10 +303,17 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun } // Register and run auto-discovery collectors - action.c.RegisterCollectors( - crafter.NewPRMetadataCollector(discoveredRunner), - crafter.NewAIAgentConfigCollector(), - ) + // PR metadata is always collected; other collectors are opt-in via --collectors flag + collectors := []crafter.Collector{crafter.NewPRMetadataCollector(discoveredRunner)} + for _, name := range opts.Collectors { + switch name { + case "aiconfig": + collectors = append(collectors, crafter.NewAIAgentConfigCollector()) + default: + action.Logger.Warn().Str("collector", name).Msg("unknown collector, skipping") + } + } + action.c.RegisterCollectors(collectors...) action.c.RunCollectors(ctx, attestationID, casBackend) // Evaluate attestation-level policies at init phase From f196304994a2b5e2e4671577de15bdbff15b717b Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 12:41:10 +0100 Subject: [PATCH 08/18] move to constant Signed-off-by: Jose I. Paris --- app/cli/pkg/action/attestation_init.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/cli/pkg/action/attestation_init.go b/app/cli/pkg/action/attestation_init.go index 6cb021a5a..995ab0c43 100644 --- a/app/cli/pkg/action/attestation_init.go +++ b/app/cli/pkg/action/attestation_init.go @@ -57,6 +57,8 @@ type AttestationInit struct { connectionInsecure bool } +const aiConfigCollectorName = "aiconfig" + // ErrAttestationAlreadyExist means that there is an attestation in progress var ErrAttestationAlreadyExist = errors.New("attestation already initialized") @@ -307,7 +309,7 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun collectors := []crafter.Collector{crafter.NewPRMetadataCollector(discoveredRunner)} for _, name := range opts.Collectors { switch name { - case "aiconfig": + case aiConfigCollectorName: collectors = append(collectors, crafter.NewAIAgentConfigCollector()) default: action.Logger.Warn().Str("collector", name).Msg("unknown collector, skipping") From 5c791a2a482eba84703d9e2ee2233a72f0debf5e Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 12:55:03 +0100 Subject: [PATCH 09/18] update cli Signed-off-by: Jose I. Paris --- app/cli/documentation/cli-reference.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 533794dfc..c7e2da07d 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -246,7 +246,7 @@ 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" "ATTESTATION" "BLACKDUCK_SCA_JSON" "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" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENVEX" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "TWISTCLI_SCAN_JSON" "ZAP_DAST_ZIP"] +--kind string kind of the material to be recorded: ["ARTIFACT" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CHAINLOOP_AI_AGENT_CONFIG" "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" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENVEX" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "TWISTCLI_SCAN_JSON" "ZAP_DAST_ZIP"] --name string name of the material as shown in the contract --no-strict-validation skip strict schema validation for SBOM_CYCLONEDX_JSON materials --registry-password string registry password, ($CHAINLOOP_REGISTRY_PASSWORD) @@ -321,6 +321,7 @@ chainloop attestation init [flags] Options ``` +--collectors strings comma-separated list of additional collectors to enable (e.g. aiconfig) --contract string name of an existing contract or the path/URL to a contract file, to attach it to the auto-created workflow (it doesn't update an existing one) --contract-revision int revision of the contract to retrieve, "latest" by default --dry-run do not record attestation in the control plane, useful for development @@ -2932,7 +2933,7 @@ Options --annotation strings Key-value pairs of material annotations (key=value) -h, --help help for eval --input stringArray Key-value pairs of policy inputs (key=value) ---kind string Kind of the material: ["ARTIFACT" "ATTESTATION" "BLACKDUCK_SCA_JSON" "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" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENVEX" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "TWISTCLI_SCAN_JSON" "ZAP_DAST_ZIP"] +--kind string Kind of the material: ["ARTIFACT" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CHAINLOOP_AI_AGENT_CONFIG" "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" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENVEX" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "TWISTCLI_SCAN_JSON" "ZAP_DAST_ZIP"] --material string Path to material or attestation file -p, --policy string Policy reference (./my-policy.yaml, https://my-domain.com/my-policy.yaml, chainloop://my-stored-policy) (default "policy.yaml") ``` From 6da6e084254deccb799edbf0d5d35161a9f41daa Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 13:03:59 +0100 Subject: [PATCH 10/18] move to top level Signed-off-by: Jose I. Paris --- internal/aiagentconfig/aiagentconfig.go | 27 +------ internal/aiagentconfig/builder.go | 8 +-- internal/aiagentconfig/builder_test.go | 72 +++++++++---------- .../crafter/collector_aiagentconfig.go | 4 +- .../materials/chainloop_ai_agent_config.go | 15 +--- 5 files changed, 46 insertions(+), 80 deletions(-) diff --git a/internal/aiagentconfig/aiagentconfig.go b/internal/aiagentconfig/aiagentconfig.go index f2220c4e2..7ca4a0f9a 100644 --- a/internal/aiagentconfig/aiagentconfig.go +++ b/internal/aiagentconfig/aiagentconfig.go @@ -15,13 +15,6 @@ package aiagentconfig -const ( - // EvidenceID is the identifier for the AI agent config evidence - EvidenceID = "CHAINLOOP_AI_AGENT_CONFIG" - // EvidenceSchemaURL is the URL to the JSON schema for AI agent config - EvidenceSchemaURL = "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json" -) - // Agent identifies the AI agent provider type Agent struct { Name string `json:"name"` @@ -43,8 +36,8 @@ type ConfigFile struct { Base64Content string `json:"base64_content"` } -// Data is the payload inside the evidence envelope -type Data struct { +// Evidence is the AI agent configuration payload +type Evidence struct { SchemaVersion string `json:"schema_version"` Agent Agent `json:"agent"` ConfigHash string `json:"config_hash"` @@ -56,19 +49,3 @@ type Data struct { MCPServers any `json:"mcp_servers,omitempty"` Subagents any `json:"subagents,omitempty"` } - -// Evidence is the full envelope matching the custom evidence format -type Evidence struct { - ID string `json:"chainloop.material.evidence.id"` - Schema string `json:"schema"` - Data Data `json:"data"` -} - -// NewEvidence creates a new Evidence instance with the standard envelope -func NewEvidence(data Data) *Evidence { - return &Evidence{ - ID: EvidenceID, - Schema: EvidenceSchemaURL, - Data: data, - } -} diff --git a/internal/aiagentconfig/builder.go b/internal/aiagentconfig/builder.go index 956577f2c..a274415a5 100644 --- a/internal/aiagentconfig/builder.go +++ b/internal/aiagentconfig/builder.go @@ -27,10 +27,10 @@ import ( "time" ) -// BuildEvidence reads discovered files and constructs the evidence payload. +// Build reads discovered files and constructs the AI agent config payload. // rootDir is the base directory, filePaths are relative to rootDir. // gitCtx may be nil if not in a git repository. -func BuildEvidence(rootDir string, filePaths []string, gitCtx *GitContext) (*Evidence, error) { +func Build(rootDir string, filePaths []string, gitCtx *GitContext) (*Evidence, error) { // Resolve rootDir to its real path so symlink comparisons are reliable realRoot, err := filepath.EvalSymlinks(rootDir) if err != nil { @@ -76,7 +76,7 @@ func BuildEvidence(rootDir string, filePaths []string, gitCtx *GitContext) (*Evi }) } - data := Data{ + data := Evidence{ SchemaVersion: "v1alpha", Agent: Agent{Name: "claude"}, ConfigHash: computeCombinedHash(hashes), @@ -85,7 +85,7 @@ func BuildEvidence(rootDir string, filePaths []string, gitCtx *GitContext) (*Evi ConfigFiles: configFiles, } - return NewEvidence(data), nil + return &data, nil } // computeCombinedHash sorts individual hashes, concatenates them, and hashes the result. diff --git a/internal/aiagentconfig/builder_test.go b/internal/aiagentconfig/builder_test.go index 1dabccebd..2a562563d 100644 --- a/internal/aiagentconfig/builder_test.go +++ b/internal/aiagentconfig/builder_test.go @@ -31,7 +31,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestBuildEvidence(t *testing.T) { +func TestBuild(t *testing.T) { rootDir := t.TempDir() // Create test files @@ -47,35 +47,30 @@ func TestBuildEvidence(t *testing.T) { CommitSHA: "abc123", } - evidence, err := BuildEvidence(rootDir, []string{"CLAUDE.md", ".claude/settings.json"}, gitCtx) + data, err := Build(rootDir, []string{"CLAUDE.md", ".claude/settings.json"}, gitCtx) require.NoError(t, err) - // Verify envelope - assert.Equal(t, EvidenceID, evidence.ID) - assert.Equal(t, EvidenceSchemaURL, evidence.Schema) - - // Verify data - assert.Equal(t, "v1alpha", evidence.Data.SchemaVersion) - assert.Equal(t, "claude", evidence.Data.Agent.Name) - assert.NotEmpty(t, evidence.Data.CapturedAt) - assert.NotEmpty(t, evidence.Data.ConfigHash) + assert.Equal(t, "v1alpha", data.SchemaVersion) + assert.Equal(t, "claude", data.Agent.Name) + assert.NotEmpty(t, data.CapturedAt) + assert.NotEmpty(t, data.ConfigHash) // Verify git context - require.NotNil(t, evidence.Data.GitContext) - assert.Equal(t, "https://github.com/org/repo", evidence.Data.GitContext.Repository) - assert.Equal(t, "abc123", evidence.Data.GitContext.CommitSHA) + require.NotNil(t, data.GitContext) + assert.Equal(t, "https://github.com/org/repo", data.GitContext.Repository) + assert.Equal(t, "abc123", data.GitContext.CommitSHA) // Verify config files - require.Len(t, evidence.Data.ConfigFiles, 2) + require.Len(t, data.ConfigFiles, 2) - cf1 := evidence.Data.ConfigFiles[0] + cf1 := data.ConfigFiles[0] assert.Equal(t, "CLAUDE.md", cf1.Path) assert.Equal(t, int64(len(file1Content)), cf1.Size) hash1 := sha256.Sum256(file1Content) assert.Equal(t, hex.EncodeToString(hash1[:]), cf1.SHA256) assert.Equal(t, base64.StdEncoding.EncodeToString(file1Content), cf1.Base64Content) - cf2 := evidence.Data.ConfigFiles[1] + cf2 := data.ConfigFiles[1] assert.Equal(t, ".claude/settings.json", cf2.Path) hash2 := sha256.Sum256(file2Content) assert.Equal(t, hex.EncodeToString(hash2[:]), cf2.SHA256) @@ -87,40 +82,45 @@ func TestBuildEvidence(t *testing.T) { } sort.Strings(hashes) combined := sha256.Sum256([]byte(strings.Join(hashes, ""))) - assert.Equal(t, hex.EncodeToString(combined[:]), evidence.Data.ConfigHash) + assert.Equal(t, hex.EncodeToString(combined[:]), data.ConfigHash) } -func TestBuildEvidenceWithoutGitContext(t *testing.T) { +func TestBuildWithoutGitContext(t *testing.T) { rootDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600)) - evidence, err := BuildEvidence(rootDir, []string{"CLAUDE.md"}, nil) + data, err := Build(rootDir, []string{"CLAUDE.md"}, nil) require.NoError(t, err) - assert.Nil(t, evidence.Data.GitContext) - assert.Len(t, evidence.Data.ConfigFiles, 1) + assert.Nil(t, data.GitContext) + assert.Len(t, data.ConfigFiles, 1) } -func TestBuildEvidenceJSONFormat(t *testing.T) { +func TestBuildJSONFormat(t *testing.T) { rootDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600)) - evidence, err := BuildEvidence(rootDir, []string{"CLAUDE.md"}, nil) + data, err := Build(rootDir, []string{"CLAUDE.md"}, nil) require.NoError(t, err) - // Verify it marshals to valid JSON with the correct envelope field - jsonData, err := json.Marshal(evidence) + // Verify it marshals to valid JSON with top-level fields (no envelope) + jsonData, err := json.Marshal(data) require.NoError(t, err) var raw map[string]any require.NoError(t, json.Unmarshal(jsonData, &raw)) - assert.Equal(t, EvidenceID, raw["chainloop.material.evidence.id"]) - assert.Equal(t, EvidenceSchemaURL, raw["schema"]) - assert.NotNil(t, raw["data"]) + assert.NotNil(t, raw["schema_version"]) + assert.NotNil(t, raw["agent"]) + assert.NotNil(t, raw["config_hash"]) + assert.NotNil(t, raw["captured_at"]) + assert.NotNil(t, raw["config_files"]) + // Ensure no envelope fields + assert.Nil(t, raw["chainloop.material.evidence.id"]) + assert.Nil(t, raw["data"]) } -func TestBuildEvidenceRejectsSymlinksEscapingRoot(t *testing.T) { +func TestBuildRejectsSymlinksEscapingRoot(t *testing.T) { rootDir := t.TempDir() outsideDir := t.TempDir() @@ -128,12 +128,12 @@ func TestBuildEvidenceRejectsSymlinksEscapingRoot(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0o600)) require.NoError(t, os.Symlink(filepath.Join(outsideDir, "secret.txt"), filepath.Join(rootDir, "CLAUDE.md"))) - _, err := BuildEvidence(rootDir, []string{"CLAUDE.md"}, nil) + _, err := Build(rootDir, []string{"CLAUDE.md"}, nil) require.Error(t, err) assert.Contains(t, err.Error(), "path escapes root directory via symlink") } -func TestBuildEvidenceRejectsSymlinkedParentDir(t *testing.T) { +func TestBuildRejectsSymlinkedParentDir(t *testing.T) { rootDir := t.TempDir() outsideDir := t.TempDir() @@ -145,16 +145,16 @@ func TestBuildEvidenceRejectsSymlinkedParentDir(t *testing.T) { // Symlink .claude -> outside directory require.NoError(t, os.Symlink(outsideClaude, filepath.Join(rootDir, ".claude"))) - _, err := BuildEvidence(rootDir, []string{".claude/settings.json"}, nil) + _, err := Build(rootDir, []string{".claude/settings.json"}, nil) require.Error(t, err) assert.Contains(t, err.Error(), "path escapes root directory via symlink") } -func TestBuildEvidenceAllowsRegularFilesInRoot(t *testing.T) { +func TestBuildAllowsRegularFilesInRoot(t *testing.T) { rootDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600)) - evidence, err := BuildEvidence(rootDir, []string{"CLAUDE.md"}, nil) + data, err := Build(rootDir, []string{"CLAUDE.md"}, nil) require.NoError(t, err) - assert.Len(t, evidence.Data.ConfigFiles, 1) + assert.Len(t, data.ConfigFiles, 1) } diff --git a/pkg/attestation/crafter/collector_aiagentconfig.go b/pkg/attestation/crafter/collector_aiagentconfig.go index 92d3a536f..db57e0d21 100644 --- a/pkg/attestation/crafter/collector_aiagentconfig.go +++ b/pkg/attestation/crafter/collector_aiagentconfig.go @@ -59,9 +59,9 @@ func (c *AIAgentConfigCollector) Collect(ctx context.Context, cr *Crafter, attes } } - evidence, err := aiagentconfig.BuildEvidence(cr.WorkingDir(), files, gitCtx) + evidence, err := aiagentconfig.Build(cr.WorkingDir(), files, gitCtx) if err != nil { - return fmt.Errorf("building AI agent config evidence: %w", err) + return fmt.Errorf("building AI agent config: %w", err) } jsonData, err := json.Marshal(evidence) diff --git a/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go b/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go index d9dc676f7..283469b10 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go @@ -22,7 +22,6 @@ import ( "os" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" - "github.com/chainloop-dev/chainloop/internal/aiagentconfig" "github.com/chainloop-dev/chainloop/internal/schemavalidators" api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/casclient" @@ -54,22 +53,12 @@ func (c *ChainloopAIAgentConfigCrafter) Craft(ctx context.Context, artifactPath return nil, fmt.Errorf("can't open the file: %w", err) } - var v aiagentconfig.Evidence - if err := json.Unmarshal(f, &v); err != nil { + var rawData any + if err := json.Unmarshal(f, &rawData); err != nil { c.logger.Debug().Err(err).Msg("error decoding file") return nil, fmt.Errorf("invalid JSON format: %w", err) } - dataBytes, err := json.Marshal(v.Data) - if err != nil { - return nil, fmt.Errorf("failed to marshal data for validation: %w", err) - } - - var rawData any - if err := json.Unmarshal(dataBytes, &rawData); err != nil { - return nil, fmt.Errorf("failed to unmarshal data for validation: %w", err) - } - if err := schemavalidators.ValidateAIAgentConfig(rawData, schemavalidators.AIAgentConfigVersion0_1); err != nil { c.logger.Debug().Err(err).Msg("schema validation failed") return nil, fmt.Errorf("AI agent config validation failed: %w", err) From e742dd1cd3192e85b5ad42beeffed0e622a19020 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 13:07:50 +0100 Subject: [PATCH 11/18] extract constants Signed-off-by: Jose I. Paris --- internal/aiagentconfig/builder.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/aiagentconfig/builder.go b/internal/aiagentconfig/builder.go index a274415a5..78c40edf5 100644 --- a/internal/aiagentconfig/builder.go +++ b/internal/aiagentconfig/builder.go @@ -25,6 +25,12 @@ import ( "sort" "strings" "time" + + "github.com/chainloop-dev/chainloop/internal/schemavalidators" +) + +const ( + claudeProvider = "claude" ) // Build reads discovered files and constructs the AI agent config payload. @@ -77,8 +83,8 @@ func Build(rootDir string, filePaths []string, gitCtx *GitContext) (*Evidence, e } data := Evidence{ - SchemaVersion: "v1alpha", - Agent: Agent{Name: "claude"}, + SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1), + Agent: Agent{Name: claudeProvider}, ConfigHash: computeCombinedHash(hashes), CapturedAt: time.Now().UTC().Format(time.RFC3339), GitContext: gitCtx, From f6a2546bde5bf28a8498395f4b88fae692ce8dab Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 13:16:41 +0100 Subject: [PATCH 12/18] add tests Signed-off-by: Jose I. Paris --- internal/aiagentconfig/builder_test.go | 18 ++++++ .../schemavalidators/schemavalidators_test.go | 62 ++++++++++++++++++- .../ai_agent_config_empty_config_files.json | 9 +++ .../ai_agent_config_extra_fields.json | 17 +++++ .../testdata/ai_agent_config_minimal.json | 16 +++++ .../ai_agent_config_missing_agent.json | 13 ++++ ...ent_config_missing_config_file_fields.json | 13 ++++ .../testdata/ai_agent_config_valid.json | 22 +++++++ 8 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 internal/schemavalidators/testdata/ai_agent_config_empty_config_files.json create mode 100644 internal/schemavalidators/testdata/ai_agent_config_extra_fields.json create mode 100644 internal/schemavalidators/testdata/ai_agent_config_minimal.json create mode 100644 internal/schemavalidators/testdata/ai_agent_config_missing_agent.json create mode 100644 internal/schemavalidators/testdata/ai_agent_config_missing_config_file_fields.json create mode 100644 internal/schemavalidators/testdata/ai_agent_config_valid.json diff --git a/internal/aiagentconfig/builder_test.go b/internal/aiagentconfig/builder_test.go index 2a562563d..e5cd82535 100644 --- a/internal/aiagentconfig/builder_test.go +++ b/internal/aiagentconfig/builder_test.go @@ -150,6 +150,24 @@ func TestBuildRejectsSymlinkedParentDir(t *testing.T) { assert.Contains(t, err.Error(), "path escapes root directory via symlink") } +func TestBuildEmptyFileList(t *testing.T) { + rootDir := t.TempDir() + + data, err := Build(rootDir, []string{}, nil) + require.NoError(t, err) + assert.Empty(t, data.ConfigFiles) + assert.NotEmpty(t, data.ConfigHash) + assert.NotEmpty(t, data.CapturedAt) +} + +func TestBuildNilFileList(t *testing.T) { + rootDir := t.TempDir() + + data, err := Build(rootDir, nil, nil) + require.NoError(t, err) + assert.Empty(t, data.ConfigFiles) +} + func TestBuildAllowsRegularFilesInRoot(t *testing.T) { rootDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600)) diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index 75402a4df..4b551240d 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-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. @@ -232,3 +232,63 @@ func TestValidateRunnerContext(t *testing.T) { }) } } + +func TestValidateAIAgentConfig(t *testing.T) { + testCases := []struct { + name string + filePath string + wantErr string + }{ + { + name: "valid full config", + filePath: "./testdata/ai_agent_config_valid.json", + }, + { + name: "valid minimal config", + filePath: "./testdata/ai_agent_config_minimal.json", + }, + { + name: "missing agent", + filePath: "./testdata/ai_agent_config_missing_agent.json", + wantErr: "missing properties: 'agent'", + }, + { + name: "empty config_files array", + filePath: "./testdata/ai_agent_config_empty_config_files.json", + wantErr: "minimum 1 items required, but found 0", + }, + { + name: "config file missing required fields", + filePath: "./testdata/ai_agent_config_missing_config_file_fields.json", + wantErr: "missing properties", + }, + { + name: "additional properties not allowed", + filePath: "./testdata/ai_agent_config_extra_fields.json", + wantErr: "additionalProperties 'unknown_field' not allowed", + }, + { + name: "completely wrong format", + filePath: "./testdata/sbom-spdx.json", + wantErr: "missing properties", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + f, err := os.ReadFile(tc.filePath) + require.NoError(t, err) + + var v any + require.NoError(t, json.Unmarshal(f, &v)) + + err = schemavalidators.ValidateAIAgentConfig(v, "") + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + }) + } +} diff --git a/internal/schemavalidators/testdata/ai_agent_config_empty_config_files.json b/internal/schemavalidators/testdata/ai_agent_config_empty_config_files.json new file mode 100644 index 000000000..0720cb106 --- /dev/null +++ b/internal/schemavalidators/testdata/ai_agent_config_empty_config_files.json @@ -0,0 +1,9 @@ +{ + "schema_version": "0.1", + "agent": { + "name": "claude" + }, + "config_hash": "abc123", + "captured_at": "2026-03-13T10:00:00Z", + "config_files": [] +} diff --git a/internal/schemavalidators/testdata/ai_agent_config_extra_fields.json b/internal/schemavalidators/testdata/ai_agent_config_extra_fields.json new file mode 100644 index 000000000..d11df4436 --- /dev/null +++ b/internal/schemavalidators/testdata/ai_agent_config_extra_fields.json @@ -0,0 +1,17 @@ +{ + "schema_version": "0.1", + "agent": { + "name": "claude" + }, + "config_hash": "abc123", + "captured_at": "2026-03-13T10:00:00Z", + "config_files": [ + { + "path": "CLAUDE.md", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 10, + "base64_content": "Y29udGVudA==" + } + ], + "unknown_field": "should fail" +} diff --git a/internal/schemavalidators/testdata/ai_agent_config_minimal.json b/internal/schemavalidators/testdata/ai_agent_config_minimal.json new file mode 100644 index 000000000..c67316e00 --- /dev/null +++ b/internal/schemavalidators/testdata/ai_agent_config_minimal.json @@ -0,0 +1,16 @@ +{ + "schema_version": "0.1", + "agent": { + "name": "claude" + }, + "config_hash": "abc123", + "captured_at": "2026-03-13T10:00:00Z", + "config_files": [ + { + "path": "CLAUDE.md", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 10, + "base64_content": "Y29udGVudA==" + } + ] +} diff --git a/internal/schemavalidators/testdata/ai_agent_config_missing_agent.json b/internal/schemavalidators/testdata/ai_agent_config_missing_agent.json new file mode 100644 index 000000000..03e45f168 --- /dev/null +++ b/internal/schemavalidators/testdata/ai_agent_config_missing_agent.json @@ -0,0 +1,13 @@ +{ + "schema_version": "0.1", + "config_hash": "abc123", + "captured_at": "2026-03-13T10:00:00Z", + "config_files": [ + { + "path": "CLAUDE.md", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 10, + "base64_content": "Y29udGVudA==" + } + ] +} diff --git a/internal/schemavalidators/testdata/ai_agent_config_missing_config_file_fields.json b/internal/schemavalidators/testdata/ai_agent_config_missing_config_file_fields.json new file mode 100644 index 000000000..26e9cfbe1 --- /dev/null +++ b/internal/schemavalidators/testdata/ai_agent_config_missing_config_file_fields.json @@ -0,0 +1,13 @@ +{ + "schema_version": "0.1", + "agent": { + "name": "claude" + }, + "config_hash": "abc123", + "captured_at": "2026-03-13T10:00:00Z", + "config_files": [ + { + "path": "CLAUDE.md" + } + ] +} diff --git a/internal/schemavalidators/testdata/ai_agent_config_valid.json b/internal/schemavalidators/testdata/ai_agent_config_valid.json new file mode 100644 index 000000000..a8cd16c4d --- /dev/null +++ b/internal/schemavalidators/testdata/ai_agent_config_valid.json @@ -0,0 +1,22 @@ +{ + "schema_version": "0.1", + "agent": { + "name": "claude", + "version": "4.0" + }, + "config_hash": "abc123def456", + "captured_at": "2026-03-13T10:00:00Z", + "git_context": { + "repository": "https://github.com/org/repo", + "branch": "main", + "commit_sha": "abc123" + }, + "config_files": [ + { + "path": "CLAUDE.md", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 42, + "base64_content": "IyBQcm9qZWN0IFJ1bGVz" + } + ] +} From c723b27febd147665789b536b9face54fa59ab2f Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 13:18:44 +0100 Subject: [PATCH 13/18] fix test Signed-off-by: Jose I. Paris --- internal/aiagentconfig/builder_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/aiagentconfig/builder_test.go b/internal/aiagentconfig/builder_test.go index e5cd82535..cfb1b2281 100644 --- a/internal/aiagentconfig/builder_test.go +++ b/internal/aiagentconfig/builder_test.go @@ -50,7 +50,7 @@ func TestBuild(t *testing.T) { data, err := Build(rootDir, []string{"CLAUDE.md", ".claude/settings.json"}, gitCtx) require.NoError(t, err) - assert.Equal(t, "v1alpha", data.SchemaVersion) + assert.Equal(t, "0.1", data.SchemaVersion) assert.Equal(t, "claude", data.Agent.Name) assert.NotEmpty(t, data.CapturedAt) assert.NotEmpty(t, data.ConfigHash) From 2ebf83237be7327da2a26594209823f88cd0ade8 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Fri, 13 Mar 2026 19:25:10 +0100 Subject: [PATCH 14/18] apply sugestions Signed-off-by: Jose I. Paris --- app/cli/cmd/attestation_init.go | 8 ++++++++ app/cli/pkg/action/attestation_init.go | 3 +++ internal/aiagentconfig/aiagentconfig.go | 8 ++++---- internal/aiagentconfig/builder.go | 16 +++++++++------- internal/aiagentconfig/builder_test.go | 2 +- internal/aiagentconfig/discover.go | 10 +++++----- .../ai-agent-config-0.1.schema.json | 4 ++-- .../testdata/ai_agent_config_extra_fields.json | 2 +- .../testdata/ai_agent_config_minimal.json | 6 +++--- .../testdata/ai_agent_config_missing_agent.json | 2 +- .../testdata/ai_agent_config_valid.json | 6 +++--- .../crafter/collector_aiagentconfig.go | 1 + 12 files changed, 41 insertions(+), 27 deletions(-) diff --git a/app/cli/cmd/attestation_init.go b/app/cli/cmd/attestation_init.go index d28d6e8e9..e346ed8fe 100644 --- a/app/cli/cmd/attestation_init.go +++ b/app/cli/cmd/attestation_init.go @@ -18,6 +18,8 @@ package cmd import ( "errors" "fmt" + "slices" + "strings" "github.com/chainloop-dev/chainloop/app/cli/cmd/output" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" @@ -75,6 +77,12 @@ func newAttestationInitCmd() *cobra.Command { return errors.New("project version is required when using --existing-version") } + for _, c := range collectors { + if !slices.Contains(action.ValidCollectors, c) { + return fmt.Errorf("unknown collector %q, valid options: %s", c, strings.Join(action.ValidCollectors, ", ")) + } + } + return nil }, RunE: func(cmd *cobra.Command, _ []string) error { diff --git a/app/cli/pkg/action/attestation_init.go b/app/cli/pkg/action/attestation_init.go index 995ab0c43..c5124461c 100644 --- a/app/cli/pkg/action/attestation_init.go +++ b/app/cli/pkg/action/attestation_init.go @@ -59,6 +59,9 @@ type AttestationInit struct { const aiConfigCollectorName = "aiconfig" +// ValidCollectors is the list of known collector names accepted by --collectors. +var ValidCollectors = []string{aiConfigCollectorName} + // ErrAttestationAlreadyExist means that there is an attestation in progress var ErrAttestationAlreadyExist = errors.New("attestation already initialized") diff --git a/internal/aiagentconfig/aiagentconfig.go b/internal/aiagentconfig/aiagentconfig.go index 7ca4a0f9a..7ed2da8b5 100644 --- a/internal/aiagentconfig/aiagentconfig.go +++ b/internal/aiagentconfig/aiagentconfig.go @@ -30,10 +30,10 @@ type GitContext struct { // ConfigFile represents a single discovered configuration file type ConfigFile struct { - Path string `json:"path"` - SHA256 string `json:"sha256"` - Size int64 `json:"size"` - Base64Content string `json:"base64_content"` + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Content string `json:"content"` } // Evidence is the AI agent configuration payload diff --git a/internal/aiagentconfig/builder.go b/internal/aiagentconfig/builder.go index 78c40edf5..e29a61ff2 100644 --- a/internal/aiagentconfig/builder.go +++ b/internal/aiagentconfig/builder.go @@ -75,10 +75,10 @@ func Build(rootDir string, filePaths []string, gitCtx *GitContext) (*Evidence, e hashes = append(hashes, fmt.Sprintf("%s:%s", relPath, hexHash)) configFiles = append(configFiles, ConfigFile{ - Path: relPath, - SHA256: hexHash, - Size: info.Size(), - Base64Content: base64.StdEncoding.EncodeToString(content), + Path: relPath, + SHA256: hexHash, + Size: info.Size(), + Content: base64.StdEncoding.EncodeToString(content), }) } @@ -108,10 +108,12 @@ func computeCombinedHash(hashes []string) string { // ensureInsideDir verifies that filePath is inside dir. Both paths must be // already resolved (no symlinks). Returns an error if the file escapes. func ensureInsideDir(filePath, dir string) error { - cleanDir := filepath.Clean(dir) + string(filepath.Separator) - cleanFile := filepath.Clean(filePath) + rel, err := filepath.Rel(dir, filePath) + if err != nil { + return fmt.Errorf("path escapes root directory via symlink") + } - if !strings.HasPrefix(cleanFile, cleanDir) { + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return fmt.Errorf("path escapes root directory via symlink") } diff --git a/internal/aiagentconfig/builder_test.go b/internal/aiagentconfig/builder_test.go index cfb1b2281..abec803d6 100644 --- a/internal/aiagentconfig/builder_test.go +++ b/internal/aiagentconfig/builder_test.go @@ -68,7 +68,7 @@ func TestBuild(t *testing.T) { assert.Equal(t, int64(len(file1Content)), cf1.Size) hash1 := sha256.Sum256(file1Content) assert.Equal(t, hex.EncodeToString(hash1[:]), cf1.SHA256) - assert.Equal(t, base64.StdEncoding.EncodeToString(file1Content), cf1.Base64Content) + assert.Equal(t, base64.StdEncoding.EncodeToString(file1Content), cf1.Content) cf2 := data.ConfigFiles[1] assert.Equal(t, ".claude/settings.json", cf2.Path) diff --git a/internal/aiagentconfig/discover.go b/internal/aiagentconfig/discover.go index 80b6fd621..c63d5c5fd 100644 --- a/internal/aiagentconfig/discover.go +++ b/internal/aiagentconfig/discover.go @@ -33,22 +33,22 @@ var claudePatterns = []string{ ".claude/skills/*/SKILL.md", } -// Discover searches rootDir for AI agent configuration files. -// It only looks in rootDir and its subdirectories, never in parent directories. +// Discover searches basePath for AI agent configuration files. +// It only looks in basePath and its subdirectories, never in parent directories. // Returns deduplicated relative paths sorted alphabetically. -func Discover(rootDir string) ([]string, error) { +func Discover(basePath string) ([]string, error) { seen := make(map[string]struct{}) var results []string for _, pattern := range claudePatterns { - absPattern := filepath.Join(rootDir, pattern) + absPattern := filepath.Join(basePath, pattern) matches, err := filepath.Glob(absPattern) if err != nil { return nil, err } for _, match := range matches { - rel, err := filepath.Rel(rootDir, match) + rel, err := filepath.Rel(basePath, match) if err != nil { return nil, err } diff --git a/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json b/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json index 21e1ada4f..fad10d12f 100644 --- a/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json +++ b/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json @@ -66,7 +66,7 @@ "minItems": 1, "items": { "type": "object", - "required": ["path", "sha256", "size", "base64_content"], + "required": ["path", "sha256", "size", "content"], "properties": { "path": { "type": "string", @@ -81,7 +81,7 @@ "minimum": 0, "description": "File size in bytes" }, - "base64_content": { + "content": { "type": "string", "description": "Base64-encoded file content" } diff --git a/internal/schemavalidators/testdata/ai_agent_config_extra_fields.json b/internal/schemavalidators/testdata/ai_agent_config_extra_fields.json index d11df4436..55426735f 100644 --- a/internal/schemavalidators/testdata/ai_agent_config_extra_fields.json +++ b/internal/schemavalidators/testdata/ai_agent_config_extra_fields.json @@ -10,7 +10,7 @@ "path": "CLAUDE.md", "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "size": 10, - "base64_content": "Y29udGVudA==" + "content": "Y29udGVudA==" } ], "unknown_field": "should fail" diff --git a/internal/schemavalidators/testdata/ai_agent_config_minimal.json b/internal/schemavalidators/testdata/ai_agent_config_minimal.json index c67316e00..12fdda8b3 100644 --- a/internal/schemavalidators/testdata/ai_agent_config_minimal.json +++ b/internal/schemavalidators/testdata/ai_agent_config_minimal.json @@ -8,9 +8,9 @@ "config_files": [ { "path": "CLAUDE.md", - "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "size": 10, - "base64_content": "Y29udGVudA==" + "sha256": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "size": 7, + "content": "Y29udGVudA==" } ] } diff --git a/internal/schemavalidators/testdata/ai_agent_config_missing_agent.json b/internal/schemavalidators/testdata/ai_agent_config_missing_agent.json index 03e45f168..7c1ae93f5 100644 --- a/internal/schemavalidators/testdata/ai_agent_config_missing_agent.json +++ b/internal/schemavalidators/testdata/ai_agent_config_missing_agent.json @@ -7,7 +7,7 @@ "path": "CLAUDE.md", "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "size": 10, - "base64_content": "Y29udGVudA==" + "content": "Y29udGVudA==" } ] } diff --git a/internal/schemavalidators/testdata/ai_agent_config_valid.json b/internal/schemavalidators/testdata/ai_agent_config_valid.json index a8cd16c4d..b1f35e060 100644 --- a/internal/schemavalidators/testdata/ai_agent_config_valid.json +++ b/internal/schemavalidators/testdata/ai_agent_config_valid.json @@ -14,9 +14,9 @@ "config_files": [ { "path": "CLAUDE.md", - "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "size": 42, - "base64_content": "IyBQcm9qZWN0IFJ1bGVz" + "sha256": "4c3b798d41eb62a633e0954b2cbdd4b388ca0b83f84a89cc7936cf8575d7349e", + "size": 15, + "content": "IyBQcm9qZWN0IFJ1bGVz" } ] } diff --git a/pkg/attestation/crafter/collector_aiagentconfig.go b/pkg/attestation/crafter/collector_aiagentconfig.go index db57e0d21..7b0b263bc 100644 --- a/pkg/attestation/crafter/collector_aiagentconfig.go +++ b/pkg/attestation/crafter/collector_aiagentconfig.go @@ -48,6 +48,7 @@ func (c *AIAgentConfigCollector) Collect(ctx context.Context, cr *Crafter, attes } cr.Logger.Info().Int("files", len(files)).Msg("discovered AI agent config files") + cr.Logger.Debug().Strs("paths", files).Msg("AI agent config file paths") var gitCtx *aiagentconfig.GitContext if head := cr.CraftingState.GetAttestation().GetHead(); head != nil { From aec7449bd78e8ec68aec0c306a16d30fb36e7894 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 14 Mar 2026 01:24:33 +0100 Subject: [PATCH 15/18] add tests Signed-off-by: Jose I. Paris --- .../ai-agent-config-0.1.schema.json | 1 - .../schemavalidators/schemavalidators_test.go | 1 - .../chainloop_ai_agent_config_test.go | 214 ++++++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 pkg/attestation/crafter/materials/chainloop_ai_agent_config_test.go diff --git a/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json b/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json index fad10d12f..8e0c30558 100644 --- a/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json +++ b/internal/schemavalidators/internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json @@ -63,7 +63,6 @@ "config_files": { "type": "array", "description": "Array of discovered configuration files", - "minItems": 1, "items": { "type": "object", "required": ["path", "sha256", "size", "content"], diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index 4b551240d..5bd4ab5ce 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -255,7 +255,6 @@ func TestValidateAIAgentConfig(t *testing.T) { { name: "empty config_files array", filePath: "./testdata/ai_agent_config_empty_config_files.json", - wantErr: "minimum 1 items required, but found 0", }, { name: "config file missing required fields", diff --git a/pkg/attestation/crafter/materials/chainloop_ai_agent_config_test.go b/pkg/attestation/crafter/materials/chainloop_ai_agent_config_test.go new file mode 100644 index 000000000..c21339e76 --- /dev/null +++ b/pkg/attestation/crafter/materials/chainloop_ai_agent_config_test.go @@ -0,0 +1,214 @@ +// +// 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 materials + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/internal/aiagentconfig" + "github.com/chainloop-dev/chainloop/internal/schemavalidators" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewChainloopAIAgentConfigCrafter_WrongType(t *testing.T) { + logger := zerolog.Nop() + + schema := &schemaapi.CraftingSchema_Material{ + Type: schemaapi.CraftingSchema_Material_SBOM_CYCLONEDX_JSON, + } + + _, err := NewChainloopAIAgentConfigCrafter(schema, nil, &logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "material type is not chainloop_ai_agent_config") +} + +func TestNewChainloopAIAgentConfigCrafter_CorrectType(t *testing.T) { + logger := zerolog.Nop() + + schema := &schemaapi.CraftingSchema_Material{ + Type: schemaapi.CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG, + } + + crafter, err := NewChainloopAIAgentConfigCrafter(schema, nil, &logger) + require.NoError(t, err) + assert.NotNil(t, crafter) +} + +func TestChainloopAIAgentConfigCrafter_Validation(t *testing.T) { + testCases := []struct { + name string + data *aiagentconfig.Evidence + wantErr bool + }{ + { + name: "valid full config", + data: &aiagentconfig.Evidence{ + SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1), + Agent: aiagentconfig.Agent{Name: "claude", Version: "4.0"}, + ConfigHash: "abc123", + CapturedAt: "2026-03-13T10:00:00Z", + GitContext: &aiagentconfig.GitContext{ + Repository: "https://github.com/org/repo", + Branch: "main", + CommitSHA: "abc123", + }, + ConfigFiles: []aiagentconfig.ConfigFile{ + { + Path: "CLAUDE.md", + SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + Size: 42, + Content: "IyBQcm9qZWN0IFJ1bGVz", + }, + }, + }, + wantErr: false, + }, + { + name: "valid minimal config", + data: &aiagentconfig.Evidence{ + SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1), + Agent: aiagentconfig.Agent{Name: "claude"}, + ConfigHash: "abc123", + CapturedAt: "2026-03-13T10:00:00Z", + ConfigFiles: []aiagentconfig.ConfigFile{ + { + Path: "CLAUDE.md", + SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + Size: 10, + Content: "Y29udGVudA==", + }, + }, + }, + wantErr: false, + }, + { + name: "missing required fields", + data: &aiagentconfig.Evidence{}, + wantErr: true, + }, + { + name: "empty config files", + data: &aiagentconfig.Evidence{ + SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1), + Agent: aiagentconfig.Agent{Name: "claude"}, + ConfigHash: "abc123", + CapturedAt: "2026-03-13T10:00:00Z", + ConfigFiles: []aiagentconfig.ConfigFile{}, + }, + wantErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dataBytes, err := json.Marshal(tc.data) + require.NoError(t, err) + + var rawData any + require.NoError(t, json.Unmarshal(dataBytes, &rawData)) + + err = schemavalidators.ValidateAIAgentConfig(rawData, schemavalidators.AIAgentConfigVersion0_1) + + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestChainloopAIAgentConfigCrafter_InvalidJSON(t *testing.T) { + logger := zerolog.Nop() + schema := &schemaapi.CraftingSchema_Material{ + Type: schemaapi.CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG, + } + + crafter, err := NewChainloopAIAgentConfigCrafter(schema, nil, &logger) + require.NoError(t, err) + + tmpFile := filepath.Join(t.TempDir(), "invalid.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(`{invalid json}`), 0o600)) + + _, err = crafter.Craft(context.Background(), tmpFile) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid JSON format") +} + +func TestChainloopAIAgentConfigCrafter_InvalidSchema(t *testing.T) { + logger := zerolog.Nop() + schema := &schemaapi.CraftingSchema_Material{ + Type: schemaapi.CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG, + } + + crafter, err := NewChainloopAIAgentConfigCrafter(schema, nil, &logger) + require.NoError(t, err) + + // Valid JSON but missing required fields + tmpFile := filepath.Join(t.TempDir(), "bad-schema.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(`{"foo": "bar"}`), 0o600)) + + _, err = crafter.Craft(context.Background(), tmpFile) + require.Error(t, err) + assert.Contains(t, err.Error(), "AI agent config validation failed") +} + +func TestChainloopAIAgentConfigCrafter_FileNotFound(t *testing.T) { + logger := zerolog.Nop() + schema := &schemaapi.CraftingSchema_Material{ + Type: schemaapi.CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG, + } + + crafter, err := NewChainloopAIAgentConfigCrafter(schema, nil, &logger) + require.NoError(t, err) + + _, err = crafter.Craft(context.Background(), "/nonexistent/file.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "can't open the file") +} + +func TestChainloopAIAgentConfigCrafter_RejectsExtraFields(t *testing.T) { + logger := zerolog.Nop() + schema := &schemaapi.CraftingSchema_Material{ + Type: schemaapi.CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG, + } + + crafter, err := NewChainloopAIAgentConfigCrafter(schema, nil, &logger) + require.NoError(t, err) + + payload := `{ + "schema_version": "0.1", + "agent": {"name": "claude"}, + "config_hash": "abc", + "captured_at": "2026-03-13T10:00:00Z", + "config_files": [{"path": "CLAUDE.md", "sha256": "abc", "size": 1, "content": "Yg=="}], + "unknown_field": "should fail" + }` + + tmpFile := filepath.Join(t.TempDir(), "extra-fields.json") + require.NoError(t, os.WriteFile(tmpFile, []byte(payload), 0o600)) + + _, err = crafter.Craft(context.Background(), tmpFile) + require.Error(t, err) + assert.Contains(t, err.Error(), "AI agent config validation failed") +} From 404d0b52c3f2f614a48b964e931cb1bab3baca90 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 14 Mar 2026 12:25:05 +0100 Subject: [PATCH 16/18] add support to cursor Signed-off-by: Jose I. Paris --- internal/aiagentconfig/builder.go | 19 +-- internal/aiagentconfig/builder_test.go | 30 +++- internal/aiagentconfig/discover.go | 92 ++++++++-- internal/aiagentconfig/discover_test.go | 159 +++++++++++++----- .../crafter/collector_aiagentconfig.go | 48 ++++-- 5 files changed, 256 insertions(+), 92 deletions(-) diff --git a/internal/aiagentconfig/builder.go b/internal/aiagentconfig/builder.go index e29a61ff2..97c550d21 100644 --- a/internal/aiagentconfig/builder.go +++ b/internal/aiagentconfig/builder.go @@ -29,16 +29,13 @@ import ( "github.com/chainloop-dev/chainloop/internal/schemavalidators" ) -const ( - claudeProvider = "claude" -) - // Build reads discovered files and constructs the AI agent config payload. -// rootDir is the base directory, filePaths are relative to rootDir. +// basePath is the base directory, filePaths are relative to basePath. +// agentName identifies the AI agent (e.g. "claude", "cursor"). // gitCtx may be nil if not in a git repository. -func Build(rootDir string, filePaths []string, gitCtx *GitContext) (*Evidence, error) { - // Resolve rootDir to its real path so symlink comparisons are reliable - realRoot, err := filepath.EvalSymlinks(rootDir) +func Build(basePath string, filePaths []string, agentName string, gitCtx *GitContext) (*Evidence, error) { + // Resolve basePath to its real path so symlink comparisons are reliable + realRoot, err := filepath.EvalSymlinks(basePath) if err != nil { return nil, fmt.Errorf("resolving root dir: %w", err) } @@ -47,11 +44,11 @@ func Build(rootDir string, filePaths []string, gitCtx *GitContext) (*Evidence, e hashes := make([]string, 0, len(filePaths)) for _, relPath := range filePaths { - absPath := filepath.Join(rootDir, relPath) + absPath := filepath.Join(basePath, relPath) // Resolve the full path through any symlinks (covers both symlinked // files and symlinked parent directories like .claude/) and verify - // the resolved path stays within rootDir. + // the resolved path stays within basePath. realPath, err := filepath.EvalSymlinks(absPath) if err != nil { return nil, fmt.Errorf("resolving %s: %w", relPath, err) @@ -84,7 +81,7 @@ func Build(rootDir string, filePaths []string, gitCtx *GitContext) (*Evidence, e data := Evidence{ SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1), - Agent: Agent{Name: claudeProvider}, + Agent: Agent{Name: agentName}, ConfigHash: computeCombinedHash(hashes), CapturedAt: time.Now().UTC().Format(time.RFC3339), GitContext: gitCtx, diff --git a/internal/aiagentconfig/builder_test.go b/internal/aiagentconfig/builder_test.go index abec803d6..ca6bda7cf 100644 --- a/internal/aiagentconfig/builder_test.go +++ b/internal/aiagentconfig/builder_test.go @@ -47,7 +47,7 @@ func TestBuild(t *testing.T) { CommitSHA: "abc123", } - data, err := Build(rootDir, []string{"CLAUDE.md", ".claude/settings.json"}, gitCtx) + data, err := Build(rootDir, []string{"CLAUDE.md", ".claude/settings.json"}, "claude", gitCtx) require.NoError(t, err) assert.Equal(t, "0.1", data.SchemaVersion) @@ -85,11 +85,25 @@ func TestBuild(t *testing.T) { assert.Equal(t, hex.EncodeToString(combined[:]), data.ConfigHash) } +func TestBuildWithCursorAgent(t *testing.T) { + rootDir := t.TempDir() + + require.NoError(t, os.MkdirAll(filepath.Join(rootDir, ".cursor", "rules"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(rootDir, ".cursor", "rules", "coding.md"), []byte("rules"), 0o600)) + + data, err := Build(rootDir, []string{".cursor/rules/coding.md"}, "cursor", nil) + require.NoError(t, err) + + assert.Equal(t, "cursor", data.Agent.Name) + require.Len(t, data.ConfigFiles, 1) + assert.Equal(t, ".cursor/rules/coding.md", data.ConfigFiles[0].Path) +} + func TestBuildWithoutGitContext(t *testing.T) { rootDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600)) - data, err := Build(rootDir, []string{"CLAUDE.md"}, nil) + data, err := Build(rootDir, []string{"CLAUDE.md"}, "claude", nil) require.NoError(t, err) assert.Nil(t, data.GitContext) @@ -100,7 +114,7 @@ func TestBuildJSONFormat(t *testing.T) { rootDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600)) - data, err := Build(rootDir, []string{"CLAUDE.md"}, nil) + data, err := Build(rootDir, []string{"CLAUDE.md"}, "claude", nil) require.NoError(t, err) // Verify it marshals to valid JSON with top-level fields (no envelope) @@ -128,7 +142,7 @@ func TestBuildRejectsSymlinksEscapingRoot(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0o600)) require.NoError(t, os.Symlink(filepath.Join(outsideDir, "secret.txt"), filepath.Join(rootDir, "CLAUDE.md"))) - _, err := Build(rootDir, []string{"CLAUDE.md"}, nil) + _, err := Build(rootDir, []string{"CLAUDE.md"}, "claude", nil) require.Error(t, err) assert.Contains(t, err.Error(), "path escapes root directory via symlink") } @@ -145,7 +159,7 @@ func TestBuildRejectsSymlinkedParentDir(t *testing.T) { // Symlink .claude -> outside directory require.NoError(t, os.Symlink(outsideClaude, filepath.Join(rootDir, ".claude"))) - _, err := Build(rootDir, []string{".claude/settings.json"}, nil) + _, err := Build(rootDir, []string{".claude/settings.json"}, "claude", nil) require.Error(t, err) assert.Contains(t, err.Error(), "path escapes root directory via symlink") } @@ -153,7 +167,7 @@ func TestBuildRejectsSymlinkedParentDir(t *testing.T) { func TestBuildEmptyFileList(t *testing.T) { rootDir := t.TempDir() - data, err := Build(rootDir, []string{}, nil) + data, err := Build(rootDir, []string{}, "claude", nil) require.NoError(t, err) assert.Empty(t, data.ConfigFiles) assert.NotEmpty(t, data.ConfigHash) @@ -163,7 +177,7 @@ func TestBuildEmptyFileList(t *testing.T) { func TestBuildNilFileList(t *testing.T) { rootDir := t.TempDir() - data, err := Build(rootDir, nil, nil) + data, err := Build(rootDir, nil, "claude", nil) require.NoError(t, err) assert.Empty(t, data.ConfigFiles) } @@ -172,7 +186,7 @@ func TestBuildAllowsRegularFilesInRoot(t *testing.T) { rootDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600)) - data, err := Build(rootDir, []string{"CLAUDE.md"}, nil) + data, err := Build(rootDir, []string{"CLAUDE.md"}, "claude", nil) require.NoError(t, err) assert.Len(t, data.ConfigFiles, 1) } diff --git a/internal/aiagentconfig/discover.go b/internal/aiagentconfig/discover.go index c63d5c5fd..fe1040ba1 100644 --- a/internal/aiagentconfig/discover.go +++ b/internal/aiagentconfig/discover.go @@ -20,27 +20,89 @@ import ( "sort" ) -// claudePatterns are glob patterns for Claude agent configuration files, -// evaluated relative to a root directory. -var claudePatterns = []string{ - "CLAUDE.md", - ".claude/CLAUDE.md", - ".claude/settings.json", +// agentDef defines an AI agent and its exclusive file patterns. +type agentDef struct { + name string + patterns []string +} + +// agents is the registry of supported AI agents and their exclusive file patterns. +var agents = []agentDef{ + {name: "claude", patterns: []string{ + "CLAUDE.md", + ".claude/CLAUDE.md", + ".claude/settings.json", + ".claude/rules/*.md", + ".claude/agents/*.md", + ".claude/commands/*.md", + ".claude/skills/*/SKILL.md", + }}, + {name: "cursor", patterns: []string{ + ".cursor/rules/*.md", + ".cursor/rules/*.mdc", + ".cursor/rules/**/*.md", + ".cursor/rules/**/*.mdc", + ".cursor/skills/*/SKILL.md", + ".cursor/agents/*.md", + }}, +} + +// sharedPatterns are file patterns not exclusive to any agent. +// They are included in every agent's evidence when that agent has exclusive files. +var sharedPatterns = []string{ ".mcp.json", - ".claude/rules/*.md", - ".claude/agents/*.md", - ".claude/commands/*.md", - ".claude/skills/*/SKILL.md", + "AGENTS.md", +} + +// DiscoverAll searches basePath for AI agent configuration files and groups them by agent. +// Only agents with at least one exclusive file match are included. +// Shared files are appended to each detected agent's file list. +// Returns a map of agent name → sorted, deduplicated relative paths. +func DiscoverAll(basePath string) (map[string][]string, error) { + sharedFiles, err := matchPatterns(basePath, sharedPatterns) + if err != nil { + return nil, err + } + + result := make(map[string][]string) + + for _, agent := range agents { + files, err := matchPatterns(basePath, agent.patterns) + if err != nil { + return nil, err + } + + if len(files) == 0 { + continue + } + + // Merge shared files into this agent's list, deduplicating + seen := make(map[string]struct{}, len(files)+len(sharedFiles)) + merged := make([]string, 0, len(files)+len(sharedFiles)) + for _, f := range files { + seen[f] = struct{}{} + merged = append(merged, f) + } + for _, f := range sharedFiles { + if _, ok := seen[f]; !ok { + merged = append(merged, f) + } + } + + sort.Strings(merged) + result[agent.name] = merged + } + + return result, nil } -// Discover searches basePath for AI agent configuration files. -// It only looks in basePath and its subdirectories, never in parent directories. -// Returns deduplicated relative paths sorted alphabetically. -func Discover(basePath string) ([]string, error) { +// matchPatterns expands glob patterns relative to basePath and returns +// deduplicated, sorted relative paths. +func matchPatterns(basePath string, patterns []string) ([]string, error) { seen := make(map[string]struct{}) var results []string - for _, pattern := range claudePatterns { + for _, pattern := range patterns { absPattern := filepath.Join(basePath, pattern) matches, err := filepath.Glob(absPattern) if err != nil { diff --git a/internal/aiagentconfig/discover_test.go b/internal/aiagentconfig/discover_test.go index 1c02198e4..588c12433 100644 --- a/internal/aiagentconfig/discover_test.go +++ b/internal/aiagentconfig/discover_test.go @@ -24,72 +24,139 @@ import ( "github.com/stretchr/testify/require" ) -func TestDiscover(t *testing.T) { +// createFiles is a test helper that creates files relative to rootDir. +func createFiles(t *testing.T, rootDir string, files []string) { + t.Helper() + for _, f := range files { + absPath := filepath.Join(rootDir, f) + require.NoError(t, os.MkdirAll(filepath.Dir(absPath), 0o755)) + require.NoError(t, os.WriteFile(absPath, []byte("test content"), 0o600)) + } +} + +func TestDiscoverAll(t *testing.T) { tests := []struct { name string files []string - expected []string + expected map[string][]string }{ { name: "no config files", files: []string{"main.go", "README.md"}, - expected: nil, + expected: map[string][]string{}, + }, + { + name: "claude only", + files: []string{"CLAUDE.md", ".claude/settings.json"}, + expected: map[string][]string{ + "claude": {".claude/settings.json", "CLAUDE.md"}, + }, + }, + { + name: "cursor only", + files: []string{".cursor/rules/coding.md", ".cursor/agents/test.md"}, + expected: map[string][]string{ + "cursor": {".cursor/agents/test.md", ".cursor/rules/coding.md"}, + }, + }, + { + name: "cursor mdc extension", + files: []string{ + ".cursor/rules/react.mdc", + ".cursor/rules/api.md", + }, + expected: map[string][]string{ + "cursor": {".cursor/rules/api.md", ".cursor/rules/react.mdc"}, + }, + }, + { + name: "cursor nested rules", + files: []string{ + ".cursor/rules/frontend/components.md", + ".cursor/rules/backend/api.mdc", + }, + expected: map[string][]string{ + "cursor": {".cursor/rules/backend/api.mdc", ".cursor/rules/frontend/components.md"}, + }, + }, + { + name: "cursor skills", + files: []string{".cursor/skills/search/SKILL.md"}, + expected: map[string][]string{ + "cursor": {".cursor/skills/search/SKILL.md"}, + }, }, { - name: "top-level CLAUDE.md only", - files: []string{"CLAUDE.md"}, - expected: []string{"CLAUDE.md"}, + name: "both agents - separate results", + files: []string{ + "CLAUDE.md", + ".claude/settings.json", + ".cursor/rules/coding.md", + ".cursor/agents/reviewer.md", + }, + expected: map[string][]string{ + "claude": {".claude/settings.json", "CLAUDE.md"}, + "cursor": {".cursor/agents/reviewer.md", ".cursor/rules/coding.md"}, + }, }, { - name: "all claude patterns", + name: "shared files included in each agent", + files: []string{ + "CLAUDE.md", + ".cursor/rules/coding.md", + ".mcp.json", + "AGENTS.md", + }, + expected: map[string][]string{ + "claude": {".mcp.json", "AGENTS.md", "CLAUDE.md"}, + "cursor": {".cursor/rules/coding.md", ".mcp.json", "AGENTS.md"}, + }, + }, + { + name: "only shared files - no agents returned", + files: []string{".mcp.json", "AGENTS.md"}, + expected: map[string][]string{}, + }, + { + name: "all claude patterns with shared", files: []string{ "CLAUDE.md", ".claude/CLAUDE.md", ".claude/settings.json", ".mcp.json", + "AGENTS.md", ".claude/rules/coding.md", ".claude/rules/testing.md", ".claude/agents/reviewer.md", ".claude/commands/deploy.md", ".claude/skills/search/SKILL.md", }, - expected: []string{ - ".claude/CLAUDE.md", - ".claude/agents/reviewer.md", - ".claude/commands/deploy.md", - ".claude/rules/coding.md", - ".claude/rules/testing.md", - ".claude/settings.json", - ".claude/skills/search/SKILL.md", - ".mcp.json", - "CLAUDE.md", + expected: map[string][]string{ + "claude": { + ".claude/CLAUDE.md", + ".claude/agents/reviewer.md", + ".claude/commands/deploy.md", + ".claude/rules/coding.md", + ".claude/rules/testing.md", + ".claude/settings.json", + ".claude/skills/search/SKILL.md", + ".mcp.json", + "AGENTS.md", + "CLAUDE.md", + }, }, }, { name: "non-matching files are ignored", files: []string{ "CLAUDE.md", - ".claude/rules/coding.md", - ".claude/rules/coding.txt", // wrong extension for rules pattern - ".claude/other/something.md", // not in a known pattern + ".claude/rules/coding.txt", // wrong extension + ".claude/other/something.md", // not a known pattern "some/nested/CLAUDE.md", // nested too deep + ".cursor/other/random.md", // not a known pattern }, - expected: []string{ - ".claude/rules/coding.md", - "CLAUDE.md", - }, - }, - { - name: "results are sorted and deduplicated", - files: []string{ - ".mcp.json", - "CLAUDE.md", - ".claude/settings.json", - }, - expected: []string{ - ".claude/settings.json", - ".mcp.json", - "CLAUDE.md", + expected: map[string][]string{ + "claude": {"CLAUDE.md"}, }, }, } @@ -97,21 +164,21 @@ func TestDiscover(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { rootDir := t.TempDir() + createFiles(t, rootDir, tt.files) - for _, f := range tt.files { - absPath := filepath.Join(rootDir, f) - require.NoError(t, os.MkdirAll(filepath.Dir(absPath), 0o755)) - require.NoError(t, os.WriteFile(absPath, []byte("test content"), 0o600)) - } - - results, err := Discover(rootDir) + results, err := DiscoverAll(rootDir) require.NoError(t, err) - assert.Equal(t, tt.expected, results) + + if len(tt.expected) == 0 { + assert.Empty(t, results) + } else { + assert.Equal(t, tt.expected, results) + } }) } } -func TestDiscoverNeverTraversesParent(t *testing.T) { +func TestDiscoverAllNeverTraversesParent(t *testing.T) { parentDir := t.TempDir() // Create a CLAUDE.md in the parent @@ -121,7 +188,7 @@ func TestDiscoverNeverTraversesParent(t *testing.T) { childDir := filepath.Join(parentDir, "subproject") require.NoError(t, os.MkdirAll(childDir, 0o755)) - results, err := Discover(childDir) + results, err := DiscoverAll(childDir) require.NoError(t, err) assert.Empty(t, results, "should not find files in parent directory") } diff --git a/pkg/attestation/crafter/collector_aiagentconfig.go b/pkg/attestation/crafter/collector_aiagentconfig.go index 7b0b263bc..b5bf151a8 100644 --- a/pkg/attestation/crafter/collector_aiagentconfig.go +++ b/pkg/attestation/crafter/collector_aiagentconfig.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" "os" + "sort" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/internal/aiagentconfig" @@ -37,19 +38,16 @@ func NewAIAgentConfigCollector() *AIAgentConfigCollector { func (c *AIAgentConfigCollector) ID() string { return "ai-agent-config" } func (c *AIAgentConfigCollector) Collect(ctx context.Context, cr *Crafter, attestationID string, casBackend *casclient.CASBackend) error { - files, err := aiagentconfig.Discover(cr.WorkingDir()) + agentFiles, err := aiagentconfig.DiscoverAll(cr.WorkingDir()) if err != nil { return fmt.Errorf("discovering AI agent config files: %w", err) } - if len(files) == 0 { + if len(agentFiles) == 0 { cr.Logger.Debug().Msg("no AI agent config files found, skipping") return nil } - cr.Logger.Info().Int("files", len(files)).Msg("discovered AI agent config files") - cr.Logger.Debug().Strs("paths", files).Msg("AI agent config file paths") - var gitCtx *aiagentconfig.GitContext if head := cr.CraftingState.GetAttestation().GetHead(); head != nil { gitCtx = &aiagentconfig.GitContext{ @@ -60,17 +58,42 @@ func (c *AIAgentConfigCollector) Collect(ctx context.Context, cr *Crafter, attes } } - evidence, err := aiagentconfig.Build(cr.WorkingDir(), files, gitCtx) + // Process each agent in deterministic order + agentNames := make([]string, 0, len(agentFiles)) + for name := range agentFiles { + agentNames = append(agentNames, name) + } + sort.Strings(agentNames) + + for _, agentName := range agentNames { + files := agentFiles[agentName] + + cr.Logger.Info().Str("agent", agentName).Int("files", len(files)).Msg("discovered AI agent config files") + cr.Logger.Debug().Str("agent", agentName).Strs("paths", files).Msg("AI agent config file paths") + + if err := c.uploadAgentConfig(ctx, cr, attestationID, casBackend, agentName, files, gitCtx); err != nil { + return err + } + } + + return nil +} + +func (c *AIAgentConfigCollector) uploadAgentConfig( + ctx context.Context, cr *Crafter, attestationID string, + casBackend *casclient.CASBackend, agentName string, files []string, gitCtx *aiagentconfig.GitContext, +) error { + evidence, err := aiagentconfig.Build(cr.WorkingDir(), files, agentName, gitCtx) if err != nil { - return fmt.Errorf("building AI agent config: %w", err) + return fmt.Errorf("building AI agent config for %s: %w", agentName, err) } jsonData, err := json.Marshal(evidence) if err != nil { - return fmt.Errorf("marshaling AI agent config: %w", err) + return fmt.Errorf("marshaling AI agent config for %s: %w", agentName, err) } - tmpFile, err := os.CreateTemp("", "ai-agent-config-*.json") + tmpFile, err := os.CreateTemp("", fmt.Sprintf("ai-agent-config-%s-*.json", agentName)) if err != nil { return fmt.Errorf("creating temp file: %w", err) } @@ -82,11 +105,12 @@ func (c *AIAgentConfigCollector) Collect(ctx context.Context, cr *Crafter, attes } tmpFile.Close() - if _, err := cr.AddMaterialContractFree(ctx, attestationID, schemaapi.CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG.String(), "ai-agent-config-claude", tmpFile.Name(), casBackend, nil); err != nil { - return fmt.Errorf("adding AI agent config material: %w", err) + materialName := fmt.Sprintf("ai-agent-config-%s", agentName) + if _, err := cr.AddMaterialContractFree(ctx, attestationID, schemaapi.CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG.String(), materialName, tmpFile.Name(), casBackend, nil); err != nil { + return fmt.Errorf("adding AI agent config material for %s: %w", agentName, err) } - cr.Logger.Info().Msg("successfully collected AI agent configuration evidence") + cr.Logger.Info().Str("agent", agentName).Msg("successfully collected AI agent configuration") return nil } From 0051da4f076bca1752580e0b23be3b6059cc3aa3 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 14 Mar 2026 12:42:43 +0100 Subject: [PATCH 17/18] add cursor tests Signed-off-by: Jose I. Paris --- .../chainloop_ai_agent_config_test.go | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/pkg/attestation/crafter/materials/chainloop_ai_agent_config_test.go b/pkg/attestation/crafter/materials/chainloop_ai_agent_config_test.go index c21339e76..9f082db7d 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_agent_config_test.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_agent_config_test.go @@ -101,6 +101,54 @@ func TestChainloopAIAgentConfigCrafter_Validation(t *testing.T) { }, wantErr: false, }, + { + name: "valid cursor config", + data: &aiagentconfig.Evidence{ + SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1), + Agent: aiagentconfig.Agent{Name: "cursor"}, + ConfigHash: "def456", + CapturedAt: "2026-03-13T10:00:00Z", + ConfigFiles: []aiagentconfig.ConfigFile{ + { + Path: ".cursor/rules/coding.md", + SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + Size: 20, + Content: "IyBDb2RpbmcgUnVsZXM=", + }, + }, + }, + wantErr: false, + }, + { + name: "valid cursor with multiple file types", + data: &aiagentconfig.Evidence{ + SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1), + Agent: aiagentconfig.Agent{Name: "cursor"}, + ConfigHash: "ghi789", + CapturedAt: "2026-03-13T10:00:00Z", + ConfigFiles: []aiagentconfig.ConfigFile{ + { + Path: ".cursor/rules/react.mdc", + SHA256: "abc123", + Size: 15, + Content: "cnVsZXM=", + }, + { + Path: ".cursor/agents/reviewer.md", + SHA256: "def456", + Size: 10, + Content: "YWdlbnQ=", + }, + { + Path: "AGENTS.md", + SHA256: "789abc", + Size: 8, + Content: "YWdlbnRz", + }, + }, + }, + wantErr: false, + }, { name: "missing required fields", data: &aiagentconfig.Evidence{}, From 27550aed67d34e5c85131bd2339647ff37c0b1e8 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 14 Mar 2026 17:07:27 +0100 Subject: [PATCH 18/18] remove recursivity Signed-off-by: Jose I. Paris --- internal/aiagentconfig/discover.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/aiagentconfig/discover.go b/internal/aiagentconfig/discover.go index fe1040ba1..ceaf41ec8 100644 --- a/internal/aiagentconfig/discover.go +++ b/internal/aiagentconfig/discover.go @@ -40,8 +40,8 @@ var agents = []agentDef{ {name: "cursor", patterns: []string{ ".cursor/rules/*.md", ".cursor/rules/*.mdc", - ".cursor/rules/**/*.md", - ".cursor/rules/**/*.mdc", + ".cursor/rules/*/*.md", + ".cursor/rules/*/*.mdc", ".cursor/skills/*/SKILL.md", ".cursor/agents/*.md", }},