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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ AGENTS.md is also symlinked to CLAUDE.md so that Claude reads the index natively
| `source list [--json]` | Show each entry's local state: `ok` / `outdated` / `modified` / `missing` |
| `source bundle` | Rebuild `sources.yaml` from installed artifacts' origin metadata |
| `source detach <name>` | Un-manage an artifact: flip its origin to manual and drop the manifest entry |
| `quarantine` | List remotely-fetched artifacts awaiting review, with their scan findings |
| `approve <name>\|--all [--force]` | Promote a quarantined artifact into `.agents/` (`--force` accepts critical findings, recorded in the lock) |
| `reject <name>\|--all` | Delete a quarantined artifact without installing it |
| `git-hook` | Install a pre-commit git hook for auto-sync (`hook` remains as a deprecated alias) |
| `inherit <label> <path>` | Add an inheritance link to AGENTS.md |
| `inherit --list` | List current inheritance links |
Expand Down Expand Up @@ -143,6 +146,7 @@ AGENTS.md is also symlinked to CLAUDE.md so that Claude reads the index natively
| `--no-clobber` | (fix only) Skip items that already exist in `.agents/` instead of merging |
| `--fix` | (lint only) Amend fixable frontmatter findings in place |
| `--no-fix` | (index only) Skip the skill frontmatter backfill |
| `--trust` | (pull/update only) Bypass the quarantine gate; the scan still runs and prints findings |

## Configuration

