Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ agent_evidence:

See [configuration](./docs/configuration.md) for more information.

The agent adds `_agent_config_hash` to plugin and agent evidence labels. The value is a deterministic SHA-256 hash of
the runtime plugin and agent evidence configuration, which prevents multiple unauthenticated agents using the fallback
`_agent=ccf` identity from writing to the same evidence seed when their configurations differ.
The agent sets the `_agent` label using the following fallback chain: `api.auth.client_id` when available, then
`KUBERNETES_POD_NAME` or `KUBERNETES_POD`, and finally a deterministic SHA-256 hash of the runtime plugin and agent
evidence configuration. This prevents multiple unauthenticated agents from writing to the same evidence seed when their
configurations differ.

### Environment variables

Expand Down
11 changes: 4 additions & 7 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,6 @@ const RunnerV2ProtocolVersion int32 = 2
const AnnotationProtocolVersionKey = "org.ccf.plugin.protocol.version"
const daemonCronStopTimeout = 30 * time.Second
const agentEvidenceErrorArtifactMaxBytes = 1024 * 1024
const agentConfigHashLabel = "_agent_config_hash"

type pluginRunStatus string

Expand Down Expand Up @@ -836,15 +835,14 @@ func agentIdentityLabel(config *agentConfig) string {
}
}

return "ccf"
return agentConfigurationHash(config)
}

func agentFoundationalLabels(config *agentConfig) map[string]string {
return map[string]string{
"_agent": agentIdentityLabel(config),
agentConfigHashLabel: agentConfigurationHash(config),
"tool": "ccf",
"type": "operations",
"_agent": agentIdentityLabel(config),
"tool": "ccf",
"type": "operations",
}
}

Expand Down Expand Up @@ -962,7 +960,6 @@ func pluginEvidenceLabelsWithHash(config *agentConfig, pluginName string, plugin
labels[k] = v
}
}
labels[agentConfigHashLabel] = configHash
return labels
Comment thread
gusfcarvalho marked this conversation as resolved.
}

