From c9c58ab0ca446502b5dd247e5a90a788bf7f1190 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:56:10 -0300 Subject: [PATCH 1/2] feat(agentstate): persisted cache for the remote configuration overlay /remote-config.json (0600, written atomically) keeps the last fetched and applied overlay with its raw ETag, and the rejected-revision memory, so the next PR can keep running the last good overlay while the API is unreachable and re-report a rejection after a restart (R7). - The cache is bound to api.url and api.auth.client_id: a mismatch discards it. - A checksum detects corruption; LoadCache returns ErrCacheCorrupt and an empty cache so the agent continues without it. - IfNoneMatch returns the raw ETag the API sent, never one built from a revision number. Co-Authored-By: Claude Opus 5.5 --- internal/agentstate/cache.go | 143 ++++++++++++++++++++++++++++++ internal/agentstate/cache_test.go | 57 ++++++++++++ internal/agentstate/store.go | 3 +- 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 internal/agentstate/cache.go create mode 100644 internal/agentstate/cache_test.go diff --git a/internal/agentstate/cache.go b/internal/agentstate/cache.go new file mode 100644 index 0000000..425a32f --- /dev/null +++ b/internal/agentstate/cache.go @@ -0,0 +1,143 @@ +package agentstate + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/compliance-framework/api/pkg/agentconfig" +) + +const ( + cacheFile = "remote-config.json" + cacheVersion = 1 +) + +// ErrCacheCorrupt is returned by LoadCache when the cache file does not parse or its checksum +// does not match. The agent reports failed/cache-corrupt once and continues without the cache. +var ErrCacheCorrupt = errors.New("remote config cache corrupt") + +// Identity binds a cache to the API and credentials it was fetched with (R7). A cache whose +// identity differs from the current one is discarded, so a new client_id or API URL never +// applies another agent's overlay. +type Identity struct { + APIURL string `json:"api_url"` + ClientID string `json:"client_id"` +} + +// OverlayRecord is one overlay document received from the API. +type OverlayRecord struct { + Revision int64 `json:"revision"` + // ETag is the RAW ETag header of the 200 response (opaque, e.g. "r-"). It is + // sent back verbatim as If-None-Match and never built from a revision number (R7). + ETag string `json:"etag"` + Overlay json.RawMessage `json:"overlay"` + FetchedAt time.Time `json:"fetched_at"` +} + +// RejectedRecord remembers that a fetched overlay was rejected against a given base, so it +// is not re-prepared on every poll. It is keyed by (ETag, BaseFingerprint); the revision is +// informational only because a reset API can reuse revision numbers, except when the +// response carried no ETag: then (Revision, OverlaySHA256) stands in for it. +type RejectedRecord struct { + Revision int64 `json:"revision"` + ETag string `json:"etag"` + // OverlaySHA256 keys the record with the revision when the response had no ETag. + OverlaySHA256 string `json:"overlay_sha256,omitempty"` + BaseFingerprint string `json:"base_fingerprint"` + Status string `json:"status"` + Reason string `json:"reason"` + Error string `json:"error"` + // Unsafe completes the outcome re-reported after a restart. It is optional: caches + // written before it existed load (and checksum) unchanged. + Unsafe []agentconfig.Change `json:"unsafe,omitempty"` +} + +// Cache is the persisted remote configuration state (0600, it may hold values an admin typed). +type Cache struct { + Version int `json:"version"` + Identity Identity `json:"identity"` + Applied *OverlayRecord `json:"applied,omitempty"` + Fetched *OverlayRecord `json:"fetched,omitempty"` // newest 200 body; may be the rejected one + Rejected *RejectedRecord `json:"rejected,omitempty"` + Checksum string `json:"checksum"` // sha256 of the JSON with Checksum = "" +} + +// IfNoneMatch is the ETag to present on the next fetch: the last 200's raw ETag, falling back +// to the applied one, or "" for an unconditional fetch. +func (c *Cache) IfNoneMatch() string { + if c == nil { + return "" + } + if c.Fetched != nil && c.Fetched.ETag != "" { + return c.Fetched.ETag + } + if c.Applied != nil { + return c.Applied.ETag + } + return "" +} + +func (c Cache) checksum() (string, error) { + c.Checksum = "" + raw, err := json.Marshal(c) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +// CachePath returns the cache file path. +func (s *Store) CachePath() string { return filepath.Join(s.dir, cacheFile) } + +// LoadCache reads the cache for identity id. A missing file yields an empty cache. A corrupt +// file yields an empty cache and ErrCacheCorrupt. A cache bound to another identity is +// discarded (empty cache, nil error, one INFO). +func (s *Store) LoadCache(id Identity) (*Cache, error) { + empty := &Cache{Version: cacheVersion, Identity: id} + raw, err := os.ReadFile(s.CachePath()) + if errors.Is(err, os.ErrNotExist) { + return empty, nil + } + if err != nil { + return empty, fmt.Errorf("%w: %v", ErrCacheCorrupt, err) + } + var c Cache + if err := json.Unmarshal(raw, &c); err != nil { + return empty, fmt.Errorf("%w: %v", ErrCacheCorrupt, err) + } + want, err := c.checksum() + if err != nil || c.Checksum != want || c.Version != cacheVersion { + return empty, fmt.Errorf("%w: checksum or version mismatch", ErrCacheCorrupt) + } + if c.Identity != id { + s.logger.Info("Discarding the remote config cache: it belongs to another API URL or client ID") + return empty, nil + } + return &c, nil +} + +// SaveCache writes the cache atomically (temp file, fsync, rename) with mode 0600. It is a +// no-op error when the store is not writable. +func (s *Store) SaveCache(c *Cache) error { + if !s.writable { + return errors.New("state directory is not writable") + } + c.Version = cacheVersion + sum, err := c.checksum() + if err != nil { + return err + } + c.Checksum = sum + raw, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + return WriteFileAtomic(s.CachePath(), raw, 0o600) +} diff --git a/internal/agentstate/cache_test.go b/internal/agentstate/cache_test.go new file mode 100644 index 0000000..a5456ed --- /dev/null +++ b/internal/agentstate/cache_test.go @@ -0,0 +1,57 @@ +package agentstate + +import ( + "encoding/json" + "errors" + "os" + "testing" + "time" +) + +func TestCache_RoundTripAndIfNoneMatch(t *testing.T) { + s := Open(t.TempDir(), nil) + id := Identity{APIURL: "http://api.test", ClientID: "c"} + c, err := s.LoadCache(id) + if err != nil || c.IfNoneMatch() != "" { + t.Fatalf("empty cache: %+v %v", c, err) + } + c.Applied = &OverlayRecord{Revision: 1, ETag: `"r1-a"`, Overlay: json.RawMessage(`{"plugins": {}}`), FetchedAt: time.Now().UTC()} + if got := c.IfNoneMatch(); got != `"r1-a"` { + t.Fatalf("IfNoneMatch falls back to applied, got %q", got) + } + c.Fetched = &OverlayRecord{Revision: 2, ETag: `W/"r2-b"`, Overlay: json.RawMessage(`{}`)} + if got := c.IfNoneMatch(); got != `W/"r2-b"` { + t.Fatalf("IfNoneMatch prefers the raw fetched ETag, got %q", got) + } + if err := s.SaveCache(c); err != nil { + t.Fatal(err) + } + loaded, err := s.LoadCache(id) + if err != nil { + t.Fatalf("reload: %v", err) + } + if loaded.Fetched.ETag != `W/"r2-b"` || loaded.Applied.Revision != 1 { + t.Fatalf("round trip lost data: %+v", loaded) + } +} + +func TestCache_CorruptAndIdentity(t *testing.T) { + s := Open(t.TempDir(), nil) + id := Identity{APIURL: "http://api.test", ClientID: "c"} + c, _ := s.LoadCache(id) + c.Applied = &OverlayRecord{Revision: 1, ETag: `"r1-a"`, Overlay: json.RawMessage(`{}`)} + if err := s.SaveCache(c); err != nil { + t.Fatal(err) + } + other, err := s.LoadCache(Identity{APIURL: "http://api.test", ClientID: "d"}) + if err != nil || other.Applied != nil { + t.Fatalf("an identity mismatch must yield an empty cache: %+v %v", other, err) + } + if err := os.WriteFile(s.CachePath(), []byte(`{"version":1,"checksum":"nope"}`), 0o600); err != nil { + t.Fatal(err) + } + empty, err := s.LoadCache(id) + if !errors.Is(err, ErrCacheCorrupt) || empty == nil || empty.Applied != nil { + t.Fatalf("expected ErrCacheCorrupt with an empty cache, got %+v %v", empty, err) + } +} diff --git a/internal/agentstate/store.go b/internal/agentstate/store.go index 05781d5..6388aa3 100644 --- a/internal/agentstate/store.go +++ b/internal/agentstate/store.go @@ -1,11 +1,12 @@ // Package agentstate owns the agent's per-instance state directory: the stable instance ID -// (R31). +// (R31) and the remote configuration cache (R7). // // Layout (the OCI download caches under .compliance-framework/{plugins,policies} are shared // and unchanged): // // .compliance-framework/state// key = hex(sha256(abs config path))[:16]; dir 0700 // instance-id 0644, UUID + "\n" +// remote-config.json 0600 // // The package is a leaf: it never imports cmd. package agentstate From c435ecbf3ee0c71de8c1e31a0ac8722b5a938c8f Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:53:49 -0300 Subject: [PATCH 2/2] fix(agentstate): keep cached overlay bytes stable across save and load SaveCache wrote the overlay with json.MarshalIndent, which re-indents a json.RawMessage and escapes <, > and &, and LoadCache returned those bytes. Anything hashed from the overlay (the no-ETag rejection key) changed across a restart. CanonicalOverlay gives the compact, HTML-escaped form a round trip yields. SaveCache and LoadCache rewrite the applied and fetched overlays to it; the checksum, computed over the same compact form, is unchanged, so existing caches still load. Co-Authored-By: Claude Opus 5.5 --- internal/agentstate/cache.go | 39 ++++++++++++++++++- internal/agentstate/cache_test.go | 65 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/internal/agentstate/cache.go b/internal/agentstate/cache.go index 425a32f..66c0216 100644 --- a/internal/agentstate/cache.go +++ b/internal/agentstate/cache.go @@ -83,6 +83,34 @@ func (c *Cache) IfNoneMatch() string { return "" } +// CanonicalOverlay returns raw in the form a save/load round trip of the cache yields: compact, +// with <, > and & escaped as encoding/json escapes them. SaveCache re-indents the overlay and +// LoadCache would otherwise return those re-indented bytes, so anything that hashes or compares +// overlay bytes (the no-ETag rejection key) must use this form, for a fresh 200 body as well +// as a cached one. An empty overlay is returned unchanged. +func CanonicalOverlay(raw json.RawMessage) (json.RawMessage, error) { + if len(raw) == 0 { + return raw, nil + } + return json.Marshal(raw) +} + +// canonicalize rewrites the overlays of c to their CanonicalOverlay form. It does not change +// the checksum, which is computed over the same compact form. +func (c *Cache) canonicalize() error { + for _, rec := range []*OverlayRecord{c.Applied, c.Fetched} { + if rec == nil { + continue + } + canon, err := CanonicalOverlay(rec.Overlay) + if err != nil { + return err + } + rec.Overlay = canon + } + return nil +} + func (c Cache) checksum() (string, error) { c.Checksum = "" raw, err := json.Marshal(c) @@ -98,7 +126,7 @@ func (s *Store) CachePath() string { return filepath.Join(s.dir, cacheFile) } // LoadCache reads the cache for identity id. A missing file yields an empty cache. A corrupt // file yields an empty cache and ErrCacheCorrupt. A cache bound to another identity is -// discarded (empty cache, nil error, one INFO). +// discarded (empty cache, nil error, one INFO). Overlays are returned in CanonicalOverlay form. func (s *Store) LoadCache(id Identity) (*Cache, error) { empty := &Cache{Version: cacheVersion, Identity: id} raw, err := os.ReadFile(s.CachePath()) @@ -120,16 +148,23 @@ func (s *Store) LoadCache(id Identity) (*Cache, error) { s.logger.Info("Discarding the remote config cache: it belongs to another API URL or client ID") return empty, nil } + if err := c.canonicalize(); err != nil { + return empty, fmt.Errorf("%w: %v", ErrCacheCorrupt, err) + } return &c, nil } // SaveCache writes the cache atomically (temp file, fsync, rename) with mode 0600. It is a -// no-op error when the store is not writable. +// no-op error when the store is not writable. It rewrites c's overlays to their +// CanonicalOverlay form, so c holds the same bytes a later LoadCache returns. func (s *Store) SaveCache(c *Cache) error { if !s.writable { return errors.New("state directory is not writable") } c.Version = cacheVersion + if err := c.canonicalize(); err != nil { + return err + } sum, err := c.checksum() if err != nil { return err diff --git a/internal/agentstate/cache_test.go b/internal/agentstate/cache_test.go index a5456ed..d444540 100644 --- a/internal/agentstate/cache_test.go +++ b/internal/agentstate/cache_test.go @@ -1,6 +1,7 @@ package agentstate import ( + "bytes" "encoding/json" "errors" "os" @@ -55,3 +56,67 @@ func TestCache_CorruptAndIdentity(t *testing.T) { t.Fatalf("expected ErrCacheCorrupt with an empty cache, got %+v %v", empty, err) } } + +// The overlay bytes, and so the no-ETag rejection key hashed from them, must not change across +// a save/load round trip: SaveCache re-indents a json.RawMessage and escapes <, > and &. +func TestCache_OverlayBytesSurviveRoundTrip(t *testing.T) { + s := Open(t.TempDir(), nil) + id := Identity{APIURL: "http://api.test", ClientID: "c"} + fresh := json.RawMessage("{\n \"plugins\": {\"p\": {\"config\": {\"q\": \"ad\"}}},\n \"x\": [1, 2]\n}") + canon, err := CanonicalOverlay(fresh) + if err != nil { + t.Fatal(err) + } + if again, _ := CanonicalOverlay(canon); !bytes.Equal(again, canon) { + t.Fatalf("CanonicalOverlay is not idempotent: %s != %s", again, canon) + } + + c, _ := s.LoadCache(id) + c.Fetched = &OverlayRecord{Revision: 3, Overlay: append(json.RawMessage(nil), fresh...)} + c.Applied = &OverlayRecord{Revision: 2, ETag: `"r2-a"`, Overlay: append(json.RawMessage(nil), fresh...)} + if err := s.SaveCache(c); err != nil { + t.Fatal(err) + } + if !bytes.Equal(c.Fetched.Overlay, canon) { + t.Fatalf("SaveCache must leave the canonical bytes in memory: %s", c.Fetched.Overlay) + } + for i := 0; i < 2; i++ { + loaded, err := s.LoadCache(id) + if err != nil { + t.Fatalf("reload %d: %v", i, err) + } + if !bytes.Equal(loaded.Fetched.Overlay, canon) || !bytes.Equal(loaded.Applied.Overlay, canon) { + t.Fatalf("reload %d changed the overlay bytes:\n got %s\nwant %s", i, loaded.Fetched.Overlay, canon) + } + if err := s.SaveCache(loaded); err != nil { + t.Fatal(err) + } + } +} + +// A cache written before overlays were canonicalized (an indented overlay, checksummed over its +// compact form) still loads, with its overlay in canonical form. +func TestCache_LoadsLegacyIndentedOverlay(t *testing.T) { + s := Open(t.TempDir(), nil) + id := Identity{APIURL: "http://api.test", ClientID: "c"} + c := &Cache{Version: cacheVersion, Identity: id, Fetched: &OverlayRecord{Revision: 1, Overlay: json.RawMessage("{ \"a\": \"<\" }")}} + sum, err := c.checksum() + if err != nil { + t.Fatal(err) + } + c.Checksum = sum + raw, err := json.MarshalIndent(c, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(s.CachePath(), raw, 0o600); err != nil { + t.Fatal(err) + } + loaded, err := s.LoadCache(id) + if err != nil { + t.Fatal(err) + } + if got, want := string(loaded.Fetched.Overlay), `{"a":"\u003c"}`; got != want { + t.Fatalf("legacy overlay: got %s, want %s", got, want) + } +}