Skip to content

Commit 0e1c114

Browse files
committed
feat: wrap AI agent config in evidence envelope
Use the standard custom schema structure for CHAINLOOP_AI_AGENT_CONFIG, matching the pattern used by CHAINLOOP_PR_INFO. The payload is now wrapped with chainloop.material.evidence.id, schema URL, and data fields. Signed-off-by: Jose I. Paris <jiparis@chainloop.dev>
1 parent 7b0148e commit 0e1c114

7 files changed

Lines changed: 102 additions & 56 deletions

File tree

internal/aiagentconfig/aiagentconfig.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ const (
2323
ConfigFileKindConfiguration ConfigFileKind = "configuration"
2424
// ConfigFileKindInstruction is for markdown instruction/rules files.
2525
ConfigFileKindInstruction ConfigFileKind = "instruction"
26+
27+
// EvidenceID is the identifier for the AI agent config material type
28+
EvidenceID = "CHAINLOOP_AI_AGENT_CONFIG"
29+
// EvidenceSchemaURL is the URL to the JSON schema for AI agent config
30+
EvidenceSchemaURL = "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json"
2631
)
2732

2833
// DiscoveredFile represents a file found during discovery, before reading its content.
@@ -53,8 +58,8 @@ type ConfigFile struct {
5358
Content string `json:"content"`
5459
}
5560

