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
112 changes: 105 additions & 7 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ func (a *App) CmdWatch() error {
return fmt.Errorf("no watcher")
}

func (a *App) CmdImport(url string) error {
func (a *App) CmdImport(url string, trust bool) error {
if url == "" {
a.Error("Usage: sync-agents import <url>")
return fmt.Errorf("missing url")
Expand Down Expand Up @@ -612,9 +612,8 @@ func (a *App) CmdImport(url string) error {
typ = Buckets[idx-1].Dir
}

destDir := filepath.Join(a.ProjectRoot, ".agents", typ)
os.MkdirAll(destDir, 0755)
dest := filepath.Join(destDir, filename)
destRel := typ + "/" + filename
dest := filepath.Join(a.ProjectRoot, ".agents", typ, filename)

a.Info(fmt.Sprintf("Importing %s → .agents/%s/%s", url, typ, filename))

Expand All @@ -626,6 +625,53 @@ func (a *App) CmdImport(url string) error {
a.Error(fmt.Sprintf("Failed to download: %s (%v)", url, err))
return err
}

// SPEC-005 Part B: scan the fetched artifact and, by default, park
// it in quarantine for review instead of dropping it into the live
// tree. This closes the hole SPEC-005 names explicitly — `import`
// used to write remote content straight into .agents/ with no scan
// and no gate, while `pull` was gated. Now both route through the
// same quarantine. `--trust` (or `quarantine = off` in config)
// bypasses the gate, but the scan still runs and prints loudly.
tmpDir, err := os.MkdirTemp("", "sync-import-")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
staged := filepath.Join(tmpDir, filename)
if err := os.WriteFile(staged, data, 0644); err != nil {
return err
}
findings := source.ScanTree(staged)

gate := ReadConfigQuarantine(filepath.Join(a.ProjectRoot, ".agents"))
if trust {
gate = false
}

if gate {
origin, _ := a.importOrigin(url, staged) // zero Origin if not a GitHub URL → stays untracked
p := a.sourcePuller(SourceCmdOpts{})
if err := p.QuarantineImport(staged, destRel, origin, findings); err != nil {
a.Error(fmt.Sprintf("Failed to quarantine: %v", err))
return err
}
a.reportImportFindings(findings)
name := strings.TrimSuffix(filename, filepath.Ext(filename))
a.Info(fmt.Sprintf("Quarantined .agents/%s — review with `sync-agents quarantine`, then `sync-agents approve %s`.", destRel, name))
return nil
}

// --trust (or quarantine disabled): install directly. The scan
// still runs and any findings are printed — the bypass is loud,
// never silent.
if len(findings) > 0 {
a.Warn("--trust: installing WITHOUT the quarantine gate — scan findings below")
a.reportImportFindings(findings)
}
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return err
}
if err := os.WriteFile(dest, data, 0644); err != nil {
a.Error(fmt.Sprintf("Failed to write: %s (%v)", dest, err))
return err
Expand All @@ -643,6 +689,28 @@ func (a *App) CmdImport(url string) error {
return nil
}

// reportImportFindings prints scanner findings for an import in a
// stable, human-readable form. Silent when there are none.
func (a *App) reportImportFindings(findings []source.Finding) {
if len(findings) == 0 {
return
}
crit := 0
for _, f := range findings {
if f.Severity == source.SeverityCritical {
crit++
}
}
a.Info(fmt.Sprintf("scan: %d finding(s), %d CRITICAL", len(findings), crit))
for _, f := range findings {
loc := f.Path
if loc == "" {
loc = "artifact"
}
a.Info(fmt.Sprintf(" [%s] %s: %s (%s)", f.Severity, f.Class, f.Detail, loc))
}
}

// fetchImportURL retrieves an import URL's content. https and file
// schemes only: plain http would silently ship artifacts over an
// unauthenticated channel, which is exactly the tampering surface the
Expand Down Expand Up @@ -674,16 +742,27 @@ func fetchImportURL(rawURL string) ([]byte, error) {
// writeImportOrigin records manual-source provenance for imports from
// raw.githubusercontent.com. Failures only warn: origin metadata is
// an enhancement to import, never a reason for it to fail.
func (a *App) writeImportOrigin(rawURL, dest string) {
// importOrigin builds manual-source provenance for an import. ok is
// false (and the Origin zero) when the URL isn't a recoverable
// raw.githubusercontent file — such imports are valid but untracked.
func (a *App) importOrigin(rawURL, file string) (source.Origin, bool) {
o, ok := originFromRawGitHubURL(rawURL)
if !ok {
return
return source.Origin{}, false
}
if h, err := source.HashTree(dest); err == nil {
if h, err := source.HashTree(file); err == nil {
o.ContentHash = h
}
o.FetchedAt = time.Now().UTC().Format(time.RFC3339)
o.Source = source.SourceManual
return o, true
}

func (a *App) writeImportOrigin(rawURL, dest string) {
o, ok := a.importOrigin(rawURL, dest)
if !ok {
return
}
if err := source.WriteOriginFor(dest, false, o); err != nil {
a.Warn(fmt.Sprintf("could not write origin metadata for %s: %v", dest, err))
}
Expand Down Expand Up @@ -1566,6 +1645,25 @@ func (a *App) generateAgentsMD() {
Semantic: sem,
})
}
// Reference docs (plans/specs/adrs) opt into the local @-import
// block via `import: true` frontmatter (#65). Discovery is flat
// (top-level .md per bucket), matching DiscoverArtifacts.
for _, bk := range Buckets {
if !isReferenceImportType(bk.Artifact) {
continue
}
bkDir := filepath.Join(agentsDir, bk.Dir)
for _, name := range listMDFiles(bkDir) {
if artifactOptsIntoImport(filepath.Join(bkDir, name+".md"), bk.Artifact) {
localArts = append(localArts, ClaudeRoutedArtifact{
Type: bk.Artifact,
Name: name,
ImportOptIn: true,
})
}
}
}

if importLines := ManagedImportBlockForLocal(localArts); len(importLines) > 0 {
importBlock := FormatManagedImportBlockForTest(importLines)
b.WriteString(importBlock)
Expand Down
139 changes: 117 additions & 22 deletions internal/agent/globalsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
)
Expand Down Expand Up @@ -126,9 +127,10 @@ func (a *App) CmdGlobalSync(opts GlobalSyncOpts) error {
case StrategySymlink:
if tool.ID == "claude" {
claudeRouted = append(claudeRouted, ClaudeRoutedArtifact{
Type: art.Type,
Name: art.Name,
Semantic: sem,
Type: art.Type,
Name: art.Name,
Semantic: sem,
ImportOptIn: artifactOptsIntoImport(art.SourcePath, art.Type),
})
}
if err := a.applySymlinkDestination(tool.ID, art, dest); err != nil {
Expand Down Expand Up @@ -385,7 +387,63 @@ type Artifact struct {
// DiscoverArtifacts is intentionally non-recursive past the first
// level of each bucket; flatter layouts are easier to reason about
// and SPEC-002's routing doesn't model nested sub-buckets.
// osScopes maps an OS-scoped subdirectory name (SPEC-006) to the
// runtime.GOOS values it matches. A bucket may carry macos/, linux/,
// unix/, windows/ subdirs whose contents route only when the host
// matches; root-level files always route (backward compatible).
var osScopes = map[string]func(goos string) bool{
"macos": func(goos string) bool { return goos == "darwin" },
"linux": func(goos string) bool { return goos == "linux" },
"unix": func(goos string) bool { return goos == "darwin" || goos == "linux" },
"windows": func(goos string) bool { return goos == "windows" },
}

// isOSScopeDir reports whether a bucket subdirectory name is a
// reserved OS scope rather than an ordinary artifact/skill directory.
func isOSScopeDir(name string) bool {
_, ok := osScopes[name]
return ok
}

// effectiveGOOS is the OS discovery filters against: the `os` key in
// .agents/config when set (for testing and cross-compile CI), else
// runtime.GOOS.
func effectiveGOOS(rootAgentsDir string) string {
if v := readConfigOS(rootAgentsDir); v != "" {
return v
}
return runtime.GOOS
}

// readConfigOS reads the optional `os = <goos>` override from
// .agents/config. Empty when absent.
func readConfigOS(rootAgentsDir string) string {
data, err := os.ReadFile(filepath.Join(rootAgentsDir, "config"))
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
continue
}
parts := strings.SplitN(line, "=", 2)
if strings.TrimSpace(parts[0]) == "os" {
return strings.ToLower(strings.TrimSpace(parts[1]))
}
}
return ""
}

func DiscoverArtifacts(rootAgentsDir string) ([]Artifact, error) {
return discoverArtifactsForOS(rootAgentsDir, effectiveGOOS(rootAgentsDir))
}

// discoverArtifactsForOS is DiscoverArtifacts with an explicit host OS,
// so tests (and the `os` config override) exercise every platform
// without cross-compiling. SPEC-006: OS-scoped subdirs route only when
// goos matches; non-matching subtrees are skipped entirely.
func discoverArtifactsForOS(rootAgentsDir, goos string) ([]Artifact, error) {
var out []Artifact

for _, b := range Buckets {
Expand All @@ -401,28 +459,25 @@ func DiscoverArtifacts(rootAgentsDir string) ([]Artifact, error) {
if strings.HasPrefix(e.Name(), ".") {
continue
}
abs := filepath.Join(dir, e.Name())
if b.DirPerArtifact {
if !e.IsDir() {
// OS-scoped subdirectory: descend one level only when the
// host matches; otherwise skip the whole subtree (cheaper
// than discovering then filtering). Scoped artifacts keep
// the subdir as a name prefix ("macos/brew") so their
// destination mirrors the source tree and platforms don't
// collide in the flat target dir.
if e.IsDir() && isOSScopeDir(e.Name()) {
if !osScopes[e.Name()](goos) {
continue
}
if _, err := os.Stat(filepath.Join(abs, "SKILL.md")); err != nil {
continue
scoped, err := discoverBucketDir(filepath.Join(dir, e.Name()), b, e.Name()+"/")
if err != nil {
return nil, err
}
out = append(out, Artifact{
Type: b.Artifact,
Name: e.Name(),
SourcePath: abs,
})
} else {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
out = append(out, Artifact{
Type: b.Artifact,
Name: strings.TrimSuffix(e.Name(), ".md"),
SourcePath: abs,
})
out = append(out, scoped...)
continue
}
if art, ok := artifactFromEntry(dir, e, b, ""); ok {
out = append(out, art)
}
}
}
Expand All @@ -438,3 +493,43 @@ func DiscoverArtifacts(rootAgentsDir string) ([]Artifact, error) {
})
return out, nil
}

// discoverBucketDir lists the artifacts directly inside one directory
// of a bucket (used for OS-scoped subdirs), naming each with prefix.
func discoverBucketDir(dir string, b Bucket, prefix string) ([]Artifact, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var out []Artifact
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") {
continue
}
if art, ok := artifactFromEntry(dir, e, b, prefix); ok {
out = append(out, art)
}
}
return out, nil
}

// artifactFromEntry builds one Artifact from a directory entry,
// honoring the bucket's DirPerArtifact rule. prefix is prepended to
// the name (OS scope, e.g. "macos/"). ok is false for entries that
// aren't artifacts (stray files, skill dirs without SKILL.md).
func artifactFromEntry(dir string, e os.DirEntry, b Bucket, prefix string) (Artifact, bool) {
abs := filepath.Join(dir, e.Name())
if b.DirPerArtifact {
if !e.IsDir() {
return Artifact{}, false
}
if _, err := os.Stat(filepath.Join(abs, "SKILL.md")); err != nil {
return Artifact{}, false
}
return Artifact{Type: b.Artifact, Name: prefix + e.Name(), SourcePath: abs}, true
}
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
return Artifact{}, false
}
return Artifact{Type: b.Artifact, Name: prefix + strings.TrimSuffix(e.Name(), ".md"), SourcePath: abs}, true
}
Loading
Loading