Repository navigation
feat: agent remote configuration (overlay, apply modes, safeguards) - #465
ccf-lisa[bot] wants to merge 36 commits into
Conversation
Implements the API side of agent remote configuration (LLD WPs A1-A4, design §12 R1-R56): - A1 pkg/agentconfig (+ regocheck): declared config types, RFC 7396 merge with locked keys, strict overlay validation with FieldError codes, change-safety classification, redaction/digests, opaque agent ETags, bundle checks, PolicyOnlyChange and the agent<->API wire types. Adds go-containerregistry v0.21.2. - A2 revisions + instances models/migrations, agentcfg service, instance cap and River prune job, authz vocabulary (configure, configure-policy, sync; policy-author role), builtin PDP gating of the agent resource, PEP AuthorizeAny, and per-route guards on /admin/agents (reads on agent:read). - A3 agent routes GET /agent/config and PUT /agent/instances/:id/config-report, heartbeat config_revision/config_digest, SDK AgentConfig client. - A4 admin config routes (get/put/preview/revisions/revert/instances), CORS for If-Match/If-None-Match/ETag, swagger.
- document the builtin agent-resource gate and the policy-author role - preview derives its validation set from PreviewBases (one instance scan) - single agentcfg.SettingsFromConfig mapping
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
gusfcarvalho
left a comment
There was a problem hiding this comment.
Approving at 52e315b. I found no blocking issues. The four inline threads are non-blocking, and two of them ask for an owner decision.
What I checked:
- Against design §12 (R1–R56) and the API LLD:
- locked keys: stripped in
Mergeand rejected byValidateOverlay; - classification:
Classify/WillApply, including R23 and R24; - builtin PDP gate on
agent(R39); - agent routes: agent JWT only, with cross-agent isolation from the JWT;
- D18 and the R22 swap;
- opaque ETag and 304 (R7);
- If-Match handling: 428, 409 and 200 for no-op saves;
- the 422 body shape (R6), and R48
validated; - redaction and the digest stored as sent (R55);
- the instance cap (R37), heartbeat R11/R45, and pagination (R49).
- locked keys: stripped in
- Local runs:
go build,go vet,go test ./...andgo mod verifyare clean.-tags integrationpasses for handler, agentcfg, authz and worker on testcontainers Postgres. - gofmt: it only flags
internal/authz/cedar_test.go, which is already on main and not touched here.
GitHub Actions has not run on this PR (only CodeRabbit, which skipped it). The bot-authored run probably needs approval before merge.
gusfcarvalho
left a comment
There was a problem hiding this comment.
Blocking: R58 (extends bypass) and R59 (file-origin errors block saves); see threads
…57 docs, D18 audit
|
Review pass (aa005f7) addressed R58, R59, R57 and the D18 audit. API change for the UI LLD (R59): preview |
gusfcarvalho
left a comment
There was a problem hiding this comment.
Re-reviewed 52e315b..aa005f7. R58 and R59 are implemented correctly, so this approval supersedes my CHANGES_REQUESTED review.
- R58:
extendsChangeAllowedruns for every base, including diffs that touch only/policy_bundles. A new or changedextendsmust be inusedSources(base)or be the source replaced by an accepted R22 swap at the same index; removing or keepingextendsis still allowed, and with no bases only the swap can add one. The unit tests cover each of these cases, and the Cedar policy-author tests check that an unknownextendsgets 403 and an already-used source gets 201. - R59:
splitIntroducedcompares errors againstMerge(base, {})on(Path, Code, Message). Only errors the overlay introduces block a save or forceinvalid-config; file-origin errors are returned aswarnings, never null. The integration test confirms a base with a bad cron still accepts{"verbosity":1}with 201, and preview shows the warning withwill-apply: true. - R57 docs and swagger, the D18 denial audit record, and the policy-author revert test all look good.
- Local verification: build, vet and unit tests are green. With
-tags integration, the handler, agentcfg, authz and worker suites pass. gofmt flags onlyinternal/authz/cedar_test.go, which is already like that on main.
GitHub Actions still hasn't run on this bot-authored PR, so the workflow needs approval before merge.
|
PR approved. Marking ready for e2e. |
gusfcarvalho
left a comment
There was a problem hiding this comment.
Re-approving at 9a512a6, the merge of main (#464, evaluation artifacts). The conflict resolutions are correct, and CI is green.
- Conflicts (
.env.example,cmd/root.go,internal/config/config.go,api.goimports): both sides were kept. sdk/client.go: both clients are kept, with our stricter header loop. It still drops a caller's Authorization and canonicalizes via Del/Add;Artifact.Upload's Content-Type override still works.- Swagger: regenerated. It adds exactly #464's artifact paths and definitions.
- Non-conflicted files from main are byte-identical to 181097e.
- Semantics:
- the manifest adds
artifactalongsidepolicy-authorand agentsync; - the builtin PDP's agent-resource special case doesn't touch
artifact; - migrator Up/Down ordering is consistent;
- the new Cedar cases check that policy-author can read artifacts but not ingest them.
- the manifest adds
- Local: build, vet and unit tests are green. With
-tags integration, handler, agentcfg, authz and worker pass. - CI: check-diff, lint, unit-tests and integration-tests all pass.
The policy contract checker (R63, pkg/policyeval) must apply the same limits, hint vocabulary and template label-key rule the risk template service enforces on save. The service lives under internal/ next to gorm, which the agent must not pull in through pkg/policyeval, so the pure rules move to a dependency-free package and the service uses them from there. A parity test pins the hint rule to the service's own check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pkg/policyeval/contract.go checks what a compliance_framework package must produce for the agent to record evidence and submit risk templates. - CheckContract(modules) []Issue is the static layer on parsed modules: missing title, contract keys defined as functions or with contains, literal type mismatches (text keys, labels, risk_templates), violation as an object rule or a complete non-collection, literal violations that are not objects or have non-string fields, and risk templates the API would reject (rules shared through pkg/risktemplate). Warnings cover an empty or conditional-only title, no violation rule, violations without an id, violation_ids no literal violation produces, and packages defined by more than one non-test module. - ValidateResult(Result) []Issue is the dynamic layer. Execute stores its issues on the new Result.Issues field; its error semantics are unchanged. - regocheck runs the static layer on authored overlay modules. Type and shape problems are errors that block a save (R54); a missing title is a warning when the bundle extends a source or patches a bundle an instance's file defines (WithPartialBundles). PolicyError gains an optional code. - Playback returns the static issues at the top level and the dynamic ones per result; neither fails the request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The UI's Override must pre-fill the vendor Rego, and the API never sees vendor sources. The #464 artifact store is the single channel: agents upload each policy tree they load and name it in their config report. - agentconfig.PolicyBundleReport and PolicyExtendsReport gain artifact-digest (omitempty). PUT config-report checks its format only (artifact.ValidDigest), not that the artifact exists. Agents keep it when they drop files to fit the report size. - GET /api/artifacts/{digest}/files lists a policy bundle artifact: {digest, treeDigest, files[{path, sha256, size, package?}]}, where treeDigest is agentconfig.BundleTreeDigest over the files (the digest config reports use) and package comes from the OPA AST (v1, then v0). - GET /api/artifacts/{digest}/files/{path} returns {path, package?, sha256, source} for one file, up to 1 MiB of UTF-8 text (422 otherwise). - Both sit on the artifact read group (same middleware and artifact:read guard): 400 malformed digest, 404 unknown digest or path, 415 for an artifact that is not a policy bundle. Responses are immutable: the ETag is the digest of the body, If-None-Match gets 304. - internal/artifact gains WalkBundleTar/ReadBundleFiles next to ReadBundleTar, and ModulePackage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… scope (R72) - docs/artifacts.md: the policy bundle file routes and their shapes, config reports naming artifacts (artifact-digest), and who can read artifacts: every role holds artifact:read, including ssp-subscriber, so inline Rego uploaded as artifacts is readable beyond agent:read. Documented, not narrowed (owner decision pending). - The same note in the manifest roles comment (next to R40/R57) and in docs/authz-oss-cedar.md. - pkg/agentconfig/digest.go: replace the stale evidence-v3 G1.1 reference (superseded by #464) with how the tree digest relates to the artifact file listing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- contract: match violation_ids like the API (trimmed, case-insensitive) - contract: drop an unused var and satisfy staticcheck - regocheck: word the missing-title downgrade for merged bundles too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- regocheck: a bundle with a module that does not parse is incomplete, so a title that module may hold is not also reported missing as an error - contract: one violation-missing-id warning with a count, not one per violation numbered in map order - docs: only agents that report artifact digests upload at apply time Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- docs: evidence props carry bundle digests too; rewrap a paragraph Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 2: e2e feedback (design §13: R62, R63, R72)New commits on top of 9a512a6 (main has not moved, so no merge was needed):
R62: vendor sources through the artifact store
R63: policy contract checker (
|
gusfcarvalho
left a comment
There was a problem hiding this comment.
Approving the §13 round, 9a512a6..6c801a3. I found nothing blocking.
- File routes (R62):
{path}is only compared, by exact equality, against the stored canonical tar's entry names; nothing touches the filesystem. So..and%2Fcan't escape, and an unescaped path that doesn't match gets 404.- The routes use the existing
artifact:readguard, and the digest is format-checked. - A single file over 1 MiB, or one that isn't valid UTF-8, gets 422. Listings carry no file contents, and the whole artifact is already capped by
CCF_ARTIFACT_MAX_BYTES. - ETags are immutable. The broader
artifact:readscope is documented as R72.
- Contract checker (R63):
- It mirrors
Execute's package selection (compliance_framework.*, non-test modules). - Computed values are left to the dynamic check, and it handles v0
violation[x] {}as well as v1contains/[x] if. - I ran a v0/v1 vendor-style probe: sprintf/concat values, comprehension-built ids, a default+conditional title, and a
ccf_libshelper. It produced no false issues. - Severities follow §13.
missing-titleis downgraded to a warning forextendsbundles, patched file bundles, and bundles with a module that doesn't parse. ValidateResultonly fillsResult.Issues, soExecute's errors are unchanged.
- It mirrors
pkg/risktemplate: a behaviour-preserving extraction. The limits, hint rule and template label-key walk are the same, and a parity test pins the hint rule.- Recorded deviations, all fine:
- (a)
treeDigestis the name R62 itself specifies; - (b) and (c) match the R63 table;
- (d) is conservative, because standalone mode can't know which bundles the agent's file defines;
- (e) no SDK consumer needs the file routes.
- (a)
- Verification:
- Locally with
GOWORK=off: build, vet and unit tests are green. With-tags integration,internal/api/handler/...,pkg/...andinternal/artifact/...pass. - CI on 6c801a3: check-diff, lint, unit-tests and integration-tests all pass.
- Locally with
policyeval: Policy.ID from a module's policy_id, SeedPath for the agent's evidence seeds, ValidPolicyID; CheckContract checks policy_id (R75: invalid-policy-id, duplicate-policy-id) and ValidateResult reports an invalid evaluated one. Playback results carry policyId. agentconfig: PolicyBundleReport.PluginPath (R77) and Report.Plugins with lib-version (R76), stored on the instance (new plugins column) and returned in instance summaries and details; PolicyCode* constants for the agent's new codes. Docs: policy-identity.md, playback.md, artifacts.md.
Fifteenth and last layer of the agent remote-configuration stack (split from #465): GET /api/admin/agents/{id}/instances lists an agent's reporting instances with derived status, sync status and per-status counts, and GET …/instances/{instanceId} returns one with its redacted base/effective config; CORS allows If-Match/If-None-Match and exposes ETag for the UI; the server recovers handler panics as 500s; builtin/Cedar authz matrices over every admin agent-config route, and the Cedar docs. The tree now matches #465. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fifteenth and last layer of the agent remote-configuration stack (split from #465): GET /api/admin/agents/{id}/instances lists an agent's reporting instances with derived status, sync status and per-status counts, and GET …/instances/{instanceId} returns one with its redacted base/effective config; CORS allows If-Match/If-None-Match and exposes ETag for the UI; the server recovers handler panics as 500s; builtin/Cedar authz matrices over every admin agent-config route, and the Cedar docs. The tree now matches #465. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fifteenth and last layer of the agent remote-configuration stack (split from #465): GET /api/admin/agents/{id}/instances lists an agent's reporting instances with derived status, sync status and per-status counts, and GET …/instances/{instanceId} returns one with its redacted base/effective config; CORS allows If-Match/If-None-Match and exposes ETag for the UI; the server recovers handler panics as 500s; builtin/Cedar authz matrices over every admin agent-config route, and the Cedar docs. The tree now matches #465. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fifteenth and last layer of the agent remote-configuration stack (split from #465): GET /api/admin/agents/{id}/instances lists an agent's reporting instances with derived status, sync status and per-status counts, and GET …/instances/{instanceId} returns one with its redacted base/effective config; CORS allows If-Match/If-None-Match and exposes ETag for the UI; the server recovers handler panics as 500s; builtin/Cedar authz matrices over every admin agent-config route, and the Cedar docs. The tree now matches #465. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fifteenth and last layer of the agent remote-configuration stack (split from #465): GET /api/admin/agents/{id}/instances lists an agent's reporting instances with derived status, sync status and per-status counts, and GET …/instances/{instanceId} returns one with its redacted base/effective config; CORS allows If-Match/If-None-Match and exposes ETag for the UI; the server recovers handler panics as 500s; builtin/Cedar authz matrices over every admin agent-config route, and the Cedar docs. The tree now matches #465. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(agentconfig): config model and RFC 7396 overlay merge First layer of the agent remote-configuration stack (split from #465): the declared agent config types, JSON helpers and canonical encoding, RFC 6901 pointers, and Merge/MergePatch/StripLocked of an API overlay onto the agent's file config. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(agentconfig): say where the design IDs cited in comments are defined Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(agentconfig): decode config documents in place decodeAny and decodeConfig decoded through a json.Decoder, which copies its input through a buffer it grows by doubling: about 4 MiB of allocations per decode of a 1 MiB document, and a config save decodes a reported base about six times. Decode with json.Unmarshal instead, which reads the bytes in place, keeping the Decoder's results exactly: - decodeAny decodes into numberValue, which builds the same map[string]any, []any and json.Number values as a UseNumber Decoder. Documents nested deeper than 12 levels (Unmarshal re-scans each level) and input Unmarshal rejects still go through the Decoder, so its errors are unchanged. - decodeConfig unmarshals the struct and takes policy_data, the one free-form field, from the UseNumber decoding it already has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#470) Second layer of the agent remote-configuration stack (split from #465): pointer-level JSON diff, ${env:NAME} references and the forbidden-name rule, plugin source kinds (OCI via go-containerregistry, local), trusted-source and overridable-flag matching for remote_config, cron schedule parsing, and opaque revision/admin ETags. Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#471) * feat(agentconfig): change-safety classification and wire types Third layer of the agent remote-configuration stack (split from #465): Classify/WillApply decide which overlay changes an agent applies under each remote_config mode (forbidden locked keys, unsafe new sources and re-enabled plugins, sources of enabled plugins already in use, trusted sources, overridable flags), plus the agent<->API wire types and FieldError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agentconfig): classify a re-enabled plugin's policies and env references Re-enabling a disabled plugin only checked its source, so its policy entries and ${env:} references, which no enabled plugin uses, were never classified and apply_safe could run an untrusted or local policy the host had disabled. They are now classified like a new plugin's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agentconfig): classify a re-enabled plugin's local source like a new one Re-enabling a plugin the base disables only checked trusted_sources, so a local-path source was just unsafe/reenables-plugin and apply_all applied it even with allow_local_sources=false. Run the kept local source through the source rules too: forbidden/local-source-not-allowed unless apply_all with allow_local_sources (then unsafe/new-local-source). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(agentconfig): secret detection, redaction and digests Fourth layer of the agent remote-configuration stack (split from #465): secret detection by key name and by value (linear URL-password scan, token patterns, scheme-less DSNs), Redact/RedactDocument/ScrubSecretText, and Digest over the redacted canonical config. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agentconfig): scale the linear-scan time budget under the race detector TestContainsSecretValueLinear asserted a fixed 2s budget, which the race detector's instrumentation exceeds (about 5s). Keep the linearity check but scale the budget x10 when built with -race (raceEnabled, set by build tag). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(agentconfig): overlay validation Fifth layer of the agent remote-configuration stack (split from #465): ValidateOverlay (strict decode, size, locked keys, plugin names, env references, schedules incl. the TZ= prefix guard, NUL characters) and the ValidationErrors/FieldError vocabulary. pkg/agentconfig is complete after this layer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(agentconfig): generate a conformance golden file from the rule tables The UI re-implements MatchTrustedSource, MatchOverridableConfigFlag, KindOf/IsOCISource, PluginNamePattern, ParseSchedule and the per-field apply_safe outcome of Classify + WillApply, and tested them against a hand-copied fixture pinned to an old api commit, so a rule change failed on neither side. Hoist those test tables to package-level vars and generate testdata/conformance.json from them (in the UI fixture's shape), with every expected value computed by the real functions. TestConformanceGolden fails when the file is stale; regenerate it with go test ./pkg/agentconfig -run TestConformanceGolden -update The apply_safe cases are a new table (applySafeCases) whose field state is derived from Classify + WillApply over probe overlays. The UI fixture's drift cases are added to the Go tables (two '%' registries for KindOf, '*/5 * * * *' and '@hourly' for ParseSchedule), plus a re-enabled local plugin source case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sixth layer of the agent remote-configuration stack (split from #465): sdk.Client.AgentConfig fetches the agent's overlay (ETag/If-None-Match) and submits instance config reports; Heartbeat gains config_revision/config_digest; extra request headers never override Authorization. Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(agentcfg): append-only agent config revision store Seventh layer of the agent remote-configuration stack (split from #465): the ccf_agent_config_revisions model (append-only hooks, unique (agent, revision)), migrations, the CCF_AGENT_* settings, and the agentcfg service's revision API: Current, GetRevision, ListRevisions (paginated) and CreateRevision (agent-row lock, optimistic expected revision), plus DeleteRevisionsForAgent for agent deletion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agentcfg): take Settings defaults from config.DefaultAgentsConfig --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(agentcfg): agent instance store Eighth layer of the agent remote-configuration stack (split from #465): the ccf_agent_instances model and the agentcfg instance API: UpsertReport (config reports) and TouchFromHeartbeat, the per-agent instance cap (prune-eligible rows don't count; the oldest stale instance is replaced; a short-lived cap-reached cache for heartbeats), ListInstances/GetInstance, and the derived status/sync-status/staleness rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(agentcfg): state the real per-instance bound of ListInstances The comment implied the unpaginated list was cheap because normalizeReport bounds the summary columns. State the actual worst case: about 3 MiB per instance across the capped columns, times the capped plus not-yet-pruned rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agentcfg): paginate ListInstances and count instances from scalar columns ListInstances loaded every instance's summary columns (about 3 MiB each in the worst case), so one agent credential could inflate the admin instance list past 1 GiB. It now returns one page (at most InstancesPageLimit = 25 rows, same order: last_seen_at DESC, instance_id) and the total. CountInstances counts every instance by freshness, status and sync status from the scalar columns only, using IsStale, DeriveStatus and DeriveSyncStatus, so fleet-wide counts no longer need the full list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… (9/15) (#477) * feat(agentcfg): validation bases, instance pruning and agent deletion Ninth layer of the agent remote-configuration stack (split from #465): ValidationBases (the instances a save validates against, R48) and the bounded PreviewBases set, the River job that prunes stale instances (CCF_AGENT_INSTANCE_PRUNE_*), and deleting an agent now deletes its instances and config revisions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agentcfg): take the instance mode from the validated report mode reportedRemote preferred the reported remote-config block's mode, which the API does not validate, over the report's validated top-level mode, so preview could classify with a different or unknown mode than the one ValidationBases selected on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(worker): take the prune schedule fallback from config.DefaultAgentsConfig * fix(agentcfg): load and decode each distinct validation base once ValidationBases loaded base_config for every fresh apply-mode instance (up to the instance cap, each up to 4 MiB), so a save's cost followed the instance count. Group the set by base content in SQL (SHA-256 of the jsonb text), load and decode one base per group in the same read-only snapshot, and share it across the group's instances. Each instance is still returned with its own fields and remote-config, plus a BaseKey so callers validate a base once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agentcfg): list instances by page in the prune test ListInstances is paginated now (#476); TestPruneInstances reads its six instances from the first page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…478) Tenth layer of the agent remote-configuration stack (split from #465): the agent resource gains configure (write an agent's overlay) and sync (an agent fetching its overlay and reporting) in the manifest and Cedar roles; the builtin PDP requires the admin check for users on agent:* and lets agent service accounts only register, ingest and sync (R39); /admin/agents list/get need agent:read while writes and key routes stay admin:manage (R40). Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…egistration (11/15) (#479) * feat(api): agent-facing config overlay route and heartbeat instance registration Eleventh layer of the agent remote-configuration stack (split from #465): GET /api/agent/config returns the authenticated agent's current overlay with an opaque ETag (If-None-Match -> 304), agent JWT only and agent:sync; authenticated heartbeats refresh the instance's last-seen time and, with config_digest/config_revision, register the instance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: heartbeats register instances under heartbeat:ingest Say in the heartbeat route description and next to the agent role's sync grant that removing sync does not stop heartbeats carrying config_digest from registering instances. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): answer a 304 config poll without loading the overlay GetConfig loaded the whole current revision (SELECT *, overlay included) before comparing If-None-Match, although nearly every poll is a 304. With an If-None-Match header, read only the revision head (id, revision, created_at; same indexed query) through agentcfg.CurrentHead, compute the ETag and return 304 on a match. The overlay is loaded only on a miss, and the response is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(api): agent instance config reports Twelfth layer of the agent remote-configuration stack (split from #465): PUT /api/agent/instances/{instanceId}/config-report stores an instance's mode, applied/attempted revision, status, redacted base/effective configs, digest, plugins, unsafe changes and warnings. The server validates and bounds the report, rejects NUL characters, re-redacts base/effective, masks secrets in free-text fields (error, warnings, plugin sources, unsafe values, remote-config) and returns 409 at the instance cap. The JSON body reader accepts application/json with parameters (415/413 otherwise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): mask report secrets before truncating free text normalizeReport cut error, warning messages, unsafe values and plugin sources to their length caps before scrubReportText ran, so a secret straddling a cap no longer matched and its prefix was stored. Masking now runs after the count caps and before the length caps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): cap the remote-config block of a config report normalizeReport never bounded remote-config, so a report with 100k trusted_sources (7 MB) was stored with truncated=false, and ListInstances returns that column for every instance. Cap it like the other summary columns, after the scrub: at most 100 trusted_sources and 100 overridable_config_flags, each at most 256 bytes JSON-encoded (an over-long entry is dropped, since a cut glob pattern can widen trust), mode at 32 and poll_interval at 64 bytes. The encoded block stays within 64 KiB even when every character is escaped, and the report is marked truncated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): bound a config report's summary after JSON escaping warnings[].code, unsafe[].safety and unsafe[].reason had no length cap, and the other caps are on raw bytes, which JSON escaping can grow sixfold ('<', '&' and control characters encode as \u00XX). One report could make its instance's listed summary about 21 MB, and a page of 25 instances about 500 MiB. normalizeReport now cuts code, safety and reason to 64 bytes (cut, not rejected: a newer agent may send values this API does not know) and keeps the summary fields (hostname, agent-version, error, warnings, unsafe, plugins, remote-config) within 3 MiB as encoding/json encodes them with HTML escaping, the way the instance list does. Over that budget it cuts the error text to 8 KiB encoded and drops the last entry of the largest list until the report fits, and marks the report truncated. A plain-text report at every cap (about 2.9 MB) fits, so it is stored unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(api): admin agent config read and save Thirteenth layer of the agent remote-configuration stack (split from #465): GET /api/admin/agents/{id}/config returns the current overlay (verbatim only to agent:configure holders, redacted otherwise, fail-closed) with an admin ETag, and GET …/config/revisions/{rev} returns one revision the same way; PUT saves a new revision with If-Match (428 without, 409 on conflict, 200 for a no-op), validating the overlay on its own and against the instances a save validates against (only errors the overlay introduces block; file-origin errors are warnings). Needs agent:configure to write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): drop the hand-synced BodyLimit on agent config PUT; readJSONBody enforces the limit * fix(api): validate each distinct reported base once on save validateCandidate merged and validated the overlay against every instance of the validation set. ValidationBases now groups instances by base content (BaseKey): validate each distinct base once and attribute its errors to every instance in the group, so the 422 body still lists each instance and the cost of a save follows the distinct bases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): reject a stray closing brace after a strict JSON body decodeStrict checked for trailing data with dec.More(), which reports false for a trailing '}' or ']', so {"overlay":{}}} was accepted. Require the next token to be io.EOF instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): reject a NUL in a revision comment with a 400 normalizeComment let a NUL character through, and Postgres cannot store one in a text column, so PUT (and revert) failed with a 500 from the insert. Reject it as a 400, like the overlay (O11) and report NUL checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…4/15) (#482) * feat(api): admin agent config preview, revision history and revert Fourteenth layer of the agent remote-configuration stack (split from #465): POST /api/admin/agents/{id}/config/preview validates a candidate overlay and shows, per instance, the redacted effective config, its diff, classified changes and whether the agent would apply it (bounded to 50 instances / 16 MiB of reported config, validated first; omitted-instances counts the rest; needs agent:configure); GET …/config/revisions lists revisions (paginated, newest first); POST …/revisions/{rev}/revert saves an old overlay as a new revision. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): keep preview within its bounds for validated instances PreviewBases loaded and decoded every validated instance's base through ValidationBases, and Preview validated the overlay against all of them only to discard the per-instance results, so the 50-instance / 16 MiB preview bound did not cover the validation set. Preview now validates the overlay alone, and PreviewBases reads the validation set as instance ids only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(agentcfg): drop the removed Validation field from PreviewSet docs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api): a NUL in a revert comment is a 400 Revert shares normalizeComment with PUT, which now rejects a NUL character; cover the revert route so it cannot regress to a 500 from the insert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(api): admin agent instances, CORS and authz coverage Fifteenth and last layer of the agent remote-configuration stack (split from #465): GET /api/admin/agents/{id}/instances lists an agent's reporting instances with derived status, sync status and per-status counts, and GET …/instances/{instanceId} returns one with its redacted base/effective config; CORS allows If-Match/If-None-Match and exposes ETag for the UI; the server recovers handler panics as 500s; builtin/Cedar authz matrices over every admin agent-config route, and the Cedar docs. The tree now matches #465. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): return the agent instance list as GenericDataListResponse * docs: add an operator guide to agent remote configuration docs/agent-remote-config.md covers the agent and admin routes with their status codes, the ETag/If-Match and If-None-Match flows, apply modes and change classification, trusted_sources / overridable_config_flags / allow_local_sources, instances (cap, pruning, report bounds), who sees redacted or verbatim overlays, and the CCF_AGENT_* settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): paginate the admin agent instance list GET /api/admin/agents/{id}/instances returned every instance's summary, about 3 MiB each in the worst case, so one agent credential could inflate it past 1 GiB. It now takes page (default 1) and limit (default 25, max 25; a larger limit is capped, as ParseParams does elsewhere) and returns one page. An invalid page or limit is a 400, as on the revision list. meta keeps desired-revision and counts, which cover all of the agent's instances (agentcfg.CountInstances, scalar columns only), and adds page, limit, total and total-pages. Swagger and the operator guide describe the paging and the per-page bound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): test the instance page bound with escaped report text TestInstancesWorstCasePageSize now builds its reports with capsReport and normalizeReport, over every cap, once with plain text and once with text that JSON escapes ('<', '&', control characters), and checks that a page of 25 stays within 25 times the per-report summary budget plus the fixed fields. The swagger description and the operator guide describe the bound after escaping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
The API side of agent remote configuration. Each agent gets an API-stored overlay over its file config (RFC 7396 JSON Merge Patch). Each instance applies the overlay according to its own
remote_config.modeand reports back what it runs.Scope: config overlay plus safeguards. An overlay can set
verbosity,agent_evidenceandplugins.<p>.{enabled, protocol_version, schedule, source, policies, config, labels, policy_data, policy_behavior}. Pluginpoliciesentries are vendor OCI sources or local paths; authoring policy modules or bundles through the overlay is out of scope (policy_bundlesis rejected as an unknown field).Ship order: this PR first, then compliance-framework/agent#95, then compliance-framework/ui#318. The agent pins this branch's pseudo-version.
What's in it
pkg/agentconfig: the shared config contract (also imported by the agent)Configtypes;Merge/MergePatch/StripLocked, using RFC 7396 merge patch;api,daemonandremote_configcan never be overlaid;DiffJSON, and RFC 6901 pointers.ValidateOverlaystrictly checks an overlay alone, with rules O1–O10 and exact pointers and codes;Validate/ValidateEditablecheck a merged config;RemoteConfig.Normalize: an agent with credentials and noremote_config.moderuns inreport(it reports but never applies); without credentials the mode is forced tooff. Remote apply is opt-in viaapply_safeorapply_all;Classify/WillApplydecide what each mode (report,apply_safe,apply_all) applies, usingtrusted_sources,overridable_config_flagsandallow_local_sources. Re-enabling a plugin disabled in the base is unsafe unless its source is trusted (reenables-plugin);policy_data,policy_behavior,labels,scheduleand removing a plugin are safe by design.Redact/WithMaskedPointersmask values as exactly••••(always the whole value, never part of it), underplugins.*.configandplugins.*.policy_data, plusapi.urlwhen it holds a secret.Digestand the server-sideRedactDocumentapply the same rules:password,passwd,passphrase,secret,token,credential,apikey,privatekey,accesskey,connectionstring/connstring/connstr,sessionid,authorizationandauthheadermatch anywhere.key(s),pass,pwd,auth,dsnandcookie(s)match as the last word or its suffix (sshkey,dbpass,basicauth), ignoring trailing format words (_pem,_b64,_hex, …). Ordinary words such asmonkeyandbypassare excluded. Sokeyword,key_id,pass_rateandauth_methodare not masked, whiletokens_per_minuteis (a deliberate false positive);password=…assignments, and high-confidence token formats adapted from gitleaks' MIT rules (AWS key IDs, GitHub, GitLab, Slack, Google API keys, Stripe, JWTs, SendGrid, npm, PyPI, OpenAI, Anthropic, Hugging Face, DigitalOcean, Shopify, Terraform Cloud, Vault, Azure AD client secrets, age). No entropy heuristics;${env:…}placeholders: a value is kept verbatim only if nothing but whitespace and:;,|/@=&is left once its placeholders are removed. Literal text mixed with a placeholder is masked under a secret-like key, and otherwise checked by content. Booleans are never masked by key;Digest;If-Matchparsing;${env:NAME}references, resolved by the agent only inplugins.*.config.OverlayDocument;Report, carryingplugins[]withlib-version;Storage, authz and pruning
ccf_agent_config_revisions: append-only, unique(agent_id, revision), revert creates a new revision; deleting an agent deletes its revisions;ccf_agent_instances: one row per reporting instance, capped per agent (non-prunable rows); when full, the oldest stale instance is replaced, and a 409 only happens when every counted instance is fresh.agent:configure(overlay writes) andagent:sync(the agent fetching its overlay and reporting);agentresource;GET /admin/agents[/:id]now needsagent:read; writes and keys still needadmin:manage.agent_instance_prunejob, driven by theCCF_AGENT_*settings.Routes
GET /api/agent/config: opaque ETag, 304,X-CCF-Remote-Config: 1;PUT /api/agent/instances/:instanceId/config-report: validated, size-capped and re-redacted (base, effective, and free text:error, warnings, plugin sources,remote-config), NUL rejected, 409 at the instance cap;config_revision/config_digest.GET|PUT /admin/agents/:id/config:If-Matchrequired (428 without it, 409 when stale);POST …/config/preview(needsagent:configure): a dry run, with overlay errors and a per-instance diff, change classification and will-apply result; an invalid overlay returns only its errors;GET …/config/revisions[/:rev]andPOST …/config/revisions/:rev/revert;GET …/instances[/:instanceId].MaxOverlayBytes), so an oversized overlay gets a 422 with the size error rather than a 413.client.AgentConfig.Get/Report, with the remote-config errors and header plumbing.Notable decisions
GET …/configandGET …/config/revisions/:revreturn the overlay verbatim only to callers withagent:configure(editors); everyone else gets it masked (supersedes R57). Secrets still belong in${env:NAME}placeholders.${env:…}references from the API, so a preview against an instance whose base has a mixed value such aspostgres://u:${env:PG_PASS}@hunderdsnreports a reused reference asnew-env-reference(unsafe). The agent classifies against its unredacted base, so what it applies is unaffected.agent:configure-policypermission andpolicy-authorrole;bundles-first-seen);GET /api/artifacts/{digest}/files[/{path}]) and the report's policy-bundle inventory (policy-bundles,BundleTreeDigest, thepolicy_bundlesinstance column). Nothing read them. Reports that still sendpolicy-bundlesare accepted and the field is ignored. Evaluation-time bundle uploads andGET /api/artifacts/{digest}are unchanged frommain.github.com/moby/go-archiveis pinned to v0.1.0, because the go-containerregistry bump otherwise breaks testcontainers builds.Testing
go build,go vet(with and without theintegrationtag),make testandgolangci-lintare clean.make swagleaves no diff, andgo mod tidyis a no-op.go test -tags integration -p 1 ./...):TestAgentCfgServiceIntegration,TestAgentConfigSyncAPI,TestAgentConfigAdminAPI(every admin route, plus builtin and Cedar authz matrices), the prune worker.RedactDocumentparity and digest stability.Release notes
remote_config.modeis set toapply_safeorapply_all. With credentials and no mode set, an agent only reports (report).agentresource (parity with Cedar).name,url,file,idand similar (no longer masked by key name; values are still content-checked), and for sources/policy entries holding credentials (now masked).${env:…}placeholder under a secret-like key is masked.Follow-ups
🤖 Generated with Claude Code