Skip to content

Commit 841459b

Browse files
7vigneshAbirAbbasclaude
authored
fix(control-plane): prevent structured logs from leaking execution payloads (#701)
* fix(control-plane): prevent structured logs from leaking execution payloads (#560) Add logging configuration (level + redact_payloads) to control what execution data appears in structured log events and the internal event bus. Changes: - Add LoggingConfig with 'level' and 'redact_payloads' options - Support AGENTFIELD_LOG_LEVEL and AGENTFIELD_LOG_REDACT_PAYLOADS env vars - Guard execution input/output/context in event publishing behind redaction flag - Default to redact_payloads=true (safe) — opt-in via config to see full payloads - Replace 32 log.Printf calls in storage layer with leveled logger.Logger calls - Add InitLoggerWithLevel() for string-based log level configuration - Re-initialize logger from config at server startup Closes #560 * fix: address Copilot review comments - Guard req.Result behind redactPayloads in handleStatusUpdate event data - Remove raw data preview from corrupted JSON warning log (log only metadata: context + length) * test(control-plane): cover payload redaction branches for patch-coverage gate (#701) The structured-log payload redaction added in #701 gated execution input/result/context data behind `redactPayloads`. Those branches were only exercised on their redact-enabled default, leaving the opt-out paths uncovered and dropping control-plane patch coverage below the 80% floor. Adds behavior tests that subscribe to the execution event bus and assert the observable contract: input/result/context payloads are omitted from published events when redaction is enabled (the safe default) and present only when an operator explicitly disables it. Covers completeExecution, failExecution, completeReplayHit, handleStatusUpdate, and the event-context path. Patch coverage on touched lines: 68% -> 93%. Additive only; does not weaken the redaction logic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Abir Abbas <abirabbas1998@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7eb7ab5 commit 841459b

13 files changed

Lines changed: 642 additions & 78 deletions

File tree

control-plane/cmd/agentfield-server/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/Agent-Field/agentfield/control-plane/internal/cli"
1515
"github.com/Agent-Field/agentfield/control-plane/internal/config"
16+
"github.com/Agent-Field/agentfield/control-plane/internal/logger"
1617
"github.com/Agent-Field/agentfield/control-plane/internal/server"
1718
"github.com/Agent-Field/agentfield/control-plane/internal/utils"
1819
"github.com/Agent-Field/agentfield/control-plane/web/client"
@@ -70,6 +71,13 @@ func runServer(cmd *cobra.Command, args []string) {
7071
log.Fatalf("Failed to load configuration: %v", err)
7172
}
7273

74+
// Re-initialize logger with configured level now that config is loaded.
75+
// The CLI root command sets a default (info/debug based on --verbose),
76+
// but the YAML/env-based level takes precedence once available.
77+
if cfg.Logging.Level != "" {
78+
logger.InitLoggerWithLevel(cfg.Logging.Level)
79+
}
80+
7381
// Override port from flag if provided
7482
if cmd.Flags().Lookup("port").Changed {
7583
port, _ := cmd.Flags().GetInt("port")

control-plane/config/agentfield.yaml

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

control-plane/internal/config/config.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,23 @@ type Config struct {
2121
UI UIConfig `yaml:"ui" mapstructure:"ui"`
2222
API APIConfig `yaml:"api" mapstructure:"api"`
2323
Telemetry TelemetryConfig `yaml:"telemetry" mapstructure:"telemetry"`
24+
Logging LoggingConfig `yaml:"logging" mapstructure:"logging"`
25+
}
26+
27+
// LoggingConfig controls structured logging behavior.
28+
type LoggingConfig struct {
29+
// Level sets the minimum log level: "debug", "info", "warn", "error".
30+
// Defaults to "info".
31+
Level string `yaml:"level" mapstructure:"level"`
32+
// RedactPayloads controls whether execution input/output payloads are
33+
// omitted from structured log events and internal event bus data.
34+
// Defaults to true (payloads are redacted).
35+
RedactPayloads *bool `yaml:"redact_payloads" mapstructure:"redact_payloads"`
36+
}
37+
38+
// ShouldRedactPayloads returns true (the safe default) unless explicitly set to false.
39+
func (l LoggingConfig) ShouldRedactPayloads() bool {
40+
return l.RedactPayloads == nil || *l.RedactPayloads
2441
}
2542

2643
// TelemetryConfig controls anonymous OSS usage telemetry. It is separate from
@@ -468,6 +485,9 @@ func ApplyDefaults(cfg *Config) {
468485
if cfg.Telemetry.Timeout <= 0 {
469486
cfg.Telemetry.Timeout = 800 * time.Millisecond
470487
}
488+
if cfg.Logging.Level == "" {
489+
cfg.Logging.Level = "info"
490+
}
471491
}
472492

473493
// ApplyEnvOverrides applies environment variable overrides to the config.
@@ -781,6 +801,15 @@ func ApplyEnvOverrides(cfg *Config) {
781801
}
782802
}
783803
}
804+
805+
// Logging overrides
806+
if val := os.Getenv("AGENTFIELD_LOG_LEVEL"); val != "" {
807+
cfg.Logging.Level = strings.ToLower(strings.TrimSpace(val))
808+
}
809+
if val := os.Getenv("AGENTFIELD_LOG_REDACT_PAYLOADS"); val != "" {
810+
b := parseEnvBool(val)
811+
cfg.Logging.RedactPayloads = &b
812+
}
784813
}
785814

