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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions internal/aiagentconfig/aiagentconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ const (
ConfigFileKindConfiguration ConfigFileKind = "configuration"
// ConfigFileKindInstruction is for markdown instruction/rules files.
ConfigFileKindInstruction ConfigFileKind = "instruction"

// EvidenceID is the identifier for the AI agent config material type
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"
)

// DiscoveredFile represents a file found during discovery, before reading its content.
Expand Down Expand Up @@ -53,16 +58,31 @@ type ConfigFile struct {
Content string `json:"content"`
}

// Evidence is the AI agent configuration payload
type Evidence 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"`
// Data is the AI agent configuration payload
type Data struct {
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 represents the complete evidence structure for AI agent config
type Evidence struct {
ID string `json:"chainloop.material.evidence.id"`
Schema string `json:"schema"`
Data Data `json:"data"`
}

// NewEvidence creates a new Evidence instance
func NewEvidence(data Data) *Evidence {
return &Evidence{
ID: EvidenceID,
Schema: EvidenceSchemaURL,
Data: data,
}
}
17 changes: 7 additions & 10 deletions internal/aiagentconfig/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,13 @@ import (
"sort"
"strings"
"time"

"github.com/chainloop-dev/chainloop/internal/schemavalidators"
)

// Build reads discovered files and constructs the AI agent config payload.
// basePath is the base directory, discovered contains files relative to basePath with their kinds.
// agentName identifies the AI agent (e.g. "claude", "cursor").
// gitCtx may be nil if not in a git repository.
func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCtx *GitContext) (*Evidence, error) {
func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCtx *GitContext) (*Data, error) {
// Resolve basePath to its real path so symlink comparisons are reliable
realRoot, err := filepath.EvalSymlinks(basePath)
if err != nil {
Expand Down Expand Up @@ -81,13 +79,12 @@ func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCt
})
}

data := Evidence{
SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1),
Agent: Agent{Name: agentName},
ConfigHash: computeCombinedHash(hashes),
CapturedAt: time.Now().UTC().Format(time.RFC3339),
GitContext: gitCtx,
ConfigFiles: configFiles,
data := Data{
Agent: Agent{Name: agentName},
ConfigHash: computeCombinedHash(hashes),
CapturedAt: time.Now().UTC().Format(time.RFC3339),
GitContext: gitCtx,
ConfigFiles: configFiles,
}

return &data, nil
Expand Down
2 changes: 0 additions & 2 deletions internal/aiagentconfig/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ func TestBuild(t *testing.T) {
}, "claude", gitCtx)
require.NoError(t, err)

