Skip to content
Open
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
147 changes: 147 additions & 0 deletions internal/adapter/claude/clauderecord/mask.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package clauderecord

import (
"encoding/json"
"fmt"
"strings"
)

// MaskOptions configures which values are replaced during masking.
type MaskOptions struct {
// WorkspaceCWD is the real CWD to replace with /test/workspace.
WorkspaceCWD string
// HomePath is the real home directory to replace with /test/home.
// If empty, it is inferred from WorkspaceCWD or ignored.
HomePath string
}

// MaskEntries returns a deep copy of entries with sensitive fields masked.
func MaskEntries(entries []Entry, opts MaskOptions) []Entry {
m := &masker{
opts: opts,
uuidMap: map[string]string{},
requestMap: map[string]string{},
}
out := make([]Entry, len(entries))
for i, e := range entries {
out[i] = Entry{
Timestamp: e.Timestamp,
Direction: e.Direction,
Seq: e.Seq,
Frame: m.maskFrame(e.Frame),
}
}
return out
}

type masker struct {
opts MaskOptions
uuidMap map[string]string
requestMap map[string]string
uuidSeq int
reqSeq int
}

func (m *masker) maskFrame(raw json.RawMessage) json.RawMessage {
var parsed any
if err := json.Unmarshal(raw, &parsed); err != nil {
return raw
}
masked := m.walkValue(parsed, "")
out, err := json.Marshal(masked)
if err != nil {
return raw
}
return out
}

func (m *masker) walkValue(v any, key string) any {
switch val := v.(type) {
case map[string]any:
return m.walkMap(val)
case []any:
return m.walkSlice(val)
case string:
return m.maskString(val, key)
default:
return v
}
}

func (m *masker) walkMap(obj map[string]any) map[string]any {
out := make(map[string]any, len(obj))
for k, v := range obj {
out[k] = m.walkValue(v, k)
}
return out
}

func (m *masker) walkSlice(arr []any) []any {
out := make([]any, len(arr))
for i, v := range arr {
out[i] = m.walkValue(v, "")
}
return out
}

func (m *masker) maskString(val string, key string) string {
// Known sensitive keys
switch key {
case "session_id":
return "masked-session-id"
case "uuid":
return m.mapUUID(val)
case "request_id":
return m.mapRequestID(val)
case "api_key", "apiKeySource":
return "REDACTED"
case "email":
return "masked@example.com"
case "organization":
return "masked-org"
}

// Path masking
val = m.maskPaths(val)
return val
}

func (m *masker) maskPaths(val string) string {
if m.opts.WorkspaceCWD != "" && strings.Contains(val, m.opts.WorkspaceCWD) {
val = strings.ReplaceAll(val, m.opts.WorkspaceCWD, "/test/workspace")
}
if m.opts.HomePath != "" && strings.Contains(val, m.opts.HomePath) {
val = strings.ReplaceAll(val, m.opts.HomePath, "/test/home")
}
return val
}

func (m *masker) mapUUID(original string) string {
if original == "" {
return ""
}
if mapped, ok := m.uuidMap[original]; ok {
return mapped
}
m.uuidSeq++
mapped := fmt.Sprintf("masked-uuid-%d", m.uuidSeq)
m.uuidMap[original] = mapped
return mapped
}

func (m *masker) mapRequestID(original string) string {
if original == "" {
return ""
}
// Keep relay-generated IDs (they're not sensitive and aid debugging)
if strings.HasPrefix(original, "relay-") {
return original
}
if mapped, ok := m.requestMap[original]; ok {
return mapped
}
m.reqSeq++
mapped := fmt.Sprintf("masked-req-%d", m.reqSeq)
m.requestMap[original] = mapped
return mapped
}
138 changes: 138 additions & 0 deletions internal/adapter/claude/clauderecord/mask_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package clauderecord

import (
"encoding/json"
"testing"
)

func TestMaskSessionID(t *testing.T) {
entries := []Entry{{
Seq: 0,
Direction: DirRecv,
Frame: json.RawMessage(`{"type":"system","session_id":"real-session-abc123","model":"claude-sonnet-4-20250514"}`),
}}
masked := MaskEntries(entries, MaskOptions{})
var frame map[string]any
if err := json.Unmarshal(masked[0].Frame, &frame); err != nil {
t.Fatal(err)
}
if frame["session_id"] != "masked-session-id" {
t.Errorf("session_id not masked: %v", frame["session_id"])
}
if frame["model"] != "claude-sonnet-4-20250514" {
t.Errorf("model should be preserved: %v", frame["model"])
}
}