786815
func parseEnvBool(value string) bool {

control-plane/internal/config/config_additional_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,3 +491,79 @@ func TestApplyEnvOverridesIgnoresInvalidValues(t *testing.T) {
491491
t.Fatalf("expected connector enabled to become false for non-true value")
492492
}
493493
}
494+
495+
func TestLoggingConfigShouldRedactPayloads(t *testing.T) {
496+
t.Parallel()
497+
498+
// Default (nil pointer) should redact
499+
cfg := LoggingConfig{}
500+
if !cfg.ShouldRedactPayloads() {
501+
t.Fatal("expected ShouldRedactPayloads() to return true when RedactPayloads is nil")
502+
}
503+
504+
// Explicitly true
505+
true_ := true
506+
cfg.RedactPayloads = &true_
507+
if !cfg.ShouldRedactPayloads() {
508+
t.Fatal("expected ShouldRedactPayloads() to return true when RedactPayloads is true")
509+
}
510+
511+
// Explicitly false
512+
false_ := false
513+
cfg.RedactPayloads = &false_
514+
if cfg.ShouldRedactPayloads() {
515+
t.Fatal("expected ShouldRedactPayloads() to return false when RedactPayloads is false")
516+
}
517+
}
518+
519+
func TestLoggingApplyDefaults(t *testing.T) {
520+
t.Parallel()
521+
522+
cfg := Config{}
523+
ApplyDefaults(&cfg)
524+
525+
if cfg.Logging.Level != "info" {
526+
t.Fatalf("expected default logging level 'info', got %q", cfg.Logging.Level)
527+
}
528+
}
529+
530+
func TestLoggingEnvOverrides(t *testing.T) {
531+
// Test log level override
532+
t.Run("log_level", func(t *testing.T) {
533+
os.Setenv("AGENTFIELD_LOG_LEVEL", "debug")
534+
defer os.Unsetenv("AGENTFIELD_LOG_LEVEL")
535+
536+
cfg := Config{}
537+
ApplyEnvOverrides(&cfg)
538+
539+
if cfg.Logging.Level != "debug" {
540+
t.Fatalf("expected log level 'debug', got %q", cfg.Logging.Level)
541+
}
542+
})
543+
544+
// Test redact payloads override
545+
t.Run("redact_payloads_false", func(t *testing.T) {
546+
os.Setenv("AGENTFIELD_LOG_REDACT_PAYLOADS", "false")
547+
defer os.Unsetenv("AGENTFIELD_LOG_REDACT_PAYLOADS")
548+
549+
cfg := Config{}
550+
ApplyEnvOverrides(&cfg)
551+
552+
if cfg.Logging.RedactPayloads == nil || *cfg.Logging.RedactPayloads != false {
553+
t.Fatal("expected RedactPayloads to be false")
554+
}
555+
})
556+
557+
// Test redact payloads override (true)
558+
t.Run("redact_payloads_true", func(t *testing.T) {
559+
os.Setenv("AGENTFIELD_LOG_REDACT_PAYLOADS", "true")
560+
defer os.Unsetenv("AGENTFIELD_LOG_REDACT_PAYLOADS")
561+
562+
cfg := Config{}
563+
ApplyEnvOverrides(&cfg)
564+
565+
if cfg.Logging.RedactPayloads == nil || *cfg.Logging.RedactPayloads != true {
566+
t.Fatal("expected RedactPayloads to be true")
567+
}
568+
})
569+
}

