diff --git a/internal/aiagentconfig/aiagentconfig.go b/internal/aiagentconfig/aiagentconfig.go index a25f31cc2..d07d5367a 100644 --- a/internal/aiagentconfig/aiagentconfig.go +++ b/internal/aiagentconfig/aiagentconfig.go @@ -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. @@ -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, + } +} diff --git a/internal/aiagentconfig/builder.go b/internal/aiagentconfig/builder.go index 197df65cc..317c8973c 100644 --- a/internal/aiagentconfig/builder.go +++ b/internal/aiagentconfig/builder.go @@ -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 { @@ -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 diff --git a/internal/aiagentconfig/builder_test.go b/internal/aiagentconfig/builder_test.go index 02607dcae..d1ff4914b 100644 --- a/internal/aiagentconfig/builder_test.go +++ b/internal/aiagentconfig/builder_test.go @@ -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) @@ -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"]) 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 c56211f2b..6a5a1b817 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 @@ -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", diff --git a/internal/schemavalidators/testdata/ai_agent_config_empty_config_files.json b/internal/schemavalidators/testdata/ai_agent_config_empty_config_files.json index 0720cb106..a206fb704 100644 --- a/internal/schemavalidators/testdata/ai_agent_config_empty_config_files.json +++ b/internal/schemavalidators/testdata/ai_agent_config_empty_config_files.json @@ -1,5 +1,4 @@ { - "schema_version": "0.1", "agent": { "name": "claude" }, diff --git a/internal/schemavalidators/testdata/ai_agent_config_extra_fields.json b/internal/schemavalidators/testdata/ai_agent_config_extra_fields.json index 567ccf50b..7794f8d72 100644 --- a/internal/schemavalidators/testdata/ai_agent_config_extra_fields.json +++ b/internal/schemavalidators/testdata/ai_agent_config_extra_fields.json @@ -1,5 +1,4 @@ { - "schema_version": "0.1", "agent": { "name": "claude" }, diff --git a/internal/schemavalidators/testdata/ai_agent_config_minimal.json b/internal/schemavalidators/testdata/ai_agent_config_minimal.json index 2f1916261..7eeb4f526 100644 --- a/internal/schemavalidators/testdata/ai_agent_config_minimal.json +++ b/internal/schemavalidators/testdata/ai_agent_config_minimal.json @@ -1,5 +1,4 @@ { - "schema_version": "0.1", "agent": { "name": "claude" }, diff --git a/internal/schemavalidators/testdata/ai_agent_config_valid.json b/internal/schemavalidators/testdata/ai_agent_config_valid.json index 75ab387a2..a51da8ae9 100644 --- a/internal/schemavalidators/testdata/ai_agent_config_valid.json +++ b/internal/schemavalidators/testdata/ai_agent_config_valid.json @@ -1,5 +1,4 @@ { - "schema_version": "0.1", "agent": { "name": "claude", "version": "4.0" diff --git a/pkg/attestation/crafter/collector_aiagentconfig.go b/pkg/attestation/crafter/collector_aiagentconfig.go index 2b88734d3..4b67f97e6 100644 --- a/pkg/attestation/crafter/collector_aiagentconfig.go +++ b/pkg/attestation/crafter/collector_aiagentconfig.go @@ -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) diff --git a/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go b/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go index d639fb41b..a89e1e03b 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_agent_config.go @@ -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" @@ -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) @@ -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 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 ecd2d6499..484be6ec8 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_agent_config_test.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_agent_config_test.go @@ -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", @@ -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", @@ -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", @@ -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", @@ -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, }, @@ -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) @@ -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") diff --git a/pkg/attestation/crafter/materials/testdata/ai-agent-config-claude.json b/pkg/attestation/crafter/materials/testdata/ai-agent-config-claude.json index 1e00d3dbf..f835a01cc 100644 --- a/pkg/attestation/crafter/materials/testdata/ai-agent-config-claude.json +++ b/pkg/attestation/crafter/materials/testdata/ai-agent-config-claude.json @@ -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" + } + ] + } } diff --git a/pkg/attestation/crafter/materials/testdata/ai-agent-config-cursor.json b/pkg/attestation/crafter/materials/testdata/ai-agent-config-cursor.json index 96159ce36..c9b6145c7 100644 --- a/pkg/attestation/crafter/materials/testdata/ai-agent-config-cursor.json +++ b/pkg/attestation/crafter/materials/testdata/ai-agent-config-cursor.json @@ -1,15 +1,18 @@ { - "schema_version": "0.1", - "agent": {"name": "cursor"}, - "config_hash": "def456", - "captured_at": "2026-03-13T10:00:00Z", - "config_files": [ - { - "path": ".cursor/rules/coding.md", - "kind": "instruction", - "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "size": 20, - "content": "IyBDb2RpbmcgUnVsZXM=" - } - ] + "chainloop.material.evidence.id": "CHAINLOOP_AI_AGENT_CONFIG", + "schema": "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json", + "data": { + "agent": {"name": "cursor"}, + "config_hash": "def456", + "captured_at": "2026-03-13T10:00:00Z", + "config_files": [ + { + "path": ".cursor/rules/coding.md", + "kind": "instruction", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 20, + "content": "IyBDb2RpbmcgUnVsZXM=" + } + ] + } }