Skip to content

feat: agent remote configuration (overlay, apply modes, safeguards) - #95

Closed
ccf-lisa[bot] wants to merge 49 commits into
mainfrom
lisa/in-flight-agent-config
Closed

ccf-lisa[bot] wants to merge 49 commits into
mainfrom
lisa/in-flight-agent-config

Conversation

@ccf-lisa

@ccf-lisa ccf-lisa Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The agent side of agent remote configuration:

  • the agent polls an API-stored overlay (RFC 7396 merge over its file config);
  • it applies the overlay according to its local remote_config.mode (off, report, apply_safe, apply_all), behind the shared Classify safeguards. With credentials and no mode set, the mode is report: applying is opt-in;
  • it reports what it runs back to the API.

An overlay can change plugins.<p>.config, policy_data, labels, schedule, enabled, source, policies (OCI or local sources), policy_behavior, agent_evidence and verbosity. Inline policy bundles are out of scope: an overlay that sets policy_bundles is rejected with unknown-field.

Dependency: this pins compliance-framework/api#465 (head b53be8f), then gets re-pinned to the API release tag. Ship order: api#465, then this PR, then compliance-framework/ui#318.

What's in it

Config, state and reloads

  • Declared config: api/pkg/agentconfig.Config is the declared form, and the existing structs remain the runtime form. The config hash is byte-identical, pinned by golden tests.
  • Per-config state dir and instance ID: --state-dir/CCF_STATE_DIR and --instance-id/CCF_INSTANCE_ID.
  • Prepare-then-cancel reloads: for file and remote triggers. An invalid file or a failed download no longer exits; the agent keeps running the last good configuration, with a 5-minute drain.
  • Startup ladder: fetched overlay, then last applied, then the file alone.

Remote config (cmd/reconciler.go, cmd/report.go)

  • Cache: 0600, checksummed and bound to the API URL and client ID. The opaque ETag is sent back verbatim.
  • prepare:
    1. ValidateOverlay;
    2. the Classify/WillApply mode gate (forbidden keys are rejected in every mode);
    3. Merge;
    4. Validate;
    5. ${env:} resolution in plugins.*.config;
    6. prefetch.
  • Rejections and failures: a rejected revision is remembered per (ETag, base). It is re-reported after a restart, so the API keeps showing rejected, with the reason and unsafe list. Failed applies back off from 1 to 10 minutes.
  • Reports:
    • contents: the redacted, unresolved base and effective config, revisions, status, reason, unsafe list and warnings; and plugins[] with lib-version, read from each plugin binary's Go build info (diagnostics only, nothing is gated on it);
    • redaction and the digest come from the API's pkg/agentconfig (Redact, Digest): secret-like keys and secret-looking values (URL passwords, PEM private keys, password= assignments, known token formats), plus literal text mixed with ${env:} under secret-like keys. The agent does not re-implement it;
    • sent at startup, on change and every 24h, with backoff on 401/403/404/409/413; oversized reports drop base.
  • Heartbeat: carries config_revision / config_digest.
  • Evidence props: agent-config-revision.

Evidence (runner)

  • Evaluation-time artifact uploads are unchanged from main (runner/policy_artifacts.go is byte-identical to main). There is no report-time policy bundle inventory and no configuration-time artifact upload.
  • _policy_source for old plugins: plugins that send no policy evaluations still get _policy_source/_policy_digest, keyed from the evidence's _policy_path label.

Plugin environment

  • Plugins get the host environment minus CCF_API_AUTH_*.

policy-manager (used by plugins built on this lib)

  • Unchanged from main. Evidence seeding is pinned by the new evidence_seed_test.go.

Docs

  • docs/configuration.md: remote config (default mode report), the classification table, redaction, plugin library versions, and the state directory.
  • docs/adr/0003-remote-config-overlay.md.
  • docs/policy_artifacts.md, docs/running_as_a_service.md, AGENTS.md, README.

