Skip to content

Commit 525bcd5

Browse files
committed
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 <jiparis@chainloop.dev> Signed-off-by: Jose I. Paris <jiparis@chainloop.dev>
1 parent c0de9a9 commit 525bcd5

10 files changed

Lines changed: 713 additions & 74 deletions

File tree

app/cli/pkg/action/attestation_init.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -300,11 +300,12 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun
300300
return "", err
301301
}
302302

303-
// Auto-collect PR/MR metadata if in PR/MR context
304-
if err := action.c.AutoCollectPRMetadata(ctx, attestationID, discoveredRunner, casBackend); err != nil {
305-
action.Logger.Warn().Err(err).Msg("failed to auto-collect PR/MR metadata")
306-
// Don't fail the init - this is best-effort
307-
}
303+
// Register and run auto-discovery collectors
304+
action.c.RegisterCollectors(
305+
crafter.NewPRMetadataCollector(discoveredRunner),
306+
crafter.NewAIAgentConfigCollector(),
307+
)
308+
action.c.RunCollectors(ctx, attestationID, casBackend)
308309

309310
// Evaluate attestation-level policies at init phase
310311
attClient := pb.NewAttestationServiceClient(action.CPConnection)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package aiagentconfig
17+
18+
const (
19+
// EvidenceID is the identifier for the AI agent config evidence
20+
EvidenceID = "CHAINLOOP_AI_AGENT_CONFIG"
21+
// EvidenceSchemaURL is the URL to the JSON schema for AI agent config
22+
EvidenceSchemaURL = "https://schemas.chainloop.dev/aiagentconfig/1.0/ai-agent-config.schema.json"
23+
)
24+
25+
// Agent identifies the AI agent provider
26+
type Agent struct {
27+
Name string `json:"name"`
28+
Version string `json:"version,omitempty"`
29+
}
30+
31+
// GitContext holds optional git information at capture time
32+
type GitContext struct {
33+
Repository string `json:"repository,omitempty"`
34+
Branch string `json:"branch,omitempty"`
35+
CommitSHA string `json:"commit_sha,omitempty"`
36+
}
37+
38+
// ConfigFile represents a single discovered configuration file
39+
type ConfigFile struct {
40+
Path string `json:"path"`
41+
SHA256 string `json:"sha256"`
42+
Size int64 `json:"size"`
43+
Base64Content string `json:"base64_content"`
44+
}
45+
46+
// Data is the payload inside the evidence envelope
47+
type Data struct {
48+
SchemaVersion string `json:"schema_version"`
49+
Agent Agent `json:"agent"`
50+
ConfigHash string `json:"config_hash"`
51+
CapturedAt string `json:"captured_at"`
52+
GitContext *GitContext `json:"git_context,omitempty"`
53+
ConfigFiles []ConfigFile `json:"config_files"`
54+
// Future fields for richer analysis
55+
Permissions any `json:"permissions,omitempty"`
56+
MCPServers any `json:"mcp_servers,omitempty"`
57+
Subagents any `json:"subagents,omitempty"`
58+
}
59+
60+
// Evidence is the full envelope matching the custom evidence format
61+
type Evidence struct {
62+
ID string `json:"chainloop.material.evidence.id"`
63+
Schema string `json:"schema"`
64+
Data Data `json:"data"`
65+
}
66+
67+
// NewEvidence creates a new Evidence instance with the standard envelope
68+
func NewEvidence(data Data) *Evidence {
69+
return &Evidence{
70+
ID: EvidenceID,
71+
Schema: EvidenceSchemaURL,
72+
Data: data,
73+
}
74+
}