control-plane/internal/handlers/execute.go

Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -132,14 +132,15 @@ type replayHit struct {
132132
}
133133

134134
type executionController struct {
135-
store ExecutionStore
136-
httpClient *http.Client
137-
payloads services.PayloadStore
138-
webhooks services.WebhookDispatcher
139-
eventBus *events.ExecutionEventBus
140-
timeout time.Duration
141-
internalToken string // sent as Authorization header when forwarding to agents
142-
readARDConfig func() config.ARDConfig
135+
store ExecutionStore
136+
httpClient *http.Client
137+
payloads services.PayloadStore
138+
webhooks services.WebhookDispatcher
139+
eventBus *events.ExecutionEventBus
140+
timeout time.Duration
141+
internalToken string // sent as Authorization header when forwarding to agents
142+
readARDConfig func() config.ARDConfig
143+
redactPayloads bool
143144
}
144145

145146
type asyncExecutionJob struct {
@@ -166,8 +167,19 @@ var (
166167

167168
completionOnce sync.Once
168169
completionQueue chan completionJob
170+
171+
// defaultRedactPayloads controls whether execution input/output data is
172+
// excluded from internal event bus payloads. Set at server startup from
173+
// config.Logging.ShouldRedactPayloads(). Default true (safe).
174+
defaultRedactPayloads = true
169175
)
170176

177+
// SetRedactPayloads configures the package-level default for payload redaction.
178+
// Call this once at server startup after loading config.
179+
func SetRedactPayloads(redact bool) {
180+
defaultRedactPayloads = redact
181+
}
182+
171183
const (
172184
maxWebhookHeaders = 20
173185
maxWebhookHeaderLength = 512
@@ -225,12 +237,13 @@ func newExecutionController(store ExecutionStore, payloads services.PayloadStore
225237
httpClient: &http.Client{
226238
Timeout: timeout,
227239
},
228-
payloads: payloads,
229-
webhooks: webhooks,
230-
eventBus: store.GetExecutionEventBus(),
231-
timeout: timeout,
232-
internalToken: internalToken,
233-
readARDConfig: ardConfigReader,
240+
payloads: payloads,
241+
webhooks: webhooks,
242+
eventBus: store.GetExecutionEventBus(),
243+
timeout: timeout,
244+
internalToken: internalToken,
245+
readARDConfig: ardConfigReader,
246+
redactPayloads: defaultRedactPayloads,
234247
}
235248
}
236249

@@ -953,15 +966,17 @@ func (c *executionController) handleStatusUpdate(ctx *gin.Context) {
953966
}
954967

955968
eventData := map[string]interface{}{
956-
"result": req.Result,
957969
"error": req.Error,
958970
"progress": req.Progress,
959971
}
960972
if req.StatusReason != nil && strings.TrimSpace(*req.StatusReason) != "" {
961973
eventData["status_reason"] = strings.TrimSpace(*req.StatusReason)
962974
}
963-
if inputPayload := decodeJSON(updated.InputPayload); inputPayload != nil {
964-
eventData["input"] = inputPayload
975+
if !c.redactPayloads {
976+
eventData["result"] = req.Result
977+
if inputPayload := decodeJSON(updated.InputPayload); inputPayload != nil {
978+
eventData["input"] = inputPayload
979+
}
965980
}
966981
c.publishExecutionEvent(updated, normalizedStatus, eventData)
967982

@@ -1076,7 +1091,7 @@ func (c *executionController) publishExecutionEventWithReasonerInfo(exec *types.
10761091
data["actor_id"] = *exec.ActorID
10771092
}
10781093
storedPayload := types.DecodeStoredExecutionPayload(exec.InputPayload)
1079-
if storedPayload.Context != nil {
1094+
if !c.redactPayloads && storedPayload.Context != nil {
10801095
data["context"] = storedPayload.Context
10811096
}
10821097
if workflowExec, err := c.store.GetWorkflowExecution(context.Background(), exec.ExecutionID); err == nil && workflowExec != nil {
@@ -1694,10 +1709,12 @@ func (c *executionController) completeReplayHit(ctx context.Context, plan *prepa
16941709
"source_execution_id": plan.replayHit.SourceExecutionID,
16951710
"source_run_id": plan.replayHit.SourceRunID,
16961711
},
1697-
"result": decodeJSON(result),
16981712
}
1699-
if inputPayload := decodeJSON(plan.exec.InputPayload); inputPayload != nil {
1700-
eventData["input"] = inputPayload
1713+
if !c.redactPayloads {
1714+
eventData["result"] = decodeJSON(result)
1715+
if inputPayload := decodeJSON(plan.exec.InputPayload); inputPayload != nil {
1716+
eventData["input"] = inputPayload
1717+
}
17011718
}
17021719
c.publishExecutionEventWithReasonerInfo(updated, string(types.ExecutionStatusSucceeded), eventData, plan.agent, &plan.target.TargetName)
17031720
return nil
@@ -1871,11 +1888,13 @@ func (c *executionController) completeExecution(ctx context.Context, plan *prepa
18711888
c.triggerWebhook(plan.exec.ExecutionID)
18721889
}
18731890
eventData := map[string]interface{}{}
1874-
if payload := decodeJSON(result); payload != nil {
1875-
eventData["result"] = payload
1876-
}
1877-
if inputPayload := decodeJSON(plan.exec.InputPayload); inputPayload != nil {
1878-
eventData["input"] = inputPayload
1891+
if !c.redactPayloads {
1892+
if payload := decodeJSON(result); payload != nil {
1893+
eventData["result"] = payload
1894+
}
1895+
if inputPayload := decodeJSON(plan.exec.InputPayload); inputPayload != nil {
1896+
eventData["input"] = inputPayload
1897+
}
18791898
}
18801899
c.publishExecutionEventWithReasonerInfo(updated, string(types.ExecutionStatusSucceeded), eventData, plan.agent, &plan.target.TargetName)
18811900
return nil
@@ -1955,11 +1974,13 @@ func (c *executionController) failExecution(ctx context.Context, plan *preparedE
19551974
eventData := map[string]interface{}{
19561975
"error": errMsg,
19571976
}
1958-
if payload := decodeJSON(result); payload != nil {
1959-
eventData["result"] = payload
1960-
}
1961-
if inputPayload := decodeJSON(plan.exec.InputPayload); inputPayload != nil {
1962-
eventData["input"] = inputPayload
1977+
if !c.redactPayloads {
1978+
if payload := decodeJSON(result); payload != nil {
1979+
eventData["result"] = payload
1980+
}
1981+
if inputPayload := decodeJSON(plan.exec.InputPayload); inputPayload != nil {
1982+
eventData["input"] = inputPayload
1983+
}
19631984
}
19641985
c.publishExecutionEventWithReasonerInfo(updated, string(types.ExecutionStatusFailed), eventData, plan.agent, &plan.target.TargetName)
19651986
return nil

0 commit comments

Comments
 (0)