Notable decisions

  • Inline policy bundles are out of scope (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. The agent therefore has no policy errors, no path shadowing and no plugin-lib gate.
  • Applying is opt-in (security review, finding 1): an unset remote_config.mode with credentials is report, not apply_safe.
  • No policy bundle inventory or report-time uploads (security review, findings 4 and 5): the report carries no policy-bundles[] and the API dropped the artifact file routes.
  • File validation shared with the API stays non-breaking: a bad schedule in the file skips that plugin, and a negative verbosity or a literal ${env:} outside plugins.*.config is only a warning (R34). An unset variable that the file references is passed through unchanged (R60).

Testing

  • With GOWORK=off against api b53be8f, all of these are clean: go build, go vet, make test (race and coverage), gofmt, make check-opa-version, make build, a GOOS=windows build, and golangci-lint v2.11.4 on the diff from main.
  • Reconciler tests:
    • invalid edits, races, the startup ladder, 304 handling;
    • rejected memory, including the restart re-report;
    • backoff and truncation.
  • Report tests: redaction (env-sourced values, a dsn mixing literal text and placeholders, a password in a URL), truncation, and plugin lib-versions.
  • Mode tests: credentials without a mode (with and without a remote_config block) run in report: they report not-applicable, never fetch, and don't apply a cached applied overlay.
  • The local-dev end-to-end runs were done on the earlier, larger scope and have not been repeated on this one.

Release notes

  • Remote config is opt-in. Agents with api.auth credentials and no remote_config.mode now run in report mode: they report their configuration but no longer fetch or apply remote configuration (including a cached overlay) unless remote_config.mode (or CCF_REMOTE_CONFIG_MODE) is set to apply_safe or apply_all.
  • Reports redact by value too. Effective digests change for configs with newly masked values; agent and API must use the same pkg/agentconfig for the reported digest to match.
  • The report's policy-bundles field is gone (the API ignores it if an older agent build sends it).

Known limitations

  • Old plugins upload no evaluation artifacts. Plugins built on old agent libs send no PolicyEvaluation, so their evidence has no input or policy artifacts and can't be played back.
  • Dev stacks need a state wipe. State layouts from earlier in-branch iterations are not migrated (never released). A remote config cache that remembers a rejection with policy errors fails its checksum and is reported once as cache-corrupt.

🤖 Generated with Claude Code

ccf-lisa Bot added 6 commits September 30, 2026 12:06
…ctor

The G0 declared/runtime refactor must keep agentConfigurationHash
byte-identical (it feeds the _agent label fallback and the agent
evidence UUID). Record the values from the current loader first so the
refactor commit can prove it.
Pin compliance-framework/api to the in-flight-agent-config branch head
(52e315b) for pkg/agentconfig and pkg/agentconfig/regocheck.

The config now has two forms (R4): agentconfig.Config is the declared
form that is decoded, validated and (later) merged, classified and
reported; the existing private structs stay the runtime form, built by
one toRuntime conversion so agentConfigurationHash stays byte-identical
(golden test).

- cmd/config.go: loadBase builds a fresh viper per load, decodes with
  viper's weak decoder exactly as before (R51), decodes policy_bundles
  outside viper (yaml/json/toml), records env-sourced plugin pointers
  (R25) and validates with the shared rules.
- R34: file-origin problems that only logged before (a bad plugin
  schedule) become reported warnings and the plugin is skipped; every
  other validation error stays fatal.
- R9: protocol_version 0 means auto; the explicit-0 file check stays.
- plugins.*.enabled: disabled plugins are dropped from the runtime.
- internal.IsOCI delegates to agentconfig.IsOCISource (R3).
- Remove the unused queryBundles field and the OPA v0 rego import; fix
  the verbosity comment.
…loads (G1)

- internal/agentstate: per-config state dir
  (.compliance-framework/state/<sha256(abs config path)[:16]>, 0700)
  with a persisted instance ID; --state-dir/CCF_STATE_DIR and
  --instance-id/CCF_INSTANCE_ID override it (R31). An unwritable dir
  keeps the ID in memory with one WARN. The resolved dir, its source and
  the ID are logged at startup (R52).
- The heartbeat uses the stable ID instead of a fresh UUID per run.
- cmd/reconciler.go: one serialized reconciler builds a complete,
  prefetched candidate before cancelling the running config (R32). An
  invalid file or a download failure no longer panics or exits; the
  running config keeps going. A run that fails on its own after a reload
  falls back to the previous config. Each load uses a fresh viper; the
  watcher only signals.
- AgentRunner: Run/runDaemon return errors instead of os.Exit; Prefetch
  downloads without touching the running locations; a per-source
  protocol cache survives reloads; UpdateConfig reuses the SDK client
  when the api block is unchanged; reloads let in-flight runs drain for
  up to 5 minutes (R33).
- Plugins no longer inherit CCF_API_AUTH_* (SkipHostEnv plus a filtered
  host environment, R26).
- Drop the no-op global viper.BindPFlag calls; run tests with -race.
G2 and G3 ship together (R35).

Reporting (G2):
- remote_config mode comes from the file (normalized: apply_safe with
  auth, off without; CCF_REMOTE_CONFIG_MODE is bound, R30).
- cmd/report.go builds the config report: redacted UNRESOLVED base and
  effective with the same env-sourced masked pointers the digest uses
  (R24, R25, R55), daemon, attempted/applied revision, status/reason,
  unsafe changes, R34 warnings, remote-config (snake_case), truncation
  to 3.5 MiB. Sent at startup, when it changes, after a send error and
  every 24h. 404/401/403 back off 10 min, 409 pauses reports for 1h,
  413 resends truncated (R8, R36).
- The heartbeat carries config_revision (0 = file only) and
  config_digest whenever the mode is not off (R11, R45).
- main sets the reported agent version (goreleaser main.version).

Pull and apply (G3):
- internal/agentstate cache (0600, checksummed, bound to api.url +
  client_id) keeps the fetched, applied and rejected revisions. The
  ETag is opaque and sent back verbatim (R7).
- prepare: ValidateOverlay (the only strict decode, R27/R51) with
  FieldError codes mapped to reasons (R43), then the Classify gate
  (forbidden rejects in every mode, R23), Merge, Validate with the R34
  origin partition, ResolveEnv on plugins.*.config only (R24), then
  Prefetch. Nothing touches the network before Classify passes.
- A rejected revision is remembered per (ETag, base fingerprint); a
  failed one is retried after 1m doubling to 10m.
- Startup ladder: fetched, then applied, then file only (R32);
  one-shot fetches, applies, reports and runs once (R37).
- Evidence carries agent-config-revision when an overlay is applied,
  through a new runner.WithEvidenceProps option (R38).
Re-pin compliance-framework/api to the approved PR #465 head
(aa005f7; only PolicyOnlyChange changed, the agent-facing contract did
not).

internal/inlinepolicy materializes policy_bundles (file or overlay)
into write-once dirs under <state>/inline/<bundle>/<tree digest>:
- R17 order: extends tree (regular files only, symlinks skipped with a
  warning), delete, modules, then data merge-patched onto the root data
  file and written as data.json (a base data.yaml is converted);
- R18: root-relative paths, re-checked after Clean; only data.json /
  data.yaml / data.yml data files (authored: error, vendor: warning);
- Check runs per (plugin, policy path), the compile unit plugins use
  (R21): the same policyeval.NewFromBundlePath prepare path as
  policy-manager, a walk of every rule reachable from authored rules
  for policyeval.DeniedBuiltins refs (including with ... as http.send,
  R19/R20), and the Rego tests with bundle data + policy_data (authored
  failures reject, vendor failures warn);
- GC keeps the active dirs plus the 5 newest per bundle (startup only).

prepare runs regocheck first, then materialize + check; errors give
rejected/policy-errors with located errors, warnings are reported.
DownloadPolicies/runPlugin use the materialized dir for inline:<name>
and never download it. Reports list inline bundles with their extends
tree and inventory OCI/local policy paths.
- configuration.md: remote_config with R29 defaults, the modes and the
  apply_safe classification table, set locally only (R30); enabled;
  policy_bundles (extends/delete/modules/data, root-relative paths,
  data-file names, no cross-bundle imports); ${env:} in plugins.*.config
  only; env-sourced masking; tolerated bad schedules; weak file typing vs
  string-only overlays; the viper case/dot caveat; state dir and ID.
- running_as_a_service.md: WorkingDirectory/StateDirectory, persistent
  state, CCF_STATE_DIR for containers, CCF_INSTANCE_ID for jobs.
- ADR 0003: declared vs runtime forms, prepare-then-cancel, the agent as
  Classify authority, per-path compile unit and transitive denied
  builtins (with the residual policy_data risk), plugin env filter,
  opaque ETag, the R34 origin rule.
- README: pointer.
@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 4879d585-73e9-4261-9e5a-f59bac38dac2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

api #465 merged api main (#464 evaluation artifacts), so the pinned
module now carries both pkg/agentconfig and the SDK Artifact client the
agent already uses. The tree builds and tests without a go.work.

@gusfcarvalho gusfcarvalho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings are inline, reviewed at b9c1cad. build, vet, test -race and gofmt are clean locally, and CI is green. There are 6 blocking threads:

  • A denied builtin reachable from authored Rego still executes during the _test.rego run. This is security-relevant and was probe-confirmed.
  • File configs that load on main are now fatal (R34/R51).
  • A no-op revision is never recorded as applied.
  • A remembered-rejected overlay is re-prepared on every poll.
  • SIGTERM is lost during the 5-minute reload drain (R33).
  • The agent-run evidence for startup download failures is gone.

The rest are non-blocking suggestions and one owner question. The overall shape (prepare-then-cancel, the agent-side Classify gate, opaque ETags, env filtering and redaction) looks solid.

Comment thread internal/inlinepolicy/check.go Outdated
Comment thread cmd/config.go
Comment thread cmd/reconciler.go
Comment thread cmd/reconciler.go
Comment thread cmd/agent.go Outdated
Comment thread cmd/config.go Outdated
Comment thread cmd/reconciler_test.go Outdated
Comment thread cmd/agent.go
Comment thread cmd/reconciler.go Outdated
Comment thread cmd/reconciler.go
ccf-lisa Bot added 4 commits September 30, 2026 13:04
Check ran the bundle's Rego tests even after the transitive walk had found a
denied builtin reachable from authored code, so an authored _test.rego calling
a vendor helper that wraps http.send made the agent issue the request before
rejecting the revision (D17, HLD §8).

Check now returns before the tests when the walk reports anything, and the
tests themselves run sandboxed: modules are compiled under
policyeval.SandboxCapabilities with every denied builtin ref rewritten to a
stub that always errors. Vendor modules that only reference a denied builtin
off the authored path still compile; their tests fail as warnings.
The shared Validate made some file values fatal that main accepted:
- verbosity: -1 (hclog Warn, a quieter agent);
- a literal ${env:X} in plugins.*.labels / policy_data (an opaque string);
- an unset ${env:X} in plugins.*.config (main passed the literal through).

File-origin negative verbosity and env-location errors outside
policy_bundles are now warn-only: reported in warnings, value unchanged,
no plugin skip. Per the owner's R60 decision, an unset variable the file
references (per pointer and variable) is a warning and the literal reaches
the plugin unchanged; an unset variable an overlay introduces still fails
with failed/env-missing. Overlay-origin validation stays strict.

Files without policy_bundles no longer go through the YAML->JSON decode,
so YAML that JSON cannot represent (.nan, .inf) loads as on main.
WalkDir does not follow a symlinked root, so a local extends pointing at a
symlink (a versioned directory, a ConfigMap mount) materialized without its
vendor policies and only warned. readTree now resolves the root with
EvalSymlinks (in-tree symlinks are still skipped), and an extends tree with
no .rego file fails with ErrResolve (download-failed, retried with backoff)
instead of silently dropping every vendor policy.
- A revision (or file edit) whose effective config equals the running one is
  adopted in place: the running runtime keeps going, its sync metadata (now
  atomic) gets the new applied revision, and the reconciler's record gets the
  new base/overlay. Before, it was never recorded active, so every poll
  re-prepared it and heartbeat/report kept the stale applied-revision.
- reconcile returns early when the remaining target is remembered-rejected
  for the current base (last-known-good), instead of re-preparing it (and
  re-running inline policy checks) on every poll (§5.4).
- The rejected memory and failed backoff key on the ETag, or on
  revision + sha256(overlay) when a response had no ETag.
- run() binds the fallback before notifying onRunFailed; file-only
  candidates that fail to prepare or to run enter the failed backoff, and a
  successful prepare of the backed-off target keeps growing the delay, so a
  bad file edit no longer flaps every poll.
- A startup download failure of the file-only config calls
  AgentRunner.ReportStartupFailure (marks the plugins, sends the
  startup-failure agent evidence) before exiting 1, as Run did on main.
- A SIGINT/SIGTERM during the 5-minute reload drain is no longer lost: it
  switches to the 30s shutdown path and exits (R33).
- Each prepare network step (Prefetch, extends, report inventory) is bounded
  by prepareNetworkTimeout (5m) and maps to failed/download-failed.
- Inline GC also runs after every swap. Cron jobs capture config, client and
  logger once per setup. Race tests assert the final config and stop rc.run.
@ccf-lisa
ccf-lisa Bot requested a review from gusfcarvalho September 30, 2026 16:21

@gusfcarvalho gusfcarvalho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of b9c1cad..0c1a8c7: all 15 threads from my previous review are addressed, and I verified each fix. This supersedes my REQUEST_CHANGES.

  • Security: Check now returns before the tests when a denied builtin is reachable. The tests also run under SandboxCapabilities, with every denied builtin rewritten to a stub that errors. I re-ran my probe: 0 requests reached a local server across authored→vendor helper, with f as http.send, dynamic data[p][q] refs, walk(data), a vendor test calling http.send, and, with the walk bypassed entirely, direct/http["send"]/with calls and opa.runtime.
  • R34/R51/R60: a file with verbosity: -1, a literal ${env:} in labels/policy_data, or an unset ${env:X} in config (whole or embedded) loads, and the plugin gets exactly the literal values main passes. All are reported as warnings and no plugin is skipped.
  • Reconciler: a no-op revision is adopted in place: one Prefetch, then applied-revision/report/heartbeat = 2, same runtime, clean under -race. The remembered-rejected early return, the SIGTERM handling during the drain, the restored startup-failure evidence, the empty-ETag key, the notify-after-bind ordering, the file-only backoff, the bounded network phase and GC after each swap all look correct.

build, vet, test -race and gofmt are clean locally, and CI is green. One non-blocking note for the symlink follow-up is inline.

Comment thread internal/inlinepolicy/materialize.go Outdated
@ccf-lisa

ccf-lisa Bot commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

PR approved. Marking ready for e2e.

ccf-lisa Bot and others added 7 commits October 1, 2026 07:32
Round 2 of api#465: artifact-digest on the policy bundle report entries and the
policy contract check (policyeval.CheckContract / ValidateResult) wired into
regocheck and Execute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The configuration report is about to upload the policy trees it names as
artifacts, the same artifacts evidence references for playback. For both
to land on the same artifact, and for a tree to be uploaded once, they
need one archiver and one uploader.

- internal/policytree: ReadTree (resolves a symlinked root, skips inner
  symlinks, as inline materialization and OPA do) and TarFiles (sorted,
  fixed headers), TarDirectory = both. A symlinked policy root, which the
  old tarDirectory refused, now archives like the directory it points to.
- runner.ArtifactUploader: the per-digest cache, retries and old-API
  backoff, now exported, keyed per API and safe to share. Endpoint binds
  it to one API; NewApiHelper takes it with WithArtifactUploader (and
  keeps a private one without it).
- WithPolicyPaths resolves each path when the helper is created, so the
  bundle artifact is the tree the run started with even if the path is a
  symlink swapped afterwards (inline stable paths, R67).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The e2e round showed revisions that compile but would record no evidence
(an override without a title), and a compile error in a vendor test that
did not say the override caused it.

R63, after the compile and the tests pass, on the materialized tree:
- static: policyeval.CheckContract on the vendor-only packages only
  (warnings; regocheck already checked the authored modules), and a
  warning for an authored package that more than one non-test module
  defines (each records evidence for the whole package);
- dynamic: a dry run on {} plus policy_data through policyeval.Execute
  and policy-manager's GetRiskTemplates, the calls plugins make, under
  SandboxCapabilities with denied builtins rewritten to erroring stubs.
  Decode errors and Result.Issues become located PolicyErrors with the
  contract codes: errors in packages that contain an authored module,
  warnings in vendor-only ones. Conflicts on {} and a missing title whose
  title rule depends on the input are warnings. A package that fails to
  evaluate is left out of the next attempt so it does not hide others.
R65: a compile error in a vendor file whose package an authored module
also defines carries a hint (keep the rule or delete the test); it stays
an error. Includes the exact e2e repro.

policy-manager gains NewWithEvaluator and a RiskTemplateError that names
the package and file. Test fixtures gain titles: authored packages
without one are now rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
policy-manager seeds evidence UUIDs with the policy file path, and inline
bundles lived in tree-digest directories, so every edit of a bundle gave
every package in it a new evidence identity and broke history.

Materialized trees stay write-once and content-addressed, now at
<name>/<digest>/bundle; Materialized.Path is <name>/current/bundle, the
same for every revision. Activate points <name>/current at a tree with
a temporary symlink renamed over it. The symlink is an intermediate path
component on purpose: OPA's bundle loader does not descend into a
symlinked root directory and would silently load nothing.

The swap is atomic for path lookups on Linux, but neither a snapshot for
a reader walking the tree nor atomic on macOS APFS, so callers swap only
when no plugin of the previous configuration runs, and serialize Activate
with GC. GC never removes the tree current points to, and cleans up stale
temporary links. Without symlink support (Windows without the privilege)
Path is the tree itself, as before. A tree directory in the pre-R67
layout is rebuilt rather than reused. readTree is policytree.ReadTree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te packages (R62, R66, R67)

R67: plugins receive each inline bundle's stable path; the runtime keeps
the materialized trees separately (inlineTrees), and the candidate
identity still hashes the trees, so any tree change restarts plugins.
The run loop points the stable paths at a candidate's trees in start(),
only after the previous configuration's run returned (after the reload
drain), and back at the fallback's trees when it falls back; a failure
to activate is a run failure. GC and activation share a lock, and GC
keeps the trees of the running, pending, starting and fallback
candidates as well as whatever current points to.

