From 954221e0d994384e1ef2ab512212df9703196192 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 21 Mar 2026 18:06:55 +0100 Subject: [PATCH 1/2] fix(attestation): make AI agent config collector deterministic Use attestation InitializedAt timestamp for CapturedAt instead of time.Now(), ensuring the same content is produced on retries. Also replace RegisterCollectors append with reset to prevent duplicate collectors, and move LoadCraftingState inside the per-collector loop so each collector sees the latest state. Signed-off-by: Jose I. Paris --- internal/aiagentconfig/builder.go | 9 +++++++-- internal/aiagentconfig/builder_test.go | 19 ++++++++++--------- .../crafter/collector_aiagentconfig.go | 2 +- pkg/attestation/crafter/crafter.go | 10 +++++++--- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/internal/aiagentconfig/builder.go b/internal/aiagentconfig/builder.go index 317c8973c..d5567dc0d 100644 --- a/internal/aiagentconfig/builder.go +++ b/internal/aiagentconfig/builder.go @@ -31,7 +31,12 @@ import ( // 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) (*Data, error) { +// capturedAt is the timestamp to record in the output; pass time.Time{} to use the current time. +func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCtx *GitContext, capturedAt time.Time) (*Data, error) { + if capturedAt.IsZero() { + capturedAt = time.Now().UTC() + } + // Resolve basePath to its real path so symlink comparisons are reliable realRoot, err := filepath.EvalSymlinks(basePath) if err != nil { @@ -82,7 +87,7 @@ func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCt data := Data{ Agent: Agent{Name: agentName}, ConfigHash: computeCombinedHash(hashes), - CapturedAt: time.Now().UTC().Format(time.RFC3339), + CapturedAt: capturedAt.Format(time.RFC3339), GitContext: gitCtx, ConfigFiles: configFiles, } diff --git a/internal/aiagentconfig/builder_test.go b/internal/aiagentconfig/builder_test.go index d1ff4914b..c86a180df 100644 --- a/internal/aiagentconfig/builder_test.go +++ b/internal/aiagentconfig/builder_test.go @@ -26,6 +26,7 @@ import ( "sort" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -50,7 +51,7 @@ func TestBuild(t *testing.T) { data, err := Build(rootDir, []DiscoveredFile{ {Path: "CLAUDE.md", Kind: ConfigFileKindInstruction}, {Path: ".claude/settings.json", Kind: ConfigFileKindConfiguration}, - }, "claude", gitCtx) + }, "claude", gitCtx, time.Time{}) require.NoError(t, err) assert.Equal(t, "claude", data.Agent.Name) @@ -97,7 +98,7 @@ func TestBuildWithCursorAgent(t *testing.T) { data, err := Build(rootDir, []DiscoveredFile{ {Path: ".cursor/rules/coding.md", Kind: ConfigFileKindInstruction}, - }, "cursor", nil) + }, "cursor", nil, time.Time{}) require.NoError(t, err) assert.Equal(t, "cursor", data.Agent.Name) @@ -112,7 +113,7 @@ func TestBuildWithoutGitContext(t *testing.T) { data, err := Build(rootDir, []DiscoveredFile{ {Path: "CLAUDE.md", Kind: ConfigFileKindInstruction}, - }, "claude", nil) + }, "claude", nil, time.Time{}) require.NoError(t, err) assert.Nil(t, data.GitContext) @@ -125,7 +126,7 @@ func TestBuildJSONFormat(t *testing.T) { data, err := Build(rootDir, []DiscoveredFile{ {Path: "CLAUDE.md", Kind: ConfigFileKindInstruction}, - }, "claude", nil) + }, "claude", nil, time.Time{}) require.NoError(t, err) // Verify it marshals to valid JSON with top-level fields (no envelope) @@ -160,7 +161,7 @@ func TestBuildRejectsSymlinksEscapingRoot(t *testing.T) { _, err := Build(rootDir, []DiscoveredFile{ {Path: "CLAUDE.md", Kind: ConfigFileKindInstruction}, - }, "claude", nil) + }, "claude", nil, time.Time{}) require.Error(t, err) assert.Contains(t, err.Error(), "path escapes root directory via symlink") } @@ -179,7 +180,7 @@ func TestBuildRejectsSymlinkedParentDir(t *testing.T) { _, err := Build(rootDir, []DiscoveredFile{ {Path: ".claude/settings.json", Kind: ConfigFileKindConfiguration}, - }, "claude", nil) + }, "claude", nil, time.Time{}) require.Error(t, err) assert.Contains(t, err.Error(), "path escapes root directory via symlink") } @@ -187,7 +188,7 @@ func TestBuildRejectsSymlinkedParentDir(t *testing.T) { func TestBuildEmptyFileList(t *testing.T) { rootDir := t.TempDir() - data, err := Build(rootDir, []DiscoveredFile{}, "claude", nil) + data, err := Build(rootDir, []DiscoveredFile{}, "claude", nil, time.Time{}) require.NoError(t, err) assert.Empty(t, data.ConfigFiles) assert.NotEmpty(t, data.ConfigHash) @@ -197,7 +198,7 @@ func TestBuildEmptyFileList(t *testing.T) { func TestBuildNilFileList(t *testing.T) { rootDir := t.TempDir() - data, err := Build(rootDir, nil, "claude", nil) + data, err := Build(rootDir, nil, "claude", nil, time.Time{}) require.NoError(t, err) assert.Empty(t, data.ConfigFiles) } @@ -208,7 +209,7 @@ func TestBuildAllowsRegularFilesInRoot(t *testing.T) { data, err := Build(rootDir, []DiscoveredFile{ {Path: "CLAUDE.md", Kind: ConfigFileKindInstruction}, - }, "claude", nil) + }, "claude", nil, time.Time{}) require.NoError(t, err) assert.Len(t, data.ConfigFiles, 1) } diff --git a/pkg/attestation/crafter/collector_aiagentconfig.go b/pkg/attestation/crafter/collector_aiagentconfig.go index 4b67f97e6..9ae56bf1e 100644 --- a/pkg/attestation/crafter/collector_aiagentconfig.go +++ b/pkg/attestation/crafter/collector_aiagentconfig.go @@ -88,7 +88,7 @@ func (c *AIAgentConfigCollector) uploadAgentConfig( ctx context.Context, cr *Crafter, attestationID string, casBackend *casclient.CASBackend, agentName string, files []aiagentconfig.DiscoveredFile, gitCtx *aiagentconfig.GitContext, ) error { - data, err := aiagentconfig.Build(cr.WorkingDir(), files, agentName, gitCtx) + data, err := aiagentconfig.Build(cr.WorkingDir(), files, agentName, gitCtx, cr.CraftingState.GetAttestation().GetInitializedAt().AsTime()) if err != nil { return fmt.Errorf("building AI agent config for %s: %w", agentName, err) } diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index f7e1164c5..d8ba9c45f 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -134,9 +134,9 @@ func (c *Crafter) WorkingDir() string { return c.workingDir } -// RegisterCollectors appends collectors to be run during attestation init. +// RegisterCollectors resets collectors to be run during attestation init. func (c *Crafter) RegisterCollectors(collectors ...Collector) { - c.collectors = append(c.collectors, collectors...) + c.collectors = collectors } // RunCollectors executes all registered collectors best-effort. @@ -150,6 +150,10 @@ func (c *Crafter) RunCollectors(ctx context.Context, attestationID string, casBa digestBeforeCollectors := c.CraftingState.UpdateCheckSum for _, collector := range c.collectors { + if err := c.LoadCraftingState(ctx, attestationID); err != nil { + c.Logger.Warn().Err(err).Msg("failed to reload crafting state before running collectors") + return + } if err := collector.Collect(ctx, c, attestationID, casBackend); err != nil { c.Logger.Warn().Err(err).Str("collector", collector.ID()).Msg("collector failed") } @@ -158,7 +162,7 @@ func (c *Crafter) RunCollectors(ctx context.Context, attestationID string, casBa // NOTE: workaround for old servers that don't return digests in Save responses. // not returning digest makes digests stale, and state save failing with conflict errors // https://github.com/chainloop-dev/chainloop/issues/2908 - // this conditition will not apply to new servers that return digests in Save responses. + // this condition will not apply to new servers that return digests in Save responses. if c.CraftingState.UpdateCheckSum != digestBeforeCollectors { if err := c.LoadCraftingState(ctx, attestationID); err != nil { c.Logger.Warn().Err(err).Msg("failed to reload crafting state after running collectors") From 4b124951cd6c04214928f3431152dfee1e5ff2ef Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Sat, 21 Mar 2026 18:15:35 +0100 Subject: [PATCH 2/2] do not reload again Signed-off-by: Jose I. Paris --- pkg/attestation/crafter/crafter.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index d8ba9c45f..b55eaca20 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -150,10 +150,6 @@ func (c *Crafter) RunCollectors(ctx context.Context, attestationID string, casBa digestBeforeCollectors := c.CraftingState.UpdateCheckSum for _, collector := range c.collectors { - if err := c.LoadCraftingState(ctx, attestationID); err != nil { - c.Logger.Warn().Err(err).Msg("failed to reload crafting state before running collectors") - return - } if err := collector.Collect(ctx, c, attestationID, casBackend); err != nil { c.Logger.Warn().Err(err).Str("collector", collector.ID()).Msg("collector failed") }