internal/aiagentconfig/builder.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package aiagentconfig
17+
18+
import (
19+
"crypto/sha256"
20+
"encoding/base64"
21+
"encoding/hex"
22+
"fmt"
23+
"os"
24+
"path/filepath"
25+
"sort"
26+
"strings"
27+
"time"
28+
)
29+
30+
// BuildEvidence reads discovered files and constructs the evidence payload.
31+
// rootDir is the base directory, filePaths are relative to rootDir.
32+
// gitCtx may be nil if not in a git repository.
33+
func BuildEvidence(rootDir string, filePaths []string, gitCtx *GitContext) (*Evidence, error) {
34+
configFiles := make([]ConfigFile, 0, len(filePaths))
35+
hashes := make([]string, 0, len(filePaths))
36+
37+
for _, relPath := range filePaths {
38+
absPath := filepath.Join(rootDir, relPath)
39+
40+
content, err := os.ReadFile(absPath)
41+
if err != nil {
42+
return nil, fmt.Errorf("reading %s: %w", relPath, err)
43+
}
44+
45+
info, err := os.Stat(absPath)
46+
if err != nil {
47+
return nil, fmt.Errorf("stat %s: %w", relPath, err)
48+
}
49+
50+
hash := sha256.Sum256(content)
51+
hexHash := hex.EncodeToString(hash[:])
52+
hashes = append(hashes, hexHash)
53+
54+
configFiles = append(configFiles, ConfigFile{
55+
Path: relPath,
56+
SHA256: hexHash,
57+
Size: info.Size(),
58+
Base64Content: base64.StdEncoding.EncodeToString(content),
59+
})
60+
}
61+
62+
data := Data{
63+
SchemaVersion: "v1alpha",
64+
Agent: Agent{Name: "claude"},
65+
ConfigHash: computeCombinedHash(hashes),
66+
CapturedAt: time.Now().UTC().Format(time.RFC3339),
67+
GitContext: gitCtx,
68+
ConfigFiles: configFiles,
69+
}
70+
71+
return NewEvidence(data), nil
72+
}
73+
74+
// computeCombinedHash sorts individual hashes, concatenates them, and hashes the result.
75+
func computeCombinedHash(hashes []string) string {
76+
sorted := make([]string, len(hashes))
77+
copy(sorted, hashes)
78+
sort.Strings(sorted)
79+
80+
combined := sha256.Sum256([]byte(strings.Join(sorted, "")))
81+
82+
return hex.EncodeToString(combined[:])
83+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package aiagentconfig
17+
18+
import (
19+
"crypto/sha256"
20+
"encoding/base64"
21+
"encoding/hex"
22+
"encoding/json"
23+
"os"
24+
"path/filepath"
25+
"sort"
26+
"strings"
27+
"testing"
28+
29+
"github.com/stretchr/testify/assert"
30+
"github.com/stretchr/testify/require"
31+
)
32+
33+
func TestBuildEvidence(t *testing.T) {
34+
rootDir := t.TempDir()
35+
36+
// Create test files
37+
file1Content := []byte("# Project Rules\nAlways use Go.")
38+
file2Content := []byte(`{"allow": ["read"]}`)
39+
40+
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), file1Content, 0o600))
41+
require.NoError(t, os.MkdirAll(filepath.Join(rootDir, ".claude"), 0o755))
42+
require.NoError(t, os.WriteFile(filepath.Join(rootDir, ".claude", "settings.json"), file2Content, 0o600))
43+
44+
gitCtx := &GitContext{
45+
Repository: "https://github.com/org/repo",
46+
CommitSHA: "abc123",
47+
}
48+
49+
evidence, err := BuildEvidence(rootDir, []string{"CLAUDE.md", ".claude/settings.json"}, gitCtx)
50+
require.NoError(t, err)
51+
52+
// Verify envelope
53+
assert.Equal(t, EvidenceID, evidence.ID)
54+
assert.Equal(t, EvidenceSchemaURL, evidence.Schema)
55+
56+
// Verify data
57+
assert.Equal(t, "v1alpha", evidence.Data.SchemaVersion)
58+
assert.Equal(t, "claude", evidence.Data.Agent.Name)
59+
assert.NotEmpty(t, evidence.Data.CapturedAt)
60+
assert.NotEmpty(t, evidence.Data.ConfigHash)
61+
62+
// Verify git context
63+
require.NotNil(t, evidence.Data.GitContext)
64+
assert.Equal(t, "https://github.com/org/repo", evidence.Data.GitContext.Repository)
65+
assert.Equal(t, "abc123", evidence.Data.GitContext.CommitSHA)
66+
67+
// Verify config files
68+
require.Len(t, evidence.Data.ConfigFiles, 2)
69+
70+
cf1 := evidence.Data.ConfigFiles[0]
71+
assert.Equal(t, "CLAUDE.md", cf1.Path)
72+
assert.Equal(t, int64(len(file1Content)), cf1.Size)
73+
hash1 := sha256.Sum256(file1Content)
74+
assert.Equal(t, hex.EncodeToString(hash1[:]), cf1.SHA256)
75+
assert.Equal(t, base64.StdEncoding.EncodeToString(file1Content), cf1.Base64Content)
76+
77+
cf2 := evidence.Data.ConfigFiles[1]
78+
assert.Equal(t, ".claude/settings.json", cf2.Path)
79+
hash2 := sha256.Sum256(file2Content)
80+
assert.Equal(t, hex.EncodeToString(hash2[:]), cf2.SHA256)
81+
82+
// Verify config hash is deterministic
83+
hashes := []string{hex.EncodeToString(hash1[:]), hex.EncodeToString(hash2[:])}
84+
sort.Strings(hashes)
85+
combined := sha256.Sum256([]byte(strings.Join(hashes, "")))
86+
assert.Equal(t, hex.EncodeToString(combined[:]), evidence.Data.ConfigHash)
87+
}
88+
89+
func TestBuildEvidenceWithoutGitContext(t *testing.T) {
90+
rootDir := t.TempDir()
91+
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600))
92+
93+
evidence, err := BuildEvidence(rootDir, []string{"CLAUDE.md"}, nil)
94+
require.NoError(t, err)
95+
96+
assert.Nil(t, evidence.Data.GitContext)
97+
assert.Len(t, evidence.Data.ConfigFiles, 1)
98+
}
99+
100+
func TestBuildEvidenceJSONFormat(t *testing.T) {
101+
rootDir := t.TempDir()
102+
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600))
103+
104+
evidence, err := BuildEvidence(rootDir, []string{"CLAUDE.md"}, nil)
105+
require.NoError(t, err)
106+
107+
// Verify it marshals to valid JSON with the correct envelope field
108+
jsonData, err := json.Marshal(evidence)
109+
require.NoError(t, err)
110+
111+
var raw map[string]any
112+
require.NoError(t, json.Unmarshal(jsonData, &raw))
113+
114+
assert.Equal(t, EvidenceID, raw["chainloop.material.evidence.id"])
115+
assert.Equal(t, EvidenceSchemaURL, raw["schema"])
116+
assert.NotNil(t, raw["data"])
117+
}