R62: the reconciler and every plugin run share one artifact uploader
(remoteAPI.UploadArtifact). At report time, so outside mode off and only
for the candidate that runs, it uploads each reported tree (inline
bundles, the trees they extend, OCI and local sources) once, memoized by
tree digest per API and bounded by the remote request timeout, and
fills artifact-digest on the bundle and its extends. Failures leave the
field empty and never reject or fail; a tree the API refused for good is
not retried, a transient failure is retried with the next report, a
local tree changed since its inventory is not uploaded under the old
digest. The field survives report truncation.

R66: a plugin using an inline bundle that loads a policy package from
two of its policy paths gets a warning naming both paths.

Policy errors are de-duplicated before they are reported: exact repeats
across plugins, and an API-side warning superseded by an agent error
for the same bundle, path and code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- run loop: start(c, fallback) sets the starting and fallback candidates
  together before activating, so GC keeps both trees throughout a swap
  and a fallback.
- writeOnce: a completion marker next to bundle/; a pre-R67 directory
  whose vendor tree has a top-level bundle/ is rebuilt, not reused.
- contract: a dry-run timeout is a warning (vendor packages are
  evaluated too); an authored test alone does not make a vendor package
  authored; the duplicate-module warning points at the authored file.
- artifacts: a local tree edited since its inventory is not re-read on
  every poll.