Expand Down
54 changes: 34 additions & 20 deletions cmd/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1301,10 +1301,9 @@ func TestAgentRunEvidenceIncludesPluginRunSummaryAndErrorArtifacts(t *testing.T)
t.Fatalf("expected readable failure reason, got %q", evidence.Status.Reason)
}
expectedLabels := map[string]string{
"_agent": clientID,
agentConfigHashLabel: agentConfigurationHash(agentRunner.getConfig()),
"tool": "ccf",
"type": "operations",
"_agent": clientID,
"tool": "ccf",
"type": "operations",
}
for key, expected := range expectedLabels {
if evidence.Labels[key] != expected {
Expand Down Expand Up @@ -1654,14 +1653,13 @@ func TestSendAgentRunEvidenceAllowsNoPlugins(t *testing.T) {
if !ok {
t.Fatalf("expected labels object, got %#v", submitted["labels"])
}
if labels["_agent"] != "ccf" || labels["tool"] != "ccf" || labels["type"] != "operations" {
// When no client_id or pod env vars, _agent should be the config hash
expectedHash := agentConfigurationHash(agentRunner.getConfig())
if labels["_agent"] != expectedHash || labels["tool"] != "ccf" || labels["type"] != "operations" {
t.Fatalf("unexpected foundational labels: %#v", labels)
}
if labels[agentConfigHashLabel] != agentConfigurationHash(agentRunner.getConfig()) {
t.Fatalf("expected agent config hash label, got %#v", labels)
}
if len(labels) != 4 {
t.Fatalf("expected only four foundational labels, got %#v", labels)
if len(labels) != 3 {
t.Fatalf("expected only three foundational labels, got %#v", labels)
}
if _, ok := submitted["back-matter"]; ok {
t.Fatalf("expected no backmatter for passing no-plugin evidence, got %#v", submitted["back-matter"])
Expand Down Expand Up @@ -1722,6 +1720,17 @@ func TestAgentIdentityLabelFallsBackToKubernetesPodName(t *testing.T) {
if got != "123e4567-e89b-12d3-a456-426614174000" {
t.Fatalf("expected API auth client id to take precedence, got %q", got)
}

// Test that hash is final fallback when no client_id or pod name
t.Setenv("KUBERNETES_POD_NAME", "")
t.Setenv("KUBERNETES_POD", "")
config := newRuntimeHashTestConfig()
config.ApiConfig.Auth = nil // Remove client_id to test hash fallback
expectedHash := agentConfigurationHash(config)
got = agentIdentityLabel(config)
if got != expectedHash {
t.Fatalf("expected config hash as final fallback, got %q (expected %q)", got, expectedHash)
}
}

func TestAgentConfigurationHashUsesRuntimeConfigOnly(t *testing.T) {
Expand Down Expand Up @@ -1807,24 +1816,28 @@ func TestAgentConfigurationHashUsesRuntimeConfigOnly(t *testing.T) {

func TestAgentFoundationalLabelsIncludeAgentConfigurationHash(t *testing.T) {
config := newRuntimeHashTestConfig()
config.ApiConfig.Auth = nil // Remove client_id to test hash fallback

labels := agentFoundationalLabels(config)

if labels[agentConfigHashLabel] != agentConfigurationHash(config) {
t.Fatalf("expected foundational labels to include config hash, got %#v", labels)
if labels["_agent"] != agentConfigurationHash(config) {
t.Fatalf("expected _agent label to contain config hash when no client_id, got %#v", labels)
}
}

func TestAgentRunEvidenceUUIDUsesAgentConfigurationHash(t *testing.T) {
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
firstConfig := newRuntimeHashTestConfig()
firstConfig.ApiConfig.Auth = nil // Remove client_id to test hash fallback
firstRunner := NewAgentRunner()
firstRunner.UpdateConfig(newRuntimeHashTestConfig())
firstRunner.UpdateConfig(firstConfig)
firstEvidence, err := firstRunner.buildAgentRunEvidence(now)
if err != nil {
t.Fatalf("build first agent run evidence: %v", err)
}

secondConfig := newRuntimeHashTestConfig()
secondConfig.ApiConfig.Auth = nil // Remove client_id to test hash fallback
secondConfig.Plugins["plugin-a"].Source = "ghcr.io/example/plugin-a:v2"
secondRunner := NewAgentRunner()
secondRunner.UpdateConfig(secondConfig)
Expand All @@ -1833,8 +1846,8 @@ func TestAgentRunEvidenceUUIDUsesAgentConfigurationHash(t *testing.T) {
t.Fatalf("build second agent run evidence: %v", err)
}

if firstEvidence.Labels[agentConfigHashLabel] == secondEvidence.Labels[agentConfigHashLabel] {
t.Fatalf("expected different config hash labels, got %q", firstEvidence.Labels[agentConfigHashLabel])
if firstEvidence.Labels["_agent"] == secondEvidence.Labels["_agent"] {
t.Fatalf("expected different _agent labels, got %q", firstEvidence.Labels["_agent"])
}
if firstEvidence.UUID == secondEvidence.UUID {
t.Fatalf("expected config hash change to alter agent evidence UUID %s", firstEvidence.UUID)
Expand All @@ -1843,11 +1856,12 @@ func TestAgentRunEvidenceUUIDUsesAgentConfigurationHash(t *testing.T) {

func TestPluginEvidenceLabelsIncludeAgentConfigurationHash(t *testing.T) {
config := newRuntimeHashTestConfig()
config.ApiConfig.Auth = nil // Remove client_id to test hash fallback

labels := pluginEvidenceLabels(config, "plugin-a", config.Plugins["plugin-a"])

if labels[agentConfigHashLabel] != agentConfigurationHash(config) {
t.Fatalf("expected plugin labels to include config hash, got %#v", labels)
if labels["_agent"] != agentConfigurationHash(config) {
t.Fatalf("expected _agent label to contain config hash when no client_id, got %#v", labels)
}
if labels["_plugin"] != "plugin-a" {
t.Fatalf("expected plugin label, got %#v", labels)
Expand All @@ -1859,7 +1873,7 @@ func TestPluginEvidenceLabelsIncludeAgentConfigurationHash(t *testing.T) {

func TestPluginEvidenceSubmissionIncludesAgentConfigurationHash(t *testing.T) {
config := newRuntimeHashTestConfig()
config.ApiConfig.Auth = nil
config.ApiConfig.Auth = nil // Remove client_id to test hash fallback
var submittedLabels map[string]string
client := newTestHTTPClient(func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/api/evidence" {
Expand Down Expand Up @@ -1902,8 +1916,8 @@ func TestPluginEvidenceSubmissionIncludesAgentConfigurationHash(t *testing.T) {
t.Fatalf("create evidence: %v", err)
}

if submittedLabels[agentConfigHashLabel] != agentConfigurationHash(config) {
t.Fatalf("expected submitted plugin evidence to include config hash, got %#v", submittedLabels)
if submittedLabels["_agent"] != agentConfigurationHash(config) {
t.Fatalf("expected submitted plugin evidence to include config hash in _agent, got %#v", submittedLabels)
}
if submittedLabels["_plugin"] != "plugin-a" || submittedLabels["team"] != "security" {
t.Fatalf("expected submitted plugin evidence to include plugin labels, got %#v", submittedLabels)
Expand Down
20 changes: 10 additions & 10 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ agent_evidence:
The `plugin_identifier` is a unique identifier for the plugin, and is used to identify the plugin in the logs, you can
name this whatever you like but it must be unique.

The `labels` should uniquely identify this agent instance. The agent also sets the `_agent` label on plugin evidence
using `api.auth.client_id` when available, then `KUBERNETES_POD_NAME` or `KUBERNETES_POD`, and finally `ccf`. The agent
also sets `_agent_config_hash`, a deterministic hash of the runtime plugin and agent evidence configuration. Because
evidence UUIDs are seeded from labels, changing either the agent identity or runtime configuration changes the evidence
stream for plugin evidence.
The `labels` should uniquely identify this agent instance. The agent sets the `_agent` label on plugin evidence using
the following fallback chain: `api.auth.client_id` when available, then `KUBERNETES_POD_NAME` or `KUBERNETES_POD`, and
finally a deterministic SHA-256 hash of the runtime plugin and agent evidence configuration. Because evidence UUIDs are
seeded from labels, changing either the agent identity or runtime configuration changes the evidence stream for plugin
evidence.

The `plugin_source` is the path to the plugin binary that the agent will run. This can be a relative or absolute path or
even a URL to a remote plugin.
Expand All @@ -64,11 +64,11 @@ is `satisfied`. A plugin remains in the `Plugins with errors` summary until it f
Plugins that have never run are listed as pending. Failed plugin errors are attached as back-matter resources and linked
from the evidence so they can be downloaded.

Agent evidence uses these labels: `_agent`, `_agent_config_hash`, `tool`, and `type`. The `_agent` label uses
`api.auth.client_id` when available, then `KUBERNETES_POD_NAME` or `KUBERNETES_POD`, and finally defaults to `ccf`. The
`_agent_config_hash` label is a SHA-256 hash of plugin names, sources, protocol versions, schedules, policies, plugin
config, plugin labels, and `agent_evidence` settings. It does not include API URL, API auth, or verbosity. The `tool`
label is `ccf`; the `type` label is `operations`.
Agent evidence uses these labels: `_agent`, `tool`, and `type`. The `_agent` label uses the following fallback chain:
`api.auth.client_id` when available, then `KUBERNETES_POD_NAME` or `KUBERNETES_POD`, and finally a SHA-256 hash of
plugin names, sources, protocol versions, schedules, policies, plugin config, plugin labels, and `agent_evidence`
settings. The hash does not include API URL, API auth, or verbosity. The `tool` label is `ccf`; the `type` label is
`operations`.

If no plugins are configured, ccf-agent still emits passing agent evidence on the configured interval when running in
daemon mode. In non-daemon mode, ccf-agent can emit agent evidence only once per invocation.
Expand Down
Loading