diff --git a/framework/logstore/clickhousestore_test.go b/framework/logstore/clickhousestore_test.go index 92ed7ae8d14..e27e799fad2 100644 --- a/framework/logstore/clickhousestore_test.go +++ b/framework/logstore/clickhousestore_test.go @@ -549,24 +549,28 @@ func TestClickHouseMCPToolLogs(t *testing.T) { chTestMCPToolLog("ch-mcp-1", ts), chTestMCPToolLog("ch-mcp-2", ts.Add(time.Millisecond)), } + entries[0].RedactionMapping = `plain:{"input":{"EMAIL-1":"private@example.com"}}` require.NoError(t, store.BatchCreateMCPToolLogsIfNotExists(ctx, entries)) require.NoError(t, store.BatchCreateMCPToolLogsIfNotExists(ctx, nil)) // no-op found, err := store.FindMCPToolLog(ctx, "ch-mcp-1") require.NoError(t, err) assert.Equal(t, "search_web", found.ToolName) + assert.Equal(t, entries[0].RedactionMapping, found.RedactionMapping) // Map update. latency := 42.0 require.NoError(t, store.UpdateMCPToolLog(ctx, "ch-mcp-1", map[string]interface{}{ - "status": "success", - "latency": latency, + "status": "success", + "latency": latency, + "redaction_mapping": `plain:{"output":{"EMAIL-2":"result@example.com"}}`, })) found, err = store.FindMCPToolLog(ctx, "ch-mcp-1") require.NoError(t, err) assert.Equal(t, "success", found.Status) require.NotNil(t, found.Latency) assert.Equal(t, 42.0, *found.Latency) + assert.Contains(t, found.RedactionMapping, "result@example.com") assert.Equal(t, int64(1), chCountRows(t, store.db, "mcp_tool_logs", "ch-mcp-1")) // Struct update preserves untouched fields and the dedup key. diff --git a/framework/logstore/hybrid.go b/framework/logstore/hybrid.go index efea9913b06..ae6ac8030b6 100644 --- a/framework/logstore/hybrid.go +++ b/framework/logstore/hybrid.go @@ -1156,6 +1156,10 @@ func applyMCPToolLogUpdateMap(target *MCPToolLog, updates map[string]interface{} target.Metadata = v target.MetadataParsed = nil } + case "redaction_mapping": + if v, ok := value.(string); ok { + target.RedactionMapping = v + } case "latency": if v, ok := numericToFloat64(value); ok { target.Latency = &v @@ -1221,6 +1225,9 @@ func applyMCPToolLogUpdateStruct(target *MCPToolLog, update *MCPToolLog) error { target.Metadata = update.Metadata target.MetadataParsed = nil } + if update.RedactionMapping != "" { + target.RedactionMapping = update.RedactionMapping + } if !update.CreatedAt.IsZero() { target.CreatedAt = update.CreatedAt } @@ -1302,6 +1309,9 @@ func prepareMCPToolLogDBUpdatesFromStruct(update MCPToolLog) (map[string]any, er if update.Metadata != "" { out["metadata"] = update.Metadata } + if update.RedactionMapping != "" { + out["redaction_mapping"] = update.RedactionMapping + } if !update.CreatedAt.IsZero() { out["created_at"] = update.CreatedAt } diff --git a/framework/logstore/hybrid_test.go b/framework/logstore/hybrid_test.go index 5269c72102a..07f177d4bd7 100644 --- a/framework/logstore/hybrid_test.go +++ b/framework/logstore/hybrid_test.go @@ -267,6 +267,7 @@ func TestHybrid_CreateAndFindMCPToolLog(t *testing.T) { ResultParsed: map[string]any{ "ok": true, }, + RedactionMapping: `plain:{"input":{"EMAIL-1":"private@example.com"}}`, } require.NoError(t, hybrid.CreateMCPToolLog(ctx, entry)) @@ -277,6 +278,7 @@ func TestHybrid_CreateAndFindMCPToolLog(t *testing.T) { assert.True(t, dbOnly.HasObject) assert.Empty(t, dbOnly.Result) assert.Nil(t, dbOnly.ResultParsed) + assert.Equal(t, entry.RedactionMapping, dbOnly.RedactionMapping) preview, ok := dbOnly.ArgumentsParsed.(string) require.True(t, ok) assert.Len(t, []rune(preview), 200) @@ -286,6 +288,7 @@ func TestHybrid_CreateAndFindMCPToolLog(t *testing.T) { assert.True(t, found.HasObject) assert.Equal(t, longInput, found.ArgumentsParsed.(map[string]interface{})["input"]) assert.Equal(t, true, found.ResultParsed.(map[string]interface{})["ok"]) + assert.Equal(t, entry.RedactionMapping, found.RedactionMapping) } func TestHybrid_BatchCreateMCPToolLogsIfNotExists(t *testing.T) { @@ -379,7 +382,8 @@ func TestHybrid_UpdateMCPToolLogOffloadsFullLog(t *testing.T) { waitForUploads(t, func() bool { return objStore.Len() == 1 }) require.NoError(t, hybrid.UpdateMCPToolLog(ctx, entry.ID, MCPToolLog{ - Status: "success", + Status: "success", + RedactionMapping: `plain:{"output":{"EMAIL-2":"result@example.com"}}`, ResultParsed: map[string]any{ "answer": "done", }, @@ -400,11 +404,13 @@ func TestHybrid_UpdateMCPToolLogOffloadsFullLog(t *testing.T) { assert.Equal(t, "success", dbOnly.Status) assert.Empty(t, dbOnly.Result) assert.Nil(t, dbOnly.ResultParsed) + assert.Contains(t, dbOnly.RedactionMapping, "result@example.com") found, err := hybrid.FindMCPToolLog(ctx, entry.ID) require.NoError(t, err) assert.Equal(t, "find this", found.ArgumentsParsed.(map[string]interface{})["query"]) assert.Equal(t, "done", found.ResultParsed.(map[string]interface{})["answer"]) + assert.Equal(t, dbOnly.RedactionMapping, found.RedactionMapping) } func TestHybrid_UpdateMCPToolLogRequiresObjectHydration(t *testing.T) { diff --git a/framework/logstore/migrations.go b/framework/logstore/migrations.go index 92590a87662..30977b3c2b0 100644 --- a/framework/logstore/migrations.go +++ b/framework/logstore/migrations.go @@ -274,6 +274,7 @@ var logstoreMigrationSteps = []migrationStep{ {IDs: []string{"logs_recreate_filter_customers_matview_multivalue"}, run: migrationRecreateFilterCustomersMatView}, {IDs: []string{"logs_add_canonical_model_columns_v2"}, run: migrationAddCanonicalModelColumns}, {IDs: []string{"logs_add_redaction_mapping_column"}, run: migrationAddRedactionMappingColumn}, + {IDs: []string{"mcp_tool_logs_add_redaction_mapping_column"}, run: migrationAddMCPRedactionMappingColumn}, {IDs: []string{"webhook_deliveries_init"}, run: migrationCreateWebhookDeliveriesTable}, {IDs: []string{"async_jobs_add_webhook_endpoint_id_column"}, run: migrationAddWebhookEndpointIDColumn}, {IDs: []string{"async_jobs_add_request_id_column"}, run: migrationAddAsyncJobRequestIDColumn}, @@ -3593,6 +3594,31 @@ func migrationAddRedactionMappingColumn(ctx context.Context, db *gorm.DB, logger return nil } +// migrationAddMCPRedactionMappingColumn adds the reversible redaction mapping +// column to MCP tool logs while keeping its lifecycle coupled to the log row. +func migrationAddMCPRedactionMappingColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error { + migrationName := "mcp_tool_logs_add_redaction_mapping_column" + logger.Info("[logstore] starting migration %s", migrationName) + defer logger.Info("[logstore] finished migration %s", migrationName) + opts := *migrator.DefaultOptions + opts.UseTransaction = true + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: migrationName, + Migrate: func(tx *gorm.DB) error { + return addColumnIfNotExists(tx.WithContext(ctx), logger, &MCPToolLog{}, "redaction_mapping") + }, + Rollback: func(*gorm.DB) error { + // No-op rollback: dropping the column would permanently destroy + // reveal data for already-redacted MCP logs. + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while adding MCP redaction_mapping column: %s", err.Error()) + } + return nil +} + // migrationAddSafeJsonbFunction installs a PL/pgSQL helper that the // /api/logs list query uses to extract the last element of input_history / // responses_input_history without aborting the whole query on a single bad row. diff --git a/framework/logstore/migrations_test.go b/framework/logstore/migrations_test.go index 0ecdc815b7c..26bb5d46369 100644 --- a/framework/logstore/migrations_test.go +++ b/framework/logstore/migrations_test.go @@ -3,16 +3,35 @@ package logstore import ( "context" "fmt" + "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) +// TestMigrationAddMCPRedactionMappingColumn verifies the MCP mapping column is additive, idempotent, and preserves existing rows. +func TestMigrationAddMCPRedactionMappingColumn(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "migrations.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.Exec("CREATE TABLE mcp_tool_logs (id TEXT PRIMARY KEY)").Error) + require.NoError(t, db.Exec("INSERT INTO mcp_tool_logs (id) VALUES (?)", "mcp-existing").Error) + + ctx := context.Background() + require.NoError(t, migrationAddMCPRedactionMappingColumn(ctx, db, testLogger{})) + require.True(t, db.Migrator().HasColumn(&MCPToolLog{}, "RedactionMapping")) + require.NoError(t, migrationAddMCPRedactionMappingColumn(ctx, db, testLogger{})) + + var count int64 + require.NoError(t, db.Table("mcp_tool_logs").Where("id = ?", "mcp-existing").Count(&count).Error) + assert.Equal(t, int64(1), count) +} + // pgTestSchema is this package's dedicated Postgres schema. Test packages // (configstore, configstore/tables, logstore) run in parallel against the same // database, so each one works in its own schema to avoid clobbering the diff --git a/framework/logstore/payload.go b/framework/logstore/payload.go index c434bcb3d47..cd5568f3d88 100644 --- a/framework/logstore/payload.go +++ b/framework/logstore/payload.go @@ -399,6 +399,7 @@ func MarshalMCPToolLogPayload(l *MCPToolLog) ([]byte, error) { func MergeMCPToolLogPayloadFromJSON(l *MCPToolLog, data []byte) error { hasObject := l.HasObject virtualKey := l.VirtualKey + redactionMapping := l.RedactionMapping var payload MCPToolLog if err := sonic.Unmarshal(data, &payload); err != nil { @@ -410,6 +411,7 @@ func MergeMCPToolLogPayloadFromJSON(l *MCPToolLog, data []byte) error { *l = payload l.HasObject = hasObject l.VirtualKey = virtualKey + l.RedactionMapping = redactionMapping return nil } diff --git a/framework/logstore/payload_test.go b/framework/logstore/payload_test.go index b73e6dd71a1..ae9a8ca78cc 100644 --- a/framework/logstore/payload_test.go +++ b/framework/logstore/payload_test.go @@ -206,12 +206,17 @@ func TestMCPToolLogPayload_RoundTripFullLog(t *testing.T) { MetadataParsed: map[string]interface{}{ "trace": "abc", }, + RedactionData: &schemas.RedactionData{ + ReversibleMappings: schemas.RedactionMapsByPhase{Input: map[string]string{"EMAIL-1": "private@example.com"}}, + }, + RedactionMapping: `plain:{"input":{"EMAIL-1":"private@example.com"}}`, } data, err := MarshalMCPToolLogPayload(entry) require.NoError(t, err) + assert.NotContains(t, string(data), "private@example.com") - dbEntry := &MCPToolLog{HasObject: true} + dbEntry := &MCPToolLog{HasObject: true, RedactionMapping: entry.RedactionMapping} err = MergeMCPToolLogPayloadFromJSON(dbEntry, data) require.NoError(t, err) @@ -225,6 +230,23 @@ func TestMCPToolLogPayload_RoundTripFullLog(t *testing.T) { assert.Equal(t, true, dbEntry.ResultParsed.(map[string]interface{})["ok"]) assert.Equal(t, "stored for round trip", dbEntry.ErrorDetailsParsed.Error.Message) assert.Equal(t, "abc", dbEntry.MetadataParsed["trace"]) + assert.Equal(t, entry.RedactionMapping, dbEntry.RedactionMapping) + assert.Nil(t, dbEntry.RedactionData) +} + +// TestMCPToolLogRedactionMappingJSONVisibility verifies only the authorized virtual mapping is API-visible. +func TestMCPToolLogRedactionMappingJSONVisibility(t *testing.T) { + entry := &MCPToolLog{ + RedactionMapping: `plain:{"input":{"EMAIL-1":"private@example.com"}}`, + RevealRedactionMapping: &schemas.RedactionMapsByPhase{ + Input: map[string]string{"EMAIL-1": "revealed@example.com"}, + }, + } + + data, err := sonic.Marshal(entry) + require.NoError(t, err) + assert.NotContains(t, string(data), "private@example.com") + assert.Contains(t, string(data), `"redaction_mapping":{"input":{"EMAIL-1":"revealed@example.com"}}`) } func TestPrepareMCPToolDBEntry_KeepsOnlyInputPreview(t *testing.T) { diff --git a/framework/logstore/rdb_perf_test.go b/framework/logstore/rdb_perf_test.go index c54ea67ab1c..58bc8fcecec 100644 --- a/framework/logstore/rdb_perf_test.go +++ b/framework/logstore/rdb_perf_test.go @@ -474,6 +474,7 @@ func TestMCPToolLogCreateSerializesFields(t *testing.T) { ResultParsed: map[string]any{ "ok": true, }, + RedactionMapping: `plain:{"input":{"EMAIL-1":"private@example.com"}}`, } if err := store.CreateMCPToolLog(context.Background(), entry); err != nil { @@ -490,6 +491,9 @@ func TestMCPToolLogCreateSerializesFields(t *testing.T) { if logEntry.Result == "" { t.Fatalf("expected Result to be serialized") } + if logEntry.RedactionMapping != entry.RedactionMapping { + t.Fatalf("RedactionMapping = %q, want %q", logEntry.RedactionMapping, entry.RedactionMapping) + } } func TestBuildBulkUpdateCostPostgresSQL(t *testing.T) { @@ -574,7 +578,8 @@ func TestUpdateMCPToolLogSerializesStructEntry(t *testing.T) { } if err := store.UpdateMCPToolLog(context.Background(), entry.ID, MCPToolLog{ - Status: "success", + Status: "success", + RedactionMapping: `plain:{"output":{"EMAIL-2":"result@example.com"}}`, ResultParsed: map[string]any{ "message": "done", }, @@ -589,6 +594,9 @@ func TestUpdateMCPToolLogSerializesStructEntry(t *testing.T) { if logEntry.Result == "" { t.Fatalf("expected Result to be serialized on UpdateMCPToolLog") } + if logEntry.RedactionMapping == "" { + t.Fatal("expected RedactionMapping to be updated") + } } func TestBulkUpdateCostSQLiteFallback(t *testing.T) { diff --git a/framework/logstore/tables.go b/framework/logstore/tables.go index 4167279f7fe..574ce79a033 100644 --- a/framework/logstore/tables.go +++ b/framework/logstore/tables.go @@ -1081,6 +1081,10 @@ type MCPToolLog struct { HasObject bool `gorm:"default:false" json:"-"` // True when payload is stored in object storage CreatedAt time.Time `gorm:"index;not null" json:"created_at"` + RedactionData *schemas.RedactionData `gorm:"-" json:"-"` // Transient guardrail redaction data consumed by enterprise logstore wrappers + RedactionMapping string `gorm:"type:text" json:"-"` // Reversible redaction mapping written by enterprise logstore wrappers; deleted with the row + RevealRedactionMapping *schemas.RedactionMapsByPhase `gorm:"-" json:"redaction_mapping,omitempty"` // Virtual field populated only on permitted MCP log-detail reads + // Endpoint-agent context. These are populated for tool calls observed on a // developer machine by the Bifrost Edge agent (rather than proxied by the // gateway). Source distinguishes the origin: empty/null for gateway-proxied diff --git a/plugins/logging/main.go b/plugins/logging/main.go index 3632926a5c2..c2763dfdab3 100644 --- a/plugins/logging/main.go +++ b/plugins/logging/main.go @@ -108,14 +108,35 @@ func applyLargePayloadPreviewsToEntry(ctx *schemas.BifrostContext, entry *logsto } } -// attachLogRedactionData copies guardrail redaction data into the log entry for async writers. -func attachLogRedactionData(ctx *schemas.BifrostContext, entry *logstore.Log, contentLoggingEnabled bool) { - if ctx == nil || entry == nil || !contentLoggingEnabled { - return +// redactionDataForLogging returns an owned request snapshot for asynchronous log writers. +func redactionDataForLogging(ctx *schemas.BifrostContext, contentLoggingEnabled bool) *schemas.RedactionData { + if ctx == nil || !contentLoggingEnabled { + return nil } if data, ok := schemas.RedactionDataFromContext(ctx); ok { snapshot := data.Clone() - entry.RedactionData = &snapshot + return &snapshot + } + return nil +} + +// attachLogRedactionData copies guardrail redaction data into an LLM log entry. +func attachLogRedactionData(ctx *schemas.BifrostContext, entry *logstore.Log, contentLoggingEnabled bool) { + if entry == nil { + return + } + if snapshot := redactionDataForLogging(ctx, contentLoggingEnabled); snapshot != nil { + entry.RedactionData = snapshot + } +} + +// attachMCPLogRedactionData copies guardrail redaction data into an MCP tool log entry. +func attachMCPLogRedactionData(ctx *schemas.BifrostContext, entry *logstore.MCPToolLog, contentLoggingEnabled bool) { + if entry == nil { + return + } + if snapshot := redactionDataForLogging(ctx, contentLoggingEnabled); snapshot != nil { + entry.RedactionData = snapshot } } @@ -1888,6 +1909,7 @@ func (p *LoggerPlugin) PostMCPHook(ctx *schemas.BifrostContext, resp *schemas.Bi p.mu.Lock() callback := p.mcpToolLogCallback p.mu.Unlock() + attachMCPLogRedactionData(ctx, entry, p.contentLoggingEnabled(ctx)) p.enqueueMCPToolLogEntry(entry, callback) return resp, bifrostErr, nil diff --git a/plugins/logging/operations_test.go b/plugins/logging/operations_test.go index 4e251f00a13..88469caaf78 100644 --- a/plugins/logging/operations_test.go +++ b/plugins/logging/operations_test.go @@ -1048,6 +1048,12 @@ func TestMCPHooksDeferDBWriteUntilPostHookBatch(t *testing.T) { ctx.SetValue(schemas.BifrostContextKeyGovernanceTeamID, "team-1") ctx.SetValue(schemas.BifrostContextKeyGovernanceCustomerID, "customer-1") ctx.SetValue(schemas.BifrostContextKeyGovernanceBusinessUnitID, "bu-1") + schemas.SetRedactionDataOnContext(ctx, schemas.RedactionData{ + ReversibleMappings: schemas.RedactionMapsByPhase{ + Input: map[string]string{"EMAIL-1": "private@example.com"}, + Output: map[string]string{"EMAIL-2": "result@example.com"}, + }, + }) toolName := "docs-search" _, _, err = plugin.PreMCPHook(ctx, &schemas.BifrostMCPRequest{ @@ -1066,6 +1072,17 @@ func TestMCPHooksDeferDBWriteUntilPostHookBatch(t *testing.T) { if _, err := store.FindMCPToolLog(context.Background(), "mcp-batch-flow"); !errors.Is(err, logstore.ErrNotFound) { t.Fatalf("expected MCP log to stay in memory before PostMCPHook, got err=%v", err) } + pendingValue, ok := plugin.pendingMCPLogsToInject.Load("mcp-batch-flow") + if !ok { + t.Fatal("expected pending MCP log entry") + } + pendingEntry, ok := pendingValue.(*logstore.MCPToolLog) + if !ok { + t.Fatalf("pending MCP log entry has type %T", pendingValue) + } + if pendingEntry.RedactionData != nil { + t.Fatal("expected redaction data to be attached only after PostMCPHook") + } result := `{"answer":"done"}` _, _, err = plugin.PostMCPHook(ctx, &schemas.BifrostMCPResponse{ @@ -1083,6 +1100,12 @@ func TestMCPHooksDeferDBWriteUntilPostHookBatch(t *testing.T) { if err != nil { t.Fatalf("PostMCPHook() error = %v", err) } + if pendingEntry.RedactionData == nil { + t.Fatal("expected PostMCPHook to attach redaction data") + } + if got := pendingEntry.RedactionData.ReversibleMappings.Output["EMAIL-2"]; got != "result@example.com" { + t.Fatalf("output redaction mapping = %q, want %q", got, "result@example.com") + } if err := plugin.Cleanup(); err != nil { t.Fatalf("Cleanup() error = %v", err) diff --git a/plugins/logging/redaction_test.go b/plugins/logging/redaction_test.go index bce4d4eb86a..72c600de047 100644 --- a/plugins/logging/redaction_test.go +++ b/plugins/logging/redaction_test.go @@ -85,3 +85,34 @@ func TestAttachLogRedactionDataIgnoresMissingContext(t *testing.T) { assert.Nil(t, entry.RedactionData) } + +// TestAttachMCPLogRedactionDataCopiesContextValue verifies MCP entries receive an owned redaction snapshot. +func TestAttachMCPLogRedactionDataCopiesContextValue(t *testing.T) { + ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) + reversibleMappings := map[string]string{"EMAIL-1": "alex_rivera@gmail.com"} + schemas.SetRedactionDataOnContext(ctx, schemas.RedactionData{ + ReversibleMappings: schemas.RedactionMapsByPhase{Input: reversibleMappings}, + }) + entry := &logstore.MCPToolLog{} + + attachMCPLogRedactionData(ctx, entry, true) + reversibleMappings["EMAIL-1"] = "mutated@example.com" + + require.NotNil(t, entry.RedactionData) + assert.Equal(t, "alex_rivera@gmail.com", entry.RedactionData.ReversibleMappings.Input["EMAIL-1"]) +} + +// TestAttachMCPLogRedactionDataSkipsUnavailableContent verifies disabled logging and missing inputs never attach sensitive data. +func TestAttachMCPLogRedactionDataSkipsUnavailableContent(t *testing.T) { + ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) + schemas.SetRedactionDataOnContext(ctx, schemas.RedactionData{ + ReversibleMappings: schemas.RedactionMapsByPhase{Input: map[string]string{"EMAIL-1": "alex_rivera@gmail.com"}}, + }) + entry := &logstore.MCPToolLog{} + + attachMCPLogRedactionData(ctx, entry, false) + attachMCPLogRedactionData(nil, entry, true) + attachMCPLogRedactionData(ctx, nil, true) + + assert.Nil(t, entry.RedactionData) +} diff --git a/transports/bifrost-http/handlers/logging.go b/transports/bifrost-http/handlers/logging.go index 86bb0e40791..638c0582968 100644 --- a/transports/bifrost-http/handlers/logging.go +++ b/transports/bifrost-http/handlers/logging.go @@ -29,10 +29,11 @@ import ( // LoggingHandler manages HTTP requests for logging operations type LoggingHandler struct { - logManager logging.LogManager - redactedKeysManager RedactedKeysManager - config *lib.Config - logRedactionMappingResolver LogRedactionMappingResolver + logManager logging.LogManager + redactedKeysManager RedactedKeysManager + config *lib.Config + logRedactionMappingResolver LogRedactionMappingResolver + mcpLogRedactionMappingResolver MCPLogRedactionMappingResolver // filterDataCache memoizes /api/logs/filterdata response bodies. Filter // dropdowns don't need request-fresh data and the underlying matview-backed @@ -331,6 +332,14 @@ type LogRedactionMappingResolver interface { ResolveLogRedactionMapping(ctx *fasthttp.RequestCtx, log *logstore.Log) (*schemas.RedactionMapsByPhase, error) } +// MCPLogRedactionMappingResolver optionally exposes decoded redaction mappings on MCP log-detail responses. +type MCPLogRedactionMappingResolver interface { + // ResolveMCPLogRedactionMapping returns phase-scoped placeholder-to-original mappings when the caller may reveal them. + // Implementations should return nil, nil when the caller is not authorized or no mapping is available. + // Errors are treated as reveal-data failures only; the base MCP log detail response is still served. + ResolveMCPLogRedactionMapping(ctx *fasthttp.RequestCtx, log *logstore.MCPToolLog) (*schemas.RedactionMapsByPhase, error) +} + // NewLoggingHandler creates a new logging handler instance func NewLoggingHandler(logManager logging.LogManager, redactedKeysManager RedactedKeysManager, config *lib.Config) *LoggingHandler { return &LoggingHandler{ @@ -345,6 +354,11 @@ func (h *LoggingHandler) SetLogRedactionMappingResolver(resolver LogRedactionMap h.logRedactionMappingResolver = resolver } +// SetMCPLogRedactionMappingResolver wires the optional resolver used by Enterprise MCP log-detail reads. +func (h *LoggingHandler) SetMCPLogRedactionMappingResolver(resolver MCPLogRedactionMappingResolver) { + h.mcpLogRedactionMappingResolver = resolver +} + func (h *LoggingHandler) shouldHideDeletedVirtualKeysInFilters() bool { if h == nil || h.config == nil { return false @@ -2603,6 +2617,14 @@ func (h *LoggingHandler) getMCPLogByID(ctx *fasthttp.RequestCtx) { SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to get MCP log: %v", err)) return } + if h.mcpLogRedactionMappingResolver != nil && log.RedactionMapping != "" { + mapping, resolveErr := h.mcpLogRedactionMappingResolver.ResolveMCPLogRedactionMapping(ctx, log) + if resolveErr != nil { + logger.Error("failed to resolve redaction mapping for MCP log %s: %v", id, resolveErr) + } else if mapping != nil && mapping.HasReplacements() { + log.RevealRedactionMapping = mapping + } + } if log.VirtualKeyID != nil && log.VirtualKeyName != nil && *log.VirtualKeyID != "" && *log.VirtualKeyName != "" { redactedVirtualKeys := h.redactedKeysManager.GetAllRedactedVirtualKeys(ctx, []string{*log.VirtualKeyID}) diff --git a/transports/bifrost-http/handlers/logging_test.go b/transports/bifrost-http/handlers/logging_test.go index ca389aed5ad..e4ab727fb1e 100644 --- a/transports/bifrost-http/handlers/logging_test.go +++ b/transports/bifrost-http/handlers/logging_test.go @@ -1,6 +1,7 @@ package handlers import ( + "bytes" "context" "encoding/json" "errors" @@ -50,6 +51,63 @@ func TestShouldUseFilterDataCacheRejectsScopedContext(t *testing.T) { } } +// TestGetMCPLogByIDRedactionMapping verifies raw mappings stay hidden and only resolver-approved mappings are returned. +func TestGetMCPLogByIDRedactionMapping(t *testing.T) { + SetLogger(&mockLogger{}) + revealed := &schemas.RedactionMapsByPhase{ + Input: map[string]string{"EMAIL-1": "revealed@example.com"}, + } + tests := []struct { + name string + resolver *staticMCPLogRedactionResolver + wantMapping bool + wantCalls int + }{ + {name: "no resolver"}, + {name: "authorized mapping", resolver: &staticMCPLogRedactionResolver{mapping: revealed}, wantMapping: true, wantCalls: 1}, + {name: "resolver error", resolver: &staticMCPLogRedactionResolver{err: errors.New("decode failed")}, wantCalls: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &dashboardLogManager{mcpLog: &logstore.MCPToolLog{ + ID: "mcp-1", + RedactionMapping: `plain:{"input":{"EMAIL-1":"private@example.com"}}`, + }} + handler := &LoggingHandler{logManager: manager} + if tt.resolver != nil { + handler.SetMCPLogRedactionMappingResolver(tt.resolver) + } + ctx := &fasthttp.RequestCtx{} + ctx.SetUserValue("id", "mcp-1") + + handler.getMCPLogByID(ctx) + + if ctx.Response.StatusCode() != fasthttp.StatusOK { + t.Fatalf("status = %d, want %d", ctx.Response.StatusCode(), fasthttp.StatusOK) + } + var response struct { + RedactionMapping *schemas.RedactionMapsByPhase `json:"redaction_mapping"` + } + if err := json.Unmarshal(ctx.Response.Body(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if bytes.Contains(ctx.Response.Body(), []byte("private@example.com")) { + t.Fatalf("raw persisted mapping leaked in response: %s", ctx.Response.Body()) + } + if tt.wantMapping != (response.RedactionMapping != nil) { + t.Fatalf("redaction mapping present = %t, want %t", response.RedactionMapping != nil, tt.wantMapping) + } + if tt.wantMapping && response.RedactionMapping.Input["EMAIL-1"] != "revealed@example.com" { + t.Fatalf("revealed mapping = %#v", response.RedactionMapping) + } + if tt.resolver != nil && tt.resolver.calls != tt.wantCalls { + t.Fatalf("resolver calls = %d, want %d", tt.resolver.calls, tt.wantCalls) + } + }) + } +} + // TestShouldCacheFilterDimensions_NarrowsToRawScans verifies the cache is spent // only where it saves real work. Matview-backed dimensions are indexed lookups // and a cache entry serves exactly one caller, so they are not worth caching; @@ -374,6 +432,7 @@ func (s *fakeSidekiqStore) ListClaimableSidekiqJobs(ctx context.Context, staleBe type dashboardLogManager struct { failStats bool + mcpLog *logstore.MCPToolLog lastLLMFilters logstore.SearchFilters lastMCPFilters logstore.MCPToolLogSearchFilters lastRecalculateFilters logstore.SearchFilters @@ -502,7 +561,11 @@ func (m *dashboardLogManager) RunCostRecalcJob(ctx context.Context, metaJSON str return metaJSON, nil } func (m *dashboardLogManager) GetMCPToolLog(ctx context.Context, id string) (*logstore.MCPToolLog, error) { - return nil, nil + if m.mcpLog == nil { + return nil, nil + } + entry := *m.mcpLog + return &entry, nil } func (m *dashboardLogManager) SearchMCPToolLogs(ctx context.Context, filters *logstore.MCPToolLogSearchFilters, pagination *logstore.PaginationOptions) (*logstore.MCPToolLogSearchResult, error) { return nil, nil @@ -532,6 +595,19 @@ func (m *dashboardLogManager) GetMCPTopTools(ctx context.Context, filters logsto func (m *dashboardLogManager) DeleteMCPToolLogs(ctx context.Context, ids []string) error { return nil } +// staticMCPLogRedactionResolver records calls and returns a configured reveal result. +type staticMCPLogRedactionResolver struct { + mapping *schemas.RedactionMapsByPhase + err error + calls int +} + +// ResolveMCPLogRedactionMapping returns the configured test result. +func (r *staticMCPLogRedactionResolver) ResolveMCPLogRedactionMapping(_ *fasthttp.RequestCtx, _ *logstore.MCPToolLog) (*schemas.RedactionMapsByPhase, error) { + r.calls++ + return r.mapping, r.err +} + func (m *dashboardLogManager) CreateUserAgentMapping(ctx context.Context, mapping *logstore.UserAgentMapping) (*logstore.UserAgentMapping, error) { return nil, nil } diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go index 784b445b379..c2faae81bc7 100644 --- a/transports/bifrost-http/server/server.go +++ b/transports/bifrost-http/server/server.go @@ -142,6 +142,12 @@ type LogRedactionMappingResolverProvider interface { GetLogRedactionMappingResolver() handlers.LogRedactionMappingResolver } +// MCPLogRedactionMappingResolverProvider is implemented by servers that can attach reveal data to MCP log-detail responses. +type MCPLogRedactionMappingResolverProvider interface { + // GetMCPLogRedactionMappingResolver returns the resolver used by the logging handler. + GetMCPLogRedactionMappingResolver() handlers.MCPLogRedactionMappingResolver +} + // BifrostHTTPServer represents a HTTP server instance. type BifrostHTTPServer struct { Ctx *schemas.BifrostContext @@ -1795,6 +1801,9 @@ func (s *BifrostHTTPServer) RegisterAPIRoutes(ctx context.Context, callbacks Ser if resolverProvider, ok := callbacks.(LogRedactionMappingResolverProvider); ok { loggingHandler.SetLogRedactionMappingResolver(resolverProvider.GetLogRedactionMappingResolver()) } + if resolverProvider, ok := callbacks.(MCPLogRedactionMappingResolverProvider); ok { + loggingHandler.SetMCPLogRedactionMappingResolver(resolverProvider.GetMCPLogRedactionMappingResolver()) + } // Wire the sidekiq runner so cost recalculation runs as a durable background // job. Registering the handler here (before RecoverIncomplete) lets a job // interrupted by a restart resume on boot. diff --git a/ui/app/workspace/logs/sheets/logDetailView.tsx b/ui/app/workspace/logs/sheets/logDetailView.tsx index 54ec70f67eb..4d1f7c792d3 100644 --- a/ui/app/workspace/logs/sheets/logDetailView.tsx +++ b/ui/app/workspace/logs/sheets/logDetailView.tsx @@ -42,6 +42,7 @@ import { useGetUserAgentMappingsQuery } from "@/lib/store"; import { cn } from "@/lib/utils"; import { downloadAsJson } from "@/lib/utils/browser-download"; import { formatCompactNumber } from "@/lib/utils/numbers"; +import { applyRedactionMapping, hasRedactionMappingEntries } from "@/lib/utils/redaction"; import { isJson } from "@/lib/utils/validation"; import { Link } from "@tanstack/react-router"; import { addMilliseconds, format } from "date-fns"; @@ -82,18 +83,6 @@ const getRealtimeTransportBadgeClass = (value: unknown): string => { } }; -const hasRedactionMappingEntries = (mapping?: LogEntry["redaction_mapping"]): boolean => - Boolean(mapping && (Object.keys(mapping.input ?? {}).length > 0 || Object.keys(mapping.output ?? {}).length > 0)); - -const applyRedactionMapping = (text: string | undefined, mapping?: Record): string => { - if (!text || !mapping) return text || ""; - let result = text; - for (const [key, value] of Object.entries(mapping)) { - result = result.replaceAll(`[${key}]`, value); - } - return result; -}; - const formatRealtimeSource = (value: unknown): string => { const source = String(value ?? "").trim(); switch (source.toLowerCase()) { diff --git a/ui/app/workspace/mcp-logs/page.tsx b/ui/app/workspace/mcp-logs/page.tsx index 5cd5df10b6b..a79176190c1 100644 --- a/ui/app/workspace/mcp-logs/page.tsx +++ b/ui/app/workspace/mcp-logs/page.tsx @@ -34,6 +34,7 @@ export default function MCPLogsPage() { const [showEmptyState, setShowEmptyState] = useState(false); const hasCheckedEmptyState = useRef(false); const hasDeleteAccess = useRbac(RbacResource.MCPLogs, RbacOperation.Delete); + const hasRevealAccess = useRbac(RbacResource.Logs, RbacOperation.Reveal); const [deleteLogs] = useDeleteMCPLogsMutation(); // Lazy query kept only for handleLogNavigate (fetches adjacent pages on demand) @@ -524,6 +525,7 @@ export default function MCPLogsPage() { open={selectedLogId !== null} onOpenChange={(open) => !open && setUrlState({ selected_log: "" }, { history: "replace" })} handleDelete={hasDeleteAccess ? handleDelete : undefined} + canReveal={hasRevealAccess} onNavigate={handleLogNavigate} hasPrev={selectedLogIndex > 0 || (selectedLogIndex !== -1 && pagination.offset > 0)} hasNext={selectedLogIndex !== -1 && (selectedLogIndex < logs.length - 1 || pagination.offset + pagination.limit < totalItems)} diff --git a/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx b/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx index 17fc7b5630e..ccda9d97278 100644 --- a/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx +++ b/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx @@ -20,16 +20,18 @@ import { } from "@/components/ui/dropdownMenu"; import { DottedSeparator } from "@/components/ui/separator"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { Switch } from "@/components/ui/switch"; import { Status, StatusColors, Statuses } from "@/lib/constants/logs"; import { useGetMCPLogByIdQuery } from "@/lib/store"; import type { MCPToolLogEntry } from "@/lib/types/logs"; import { downloadAsJson } from "@/lib/utils/browser-download"; +import { applyRedactionMappingToValue, hasRedactionMappingEntries, mergeRedactionMappings } from "@/lib/utils/redaction"; import { Link } from "@tanstack/react-router"; import { addMilliseconds, format, isValid } from "date-fns"; import { SheetNavigationButtons } from "@/components/sheetNavigationButtons"; import { useSheetNavigation } from "@/hooks/useSheetNavigation"; import { Download, Loader2, MoreVertical, Trash2 } from "lucide-react"; -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { toast } from "sonner"; interface MCPLogDetailSheetProps { @@ -37,6 +39,7 @@ interface MCPLogDetailSheetProps { open: boolean; onOpenChange: (open: boolean) => void; handleDelete?: (log: MCPToolLogEntry) => Promise; + canReveal?: boolean; onNavigate?: (direction: "prev" | "next") => void; hasPrev?: boolean; hasNext?: boolean; @@ -73,12 +76,14 @@ export function MCPLogDetailSheet({ open, onOpenChange, handleDelete, + canReveal = false, onNavigate, hasPrev = false, hasNext = false, }: MCPLogDetailSheetProps) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); + const [showRevealedValues, setShowRevealedValues] = useState(false); const { data: fullLog, isLoading, @@ -95,10 +100,20 @@ export function MCPLogDetailSheet({ onNavigate: (direction) => onNavigate?.(direction), }); - if (!log) return null; + const isFullDataReady = Boolean(log) && (isError || (fullLog?.id === log?.id && !isLoading)); + const displayLog = log ? (isFullDataReady && fullLog ? fullLog : log) : null; + const revealMapping = displayLog?.redaction_mapping; + const revealAvailable = canReveal && hasRedactionMappingEntries(revealMapping); + const revealEnabled = revealAvailable && showRevealedValues; + const inputRevealMapping = revealEnabled ? revealMapping?.input : undefined; + const outputRevealMapping = revealEnabled ? revealMapping?.output : undefined; + const mixedRevealMapping = revealEnabled ? mergeRedactionMappings(revealMapping) : undefined; - const isFullDataReady = isError || (fullLog?.id === log.id && !isLoading); - const displayLog = isFullDataReady && fullLog ? fullLog : log; + useEffect(() => { + setShowRevealedValues(false); + }, [displayLog?.id, revealAvailable]); + + if (!log || !displayLog) return null; if (!isFullDataReady) { return ( @@ -113,6 +128,10 @@ export function MCPLogDetailSheet({ ); } + const displayedArguments = applyRedactionMappingToValue(displayLog.arguments, inputRevealMapping); + const displayedResult = applyRedactionMappingToValue(displayLog.result, outputRevealMapping); + const displayedErrorDetails = applyRedactionMappingToValue(displayLog.error_details, mixedRevealMapping); + return ( @@ -133,6 +152,19 @@ export function MCPLogDetailSheet({ nextKeys={nextKeys} entityLabel="log" /> + {revealAvailable && ( +
+ + setShowRevealedValues(checked && revealAvailable)} + data-testid="mcplogdetails-reveal-toggle" + /> +
+ )} @@ -301,7 +333,7 @@ export function MCPLogDetailSheet({ {/* Arguments */} - {displayLog.arguments && ( + {displayedArguments && (
Arguments
, null, 2) - } + code={typeof displayedArguments === "string" ? displayedArguments : JSON.stringify(displayedArguments, null, 2)} lang="json" readonly={true} options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }} @@ -322,7 +350,7 @@ export function MCPLogDetailSheet({ )} {/* Result */} - {displayLog.result && displayLog.status !== "processing" && ( + {displayedResult && displayLog.status !== "processing" && (
Result
Error Details
; + output?: Record; +} + export interface LogEntry { id: string; object: string; // text.completion, chat.completion, embedding, audio.speech, audio.transcription @@ -586,10 +591,7 @@ export interface LogEntry { passthrough_request_body?: string; // Raw passthrough request body (UTF-8) passthrough_response_body?: string; // Raw passthrough response body (UTF-8) metadata?: Record; // JSON metadata (e.g., isAsyncRequest) - redaction_mapping?: { - input?: Record; - output?: Record; - }; // Phase-scoped placeholder-to-original mappings, present only when caller has Logs:Reveal + redaction_mapping?: RedactionMapping; // Phase-scoped placeholder-to-original mappings, present only when caller has Logs:Reveal user_agent?: string; // Raw HTTP User-Agent of the calling client app?: string; // Backend-detected client app } @@ -1141,6 +1143,7 @@ export interface MCPToolLogEntry { cost?: number; // Cost in dollars (per execution cost) status: string; // "processing", "success", or "error" metadata?: Record; + redaction_mapping?: RedactionMapping; // Present on detail responses only when the caller has Logs:Reveal created_at: string; // ISO string format virtual_key?: VirtualKey; user_agent?: string; // Raw HTTP User-Agent of the calling client diff --git a/ui/lib/utils/redaction.test.ts b/ui/lib/utils/redaction.test.ts new file mode 100644 index 00000000000..a0ff3bdd235 --- /dev/null +++ b/ui/lib/utils/redaction.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { applyRedactionMapping, applyRedactionMappingToValue, hasRedactionMappingEntries, mergeRedactionMappings } from "./redaction"; + +describe("redaction reveal helpers", () => { + it("requires at least one phase mapping", () => { + expect(hasRedactionMappingEntries()).toBe(false); + expect(hasRedactionMappingEntries({ input: {}, output: {} })).toBe(false); + expect(hasRedactionMappingEntries({ input: { "EMAIL-1": "private@example.com" } })).toBe(true); + }); + + it("reveals placeholders without mutating structured log data", () => { + const source = { owner: "[EMAIL-1]", nested: ["hello [NAME-1]"] }; + const revealed = applyRedactionMappingToValue(source, { + "EMAIL-1": "private@example.com", + "NAME-1": "Madhu", + }); + + expect(revealed).toEqual({ owner: "private@example.com", nested: ["hello Madhu"] }); + expect(source).toEqual({ owner: "[EMAIL-1]", nested: ["hello [NAME-1]"] }); + }); + + it("reveals each source placeholder once without reprocessing replacement text", () => { + expect(applyRedactionMapping("[A] [B]", { A: "[B]", B: "$&" })).toBe("[B] $&"); + }); + + it("leaves conflicting phase placeholders redacted in mixed fields", () => { + const merged = mergeRedactionMappings({ + input: { "SECRET-1": "input", "INPUT-ONLY": "request" }, + output: { "SECRET-1": "output", "OUTPUT-ONLY": "response" }, + }); + + expect(applyRedactionMapping("[SECRET-1] [INPUT-ONLY] [OUTPUT-ONLY]", merged)).toBe("[SECRET-1] request response"); + }); + + it("keeps identical phase mappings revealable", () => { + const merged = mergeRedactionMappings({ input: { "SECRET-1": "same" }, output: { "SECRET-1": "same" } }); + expect(applyRedactionMapping("[SECRET-1]", merged)).toBe("same"); + }); +}); \ No newline at end of file diff --git a/ui/lib/utils/redaction.ts b/ui/lib/utils/redaction.ts new file mode 100644 index 00000000000..6b7804b19b1 --- /dev/null +++ b/ui/lib/utils/redaction.ts @@ -0,0 +1,41 @@ +import type { RedactionMapping } from "@/lib/types/logs"; + +// hasRedactionMappingEntries reports whether a detail response contains anything the UI can reveal. +export function hasRedactionMappingEntries(mapping?: RedactionMapping): boolean { + return Boolean(mapping && (Object.keys(mapping.input ?? {}).length > 0 || Object.keys(mapping.output ?? {}).length > 0)); +} + +// applyRedactionMapping replaces reversible placeholders in display text without mutating source data. +export function applyRedactionMapping(text: string | undefined, mapping?: Record): string { + if (!text || !mapping) return text || ""; + return text.replace(/\[([^\]]+)\]/g, (placeholder, key: string) => + Object.prototype.hasOwnProperty.call(mapping, key) ? mapping[key] : placeholder, + ); +} + +// mergeRedactionMappings combines phase maps for fields, such as errors, that can contain input and output content. +export function mergeRedactionMappings(mapping?: RedactionMapping): Record | undefined { + if (!mapping) return undefined; + const merged = { ...mapping.input }; + for (const [key, value] of Object.entries(mapping.output ?? {})) { + if (Object.prototype.hasOwnProperty.call(merged, key) && merged[key] !== value) { + delete merged[key]; + continue; + } + merged[key] = value; + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + +// applyRedactionMappingToValue recursively reveals JSON-like display values while preserving the fetched object. +export function applyRedactionMappingToValue(value: T, mapping?: Record): T { + if (!mapping || value == null) return value; + if (typeof value === "string") return applyRedactionMapping(value, mapping) as T; + if (Array.isArray(value)) return value.map((item) => applyRedactionMappingToValue(item, mapping)) as T; + if (typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [applyRedactionMapping(key, mapping), applyRedactionMappingToValue(item, mapping)]), + ) as T; + } + return value; +} \ No newline at end of file