- R66 wording; sortedMapKeys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- contract: skip policy-manager's risk-template error only when this
  package's risk templates were already reported, not on any invalid-type.
- docs: an authored non-test module makes a package authored.
- Activate doc comment reflowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ccf-lisa

ccf-lisa Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Round 2 (design §13: R62, R63, R65, R66, R67)

API pin bumped to api#465 round 2 (6c801a3). Commits 66af7bf..f88bde9.

R62: one channel for policy sources

  • New internal/policytree: ReadTree + TarFiles (sorted, fixed headers). It is the only archiver, so a tree uploaded at config time and at evaluation time produces the same bytes and the same artifact. A symlinked root now works; inner symlinks are skipped, as OPA does.
  • runner.ArtifactUploader is exported and keyed per API. One instance per process is shared by the reconciler (remoteAPI.UploadArtifact) and every plugin run (runner.WithArtifactUploader).
  • At report time (mode ≠ off, only for the candidate that runs, within the remote request timeout), the reconciler uploads every reported tree once, memoized by tree digest per API:
    • inline bundles
    • the tree each one extends
    • OCI and local sources
  • It fills artifact-digest on policy-bundles[] and on policy-bundles[].extends. The field survives truncation.
  • Failures never reject or fail a revision; the field is left empty:
    • 404/405 (old API): backoff
    • 400/413: not retried
    • 429/5xx/transport errors/timeouts: retried on the next report

