diff --git a/agents/manager/downloader.go b/agents/manager/downloader.go index 1a9a3714..529b71f6 100644 --- a/agents/manager/downloader.go +++ b/agents/manager/downloader.go @@ -112,6 +112,52 @@ func writeArchive(f *os.File, body io.Reader, size int64) (digest string, err er return hex.EncodeToString(hash.Sum(nil)), nil } +// releaseDigestFile is the sidecar Download writes beside an installed agent: +// the SHA-256 of the executable as it was installed from its release, in +// sha256sum's format. Its name begins with a dot so no scan of the cache +// directory — local latest, installed inventory, the CLI's version listing — +// reads it as an agent. Its presence says the executable came from a release +// through this downloader; its value is what Load holds the executable to. +func releaseDigestFile(executable string) string { + return filepath.Join(filepath.Dir(executable), "."+filepath.Base(executable)+".sha256") +} + +// InstalledReleaseDigest reads the digest an agent was installed from its +// release with: the hex SHA-256 of its executable at install time. It is "" +// with no error when the agent is cached but was never installed from a release +// by this downloader — a local build, or an install older than the sidecar. +func InstalledReleaseDigest(ctx context.Context, p *resources.Agent) (string, error) { + bin, err := p.Path(ctx) + if err != nil { + return "", err + } + return readReleaseDigest(bin) +} + +func readReleaseDigest(executable string) (string, error) { + raw, err := os.ReadFile(releaseDigestFile(executable)) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + entries, err := parseChecksums(raw) + if err != nil { + return "", fmt.Errorf("%s: %w", releaseDigestFile(executable), err) + } + digest, ok := entries[filepath.Base(executable)] + if !ok { + return "", fmt.Errorf("%s records no digest for %s", releaseDigestFile(executable), filepath.Base(executable)) + } + return digest, nil +} + +func writeReleaseDigest(executable, digest string) error { + line := fmt.Sprintf("%s %s\n", digest, filepath.Base(executable)) + return os.WriteFile(releaseDigestFile(executable), []byte(line), 0o600) +} + func Download(ctx context.Context, p *resources.Agent) error { w := wool.Get(ctx).In("agents.Download", wool.Field("agent", p.Identifier())) registration, err := resources.AgentKindRegistrationFor(p.Kind) @@ -187,10 +233,22 @@ func Download(ctx context.Context, p *resources.Agent) error { if err := ctx.Err(); err != nil { return err } + binaryDigest, err := fileSHA256(binary) + if err != nil { + return w.Wrapf(err, "cannot digest the extracted agent") + } + // The sidecar is removed first so a failure between here and the install + // never leaves a digest beside an executable it does not describe. + if err := os.Remove(releaseDigestFile(target)); err != nil && !os.IsNotExist(err) { + return w.Wrapf(err, "cannot clear the previous install's digest") + } err = CopyFile(binary, target) if err != nil { return w.Wrapf(err, "cannot install binary") } + if err := writeReleaseDigest(target, binaryDigest); err != nil { + return w.Wrapf(err, "cannot record the installed agent's digest") + } return nil } @@ -208,6 +266,19 @@ func checkArchiveDigest(identifier string, asset *ReleaseAsset, archiveDigest st return "", nil } +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) //nolint:gosec // G304: path is the executable this downloader just extracted + if err != nil { + return "", err + } + defer func() { _ = file.Close() }() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + // extractTarGz decompresses gzip then extracts the tar archive at src into // dst. Replaces mholt/archiver.Unarchive (which had two unpatched path // traversal CVEs). Only .tar.gz is supported — this is all GitHub release diff --git a/agents/manager/downloader_test.go b/agents/manager/downloader_test.go index 18c6c937..28f58e55 100644 --- a/agents/manager/downloader_test.go +++ b/agents/manager/downloader_test.go @@ -374,3 +374,82 @@ func TestCheckArchiveDigestHoldsBytesToTheReleaseRecord(t *testing.T) { require.NoError(t, err) require.Contains(t, warning, "unverified") } + +// TestReleaseDigestSidecarIsInvisibleToCacheScans: the record Download leaves +// beside an executable must never read as an agent to anything listing the +// cache by name, and must round-trip the digest it records. +func TestReleaseDigestSidecarIsInvisibleToCacheScans(t *testing.T) { + ctx := t.Context() + t.Setenv(resources.CodeflyHomeEnv, t.TempDir()) + agent := &resources.Agent{Kind: resources.ServiceAgent, Publisher: "example.test", Name: "widget", Version: "1.2.3"} + bin, err := agent.Path(ctx) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(bin), 0o750)) + require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755)) + + recorded, err := InstalledReleaseDigest(ctx, agent) + require.NoError(t, err) + require.Empty(t, recorded, "a cached executable with no record is reported as such, not as an error") + + require.NoError(t, writeReleaseDigest(bin, strings.Repeat("a", 64))) + recorded, err = InstalledReleaseDigest(ctx, agent) + require.NoError(t, err) + require.Equal(t, strings.Repeat("a", 64), recorded) + + latest := &resources.Agent{Kind: resources.ServiceAgent, Publisher: "example.test", Name: "widget"} + require.NoError(t, findLocalLatestInDir(filepath.Dir(bin), latest)) + require.Equal(t, "1.2.3", latest.Version) + installed, err := Installed(ctx, resources.ServiceAgent) + require.NoError(t, err) + require.Len(t, installed, 1) +} + +// TestAdmitCachedReleaseHoldsTheCacheToItsRecord covers the four states a cached +// executable can be in: installed from a release and unchanged; installed from +// a release and replaced since; never installed from a release under local +// resolution; and never installed from a release under remote resolution, where +// the release cannot be fetched. +func TestAdmitCachedReleaseHoldsTheCacheToItsRecord(t *testing.T) { + ctx := t.Context() + t.Setenv(resources.CodeflyHomeEnv, t.TempDir()) + agent := &resources.Agent{Kind: resources.ServiceAgent, Publisher: "example.test", Name: "widget", Version: "1.2.3"} + bin, err := agent.Path(ctx) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(bin), 0o750)) + require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755)) + digest, info, err := executableIdentity(ctx, bin) + require.NoError(t, err) + + require.NoError(t, writeReleaseDigest(bin, strings.TrimPrefix(digest, "sha256:"))) + got, _, err := admitCachedRelease(ctx, agent, bin, digest, info) + require.NoError(t, err) + require.Equal(t, digest, got) + + require.NoError(t, writeReleaseDigest(bin, strings.Repeat("a", 64))) + _, _, err = admitCachedRelease(ctx, agent, bin, digest, info) + require.ErrorIs(t, err, ErrAgentAdmission) + require.ErrorContains(t, err, "installed from its release as sha256:"+strings.Repeat("a", 64)) + require.ErrorContains(t, err, digest) + + // A local rebuild over a released name leaves the release's record beside + // bytes it no longer describes. Under local resolution that is the build + // the developer asked for: admitted, and the stale record removed so a + // later remote load fetches the release instead of trusting the record. + t.Setenv(AgentSourceEnv, "local") + got, _, err = admitCachedRelease(ctx, agent, bin, digest, info) + require.NoError(t, err, "a local rebuild over a release is what CODEFLY_AGENT_SOURCE=local asks for, record or not") + require.Equal(t, digest, got) + require.NoFileExists(t, releaseDigestFile(bin), "the release record no longer describes these bytes") + + _, _, err = admitCachedRelease(ctx, agent, bin, digest, info) + require.NoError(t, err, "a local build is what CODEFLY_AGENT_SOURCE=local asks for") + + t.Setenv(AgentSourceEnv, "") + cancelled, cancel := context.WithCancel(ctx) + cancel() + _, _, err = admitCachedRelease(cancelled, agent, bin, digest, info) + require.ErrorIs(t, err, ErrAgentAdmission) + require.ErrorContains(t, err, "was not installed from a release and the release could not be fetched") + require.ErrorContains(t, err, "CODEFLY_AGENT_SOURCE=local") + require.FileExists(t, bin, "a refused cache entry is left for the operator to see, not deleted") +} diff --git a/agents/manager/loader.go b/agents/manager/loader.go index 1ee00165..1fc6df6d 100644 --- a/agents/manager/loader.go +++ b/agents/manager/loader.go @@ -803,6 +803,7 @@ func Load(ctx context.Context, p *resources.Agent, opts ...LoadOption) (*AgentCo } // Resolve binary: local cache → Nix flake → OCI registry → GitHub release. + cachePath := bin if _, err := exec.LookPath(bin); err != nil { pulled := false // Try Nix store first (AGENT_NIX_FLAKE env var). Content-addressed, @@ -850,6 +851,12 @@ func Load(ctx context.Context, p *resources.Agent, opts ...LoadOption) (*AgentCo if err != nil { return nil, err } + if bin == cachePath && resolvesFromGitHubRelease(p) && registration.AutoDownload { + artifactDigest, artifactInfo, err = admitCachedRelease(ctx, p, bin, artifactDigest, artifactInfo) + if err != nil { + return nil, err + } + } var verifiedProvider *providerartifact.Verified if registration.Resolution.Local == resources.AgentResolutionVerifiedArtifact { verifiedProvider, err = providerartifact.VerifyExecutable(bin, p) @@ -860,6 +867,63 @@ func Load(ctx context.Context, p *resources.Agent, opts ...LoadOption) (*AgentCo return spawnAgent(ctx, bin, p.Unique(), string(p.Kind), artifactDigest, artifactInfo, verifiedProvider, cfg) } +// admitCachedRelease holds an executable found in the agent cache to the digest +// it was installed from its release with. +// +// The cache is keyed by name alone — __ — and nothing used to +// compare its bytes with the release that name stands for. So a build left +// under a released version's name, by a developer or by a CI run that installed +// what it built, was run by every process resolving that version, silently and +// with nothing in any output to tell it from the release. Download now records +// the installed digest beside the executable; this is where it is held to it. +// +// An executable with no record was not installed from a release by this +// downloader: a local build, or an install that predates the record. Under +// CODEFLY_AGENT_SOURCE=local that is what the developer asked for. Under remote +// resolution the name means the release, so the release is fetched over it; if +// it cannot be, the cached bytes are refused rather than run on trust, and the +// refusal names the way to run a local build deliberately. +func admitCachedRelease(ctx context.Context, p *resources.Agent, bin, digest string, info os.FileInfo) (string, os.FileInfo, error) { + w := wool.Get(ctx).In("manager.admitCachedRelease", wool.Field("agent", p.Identifier())) + recorded, err := readReleaseDigest(bin) + if err != nil { + return "", nil, fmt.Errorf("%w: %v", ErrAgentAdmission, err) + } + switch { + case recorded != "" && "sha256:"+recorded == digest: + return digest, info, nil + case AgentSourceLocal(): + // A local build is what the developer asked for, whatever sits beside + // it. A record left by an earlier release install no longer describes + // these bytes, so it goes: the next remote load then sees an entry with + // no record and fetches the release, instead of trusting a stale one. + if recorded != "" { + if err := os.Remove(releaseDigestFile(bin)); err != nil { + return "", nil, fmt.Errorf("%w: cached agent %s was rebuilt locally over its release but the release record beside it cannot be removed: %v", ErrAgentAdmission, bin, err) + } + w.Debug("cached agent was rebuilt over its release; CODEFLY_AGENT_SOURCE=local admits it and the release record is removed", wool.Field("digest", digest), wool.Field("recorded", recorded)) + return digest, info, nil + } + w.Debug("cached agent was not installed from a release; CODEFLY_AGENT_SOURCE=local admits it", wool.Field("digest", digest)) + return digest, info, nil + case recorded != "": + return "", nil, fmt.Errorf("%w: cached agent %s is %s but was installed from its release as sha256:%s; something replaced it after install — delete it and it is downloaded again, or export %s=local to run a local build deliberately", ErrAgentAdmission, bin, digest, recorded, AgentSourceEnv) + } + w.Warn(fmt.Sprintf("cached agent %s (%s) was not installed from a release; fetching the release over it", p.Identifier(), digest)) + if err := Download(ctx, p); err != nil { + return "", nil, fmt.Errorf("%w: cached agent %s (%s) was not installed from a release and the release could not be fetched to replace it: %v; export %s=local to run local builds deliberately", ErrAgentAdmission, bin, digest, err, AgentSourceEnv) + } + digest, info, err = executableIdentity(ctx, bin) + if err != nil { + return "", nil, err + } + recorded, err = readReleaseDigest(bin) + if err != nil || "sha256:"+recorded != digest { + return "", nil, fmt.Errorf("%w: agent %s was just installed from its release as sha256:%s but reads as %s", ErrAgentAdmission, bin, recorded, digest) + } + return digest, info, nil +} + func spawnAgent(ctx context.Context, bin, identity, telemetryName, artifactDigest string, artifactInfo os.FileInfo, verifiedProvider *providerartifact.Verified, cfg loadConfig) (*AgentConn, error) { sweepStaleAgentSocketsOnce() w := wool.Get(ctx).In("manager.Load", wool.Field("agent", identity))