assert.Equal(t, "0.1", data.SchemaVersion)
assert.Equal(t, "claude", data.Agent.Name)
assert.NotEmpty(t, data.CapturedAt)
assert.NotEmpty(t, data.ConfigHash)
Expand Down Expand Up @@ -136,7 +135,6 @@ func TestBuildJSONFormat(t *testing.T) {
var raw map[string]any
require.NoError(t, json.Unmarshal(jsonData, &raw))

assert.NotNil(t, raw["schema_version"])
assert.NotNil(t, raw["agent"])
assert.NotNil(t, raw["config_hash"])
assert.NotNil(t, raw["captured_at"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,12 @@
"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",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"schema_version": "0.1",
"agent": {
"name": "claude"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"schema_version": "0.1",
"agent": {
"name": "claude"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"schema_version": "0.1",
"agent": {
"name": "claude"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"schema_version": "0.1",
"agent": {
"name": "claude",
"version": "4.0"
Expand Down
3 changes: 2 additions & 1 deletion pkg/attestation/crafter/collector_aiagentconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,12 @@ func (c *AIAgentConfigCollector) uploadAgentConfig(
ctx context.Context, cr *Crafter, attestationID string,
casBackend *casclient.CASBackend, agentName string, files []aiagentconfig.DiscoveredFile, gitCtx *aiagentconfig.GitContext,
) error {
evidence, err := aiagentconfig.Build(cr.WorkingDir(), files, agentName, gitCtx)
data, err := aiagentconfig.Build(cr.WorkingDir(), files, agentName, gitCtx)
if err != nil {
return fmt.Errorf("building AI agent config for %s: %w", agentName, err)
}

evidence := aiagentconfig.NewEvidence(*data)
jsonData, err := json.Marshal(evidence)
if err != nil {
return fmt.Errorf("marshaling AI agent config for %s: %w", agentName, err)
Expand Down
32 changes: 22 additions & 10 deletions pkg/attestation/crafter/materials/chainloop_ai_agent_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ 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"
Expand Down Expand Up @@ -55,12 +56,28 @@ func (c *ChainloopAIAgentConfigCrafter) Craft(ctx context.Context, artifactPath
return nil, fmt.Errorf("can't open the file: %w", err)
}

var rawData any
if err := json.Unmarshal(f, &rawData); err != nil {
// Unmarshal envelope, keeping data as raw JSON for schema validation
var envelope struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(f, &envelope); err != nil {
c.logger.Debug().Err(err).Msg("error decoding file")
return nil, fmt.Errorf("invalid JSON format: %w", err)
}

// Unmarshal data into typed struct for agent name extraction
var data aiagentconfig.Data
if err := json.Unmarshal(envelope.Data, &data); err != nil {
c.logger.Debug().Err(err).Msg("error decoding data field")
return nil, fmt.Errorf("failed to unmarshal data: %w", err)
}

// Validate using raw JSON to preserve unknown fields for strict schema validation
var rawData any
if err := json.Unmarshal(envelope.Data, &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)
Expand All @@ -71,14 +88,9 @@ func (c *ChainloopAIAgentConfigCrafter) Craft(ctx context.Context, artifactPath
return nil, err
}

// Extract agent name from the validated JSON and surface it as an annotation
var envelope struct {
Agent struct {
Name string `json:"name"`
} `json:"agent"`
}
if err := json.Unmarshal(f, &envelope); err == nil && envelope.Agent.Name != "" {
material.Annotations[annotationAIAgentName] = envelope.Agent.Name
// Surface agent name as an annotation
if data.Agent.Name != "" {
material.Annotations[annotationAIAgentName] = data.Agent.Name
}

return material, nil
Expand Down
70 changes: 34 additions & 36 deletions pkg/attestation/crafter/materials/chainloop_ai_agent_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,15 @@ func TestNewChainloopAIAgentConfigCrafter_CorrectType(t *testing.T) {
func TestChainloopAIAgentConfigCrafter_Validation(t *testing.T) {
testCases := []struct {
name string
data *aiagentconfig.Evidence
data *aiagentconfig.Data
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",
data: &aiagentconfig.Data{
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",
Expand All @@ -88,11 +87,10 @@ func TestChainloopAIAgentConfigCrafter_Validation(t *testing.T) {
},
{
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",
data: &aiagentconfig.Data{
Agent: aiagentconfig.Agent{Name: "claude"},
ConfigHash: "abc123",
CapturedAt: "2026-03-13T10:00:00Z",
ConfigFiles: []aiagentconfig.ConfigFile{
{
Path: "CLAUDE.md",
Expand All @@ -107,11 +105,10 @@ func TestChainloopAIAgentConfigCrafter_Validation(t *testing.T) {
},
{
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",
data: &aiagentconfig.Data{
Agent: aiagentconfig.Agent{Name: "cursor"},
ConfigHash: "def456",
CapturedAt: "2026-03-13T10:00:00Z",
ConfigFiles: []aiagentconfig.ConfigFile{
{
Path: ".cursor/rules/coding.md",
Expand All @@ -126,11 +123,10 @@ func TestChainloopAIAgentConfigCrafter_Validation(t *testing.T) {
},
{
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",
data: &aiagentconfig.Data{
Agent: aiagentconfig.Agent{Name: "cursor"},
ConfigHash: "ghi789",
CapturedAt: "2026-03-13T10:00:00Z",
ConfigFiles: []aiagentconfig.ConfigFile{
{
Path: ".cursor/rules/react.mdc",
Expand Down Expand Up @@ -159,17 +155,16 @@ func TestChainloopAIAgentConfigCrafter_Validation(t *testing.T) {
},
{
name: "missing required fields",
data: &aiagentconfig.Evidence{},
data: &aiagentconfig.Data{},
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{},
data: &aiagentconfig.Data{
Agent: aiagentconfig.Agent{Name: "claude"},
ConfigHash: "abc123",
CapturedAt: "2026-03-13T10:00:00Z",
ConfigFiles: []aiagentconfig.ConfigFile{},
},
wantErr: false,
},
Expand Down Expand Up @@ -220,9 +215,9 @@ func TestChainloopAIAgentConfigCrafter_InvalidSchema(t *testing.T) {
crafter, err := NewChainloopAIAgentConfigCrafter(schema, nil, &logger)
require.NoError(t, err)

// Valid JSON but missing required fields
// Valid envelope but data is missing required fields
tmpFile := filepath.Join(t.TempDir(), "bad-schema.json")
require.NoError(t, os.WriteFile(tmpFile, []byte(`{"foo": "bar"}`), 0o600))
require.NoError(t, os.WriteFile(tmpFile, []byte(`{"chainloop.material.evidence.id":"CHAINLOOP_AI_AGENT_CONFIG","schema":"test","data":{"foo":"bar"}}`), 0o600))

_, err = crafter.Craft(context.Background(), tmpFile)
require.Error(t, err)
Expand Down Expand Up @@ -253,12 +248,15 @@ func TestChainloopAIAgentConfigCrafter_RejectsExtraFields(t *testing.T) {
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", "kind": "instruction", "sha256": "abc", "size": 1, "content": "Yg=="}],
"unknown_field": "should fail"
"chainloop.material.evidence.id": "CHAINLOOP_AI_AGENT_CONFIG",
"schema": "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json",
"data": {
"agent": {"name": "claude"},
"config_hash": "abc",
"captured_at": "2026-03-13T10:00:00Z",
"config_files": [{"path": "CLAUDE.md", "kind": "instruction", "sha256": "abc", "size": 1, "content": "Yg=="}],
"unknown_field": "should fail"
}
}`

tmpFile := filepath.Join(t.TempDir(), "extra-fields.json")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
{
"schema_version": "0.1",
"agent": {"name": "claude"},
"config_hash": "abc123",
"captured_at": "2026-03-13T10:00:00Z",
"config_files": [
{
"path": "CLAUDE.md",
"kind": "instruction",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 42,
"content": "IyBQcm9qZWN0IFJ1bGVz"
}
]
"chainloop.material.evidence.id": "CHAINLOOP_AI_AGENT_CONFIG",
"schema": "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json",
"data": {
"agent": {"name": "claude"},
"config_hash": "abc123",
"captured_at": "2026-03-13T10:00:00Z",
"config_files": [
{
"path": "CLAUDE.md",
"kind": "instruction",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 42,
"content": "IyBQcm9qZWN0IFJ1bGVz"
}
]
}
}
Loading
Loading