Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions internal/aiagentconfig/aiagentconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@

package aiagentconfig

// ConfigFileKind classifies the purpose of a discovered configuration file.
type ConfigFileKind string

const (
// ConfigFileKindConfiguration is for settings and JSON config files.
ConfigFileKindConfiguration ConfigFileKind = "configuration"
// ConfigFileKindInstruction is for markdown instruction/rules files.
ConfigFileKindInstruction ConfigFileKind = "instruction"
)

// DiscoveredFile represents a file found during discovery, before reading its content.
type DiscoveredFile struct {
Path string
Kind ConfigFileKind
}

// Agent identifies the AI agent provider
type Agent struct {
Name string `json:"name"`
Expand All @@ -30,10 +46,11 @@ type GitContext struct {

// ConfigFile represents a single discovered configuration file
type ConfigFile struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
Content string `json:"content"`
Path string `json:"path"`
Kind ConfigFileKind `json:"kind"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
Content string `json:"content"`
}

// Evidence is the AI agent configuration payload
Expand Down
12 changes: 7 additions & 5 deletions internal/aiagentconfig/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,21 @@ import (
)

// Build reads discovered files and constructs the AI agent config payload.
// basePath is the base directory, filePaths are relative to basePath.
// 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, filePaths []string, agentName string, gitCtx *GitContext) (*Evidence, error) {
func Build(basePath string, discovered []DiscoveredFile, agentName string, gitCtx *GitContext) (*Evidence, error) {
// Resolve basePath to its real path so symlink comparisons are reliable
realRoot, err := filepath.EvalSymlinks(basePath)
if err != nil {
return nil, fmt.Errorf("resolving root dir: %w", err)
}

configFiles := make([]ConfigFile, 0, len(filePaths))
hashes := make([]string, 0, len(filePaths))
configFiles := make([]ConfigFile, 0, len(discovered))
hashes := make([]string, 0, len(discovered))

for _, relPath := range filePaths {
for _, df := range discovered {
relPath := df.Path
absPath := filepath.Join(basePath, relPath)

// Resolve the full path through any symlinks (covers both symlinked
Expand Down Expand Up @@ -73,6 +74,7 @@ func Build(basePath string, filePaths []string, agentName string, gitCtx *GitCon

configFiles = append(configFiles, ConfigFile{
Path: relPath,
Kind: df.Kind,
SHA256: hexHash,
Size: info.Size(),
Content: base64.StdEncoding.EncodeToString(content),
Expand Down
40 changes: 32 additions & 8 deletions internal/aiagentconfig/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ func TestBuild(t *testing.T) {
CommitSHA: "abc123",
}

data, err := Build(rootDir, []string{"CLAUDE.md", ".claude/settings.json"}, "claude", gitCtx)
data, err := Build(rootDir, []DiscoveredFile{
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
{Path: ".claude/settings.json", Kind: ConfigFileKindConfiguration},
}, "claude", gitCtx)
require.NoError(t, err)

assert.Equal(t, "0.1", data.SchemaVersion)
Expand All @@ -65,13 +68,15 @@ func TestBuild(t *testing.T) {

cf1 := data.ConfigFiles[0]
assert.Equal(t, "CLAUDE.md", cf1.Path)
assert.Equal(t, ConfigFileKindInstruction, cf1.Kind)
assert.Equal(t, int64(len(file1Content)), cf1.Size)
hash1 := sha256.Sum256(file1Content)
assert.Equal(t, hex.EncodeToString(hash1[:]), cf1.SHA256)
assert.Equal(t, base64.StdEncoding.EncodeToString(file1Content), cf1.Content)

cf2 := data.ConfigFiles[1]
assert.Equal(t, ".claude/settings.json", cf2.Path)
assert.Equal(t, ConfigFileKindConfiguration, cf2.Kind)
hash2 := sha256.Sum256(file2Content)
assert.Equal(t, hex.EncodeToString(hash2[:]), cf2.SHA256)

Expand All @@ -91,19 +96,24 @@ func TestBuildWithCursorAgent(t *testing.T) {
require.NoError(t, os.MkdirAll(filepath.Join(rootDir, ".cursor", "rules"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(rootDir, ".cursor", "rules", "coding.md"), []byte("rules"), 0o600))

data, err := Build(rootDir, []string{".cursor/rules/coding.md"}, "cursor", nil)
data, err := Build(rootDir, []DiscoveredFile{
{Path: ".cursor/rules/coding.md", Kind: ConfigFileKindInstruction},
}, "cursor", nil)
require.NoError(t, err)

assert.Equal(t, "cursor", data.Agent.Name)
require.Len(t, data.ConfigFiles, 1)
assert.Equal(t, ".cursor/rules/coding.md", data.ConfigFiles[0].Path)
assert.Equal(t, ConfigFileKindInstruction, data.ConfigFiles[0].Kind)
}

func TestBuildWithoutGitContext(t *testing.T) {
rootDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600))

data, err := Build(rootDir, []string{"CLAUDE.md"}, "claude", nil)
data, err := Build(rootDir, []DiscoveredFile{
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
}, "claude", nil)
require.NoError(t, err)

assert.Nil(t, data.GitContext)
Expand All @@ -114,7 +124,9 @@ func TestBuildJSONFormat(t *testing.T) {
rootDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600))

data, err := Build(rootDir, []string{"CLAUDE.md"}, "claude", nil)
data, err := Build(rootDir, []DiscoveredFile{
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
}, "claude", nil)
require.NoError(t, err)

// Verify it marshals to valid JSON with top-level fields (no envelope)
Expand All @@ -132,6 +144,12 @@ func TestBuildJSONFormat(t *testing.T) {
// Ensure no envelope fields
assert.Nil(t, raw["chainloop.material.evidence.id"])
assert.Nil(t, raw["data"])

// Verify kind is present in config_files
files := raw["config_files"].([]any)
require.Len(t, files, 1)
file := files[0].(map[string]any)
assert.Equal(t, "instruction", file["kind"])
}

func TestBuildRejectsSymlinksEscapingRoot(t *testing.T) {
Expand All @@ -142,7 +160,9 @@ func TestBuildRejectsSymlinksEscapingRoot(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0o600))
require.NoError(t, os.Symlink(filepath.Join(outsideDir, "secret.txt"), filepath.Join(rootDir, "CLAUDE.md")))

_, err := Build(rootDir, []string{"CLAUDE.md"}, "claude", nil)
_, err := Build(rootDir, []DiscoveredFile{
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
}, "claude", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "path escapes root directory via symlink")
}
Expand All @@ -159,15 +179,17 @@ func TestBuildRejectsSymlinkedParentDir(t *testing.T) {
// Symlink .claude -> outside directory
require.NoError(t, os.Symlink(outsideClaude, filepath.Join(rootDir, ".claude")))

_, err := Build(rootDir, []string{".claude/settings.json"}, "claude", nil)
_, err := Build(rootDir, []DiscoveredFile{
{Path: ".claude/settings.json", Kind: ConfigFileKindConfiguration},
}, "claude", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "path escapes root directory via symlink")
}

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

data, err := Build(rootDir, []string{}, "claude", nil)
data, err := Build(rootDir, []DiscoveredFile{}, "claude", nil)
require.NoError(t, err)
assert.Empty(t, data.ConfigFiles)
assert.NotEmpty(t, data.ConfigHash)
Expand All @@ -186,7 +208,9 @@ func TestBuildAllowsRegularFilesInRoot(t *testing.T) {
rootDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "CLAUDE.md"), []byte("content"), 0o600))

data, err := Build(rootDir, []string{"CLAUDE.md"}, "claude", nil)
data, err := Build(rootDir, []DiscoveredFile{
{Path: "CLAUDE.md", Kind: ConfigFileKindInstruction},
}, "claude", nil)
require.NoError(t, err)
assert.Len(t, data.ConfigFiles, 1)
}
72 changes: 39 additions & 33 deletions internal/aiagentconfig/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,51 +20,57 @@ import (
"sort"
)

// patternDef pairs a glob pattern with the kind of file it matches.
type patternDef struct {
pattern string
kind ConfigFileKind
}

// agentDef defines an AI agent and its exclusive file patterns.
type agentDef struct {
name string
patterns []string
patterns []patternDef
}

// agents is the registry of supported AI agents and their exclusive file patterns.
var agents = []agentDef{
{name: "claude", patterns: []string{
"CLAUDE.md",
".claude/CLAUDE.md",
".claude/settings.json",
".claude/rules/*.md",
".claude/agents/*.md",
".claude/commands/*.md",
".claude/skills/*/SKILL.md",
{name: "claude", patterns: []patternDef{
{"CLAUDE.md", ConfigFileKindInstruction},
{".claude/CLAUDE.md", ConfigFileKindInstruction},
{".claude/settings.json", ConfigFileKindConfiguration},
{".claude/rules/*.md", ConfigFileKindInstruction},
Comment thread
jiparis marked this conversation as resolved.
{".claude/agents/*.md", ConfigFileKindInstruction},
{".claude/commands/*.md", ConfigFileKindInstruction},
{".claude/skills/*/SKILL.md", ConfigFileKindInstruction},
}},
{name: "cursor", patterns: []string{
".cursor/rules/*.md",
".cursor/rules/*.mdc",
".cursor/rules/*/*.md",
".cursor/rules/*/*.mdc",
".cursor/skills/*/SKILL.md",
".cursor/agents/*.md",
{name: "cursor", patterns: []patternDef{
{".cursor/rules/*.md", ConfigFileKindInstruction},
{".cursor/rules/*.mdc", ConfigFileKindInstruction},
{".cursor/rules/*/*.md", ConfigFileKindInstruction},
{".cursor/rules/*/*.mdc", ConfigFileKindInstruction},
{".cursor/skills/*/SKILL.md", ConfigFileKindInstruction},
{".cursor/agents/*.md", ConfigFileKindInstruction},
}},
}

// sharedPatterns are file patterns not exclusive to any agent.
// They are included in every agent's evidence when that agent has exclusive files.
var sharedPatterns = []string{
".mcp.json",
"AGENTS.md",
var sharedPatterns = []patternDef{
{".mcp.json", ConfigFileKindConfiguration},
{"AGENTS.md", ConfigFileKindInstruction},
}

// DiscoverAll searches basePath for AI agent configuration files and groups them by agent.
// Only agents with at least one exclusive file match are included.
// Shared files are appended to each detected agent's file list.
// Returns a map of agent name → sorted, deduplicated relative paths.
func DiscoverAll(basePath string) (map[string][]string, error) {
// Returns a map of agent name → sorted, deduplicated discovered files.
func DiscoverAll(basePath string) (map[string][]DiscoveredFile, error) {
sharedFiles, err := matchPatterns(basePath, sharedPatterns)
if err != nil {
return nil, err
}

result := make(map[string][]string)
result := make(map[string][]DiscoveredFile)

for _, agent := range agents {
files, err := matchPatterns(basePath, agent.patterns)
Expand All @@ -78,32 +84,32 @@ func DiscoverAll(basePath string) (map[string][]string, error) {

// Merge shared files into this agent's list, deduplicating
seen := make(map[string]struct{}, len(files)+len(sharedFiles))
merged := make([]string, 0, len(files)+len(sharedFiles))
merged := make([]DiscoveredFile, 0, len(files)+len(sharedFiles))
for _, f := range files {
seen[f] = struct{}{}
seen[f.Path] = struct{}{}
merged = append(merged, f)
}
for _, f := range sharedFiles {
if _, ok := seen[f]; !ok {
if _, ok := seen[f.Path]; !ok {
merged = append(merged, f)
}
}

sort.Strings(merged)
sort.Slice(merged, func(i, j int) bool { return merged[i].Path < merged[j].Path })
result[agent.name] = merged
}

return result, nil
}

// matchPatterns expands glob patterns relative to basePath and returns
// deduplicated, sorted relative paths.
func matchPatterns(basePath string, patterns []string) ([]string, error) {
// deduplicated, sorted discovered files.
func matchPatterns(basePath string, patterns []patternDef) ([]DiscoveredFile, error) {
seen := make(map[string]struct{})
var results []string
var results []DiscoveredFile

for _, pattern := range patterns {
absPattern := filepath.Join(basePath, pattern)
for _, pd := range patterns {
absPattern := filepath.Join(basePath, pd.pattern)
matches, err := filepath.Glob(absPattern)
if err != nil {
return nil, err
Expand All @@ -117,12 +123,12 @@ func matchPatterns(basePath string, patterns []string) ([]string, error) {

if _, ok := seen[rel]; !ok {
seen[rel] = struct{}{}
results = append(results, rel)
results = append(results, DiscoveredFile{Path: rel, Kind: pd.kind})
}
}
}

sort.Strings(results)
sort.Slice(results, func(i, j int) bool { return results[i].Path < results[j].Path })

return results, nil
}
Loading
Loading