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..b55eaca20 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. @@ -158,7 +158,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")