func TestMaskUUID(t *testing.T) {
entries := []Entry{
{Seq: 0, Direction: DirRecv, Frame: json.RawMessage(`{"uuid":"uuid-aaa"}`)},
{Seq: 1, Direction: DirRecv, Frame: json.RawMessage(`{"uuid":"uuid-bbb"}`)},
{Seq: 2, Direction: DirRecv, Frame: json.RawMessage(`{"uuid":"uuid-aaa"}`)},
}
masked := MaskEntries(entries, MaskOptions{})

get := func(i int) string {
var f map[string]any
json.Unmarshal(masked[i].Frame, &f)
return f["uuid"].(string)
}
if get(0) != "masked-uuid-1" {
t.Errorf("first uuid: %s", get(0))
}
if get(1) != "masked-uuid-2" {
t.Errorf("second uuid: %s", get(1))
}
// Same original UUID gets same masked value
if get(2) != "masked-uuid-1" {
t.Errorf("repeated uuid: %s", get(2))
}
}

func TestMaskRequestID(t *testing.T) {
entries := []Entry{
{Seq: 0, Direction: DirRecv, Frame: json.RawMessage(`{"request_id":"cli-generated-id"}`)},
{Seq: 1, Direction: DirSend, Frame: json.RawMessage(`{"request_id":"relay-init-0"}`)},
}
masked := MaskEntries(entries, MaskOptions{})

get := func(i int) string {
var f map[string]any
json.Unmarshal(masked[i].Frame, &f)
return f["request_id"].(string)
}
if get(0) != "masked-req-1" {
t.Errorf("cli request_id not masked: %s", get(0))
}
// relay-prefixed IDs are kept
if get(1) != "relay-init-0" {
t.Errorf("relay request_id should be kept: %s", get(1))
}
}

func TestMaskPaths(t *testing.T) {
entries := []Entry{{
Seq: 0,
Direction: DirRecv,
Frame: json.RawMessage(`{"cwd":"/home/realuser/projects/myapp","file":"/home/realuser/.config/something"}`),
}}
masked := MaskEntries(entries, MaskOptions{
WorkspaceCWD: "/home/realuser/projects/myapp",
HomePath: "/home/realuser",
})
var frame map[string]any
if err := json.Unmarshal(masked[0].Frame, &frame); err != nil {
t.Fatal(err)
}
if frame["cwd"] != "/test/workspace" {
t.Errorf("cwd not masked: %v", frame["cwd"])
}
if frame["file"] != "/test/home/.config/something" {
t.Errorf("home path not masked: %v", frame["file"])
}
}

func TestMaskAPIKey(t *testing.T) {
entries := []Entry{{
Seq: 0,
Direction: DirRecv,
Frame: json.RawMessage(`{"apiKeySource":"env:ANTHROPIC_API_KEY","api_key":"sk-ant-secret"}`),
}}
masked := MaskEntries(entries, MaskOptions{})
var frame map[string]any
json.Unmarshal(masked[0].Frame, &frame)
if frame["apiKeySource"] != "REDACTED" {
t.Errorf("apiKeySource not masked: %v", frame["apiKeySource"])
}
if frame["api_key"] != "REDACTED" {
t.Errorf("api_key not masked: %v", frame["api_key"])
}
}

func TestMaskNestedStructures(t *testing.T) {
entries := []Entry{{
Seq: 0,
Direction: DirRecv,
Frame: json.RawMessage(`{
"type":"control_request",
"request_id":"cli-req-1",
"request":{
"subtype":"mcp_message",
"server_name":"my-tools",
"message":{"method":"initialize","id":1}
}
}`),
}}
masked := MaskEntries(entries, MaskOptions{})
var frame map[string]any
json.Unmarshal(masked[0].Frame, &frame)
if frame["request_id"] != "masked-req-1" {
t.Errorf("nested request_id not masked: %v", frame["request_id"])
}
// Numeric id in MCP message should be preserved
req := frame["request"].(map[string]any)
msg := req["message"].(map[string]any)
if msg["id"] != float64(1) {
t.Errorf("mcp numeric id should be preserved: %v", msg["id"])
}
}
Loading