Expand Down Expand Up @@ -223,6 +227,19 @@ sync-agents update # bump tag-tracked entries when upstream moves
sync-agents pull --offline # cache-only, for CI or airplanes
```

## Quarantine (remote content review)

Remote installs are treated like a hostile supply chain. By default, everything `pull`/`update` fetches lands in `.agents/.quarantine/` — invisible to `sync` and the index — after a static scan for network-then-execute patterns (`curl | bash`), credential access combined with network calls, obfuscation (long base64, zero-width Unicode), and prompt-injection phrasing aimed at your agent.

```bash
sync-agents pull # → 1 quarantined (run `sync-agents quarantine`)
sync-agents quarantine # review findings per artifact
sync-agents approve code-review # promote into .agents/ (blocked on CRITICAL unless --force)
sync-agents reject sketchy-rule # delete without installing
```

Critical findings block `approve`; overriding with `--force` is recorded in `sources.lock` as `approved_with_findings` so the decision is auditable. `--trust` on `pull`/`update` skips the gate for one invocation (findings still print), and `quarantine = off` in `.agents/config` disables it for teams that review via pinned SHAs in PRs instead.

## ADRs (Architecture Decision Records)

ADRs live in `.agents/adrs/` with **status encoded by subdirectory**: `proposed/`, `accepted/`, `denied/`. `add adr <name>` scaffolds into `proposed/`; `sync-agents adr accept|deny|propose <name>` moves a record between statuses (nested grouping subdirs are preserved) and regenerates the index.
Expand Down
6 changes: 5 additions & 1 deletion internal/agent/source/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ type LockEntry struct {
Entry string `yaml:"entry"`
ResolvedSHA string `yaml:"resolved_sha"`
ContentHash string `yaml:"content_hash"`
FetchedAt string `yaml:"fetched_at"`
// ApprovedWithFindings marks an artifact promoted out of
// quarantine with `approve --force` despite critical scanner
// findings — an auditable record of the human override.
ApprovedWithFindings bool `yaml:"approved_with_findings,omitempty"`
FetchedAt string `yaml:"fetched_at"`
}

// Find returns the lock entry for the given manifest entry string,
Expand Down
130 changes: 129 additions & 1 deletion internal/agent/source/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ type Puller struct {
// grew the registry and pull must follow it.
Buckets []BucketInfo

// Quarantine gates remote installs (SPEC-005 Part B): staged
// artifacts are scanned and parked under .agents/.quarantine/
// for human approval instead of entering the live tree. The
// caller wires this from .agents/config (`quarantine = on|off`,
// default on); PullOpts.Trust bypasses it per invocation.
Quarantine bool

Out io.Writer
Err io.Writer

Expand Down Expand Up @@ -89,6 +96,10 @@ type PullOpts struct {
// UpdateMode switches pull semantics to `update`: SHA-pinned
// entries are skipped with a note, and moved tags are called out.
UpdateMode bool
// Trust bypasses the quarantine gate for this invocation. The
// scan still runs and findings still print — trust skips the
// approval step, not the look.
Trust bool
}

// ResultKind classifies one entry's outcome in a PullReport.
Expand All @@ -102,6 +113,7 @@ const (
ResultFailed ResultKind = "failed"
ResultWouldAdd ResultKind = "would-add"
ResultWouldUpdate ResultKind = "would-update"
ResultQuarantined ResultKind = "quarantined"
)

// EntryResult is one entry's outcome.
Expand Down Expand Up @@ -150,6 +162,7 @@ func (r PullReport) Summary() string {
add(r.Count(ResultCurrent), "already current")
add(r.Count(ResultWouldAdd), "would add")
add(r.Count(ResultWouldUpdate), "would update")
add(r.Count(ResultQuarantined), "quarantined (run `sync-agents quarantine`)")
add(r.Count(ResultSkipped), "skipped")
add(r.Count(ResultFailed), "failed")
if len(parts) == 0 {
Expand Down Expand Up @@ -432,6 +445,35 @@ func (p *Puller) syncArtifact(ctx context.Context, e Entry, name, sha string, re
return res
}

// SPEC-005 Part B: scan the staged artifact. With the gate on,
// it parks in quarantine instead of installing; with --trust (or
// quarantine=off) it installs but the findings still print.
findings := ScanTree(src)
fetchedAtQ := p.now().Format(time.RFC3339)
if p.Quarantine && !opts.Trust {
pa := PendingArtifact{
Entry: res.Entry,
Name: name,
DestRel: p.destRel(dest),
DirArtifact: dirArtifact,
Origin: Origin{
Owner: e.Owner, Repo: e.Repo, Path: effPath, Ref: e.Ref,
SHA: sha, ContentHash: stagedHash, FetchedAt: fetchedAtQ, Source: SourceManifest,
},
Lock: LockEntry{Entry: res.Entry, ResolvedSHA: sha, ContentHash: stagedHash, FetchedAt: fetchedAtQ},
Findings: findings,
}
if err := p.quarantineStaged(src, pa); err != nil {
res.Kind = ResultFailed
res.Detail = err.Error()
return res
}
res.Kind = ResultQuarantined
res.Detail = quarantineDetail(findings)
return res
}
p.printFindings(name, findings)

if err := installArtifact(src, dest, dirArtifact); err != nil {
res.Kind = ResultFailed
res.Detail = err.Error()
Expand Down Expand Up @@ -591,9 +633,48 @@ func (p *Puller) syncTree(ctx context.Context, e Entry, sha string, res EntryRes
return res
}

// SPEC-005 Part B: scan and (with the gate on) quarantine the
// whole entry — every artifact parks together, mirroring the
// all-or-nothing install rule. The entry-level lock rides in
// each pending record and is applied when the last one is
// approved.
fetchedAt := p.now().Format(time.RFC3339)
if p.Quarantine && !opts.Trust {
total := 0
for i, a := range arts {
if states[i] == destCurrent {
continue
}
findings := ScanTree(a.src)
total += len(findings)
pa := PendingArtifact{
Entry: res.Entry,
Name: artifactNameFromRel(a.rel),
DestRel: p.destRel(a.dest),
DirArtifact: a.dirArtifact,
Origin: Origin{
Owner: e.Owner, Repo: e.Repo, Path: a.repoPath, Ref: e.Ref,
SHA: sha, ContentHash: "", FetchedAt: fetchedAt, Source: SourceManifest,
},
Lock: LockEntry{Entry: res.Entry, ResolvedSHA: sha, ContentHash: stagedHash, FetchedAt: fetchedAt},
Findings: findings,
}
if err := p.quarantineStaged(a.src, pa); err != nil {
res.Kind = ResultFailed
res.Detail = err.Error()
return res
}
}
res.Kind = ResultQuarantined
res.Detail = fmt.Sprintf("%d artifact(s), %d finding(s)", len(arts), total)
return res
}
for _, a := range arts {
p.printFindings(artifactNameFromRel(a.rel), ScanTree(a.src))
}

// Phase 3: install. Per-artifact origins record the in-repo path
// so bundle/list/promote can trace each file individually.
fetchedAt := p.now().Format(time.RFC3339)
for i, a := range arts {
if states[i] == destCurrent {
continue
Expand Down Expand Up @@ -1002,3 +1083,50 @@ func (p *Puller) scanInstalled() []installedArtifact {
}
return out
}

// destRel converts an absolute destination under AgentsDir to the
// bucket-relative slash path used by quarantine records.
func (p *Puller) destRel(dest string) string {
rel, err := filepath.Rel(p.AgentsDir, dest)
if err != nil {
return filepath.ToSlash(dest)
}
return filepath.ToSlash(rel)
}

// artifactNameFromRel derives the approve/reject name for a tree
// artifact from its bucket-relative path.
func artifactNameFromRel(rel string) string {
base := path.Base(rel)
return strings.TrimSuffix(base, path.Ext(base))
}

// quarantineDetail summarizes findings for the pull report line.
func quarantineDetail(findings []Finding) string {
crit := 0
for _, f := range findings {
if f.Severity == SeverityCritical {
crit++
}
}
switch {
case crit > 0:
return fmt.Sprintf("%d finding(s), %d CRITICAL — review with `sync-agents quarantine`", len(findings), crit)
case len(findings) > 0:
return fmt.Sprintf("%d finding(s) — review with `sync-agents quarantine`", len(findings))
default:
return "clean scan — `sync-agents approve` to install"
}
}

// printFindings surfaces scan results on the trust / quarantine-off
// path: installation proceeds, but never silently.
func (p *Puller) printFindings(name string, findings []Finding) {
for _, f := range findings {
loc := name
if f.Path != "" && f.Path != name {
loc = name + "/" + f.Path
}
fmt.Fprintf(p.errOut(), "[scan:%s] %s: %s (%s)\n", f.Severity, loc, f.Detail, f.Class)
}
}
Loading
Loading