internal/aiagentconfig/discover.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package aiagentconfig
17+
18+
import (
19+
"path/filepath"
20+
"sort"
21+
)
22+
23+
// claudePatterns are glob patterns for Claude agent configuration files,
24+
// evaluated relative to a root directory.
25+
var claudePatterns = []string{
26+
"CLAUDE.md",
27+
".claude/CLAUDE.md",
28+
".claude/settings.json",
29+
".mcp.json",
30+
".claude/rules/*.md",
31+
".claude/agents/*.md",
32+
".claude/commands/*.md",
33+
".claude/skills/*/SKILL.md",
34+
}
35+
36+
// Discover searches rootDir for AI agent configuration files.
37+
// It only looks in rootDir and its subdirectories, never in parent directories.
38+
// Returns deduplicated relative paths sorted alphabetically.
39+
func Discover(rootDir string) ([]string, error) {
40+
seen := make(map[string]struct{})
41+
var results []string
42+
43+
for _, pattern := range claudePatterns {
44+
absPattern := filepath.Join(rootDir, pattern)
45+
matches, err := filepath.Glob(absPattern)
46+
if err != nil {
47+
return nil, err
48+
}
49+
50+
for _, match := range matches {
51+
rel, err := filepath.Rel(rootDir, match)
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
if _, ok := seen[rel]; !ok {
57+
seen[rel] = struct{}{}
58+
results = append(results, rel)
59+
}
60+
}
61+
}
62+
63+
sort.Strings(results)
64+
65+
return results, nil
66+
}

0 commit comments

Comments
 (0)