R63: policy contract (agent is authoritative)

  • After compile and tests, inlinepolicy.Check runs CheckContract only on vendor-only packages (as warnings). It never re-runs it on authored modules.
  • Then a sandboxed dry run on {} plus policy_data through policyeval.Execute and policy-manager GetRiskTemplates.
  • Results are located PolicyErrors with contract codes:
    • error for packages with an authored non-test module
    • warning for vendor-only packages, for conflicts on {}, and for input-dependent titles
  • A broken package is excluded and the dry run retried, so it doesn't hide the others.

R65: a compile error in a vendor file whose package an override also defines gets the hint vendor test references rules removed by the override of `<file>`; keep the rule or add `delete: [<test>]` . It stays an error. The exact e2e repro is a test.

R66: if a plugin uses an inline bundle and the same package appears in two of its policy paths, the agent emits a duplicate-policy-package warning that names both paths.

R67: stable evidence identity

  • Plugins receive <state>/inline/<name>/current/bundle.
  • current is a symlink, swapped by an atomic rename to the content-addressed <name>/<digest>/bundle.
    • Why not <name>/current: OPA's dir loader does not descend into a symlinked root directory and would load nothing.
  • Swap timing: the run loop swaps only between configuration runs (after the reload drain) and on fallback. Never at prepare time.
  • The API helper resolves the path when the run starts, so evidence artifacts are the tree that was actually evaluated.
  • GC and activation share a lock. GC never deletes the tree current points to.
  • Tested: two inline revisions with an unchanged package produce the same policy_file and evidence UUID.

