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
8 changes: 6 additions & 2 deletions framework/logstore/clickhousestore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions framework/logstore/hybrid.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
8 changes: 7 additions & 1 deletion framework/logstore/hybrid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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",
},
Expand All @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions framework/logstore/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions framework/logstore/migrations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions framework/logstore/payload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -410,6 +411,7 @@ func MergeMCPToolLogPayloadFromJSON(l *MCPToolLog, data []byte) error {
*l = payload
l.HasObject = hasObject
l.VirtualKey = virtualKey
l.RedactionMapping = redactionMapping
return nil
}

Expand Down
24 changes: 23 additions & 1 deletion framework/logstore/payload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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) {
Expand Down
10 changes: 9 additions & 1 deletion framework/logstore/rdb_perf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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",
},
Expand All @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions framework/logstore/tables.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 27 additions & 5 deletions plugins/logging/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions plugins/logging/operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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{
Expand All @@ -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)
Expand Down
Loading
Loading