From 715c26890f922880cc0d255d0d23d22a46273dd2 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:57:07 -0300 Subject: [PATCH] feat(config): ${env:NAME} placeholders in plugin config; ADR 0003 - plugins.*.config values may reference environment variables, whole or embedded (R24), in the file and in an overlay. Reports, redaction and the digest keep the unresolved placeholder, so rotating a secret changes none of them. CCF_API_AUTH_* may never be referenced. - An unset variable the file references is a warning and the literal reaches the plugin unchanged, exactly as on main (R60). One an overlay introduces fails the revision with env-missing; the error names the variable, never a value. - ADR 0003 records the remote configuration overlay design; README points to it and to the state directory settings. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +++ cmd/config.go | 75 ++++++++++++++++ cmd/reconciler.go | 23 ++++- cmd/remote_test.go | 105 +++++++++++++++++++++++ docs/adr/0003-remote-config-overlay.md | 114 +++++++++++++++++++++++++ docs/configuration.md | 39 +++++++-- 6 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 docs/adr/0003-remote-config-overlay.md diff --git a/README.md b/README.md index 3864fab..43d85e0 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,18 @@ The API auth settings follow the same rule, so `api.auth.client_id` and `api.aut `CCF_API_AUTH_CLIENT_ID` and `CCF_API_AUTH_CLIENT_SECRET`. These values must be configured together; setting only one will fail agent startup validation. The `client_id` value must be a valid UUID. +Values that come from `CCF_PLUGINS_*` variables are masked in the configuration reports the agent sends to the API, +as are secret-like keys and values (see [configuration](./docs/configuration.md#envname-placeholders)). +Plugins never receive `CCF_API_AUTH_*` variables. + +### Remote configuration and state + +With `api.auth` credentials the agent reports its configuration to the API. With `remote_config.mode` set to +`apply_safe` or `apply_all` it also applies a configuration overlay stored there, including `${env:NAME}` placeholders +in plugin config; the default mode, `report`, never fetches or applies one. Each instance keeps a stable ID and a cache in a state directory (`--state-dir` / `CCF_STATE_DIR`; +`--instance-id` / `CCF_INSTANCE_ID`). See [configuration](./docs/configuration.md#remote-configuration) and +[ADR 0003](./docs/adr/0003-remote-config-overlay.md). + ## Usage To run the agent, you must first build the agent, and then run it with the `agent` command. It is recommended, diff --git a/cmd/config.go b/cmd/config.go index 5494c9b..75ea156 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "maps" "os" "path/filepath" "regexp" @@ -307,6 +308,80 @@ func touchedByOverlay(ptr string, touched []string) bool { return false } +// resolveEnv resolves ${env:NAME} placeholders in plugins.*.config values (R24) with the R60 +// file-origin leniency: when every unset variable of a value is already referenced by the +// base's (file) value at the same pointer, the value is passed to the plugin unchanged, as on +// main, and a warning is returned. An unset variable the overlay introduced still fails with +// agentconfig.ErrEnvMissing; forbidden names always fail with agentconfig.ErrEnvForbidden. +func resolveEnv(declared, base agentconfig.Config, lookup func(string) (string, bool)) (agentconfig.Config, []agentconfig.FieldError, error) { + type literal struct{ plugin, key, value string } + var keep []literal + var warnings []agentconfig.FieldError + work := declared + copied := map[string]bool{} // plugins whose Config was copied into work + for _, name := range slices.Sorted(maps.Keys(declared.Plugins)) { + p := declared.Plugins[name] + if p == nil { + continue + } + for _, key := range slices.Sorted(maps.Keys(p.Config)) { + value := p.Config[key] + names := agentconfig.EnvRefs(value) + if len(names) == 0 || slices.ContainsFunc(names, agentconfig.IsForbiddenEnvName) { + continue + } + var missing []string + for _, n := range names { + if _, ok := lookup(n); !ok { + missing = append(missing, n) + } + } + if len(missing) == 0 { + continue + } + fileRefs := agentconfig.EnvRefs(basePluginConfigValue(base, name, key)) + if slices.ContainsFunc(missing, func(n string) bool { return !slices.Contains(fileRefs, n) }) { + continue // overlay-introduced: ResolveEnv reports env-missing + } + if !copied[name] { + if len(copied) == 0 { + work.Plugins = maps.Clone(declared.Plugins) + } + cp := *p + cp.Config = maps.Clone(p.Config) + work.Plugins[name] = &cp + copied[name] = true + } + delete(work.Plugins[name].Config, key) + keep = append(keep, literal{name, key, value}) + warnings = append(warnings, agentconfig.FieldError{ + Path: agentconfig.Pointer("plugins", name, "config", key), + Code: agentconfig.FieldCodeEnvMissing, + Message: fmt.Sprintf("environment variable %s is not set; the value is passed to the plugin unchanged", strings.Join(missing, ", ")), + }) + } + } + resolved, err := agentconfig.ResolveEnv(work, lookup) + if err != nil { + return agentconfig.Config{}, nil, err + } + for _, l := range keep { + p := resolved.Plugins[l.plugin] + if p.Config == nil { + p.Config = map[string]string{} + } + p.Config[l.key] = l.value + } + return resolved, warnings, nil +} + +func basePluginConfigValue(base agentconfig.Config, plugin, key string) string { + if p := base.Plugins[plugin]; p != nil { + return p.Config[key] + } + return "" +} + // toRuntime converts a merged, env-resolved declared config into the runtime structs. // Disabled plugins and plugins named in skip (R34) are dropped: they get no cron, no download // and no run state, but they stay in the declared form and in reports. diff --git a/cmd/reconciler.go b/cmd/reconciler.go index 082d886..46d2b61 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "math/rand" + "os" "slices" "strings" "sync" @@ -181,6 +182,8 @@ type reconciler struct { // newRemote builds the remote client from a base (a test seam). newRemote func(agentconfig.Config) remoteAPI + // lookupEnv resolves ${env:NAME} placeholders (a test seam). + lookupEnv func(string) (string, bool) // pluginLib reads the agent library version of a prefetched plugin source (R76); // nil leaves the plugins report empty. pluginLib pluginLibFunc @@ -225,6 +228,7 @@ func newReconciler(cmd *cobra.Command, configPath string, store *agentstate.Stor fileEvents: make(chan struct{}, 1), runFailed: make(chan *candidate, 1), debounce: 500 * time.Millisecond, + lookupEnv: os.LookupEnv, now: time.Now, loggedOnce: map[string]bool{}, newRemote: newSDKRemote, @@ -560,7 +564,22 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent return nil, rejected(agentconfig.ReasonInvalidConfig, errs) } - runtime, err := toRuntime(declared, part.skip) + resolved, envWarnings, err := resolveEnv(declared, base.declared, rc.lookupEnv) + switch { + case errors.Is(err, agentconfig.ErrEnvForbidden): + return nil, rejected(agentconfig.ReasonForbiddenChanges, err) + case errors.Is(err, agentconfig.ErrEnvMissing): + return nil, failed(agentconfig.ReasonEnvMissing, err) + case err != nil: + return nil, failed(agentconfig.ReasonInternal, err) + } + for _, w := range envWarnings { + if rc.logOnce("env-missing\x00" + w.Path + "\x00" + w.Message) { + rc.logWarnings([]agentconfig.FieldError{w}) + } + } + + runtime, err := toRuntime(resolved, part.skip) if err != nil { return nil, failed(agentconfig.ReasonInvalidConfig, err) } @@ -592,7 +611,7 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent runtime: runtime, digest: digest, identity: candidateIdentity(declared), - warnings: append([]agentconfig.FieldError{}, part.warnings...), + warnings: append(append([]agentconfig.FieldError{}, part.warnings...), envWarnings...), plugins: plugins, }, nil } diff --git a/cmd/remote_test.go b/cmd/remote_test.go index 913fa40..f0dba62 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -167,6 +167,7 @@ func (h *remoteHarness) newReconciler() *reconciler { rc := newReconciler(AgentCmd(), h.path, agentstate.Open(filepath.Join(h.dir, "state"), nil), h.pf, nil) rc.newRemote = func(agentconfig.Config) remoteAPI { return h.remote } rc.now = h.clock.Now + rc.lookupEnv = func(string) (string, bool) { return "", false } return rc } @@ -208,6 +209,7 @@ func TestStartupReport_RedactsAndDescribes(t *testing.T) { org: "${env:GITHUB_ORG}" endpoint: https://bot:hunter2@git.example `) + h.rc.lookupEnv = func(n string) (string, bool) { return "acme", n == "GITHUB_ORG" } h.remote.publish(0, `{}`) mustStartup(t, h.rc) @@ -254,6 +256,7 @@ func TestStartupReport_RedactsAndDescribes(t *testing.T) { } t.Setenv("CCF_PLUGINS_GITHUB_CONFIG_TOKEN", "rotated") rotated := h.newReconciler() + rotated.lookupEnv = h.rc.lookupEnv if active := mustStartup(t, rotated); active.digest != r.EffectiveDigest { t.Fatalf("digest changed when the env value rotated: %s vs %s", active.digest, r.EffectiveDigest) } @@ -823,6 +826,108 @@ func TestStartupLadder(t *testing.T) { }) } +func TestEnvPlaceholders(t *testing.T) { + // ${env:} is only resolved in plugins.*.config (R24): in the file's policy_data it is a + // literal passed through unchanged with a warning (R34, as on main), and an overlay using + // it there is rejected. + lenient := newRemoteHarness(t, remoteConfig("apply_all", "")+` + policy_data: + url: "${env:NOT_RESOLVED}" +`) + started, err := lenient.rc.startup(context.Background()) + if err != nil { + t.Fatalf("a file policy_data placeholder must not be fatal: %v", err) + } + if got := started.runtime.Plugins["ssh"].PolicyData["url"]; got != "${env:NOT_RESOLVED}" { + t.Fatalf("policy_data must be passed through unchanged, got %v", got) + } + if r := lenient.remote.lastReport(t); len(r.Warnings) != 1 || r.Warnings[0].Code != agentconfig.FieldCodeEnvLocation { + t.Fatalf("expected one env-location warning, got %+v", r.Warnings) + } + + h := newRemoteHarness(t, remoteConfig("apply_all", "")) + env := map[string]string{"HOST": "db.internal", "PORT": "5432"} + h.rc.lookupEnv = func(n string) (string, bool) { v, ok := env[n]; return v, ok } + h.remote.publish(1, `{"plugins":{"ssh":{"policy_data":{"url":"${env:HOST}"}}}}`) + mustStartup(t, h.rc) + if r := h.remote.lastReport(t); r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonInvalidConfig { + t.Fatalf("expected an overlay policy_data placeholder to be rejected, got %s/%s", r.Status, r.Reason) + } + + h.remote.publish(2, `{"plugins":{"ssh":{"config":{"host":"${env:HOST}","dsn":"pg://${env:HOST}:${env:PORT}/db"}}}}`) + active := h.poll(t) + cfg := active.runtime.Plugins["ssh"].Config + if cfg["host"] != "db.internal" || cfg["dsn"] != "pg://db.internal:5432/db" { + t.Fatalf("placeholders not resolved whole/embedded: %#v", cfg) + } + var eff agentconfig.Config + if err := json.Unmarshal(h.remote.lastReport(t).Effective, &eff); err != nil { + t.Fatal(err) + } + reported := eff.Plugins["ssh"].Config + if reported["host"] != "${env:HOST}" { + t.Fatalf("the report must carry the unresolved placeholder, got %#v", reported) + } + // Literal text mixed with a placeholder under a secret-like key is masked; the API's + // agentconfig redaction is the source of truth. + if reported["dsn"] != agentconfig.MaskedValue { + t.Fatalf("a dsn mixing literal text and placeholders must be masked, got %#v", reported) + } + digest := active.digest + env["HOST"] = "rotated" + restarted := h.newReconciler() + restarted.lookupEnv = h.rc.lookupEnv + if again := mustStartup(t, restarted); again.digest != digest || again.overlay == nil { + t.Fatal("the digest must not change when an env value changes") + } + + delete(env, "PORT") + h.remote.publish(3, `{"plugins":{"ssh":{"config":{"host":"${env:HOST}","dsn":"pg://${env:PORT}"}}}}`) + h.rc.lookupEnv = func(n string) (string, bool) { v, ok := env[n]; return v, ok } + h.poll(t) + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusFailed || r.Reason != agentconfig.ReasonEnvMissing { + t.Fatalf("expected failed/env-missing, got %s/%s", r.Status, r.Reason) + } + if !strings.Contains(*r.Error, "PORT") || strings.Contains(*r.Error, "rotated") { + t.Fatalf("the error must name the variable, never values: %q", *r.Error) + } +} + +// TestEnvPlaceholders_FileOriginUnsetIsWarning pins R60: an unset variable the FILE references +// is a warning and the literal reaches the plugin unchanged (as on main); an unset variable the +// overlay introduces still fails with failed/env-missing. +func TestEnvPlaceholders_FileOriginUnsetIsWarning(t *testing.T) { + content := strings.Replace(remoteConfig("apply_all", ""), "token: t0ken", "token: \"${env:UNSET_TOKEN}\"\n dsn: \"pg://${env:DB_HOST}/x\"", 1) + h := newRemoteHarness(t, content) + env := map[string]string{"DB_HOST": "db.internal"} + h.rc.lookupEnv = func(n string) (string, bool) { v, ok := env[n]; return v, ok } + h.remote.publish(1, `{}`) + + active := mustStartup(t, h.rc) + cfg := active.runtime.Plugins["ssh"].Config + if cfg["token"] != "${env:UNSET_TOKEN}" || cfg["dsn"] != "pg://db.internal/x" { + t.Fatalf("expected the unset literal unchanged and the set one resolved, got %#v", cfg) + } + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusApplied || len(r.Warnings) != 1 || r.Warnings[0].Path != "/plugins/ssh/config/token" || r.Warnings[0].Code != agentconfig.FieldCodeEnvMissing { + t.Fatalf("expected applied with one env-missing warning, got %s %+v", r.Status, r.Warnings) + } + + h.remote.publish(2, `{"plugins":{"ssh":{"config":{"extra":"${env:NEW_UNSET}"}}}}`) + h.poll(t) + if r := h.remote.lastReport(t); r.Status != agentconfig.StatusFailed || r.Reason != agentconfig.ReasonEnvMissing { + t.Fatalf("an overlay-introduced unset variable must fail with env-missing, got %s/%s", r.Status, r.Reason) + } + + // Per (pointer, variable): the overlay rewrites the value but the variable is the file's. + h.remote.publish(3, `{"plugins":{"ssh":{"config":{"token":"x-${env:UNSET_TOKEN}"}}}}`) + next := h.poll(t) + if got := next.runtime.Plugins["ssh"].Config["token"]; got != "x-${env:UNSET_TOKEN}" || next.appliedRevision() == nil || *next.appliedRevision() != 3 { + t.Fatalf("expected revision 3 applied with the literal unchanged, got %q", got) + } +} + func TestOneShot_FetchApplyReportRun(t *testing.T) { h := newRemoteHarness(t, strings.Replace(remoteConfig("apply_safe", ""), "daemon: true", "daemon: false", 1)) h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md new file mode 100644 index 0000000..83fef82 --- /dev/null +++ b/docs/adr/0003-remote-config-overlay.md @@ -0,0 +1,114 @@ +# ADR 0003: Remote configuration overlay + +- Date: 2026-09-30 +- ADR 0002 is reserved for evidence-v3. + +## Context + +Operators want to see the configuration each agent is running and to change it from the API (new schedules, plugin +config, policy data, policy sources) without logging in to every host. The local config file must stay the bootstrap and +the host owner's control: the API connection, the daemon flag and the remote-configuration policy itself must never be +changeable remotely, and a bad remote change must never take a working agent down. + +The shared configuration model lives in the API module (`api/pkg/agentconfig`), so the agent, the API's validation and +the UI preview classify and validate changes the same way. + +## Decision + +### Declared and runtime forms + +`agentconfig.Config` is the *declared* form: what is decoded, merged (RFC 7396), classified, validated, redacted, +digested and reported. The agent's existing private structs remain the *runtime* form, built by one `toRuntime` +conversion. Type aliases were not possible (methods on the structs, an unexported field, and many tests), and keeping +the runtime form keeps `agentConfigurationHash`, and so evidence identity, byte-identical. + +The file is decoded through viper's weak decoder exactly as before (R51). Only a remote overlay is decoded strictly +(`ValidateOverlay`): unknown keys and wrongly-typed values reject the revision (R27). + +### Prepare, then cancel + +One reconciler goroutine serializes every trigger (config file change, poll). A trigger builds a complete candidate: +overlay validation, the `Classify` gate, merge, validation, `${env:}` resolution, and every download (`Prefetch`). Only then is the running configuration cancelled. Any failure leaves the +running configuration untouched and is reported. Each network step of a prepare is bounded (5 minutes), so a hung +registry is a `download-failed`, not a stalled reconciler. In-flight plugin runs drain for up to 5 minutes on a swap; a +SIGINT/SIGTERM during the drain still exits within 30 seconds. A run that fails on its own after a swap falls back to +the previous configuration and that candidate (overlay or file-only) enters the failed backoff, so it is not re-applied +on every poll. A candidate whose effective configuration equals the running one (a no-op revision, a comment-only file +edit) is recorded as applied without a restart: the heartbeat, evidence and report show its revision. An applied +overlay that a file edit makes invalid is remembered as rejected and the last good configuration keeps running. +Startup tries the fetched overlay, +then the cached applied overlay, then the file alone; only an unusable file exits (a download failure of the file +alone still sends the startup-failure agent evidence first, as before). + +### Applying is opt-in + +With `api.auth` credentials and no `remote_config.mode`, the mode is `report` (R29, `agentconfig.RemoteConfig.Normalize` +in the API): the agent reports its configuration but never fetches or applies an overlay. The host owner opts in to +remote changes with `apply_safe` or `apply_all`. In `report` mode a cached applied overlay is not applied either. + +### The agent is the Classify authority + +The API validates and previews, but the agent classifies every revision against its own base and its own +`remote_config` before applying it (`agentconfig.Classify` + `WillApply`). Forbidden changes (the locked keys, local +sources, `${env:CCF_API_AUTH_*}`) reject the whole revision in every mode (R23). Nothing touches the network before the +gate passes. + +### Inline policy bundles are out of scope + +Inline policy bundles (authoring, overriding or deleting policy modules through the config file or an overlay) are not +part of this design (decision of 2026-10-02): keeping vendor evidence streams, checking the policy contract and +sandboxing the checks made them the largest and riskiest part of the change. Policies +reach plugins only as OCI or local sources, which an overlay may add, remove or reorder under the `Classify` rules. An +overlay that sets `policy_bundles` is rejected with `unknown-field`, and the report has no policy errors. + +### Plugin library versions (R76) + +What a plugin does with a policy depends on the `policy-manager` compiled into it, not on the running agent. The agent +reads the `github.com/compliance-framework/agent` version from each plugin binary with `debug/buildinfo.ReadFile` +(memoized by path, size and modification time) after prefetch and reports it (`plugins[].lib-version`) as diagnostics. +Nothing is gated on it. A `replace` or devel build reports an empty version. + +### Plugin environment filter + +go-plugin hands the whole host environment to plugins. The agent now sets `SkipHostEnv` and passes the host +environment minus `CCF_API_AUTH_*`, so plugins never see the agent's API credentials; cloud credentials, `PATH`, +`HOME` and the rest still pass through (R26). + +### Opaque ETag + +The overlay ETag is opaque (`"r-"`). The agent stores the raw header in its cache and sends it back +verbatim as `If-None-Match`; it never builds one from a revision number, so a reset or recreated API can never produce +a false 304 (R7). The cache is bound to `api.url` and `client_id`, and a rejected revision is remembered per +(ETag, base fingerprint), never per revision number alone. A response without an ETag (a stripping proxy) is keyed by +revision + sha256 of the overlay instead, so one rejection never blocks later revisions. The remembered rejection keeps +its status, reason, error and unsafe changes, so the agent re-reports it after a restart and while the fetch keeps +answering 304. + +### File-origin tolerance (R34) + +The shared `Validate` is stricter than the agent used to be in one way: it parses `schedule`. A bad schedule in the +file used to be only logged, and the plugin never ran. To stay non-breaking, a validation error is attributed by +origin: an error at a pointer the overlay touched (equal, prefix or extension, segment-wise) is overlay-origin and +rejects the revision; otherwise it is file-origin. File-origin errors on the closed tolerated list (only +`/plugins/

/schedule`) become reported warnings and the plugin is skipped. A second, warn-only list covers values +that load on `main` with a meaning the agent keeps: a negative `verbosity` (hclog Warn) and a literal `${env:...}` +outside `plugins.*.config`. They are reported as warnings; nothing is +skipped and the value is unchanged. Every other file-origin error stays fatal (startup exit 1, or last-known-good on +reload). Overlay-origin errors are always strict. + +### Unset `${env:}` in the file (R60) + +Owner decision (2026-09-30, review of agent#95): R24 resolves `${env:NAME}` in the file's `plugins.*.config` too, but +an unset variable that the file references is a **warning** and the literal value is passed to the plugin unchanged, +exactly as on `main`, where placeholders were never resolved. "File-origin" is decided per (pointer, variable): the +base's value at that pointer references the variable. An unset variable that the overlay introduces still fails the +revision with `failed/env-missing`. + +## Consequences + +- Reports never carry resolved secrets: base and effective are the unresolved forms, redacted with the same masked + pointers the effective digest uses (R24, R25, R55). The redaction rules (secret-like keys and values) are the API's + `pkg/agentconfig`; the agent does not re-implement them. +- The default state directory depends on the config path; containers must pin `CCF_STATE_DIR` (R52). +- A new agent that does not understand a newer overlay key rejects the revision with `unknown-field`, visible in the UI. +- Known limit: viper stops watching the config file after a `Remove` event (follow-up). diff --git a/docs/configuration.md b/docs/configuration.md index 88f2753..75b632a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -180,14 +180,43 @@ Viper lowercases keys and splits them on dots. Plugin names and config keys in t cannot contain dots; a remote overlay that uses `GitHub` addresses a different plugin than the file's `github`. Plugin names an overlay introduces must match `^[a-z0-9][a-z0-9_-]{0,62}$` (R28). +## `${env:NAME}` placeholders + +A `plugins.

.config` value may reference environment variables, whole or embedded: + +```yaml +plugins: + postgres: + config: + password: "${env:PG_PASSWORD}" + dsn: "postgres://app:${env:PG_PASSWORD}@db:5432/app" +``` + +Placeholders are resolved **only** in `plugins.*.config`, in the file and in a remote overlay. Anywhere else (for +example `policy_data` or `labels`) a placeholder is not resolved: in the file it is passed through as a literal string, +as it always was, and reported as a warning; a remote overlay that puts one there is rejected. `CCF_API_AUTH_*` may +never be referenced. An unset variable that the **file** references is a warning, and the value reaches the plugin +unchanged (the literal `${env:NAME}`), exactly as before placeholders were resolved (R60). An unset variable that a +remote overlay introduces fails the revision with `env-missing`; the error names the variable, never a value. Reports, +redaction and the configuration digest always use the unresolved placeholder, so rotating a secret never changes them +(R24). + +Plugin values set through viper environment variables (`CCF_PLUGINS_

_CONFIG_`, see the README) are masked as +`••••` in every report and in the configuration digest (R25). The rest of the redaction is the API's +`pkg/agentconfig` (`Redact`, `Digest`), which the agent uses as is and which is the source of truth. In short, it masks +values under secret-like keys (for example `password`, `token`, `secret`, `api_key`, `dsn`, `auth`) and secret-looking +values under any key (a password in a URL, a PEM private key, a `password=` assignment, known token formats). Literal +text mixed with a `${env:NAME}` placeholder under a secret-like key is masked too; a value made only of placeholders is +reported as written. + ## Tolerated file problems A plugin `schedule` in the file that does not parse does not stop the agent: that plugin is skipped, the others run, and the problem is logged and reported as a warning (R34). A few other file values that always loaded are also only -warnings, and are kept unchanged: a negative `verbosity` (`-1` logs WARN and above) and a literal `${env:...}` outside -`plugins.*.config`. Every other invalid value in the file (for example a missing `api.url`) still fails startup, and on -a live reload the agent keeps running its last good configuration. Values set by a remote overlay are always validated -strictly. +warnings, and are kept unchanged: a negative `verbosity` (`-1` logs WARN and above), a literal `${env:...}` outside +`plugins.*.config`, and an unset variable referenced from the file's `plugins.*.config` (see above). Every other +invalid value in the file (for example a missing `api.url`) still fails startup, and on a live reload the agent keeps +running its last good configuration. Values set by a remote overlay are always validated strictly. ## Remote configuration @@ -235,7 +264,7 @@ A change is classified as follows (the agent is the authority; the API preview u A rejected or failed revision never interrupts the running configuration: the agent prepares the whole new configuration (validation, downloads) first and swaps only when it is ready. Every outcome is reported to the API with -a reason (`unsafe-changes`, `forbidden-changes`, `invalid-config`, `invalid-type`, `unknown-field`, +a reason (`unsafe-changes`, `forbidden-changes`, `invalid-config`, `invalid-type`, `unknown-field`, `env-missing`, `download-failed`, `cache-corrupt`, `internal`). When a new configuration is applied, in-flight plugin runs get up to 5 minutes to finish (R33). Evidence produced under an overlay carries the prop `agent-config-revision` (namespace `https://compliance-framework.github.io/ns`).