Skip to content

Commit caa231e

Browse files
authored
fix(attestation): make AI agent config collector deterministic (#2911)
Signed-off-by: Jose I. Paris <jiparis@chainloop.dev>
1 parent 08e67ad commit caa231e

4 files changed

Lines changed: 21 additions & 15 deletions

File tree

internal/aiagentconfig/builder.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ import (
3131
// basePath is the base directory, discovered contains files relative to basePath with their kinds.
3232
// agentName identifies the AI agent (e.g. "claude", "cursor").
3333
// gitCtx may be nil if not in a git repository.
34-
func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCtx *GitContext) (*Data, error) {
34+
// capturedAt is the timestamp to record in the output; pass time.Time{} to use the current time.
35+
func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCtx *GitContext, capturedAt time.Time) (*Data, error) {
36+
if capturedAt.IsZero() {
37+
capturedAt = time.Now().UTC()
38+
}
39+
3540
// Resolve basePath to its real path so symlink comparisons are reliable
3641
realRoot, err := filepath.EvalSymlinks(basePath)
3742
if err != nil {
@@ -82,7 +87,7 @@ func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCt
8287
data := Data{
8388
Agent: Agent{Name: agentName},
8489
ConfigHash: computeCombinedHash(hashes),
85-
CapturedAt: time.Now().UTC().Format(time.RFC3339),
90+
CapturedAt: capturedAt.Format(time.RFC3339),
8691
GitContext: gitCtx,
8792
ConfigFiles: configFiles,
8893
}

internal/aiagentconfig/builder_test.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"sort"
2727
"strings"
2828
"testing"
29+
"time"
2930

3031
"github.com/stretchr/testify/assert"
3132
"github.com/stretchr/testify/require"
@@ -50,7 +51,7 @@ func TestBuild(t *testing.T) {
5051
data, err := Build(rootDir, []DiscoveredFile{
5152
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
5253
{Path: ".claude/settings.json", Kind: ConfigFileKindConfiguration},
53-
}, "claude", gitCtx)
54+
}, "claude", gitCtx, time.Time{})
5455
require.NoError(t, err)
5556

5657
assert.Equal(t, "claude", data.Agent.Name)
@@ -97,7 +98,7 @@ func TestBuildWithCursorAgent(t *testing.T) {
9798

9899
data, err := Build(rootDir, []DiscoveredFile{
99100
{Path: ".cursor/rules/coding.md", Kind: ConfigFileKindInstruction},
100-
}, "cursor", nil)
101+
}, "cursor", nil, time.Time{})
101102
require.NoError(t, err)
102103

103104
assert.Equal(t, "cursor", data.Agent.Name)
@@ -112,7 +113,7 @@ func TestBuildWithoutGitContext(t *testing.T) {
112113

113114
data, err := Build(rootDir, []DiscoveredFile{
114115
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
115-
}, "claude", nil)
116+
}, "claude", nil, time.Time{})
116117
require.NoError(t, err)
117118

118119
assert.Nil(t, data.GitContext)
@@ -125,7 +126,7 @@ func TestBuildJSONFormat(t *testing.T) {
125126

126127
data, err := Build(rootDir, []DiscoveredFile{
127128
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
128-
}, "claude", nil)
129+
}, "claude", nil, time.Time{})
129130
require.NoError(t, err)
130131

131132
// Verify it marshals to valid JSON with top-level fields (no envelope)
@@ -160,7 +161,7 @@ func TestBuildRejectsSymlinksEscapingRoot(t *testing.T) {
160161

161162
_, err := Build(rootDir, []DiscoveredFile{
162163
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
163-
}, "claude", nil)
164+
}, "claude", nil, time.Time{})
164165
require.Error(t, err)
165166
assert.Contains(t, err.Error(), "path escapes root directory via symlink")
166167
}
@@ -179,15 +180,15 @@ func TestBuildRejectsSymlinkedParentDir(t *testing.T) {
179180

180181
_, err := Build(rootDir, []DiscoveredFile{
181182
{Path: ".claude/settings.json", Kind: ConfigFileKindConfiguration},
182-
}, "claude", nil)
183+
}, "claude", nil, time.Time{})
183184
require.Error(t, err)
184185
assert.Contains(t, err.Error(), "path escapes root directory via symlink")
185186
}
186187

187188
func TestBuildEmptyFileList(t *testing.T) {
188189
rootDir := t.TempDir()
189190

190-
data, err := Build(rootDir, []DiscoveredFile{}, "claude", nil)
191+
data, err := Build(rootDir, []DiscoveredFile{}, "claude", nil, time.Time{})
191192
require.NoError(t, err)
192193
assert.Empty(t, data.ConfigFiles)
193194
assert.NotEmpty(t, data.ConfigHash)
@@ -197,7 +198,7 @@ func TestBuildEmptyFileList(t *testing.T) {
197198
func TestBuildNilFileList(t *testing.T) {
198199
rootDir := t.TempDir()
199200

200-
data, err := Build(rootDir, nil, "claude", nil)
201+
data, err := Build(rootDir, nil, "claude", nil, time.Time{})
201202
require.NoError(t, err)
202203
assert.Empty(t, data.ConfigFiles)
203204
}
@@ -208,7 +209,7 @@ func TestBuildAllowsRegularFilesInRoot(t *testing.T) {
208209

209210
data, err := Build(rootDir, []DiscoveredFile{
210211
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
211-
}, "claude", nil)
212+
}, "claude", nil, time.Time{})
212213
require.NoError(t, err)
213214
assert.Len(t, data.ConfigFiles, 1)
214215
}

pkg/attestation/crafter/collector_aiagentconfig.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ 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-
data, err := aiagentconfig.Build(cr.WorkingDir(), files, agentName, gitCtx)
91+
data, err := aiagentconfig.Build(cr.WorkingDir(), files, agentName, gitCtx, cr.CraftingState.GetAttestation().GetInitializedAt().AsTime())
9292
if err != nil {
9393
return fmt.Errorf("building AI agent config for %s: %w", agentName, err)
9494
}

pkg/attestation/crafter/crafter.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@ func (c *Crafter) WorkingDir() string {
134134
return c.workingDir
135135
}
136136

137-
// RegisterCollectors appends collectors to be run during attestation init.
137+
// RegisterCollectors resets collectors to be run during attestation init.
138138
func (c *Crafter) RegisterCollectors(collectors ...Collector) {
139-
c.collectors = append(c.collectors, collectors...)
139+
c.collectors = collectors
140140
}
141141

142142
// RunCollectors executes all registered collectors best-effort.
@@ -158,7 +158,7 @@ func (c *Crafter) RunCollectors(ctx context.Context, attestationID string, casBa
158158
// NOTE: workaround for old servers that don't return digests in Save responses.
159159
// not returning digest makes digests stale, and state save failing with conflict errors
160160
// https://github.com/chainloop-dev/chainloop/issues/2908
161-
// this conditition will not apply to new servers that return digests in Save responses.
161+
// this condition will not apply to new servers that return digests in Save responses.
162162
if c.CraftingState.UpdateCheckSum != digestBeforeCollectors {
163163
if err := c.LoadCraftingState(ctx, attestationID); err != nil {
164164
c.Logger.Warn().Err(err).Msg("failed to reload crafting state after running collectors")

0 commit comments

Comments
 (0)