56-
// Evidence is the AI agent configuration payload
57-
type Evidence struct {
61+
// Data is the AI agent configuration payload
62+
type Data struct {
5863
SchemaVersion string `json:"schema_version"`
5964
Agent Agent `json:"agent"`
6065
ConfigHash string `json:"config_hash"`
@@ -66,3 +71,19 @@ type Evidence struct {
6671
MCPServers any `json:"mcp_servers,omitempty"`
6772
Subagents any `json:"subagents,omitempty"`
6873
}
74+
75+
// Evidence represents the complete evidence structure for AI agent config
76+
type Evidence struct {
77+
ID string `json:"chainloop.material.evidence.id"`
78+
Schema string `json:"schema"`
79+
Data Data `json:"data"`
80+
}
81+
82+
// NewEvidence creates a new Evidence instance
83+
func NewEvidence(data Data) *Evidence {
84+
return &Evidence{
85+
ID: EvidenceID,
86+
Schema: EvidenceSchemaURL,
87+
Data: data,
88+
}
89+
}

internal/aiagentconfig/builder.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import (
3333
// basePath is the base directory, discovered contains files relative to basePath with their kinds.
3434
// agentName identifies the AI agent (e.g. "claude", "cursor").
3535
// gitCtx may be nil if not in a git repository.
36-
func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCtx *GitContext) (*Evidence, error) {
36+
func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCtx *GitContext) (*Data, error) {
3737
// Resolve basePath to its real path so symlink comparisons are reliable
3838
realRoot, err := filepath.EvalSymlinks(basePath)
3939
if err != nil {
@@ -81,7 +81,7 @@ func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCt
8181
})
8282
}
8383

84-
data := Evidence{
84+
data := Data{
8585
SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1),
8686
Agent: Agent{Name: agentName},
8787
ConfigHash: computeCombinedHash(hashes),

pkg/attestation/crafter/collector_aiagentconfig.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,12 @@ func (c *AIAgentConfigCollector) uploadAgentConfig(
8888
ctx context.Context, cr *Crafter, attestationID string,
8989
casBackend *casclient.CASBackend, agentName string, files []aiagentconfig.DiscoveredFile, gitCtx *aiagentconfig.GitContext,
9090
) error {
91-
evidence, err := aiagentconfig.Build(cr.WorkingDir(), files, agentName, gitCtx)
91+
data, err := aiagentconfig.Build(cr.WorkingDir(), files, agentName, gitCtx)
9292
if err != nil {
9393
return fmt.Errorf("building AI agent config for %s: %w", agentName, err)
9494
}
9595

96+
evidence := aiagentconfig.NewEvidence(*data)
9697
jsonData, err := json.Marshal(evidence)
9798
if err != nil {
9899
return fmt.Errorf("marshaling AI agent config for %s: %w", agentName, err)

pkg/attestation/crafter/materials/chainloop_ai_agent_config.go

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"os"
2323

2424
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
25+
"github.com/chainloop-dev/chainloop/internal/aiagentconfig"
2526
"github.com/chainloop-dev/chainloop/internal/schemavalidators"
2627
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2728
"github.com/chainloop-dev/chainloop/pkg/casclient"
@@ -55,12 +56,28 @@ func (c *ChainloopAIAgentConfigCrafter) Craft(ctx context.Context, artifactPath
5556
return nil, fmt.Errorf("can't open the file: %w", err)
5657
}
5758

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

68+
// Unmarshal data into typed struct for agent name extraction
69+
var data aiagentconfig.Data
70+
if err := json.Unmarshal(envelope.Data, &data); err != nil {
71+
c.logger.Debug().Err(err).Msg("error decoding data field")
72+
return nil, fmt.Errorf("failed to unmarshal data: %w", err)
73+
}
74+
75+
// Validate using raw JSON to preserve unknown fields for strict schema validation
76+
var rawData any
77+
if err := json.Unmarshal(envelope.Data, &rawData); err != nil {
78+
return nil, fmt.Errorf("failed to unmarshal data for validation: %w", err)
79+
}
80+
6481
if err := schemavalidators.ValidateAIAgentConfig(rawData, schemavalidators.AIAgentConfigVersion0_1); err != nil {
6582
c.logger.Debug().Err(err).Msg("schema validation failed")
6683
return nil, fmt.Errorf("AI agent config validation failed: %w", err)
@@ -71,14 +88,9 @@ func (c *ChainloopAIAgentConfigCrafter) Craft(ctx context.Context, artifactPath
7188
return nil, err
7289
}
7390

74-
// Extract agent name from the validated JSON and surface it as an annotation
75-
var envelope struct {
76-
Agent struct {
77-
Name string `json:"name"`
78-
} `json:"agent"`
79-
}
80-
if err := json.Unmarshal(f, &envelope); err == nil && envelope.Agent.Name != "" {
81-
material.Annotations[annotationAIAgentName] = envelope.Agent.Name
91+
// Surface agent name as an annotation
92+
if data.Agent.Name != "" {
93+
material.Annotations[annotationAIAgentName] = data.Agent.Name
8294
}
8395

8496
return material, nil

pkg/attestation/crafter/materials/chainloop_ai_agent_config_test.go

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@ func TestNewChainloopAIAgentConfigCrafter_CorrectType(t *testing.T) {
5959
func TestChainloopAIAgentConfigCrafter_Validation(t *testing.T) {
6060
testCases := []struct {
6161
name string
62-
data *aiagentconfig.Evidence
62+
data *aiagentconfig.Data
6363
wantErr bool
6464
}{
6565
{
6666
name: "valid full config",
67-
data: &aiagentconfig.Evidence{
67+
data: &aiagentconfig.Data{
6868
SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1),
6969
Agent: aiagentconfig.Agent{Name: "claude", Version: "4.0"},
7070
ConfigHash: "abc123",
@@ -88,7 +88,7 @@ func TestChainloopAIAgentConfigCrafter_Validation(t *testing.T) {
8888
},
8989
{
9090
name: "valid minimal config",
91-
data: &aiagentconfig.Evidence{
91+
data: &aiagentconfig.Data{
9292
SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1),
9393
Agent: aiagentconfig.Agent{Name: "claude"},
9494
ConfigHash: "abc123",
@@ -107,7 +107,7 @@ func TestChainloopAIAgentConfigCrafter_Validation(t *testing.T) {
107107
},
108108
{
109109
name: "valid cursor config",
110-
data: &aiagentconfig.Evidence{
110+
data: &aiagentconfig.Data{
111111
SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1),
112112
Agent: aiagentconfig.Agent{Name: "cursor"},
113113
ConfigHash: "def456",
@@ -126,7 +126,7 @@ func TestChainloopAIAgentConfigCrafter_Validation(t *testing.T) {
126126
},
127127
{
128128
name: "valid cursor with multiple file types",
129-
data: &aiagentconfig.Evidence{
129+
data: &aiagentconfig.Data{
130130
SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1),
131131
Agent: aiagentconfig.Agent{Name: "cursor"},
132132
ConfigHash: "ghi789",
@@ -159,12 +159,12 @@ func TestChainloopAIAgentConfigCrafter_Validation(t *testing.T) {
159159
},
160160
{
161161
name: "missing required fields",
162-
data: &aiagentconfig.Evidence{},
162+
data: &aiagentconfig.Data{},
163163
wantErr: true,
164164
},
165165
{
166166
name: "empty config files",
167-
data: &aiagentconfig.Evidence{
167+
data: &aiagentconfig.Data{
168168
SchemaVersion: string(schemavalidators.AIAgentConfigVersion0_1),
169169
Agent: aiagentconfig.Agent{Name: "claude"},
170170
ConfigHash: "abc123",
@@ -220,9 +220,9 @@ func TestChainloopAIAgentConfigCrafter_InvalidSchema(t *testing.T) {
220220
crafter, err := NewChainloopAIAgentConfigCrafter(schema, nil, &logger)
221221
require.NoError(t, err)
222222

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

227227
_, err = crafter.Craft(context.Background(), tmpFile)
228228
require.Error(t, err)
@@ -253,12 +253,16 @@ func TestChainloopAIAgentConfigCrafter_RejectsExtraFields(t *testing.T) {
253253
require.NoError(t, err)
254254

255255
payload := `{
256-
"schema_version": "0.1",
257-
"agent": {"name": "claude"},
258-
"config_hash": "abc",
259-
"captured_at": "2026-03-13T10:00:00Z",
260-
"config_files": [{"path": "CLAUDE.md", "kind": "instruction", "sha256": "abc", "size": 1, "content": "Yg=="}],
261-
"unknown_field": "should fail"
256+
"chainloop.material.evidence.id": "CHAINLOOP_AI_AGENT_CONFIG",
257+
"schema": "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json",
258+
"data": {
259+
"schema_version": "0.1",
260+
"agent": {"name": "claude"},
261+
"config_hash": "abc",
262+
"captured_at": "2026-03-13T10:00:00Z",
263+
"config_files": [{"path": "CLAUDE.md", "kind": "instruction", "sha256": "abc", "size": 1, "content": "Yg=="}],
264+
"unknown_field": "should fail"
265+
}
262266
}`
263267

264268
tmpFile := filepath.Join(t.TempDir(), "extra-fields.json")
Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
{
2-
"schema_version": "0.1",
3-
"agent": {"name": "claude"},
4-
"config_hash": "abc123",
5-
"captured_at": "2026-03-13T10:00:00Z",
6-
"config_files": [
7-
{
8-
"path": "CLAUDE.md",
9-
"kind": "instruction",
10-
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
11-
"size": 42,
12-
"content": "IyBQcm9qZWN0IFJ1bGVz"
13-
}
14-
]
2+
"chainloop.material.evidence.id": "CHAINLOOP_AI_AGENT_CONFIG",
3+
"schema": "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json",
4+
"data": {
5+
"schema_version": "0.1",
6+
"agent": {"name": "claude"},
7+
"config_hash": "abc123",
8+
"captured_at": "2026-03-13T10:00:00Z",
9+
"config_files": [
10+
{
11+
"path": "CLAUDE.md",
12+
"kind": "instruction",
13+
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
14+
"size": 42,
15+
"content": "IyBQcm9qZWN0IFJ1bGVz"
16+
}
17+
]
18+
}
1519
}
Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
{
2-
"schema_version": "0.1",
3-
"agent": {"name": "cursor"},
4-
"config_hash": "def456",
5-
"captured_at": "2026-03-13T10:00:00Z",
6-
"config_files": [
7-
{
8-
"path": ".cursor/rules/coding.md",
9-
"kind": "instruction",
10-
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
11-
"size": 20,
12-
"content": "IyBDb2RpbmcgUnVsZXM="
13-
}
14-
]
2+
"chainloop.material.evidence.id": "CHAINLOOP_AI_AGENT_CONFIG",
3+
"schema": "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json",
4+
"data": {
5+
"schema_version": "0.1",
6+
"agent": {"name": "cursor"},
7+
"config_hash": "def456",
8+
"captured_at": "2026-03-13T10:00:00Z",
9+
"config_files": [
10+
{
11+
"path": ".cursor/rules/coding.md",
12+
"kind": "instruction",
13+
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
14+
"size": 20,
15+
"content": "IyBDb2RpbmcgUnVsZXM="
16+
}
17+
]
18+
}
1519
}

0 commit comments

Comments
 (0)