UI wire changes

  • artifact-digest on PolicyBundleReport and PolicyExtendsReport (sha256:<64 hex>).
  • New policy error codes:
    • policyeval.Issue* (static contract and dry run)
    • eval-error, eval-conflict, dry-run-timeout
    • duplicate-policy-package (R66)
  • R65 hints are appended to the compile error message (no code).

Behaviour change: an authored package with no title now rejects the revision, because it would record no evidence. Fixtures were updated.

Checks: go build, go vet, gofmt, go test -race ./... and make check-opa-version are green, and GOOS=windows builds. Three self-review passes were run; deferred lows are in the lisa-selfreview note. Docs updated: configuration.md, ADR 0003, policy_artifacts.md.

🤖 Generated with Claude Code

@ccf-lisa
ccf-lisa Bot requested a review from gusfcarvalho October 1, 2026 11:03

@gusfcarvalho gusfcarvalho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round-2 (§13) re-review of 0c1a8c7..f88bde9: approve. build, vet, test -race and gofmt are clean locally, and CI is green. What I verified:

  • R67: I confirmed independently that OPA v1.14.1 loads 0 modules from a symlinked root, 1 from the real dir and 1 from current/bundle, so the <name>/current/bundle deviation is justified. TestActivate_EvidenceIdentityIsStable_R67 goes through policy-manager Execute/GenerateResults and fails on empty results, so it proves that modules load through the stable path and that an unchanged package keeps its UUID. The swap happens only in start() after the previous run returned (and on fallback), under inlineMu. GC runs on the same lock, keeps active, pending, starting and fallback plus the current target, and runs on the materializing goroutine.
  • R62: both upload paths use policytree.ReadTree and TarFiles, so the tars are identical. Uploads run at report time and never feed status. The memo resets when the remote client is rebuilt.
  • R63: the dry run uses sandboxed copies with stubs and SandboxCapabilities, only after the reachability check and tests. 0 requests reached a probe server across authored→vendor helper, with … as http.send, a vendor test, a vendor violation rule and risk_templates calling http.send. Severity is an error only for authored non-test packages; timeouts are bounded warnings. The wording-based classification can only fail open (a warning), never reject wrongly, and tests pin the codes at this API pin.
  • R65/R66: the e2e repro test and the duplicate-package test are both in place.

One non-blocking owner question is inline (bundle data.json in artifacts vs Redact). The ConfigMap follow-up thread stays open, as agreed.

Comment thread cmd/artifacts.go Outdated
ccf-lisa Bot added 3 commits October 1, 2026 11:41
Conflicts in runner/result.go and cmd/agent.go: keep the shared ArtifactUploader,
WithArtifactUploader, WithPolicyPaths symlink resolution and WithEvidenceProps alongside
main's WithSources. Semantic integration (design 13.4): policy sources are keyed by the
exact path plugins receive, including an inline bundle's stable current/bundle path, and
an inline bundle records _policy_source = inline:<name> with the stored bundle's artifact
digest, or its tree digest when the bundle could not be stored.
A module that declares policy_id writes to a stream that follows the policy, not
where its bundle lives: policyeval.SeedPath replaces the policy_file seed and, when
the plugin labels carry one, the _policy_path seed. A policy_id equal to the legacy
policy file reproduces the legacy UUID, so an override can continue the vendor
stream. Without a policy_id the seed is unchanged byte for byte (golden test).
Evidence keeps the real _policy_path label and gains _policy_id.

@gusfcarvalho gusfcarvalho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed bce3ad3: M1 (view Ensure errors now only fail that plugin's runs; blast-radius + restart test), M2 (_inline), R88 removal, shared policyeval/regocheck helpers, and the per-(bundle, plugin) policy-stream-forked warning for unshadowed extends bundles all look right. CI green. Note for release: pluginlib.MinPolicyID = v0.9.0 must match the release that ships this PR. Dev stacks need a state wipe (R67 migration removed).

@gusfcarvalho gusfcarvalho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One logic flaw found while bringing the RC stack up on bce3ad3 (pre-existing, not from this round): a remembered rejection isn't re-reported after restart, so the instance shows 'pending'. Small fix inline.

Comment thread cmd/reconciler.go
startup skipped a fetched overlay remembered as rejected but set neither the
attempted revision nor the outcome, so with a 304 the API showed the instance
as pending until a new revision. Rebuild the outcome from the persisted
RejectedRecord (now also keeping Unsafe and up to 100 PolicyErrors, optional
fields), and keep it as the outcome when the applied overlay is re-prepared.

@gusfcarvalho gusfcarvalho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed e8afe72: remembered rejections are re-reported at startup and kept through 304/fallback re-prepares (unsafe + policy errors persisted, bounded); restart test covers both paths. CI green.

gusfcarvalho and others added 7 commits October 2, 2026 06:20
…streams

The API dropped authored policy_id (api#465): path shadowing already keeps
vendor evidence streams with every plugin build, so a declared identity
is no longer needed.

- policy-manager: evidence seeding and labels are back to main's
  (no SeedPath branch, no _policy_id label). The seed golden test moves
  to evidence_seed_test.go; the policy_id tests are removed.
- inlinepolicy: ModuleIdentity has no PolicyID; SeedOf is the plain
  (plugin path joined with the module path, plugin path) pair, which is
  what SeedPath returned without an id. OverrideStreams only reports
  policy-package-changed; policy-stream-forked is the unshadowed-bundle
  warning alone. Materialized.PolicyIDRules is gone.
- cmd: no duplicate-policy-id (duplicate-policy-identity stays), no
  plugin-lib-policy-id-unsupported; the plugin library check is the
  set-form violation gate (MinViolationSet, v0.7.1) alone.
- pluginlib: MinPolicyID is removed.
- Docs and AGENTS.md no longer describe policy_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tity checks

Inline policy bundles (policy_bundles in the config file or overlay, inline:<name>
policy entries) are removed, with everything that only served them:

- cmd: inline bundle materialization and activation, path shadowing and plugin
  views, the duplicate-policy-identity check, the policy_bundles decoding of the
  config file (and its TOML/JSON path), and the set-form violation gate, which
  only judged inline bundles. Plugins run in the agent's working directory again.
- internal/inlinepolicy and internal/policyview are deleted.
- runner: WithPolicyRoot, the symlink resolution of policy paths and
  Source.BundleArtifact are removed; evidence records the policy source as on
  main. The shared artifact uploader and the _policy_path label fallback stay.
- policy-manager: NewWithEvaluator and RiskTemplateError are removed;
  policy-manager.go equals main. The evidence seed golden test stays.

The report keeps its policy-bundles inventory of the OCI and local sources each
instance loads, and plugins[] with each plugin's lib-version. The inventory
moves to policytree.Inventory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing produces policy errors any more, so the plumbing goes: applyError,
the candidate's policy warnings, the report's policy-errors and the rejected
record's policy_errors in the remote config cache. pluginlib keeps only the
build-info version reader the plugins report uses; MinViolationSet and AtLeast
(and golang.org/x/mod) are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API drops inline policy bundles, policy errors and the policy contract
check from pkg/agentconfig and pkg/policyeval. An overlay that still sets
policy_bundles is now rejected as unknown-field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ting

Remove the inline policy bundle, path shadowing, policy identity, plugin
compatibility and policy error content from the configuration guide, ADR 0003,
policy_artifacts.md, AGENTS.md, the README and the service guide. Document what
stays: the report's policy-bundles inventory with artifact digests ("Sources for
the UI"), plugins[].lib-version as diagnostics, and the _policy_path label
fallback for _policy_source. ADR 0003 records that inline bundles are out of
scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gusfcarvalho gusfcarvalho changed the title feat: agent remote configuration (overlay, apply modes, inline policies) feat: agent remote configuration (overlay, apply modes, safeguards) Oct 2, 2026
gusfcarvalho and others added 4 commits October 2, 2026 11:43
The config report no longer inventories the policy sources (policy-bundles[]
with tree digests and file lists), and the reconciler no longer uploads policy
trees as artifacts at report time. The API removed the matching report fields
and the artifact file routes.

The evaluation-time policy bundle upload (evidence playback) is restored to
main: each plugin run's API helper has its own uploader again, and the shared
process-wide ArtifactUploader/ArtifactEndpoint and internal/policytree, which
only existed to share code with the report-time upload, are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks up compliance-framework/api#465 at b53be8f: agentconfig redaction now
also masks by value (URL passwords, private keys, password= assignments, known
token formats) and masks literal text mixed with ${env:...} under secret-like
keys, and remote_config.mode defaults to report. The report and heartbeat
digests come from agentconfig.Redact/Digest, so they follow the API.

The report tests now pin that a dsn mixing literal text and placeholders is
masked and that a password in a URL is masked by value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With api.auth credentials and no remote_config.mode, the API's
agentconfig.RemoteConfig.Normalize now defaults the mode to report instead of
apply_safe: the agent reports its configuration but never fetches or applies
an overlay (not even a cached applied one) until the host owner sets
apply_safe or apply_all.

The agent delegates the default to the API package; this pins it with a test
(with and without a remote_config block, after an overlay was applied under
apply_safe) and updates the README, configuration docs and ADR 0003.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove the report-time policy-bundles inventory, tree digests and policy
tree uploads from the configuration docs and ADR 0003, and restore the
evaluation-time upload description in policy_artifacts.md to main. Report
truncation now only drops base.

Point the redaction description at the API's pkg/agentconfig as the source
of truth and summarize it: secret-like keys, secret-looking values, and
literal text mixed with placeholders under secret-like keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ccf-lisa

ccf-lisa Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favour of a stack of smaller PRs (stack #112). The top of the stack is byte-identical to this branch merged with main; each PR builds, vets and passes go test -race ./... on its own.

  1. test: pin agent configuration hashes and evidence seeds #101 test: pin agent configuration hashes and evidence seeds
  2. feat(runner): policy source from the _policy_path label; extra evidence props #102 feat(runner): policy source from the _policy_path label; extra evidence props
  3. refactor(config): adopt api/pkg/agentconfig as the declared config #103 refactor(config): adopt api/pkg/agentconfig as the declared config
  4. feat(agent): stable instance ID and per-instance state directory #104 feat(agent): stable instance ID and per-instance state directory
  5. feat(agent): AgentRunner primitives for prepare-then-cancel reloads #105 feat(agent): AgentRunner primitives for prepare-then-cancel reloads
  6. feat(agent): prepare-then-cancel config file reloads #106 feat(agent): prepare-then-cancel config file reloads
  7. feat(pluginlib): read the agent library version a plugin binary was built with #107 feat(pluginlib): read the agent library version a plugin binary was built with
  8. feat(agent): report the configuration to the API (remote_config off/report) #108 feat(agent): report the configuration to the API (remote_config off/report)
  9. feat(agentstate): persisted cache for the remote configuration overlay #109 feat(agentstate): persisted cache for the remote configuration overlay
  10. feat(agent): pull and apply remote configuration overlays (apply_safe/apply_all) #110 feat(agent): pull and apply remote configuration overlays (apply_safe/apply_all) — size-exception (≈780 of its 1417 lines are tests)
  11. feat(config): ${env:NAME} placeholders in plugin config; ADR 0003 #111 feat(config): ${env:NAME} placeholders in plugin config; ADR 0003

Review threads on this PR stay here for reference. The branch lisa/in-flight-agent-config is kept.

🤖 Generated with Claude Code

@ccf-lisa ccf-lisa Bot closed this Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant