diff --git a/plugins/claude-code/README.md b/plugins/claude-code/README.md index d65fe46..898c09b 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -33,7 +33,8 @@ Add to `~/.claude/settings.json`: "env": { "SIGIL_URL": "https://sigil.example.com", "SIGIL_USER": "your-tenant-id", - "SIGIL_PASSWORD": "glc_..." + "SIGIL_PASSWORD": "glc_...", + "SIGIL_CONTENT_CAPTURE_MODE": "metadata_only" } } ``` @@ -45,7 +46,7 @@ Add to `~/.claude/settings.json`: | `SIGIL_URL` | yes | Sigil endpoint | | `SIGIL_USER` | yes | Basic auth username (also `X-Scope-OrgID`) | | `SIGIL_PASSWORD` | yes | Basic auth password | -| `SIGIL_CONTENT_CAPTURE` | no | `true` to include redacted conversation content (default: metadata-only) | +| `SIGIL_CONTENT_CAPTURE_MODE` | no | Content capture mode: `full`, `metadata_only`, `no_tool_content` (default: `metadata_only`) | | `SIGIL_EXTRA_TAGS` | no | Comma-separated `key=value` tags added to every generation (e.g. `account=work,env=dev`). Built-in tags (`git.branch`, `cwd`, `entrypoint`, `subagent`) take precedence on collision. | | `SIGIL_OTEL_ENDPOINT` | no | OTLP HTTP endpoint for metrics + traces (e.g. `https://otlp-gateway.grafana.net/otlp` or `host:4318`) | | `SIGIL_OTEL_USER` | no | OTLP auth username (defaults to `SIGIL_USER`) | @@ -65,9 +66,17 @@ Each assistant API response becomes one Generation with model, tokens, tools, ti ## Content Capture -By default, only metadata is sent (model, tokens, tool names, timestamps). Set `SIGIL_CONTENT_CAPTURE=true` to include conversation content with automatic secret redaction: +Content capture is controlled by `SIGIL_CONTENT_CAPTURE_MODE` (default: `metadata_only`): -- User prompts: sent as-is (user's own input) +| Mode | What's sent | +|------|-------------| +| `metadata_only` | Model, tokens, tool names, timestamps, tags. All text content stripped by the SDK. | +| `full` | Full conversation content with automatic secret redaction (see below). | +| `no_tool_content` | Full generation content but tool execution arguments/results excluded from spans. | + +When content is included (`full` or `no_tool_content`), automatic redaction is applied: + +- User prompts: Tier 1 redaction (known token formats) - Assistant text: Tier 1 redaction (known token formats) - Tool inputs/outputs: Tier 1 + Tier 2 redaction (tokens + env-file heuristics) - Thinking blocks: omitted (noted in metadata) diff --git a/plugins/claude-code/cmd/sigil-cc/main.go b/plugins/claude-code/cmd/sigil-cc/main.go index f81164a..d643157 100644 --- a/plugins/claude-code/cmd/sigil-cc/main.go +++ b/plugins/claude-code/cmd/sigil-cc/main.go @@ -65,7 +65,7 @@ func run() { return } - contentCapture := strings.EqualFold(os.Getenv("SIGIL_CONTENT_CAPTURE"), "true") + contentMode := resolveContentMode() extraTags := parseExtraTags(os.Getenv("SIGIL_EXTRA_TAGS")) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -116,15 +116,14 @@ func run() { logger.Printf("coalesced to %d lines, safe offset=%d", len(lines), safeOffset) var r *redact.Redactor - if contentCapture { + if contentMode != sigil.ContentCaptureModeMetadataOnly { r = redact.New() } gens := mapper.Process(lines, &st, mapper.Options{ - SessionID: input.SessionID, - ContentCapture: contentCapture, - Logger: logger, - ExtraTags: extraTags, + SessionID: input.SessionID, + Logger: logger, + ExtraTags: extraTags, }, r) if len(gens) == 0 { @@ -135,6 +134,7 @@ func run() { logger.Printf("produced %d generations", len(gens)) cfg := sigil.Config{ + ContentCapture: contentMode, GenerationExport: sigil.GenerationExportConfig{ Protocol: sigil.GenerationExportProtocolHTTP, Endpoint: sigilURL + "/api/v1/generations:export", @@ -174,7 +174,7 @@ func run() { genCtx, rec := client.StartGeneration(ctx, genStart) rec.SetResult(gen, nil) - emitToolSpans(genCtx, client, gen, toolResults, contentCapture) + emitToolSpans(genCtx, client, gen, toolResults) rec.End() @@ -272,7 +272,7 @@ func buildToolResultMap(gens []sigil.Generation) map[string]*sigil.ToolResult { } // emitToolSpans creates execute_tool spans for each tool call in the generation output. -func emitToolSpans(ctx context.Context, client *sigil.Client, gen sigil.Generation, results map[string]*sigil.ToolResult, contentCapture bool) { +func emitToolSpans(ctx context.Context, client *sigil.Client, gen sigil.Generation, results map[string]*sigil.ToolResult) { for _, msg := range gen.Output { for _, part := range msg.Parts { if part.ToolCall == nil { @@ -289,21 +289,18 @@ func emitToolSpans(ctx context.Context, client *sigil.Client, gen sigil.Generati RequestModel: gen.Model.Name, RequestProvider: gen.Model.Provider, StartedAt: gen.CompletedAt, - IncludeContent: contentCapture, } _, toolRec := client.StartToolExecution(ctx, start) end := sigil.ToolExecutionEnd{ CompletedAt: gen.CompletedAt, + Arguments: string(tc.InputJSON), } - if contentCapture { - end.Arguments = string(tc.InputJSON) - if tr, ok := results[tc.ID]; ok { - if tr.Content != "" { - end.Result = tr.Content - } else if len(tr.ContentJSON) > 0 { - end.Result = string(tr.ContentJSON) - } + if tr, ok := results[tc.ID]; ok { + if tr.Content != "" { + end.Result = tr.Content + } else if len(tr.ContentJSON) > 0 { + end.Result = string(tr.ContentJSON) } } @@ -316,3 +313,20 @@ func emitToolSpans(ctx context.Context, client *sigil.Client, gen sigil.Generati } } } + +// resolveContentMode returns the effective ContentCaptureMode from environment. +// Priority: SIGIL_CONTENT_CAPTURE_MODE > SIGIL_CONTENT_CAPTURE (legacy) > MetadataOnly. +func resolveContentMode() sigil.ContentCaptureMode { + if v := os.Getenv("SIGIL_CONTENT_CAPTURE_MODE"); v != "" { + var mode sigil.ContentCaptureMode + if err := mode.UnmarshalText([]byte(v)); err != nil { + return sigil.ContentCaptureModeMetadataOnly + } + return mode + } + // Backward compat: SIGIL_CONTENT_CAPTURE=true maps to Full. + if strings.EqualFold(os.Getenv("SIGIL_CONTENT_CAPTURE"), "true") { + return sigil.ContentCaptureModeFull + } + return sigil.ContentCaptureModeMetadataOnly +} diff --git a/plugins/claude-code/cmd/sigil-cc/main_test.go b/plugins/claude-code/cmd/sigil-cc/main_test.go index a546938..506bfb5 100644 --- a/plugins/claude-code/cmd/sigil-cc/main_test.go +++ b/plugins/claude-code/cmd/sigil-cc/main_test.go @@ -139,14 +139,15 @@ func TestBuildToolResultMap(t *testing.T) { } } -func newSpanRecordingClient(t *testing.T) (*sigil.Client, *tracetest.SpanRecorder) { +func newSpanRecordingClient(t *testing.T, mode sigil.ContentCaptureMode) (*sigil.Client, *tracetest.SpanRecorder) { t.Helper() recorder := tracetest.NewSpanRecorder() tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) client := sigil.NewClient(sigil.Config{ - Tracer: tp.Tracer("test"), + Tracer: tp.Tracer("test"), + ContentCapture: mode, }) t.Cleanup(func() { _ = client.Shutdown(context.Background()) }) return client, recorder @@ -175,14 +176,14 @@ func TestEmitToolSpans(t *testing.T) { ts := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) tests := []struct { - name string - gen sigil.Generation - results map[string]*sigil.ToolResult - contentCapture bool - wantSpans int - wantNames []string - wantArgs map[string]string // tool name → expected arguments attr - wantResults map[string]string // tool name → expected result attr + name string + gen sigil.Generation + results map[string]*sigil.ToolResult + contentMode sigil.ContentCaptureMode + wantSpans int + wantNames []string + wantArgs map[string]string // tool name → expected arguments attr + wantResults map[string]string // tool name → expected result attr }{ { name: "no tool calls", @@ -196,7 +197,7 @@ func TestEmitToolSpans(t *testing.T) { wantSpans: 0, }, { - name: "single tool call without content capture", + name: "single tool call metadata only", gen: sigil.Generation{ ConversationID: "conv-1", AgentName: "claude-code", @@ -215,12 +216,12 @@ func TestEmitToolSpans(t *testing.T) { }}, }}, }, - contentCapture: false, - wantSpans: 1, - wantNames: []string{"Read"}, + contentMode: sigil.ContentCaptureModeMetadataOnly, + wantSpans: 1, + wantNames: []string{"Read"}, }, { - name: "multiple tool calls with content capture and results", + name: "multiple tool calls with full content and results", gen: sigil.Generation{ ConversationID: "conv-1", AgentName: "claude-code", @@ -243,9 +244,9 @@ func TestEmitToolSpans(t *testing.T) { "tc_1": {ToolCallID: "tc_1", Content: "package main"}, "tc_2": {ToolCallID: "tc_2", Content: "found 3 matches"}, }, - contentCapture: true, - wantSpans: 2, - wantNames: []string{"Read", "Grep"}, + contentMode: sigil.ContentCaptureModeFull, + wantSpans: 2, + wantNames: []string{"Read", "Grep"}, wantArgs: map[string]string{ "Read": `{"path":"a.go"}`, "Grep": `{"pattern":"TODO"}`, @@ -272,9 +273,9 @@ func TestEmitToolSpans(t *testing.T) { results: map[string]*sigil.ToolResult{ "tc_1": {ToolCallID: "tc_1", ContentJSON: json.RawMessage(`{"files":["a","b"]}`)}, }, - contentCapture: true, - wantSpans: 1, - wantNames: []string{"Bash"}, + contentMode: sigil.ContentCaptureModeFull, + wantSpans: 1, + wantNames: []string{"Bash"}, wantResults: map[string]string{ "Bash": `{"files":["a","b"]}`, }, @@ -296,21 +297,21 @@ func TestEmitToolSpans(t *testing.T) { results: map[string]*sigil.ToolResult{ "tc_1": {ToolCallID: "tc_1", Content: "permission denied", IsError: true}, }, - contentCapture: false, - wantSpans: 1, - wantNames: []string{"Write"}, + contentMode: sigil.ContentCaptureModeMetadataOnly, + wantSpans: 1, + wantNames: []string{"Write"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client, recorder := newSpanRecordingClient(t) + client, recorder := newSpanRecordingClient(t, tt.contentMode) results := tt.results if results == nil { results = map[string]*sigil.ToolResult{} } - emitToolSpans(context.Background(), client, tt.gen, results, tt.contentCapture) + emitToolSpans(context.Background(), client, tt.gen, results) // Force flush to ensure all spans are recorded. _ = client.Shutdown(context.Background()) @@ -351,7 +352,7 @@ func TestEmitToolSpans(t *testing.T) { } func TestEmitToolSpans_ErrorStatus(t *testing.T) { - client, recorder := newSpanRecordingClient(t) + client, recorder := newSpanRecordingClient(t, sigil.ContentCaptureModeMetadataOnly) ts := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) gen := sigil.Generation{ @@ -368,7 +369,7 @@ func TestEmitToolSpans_ErrorStatus(t *testing.T) { "tc_err": {ToolCallID: "tc_err", Content: "denied", IsError: true}, } - emitToolSpans(context.Background(), client, gen, results, false) + emitToolSpans(context.Background(), client, gen, results) _ = client.Shutdown(context.Background()) spans := spansByName(recorder.Ended(), "execute_tool") diff --git a/plugins/claude-code/go.mod b/plugins/claude-code/go.mod index 6a645a9..4c41fcf 100644 --- a/plugins/claude-code/go.mod +++ b/plugins/claude-code/go.mod @@ -4,7 +4,7 @@ go 1.25.6 require ( github.com/google/uuid v1.6.0 - github.com/grafana/sigil-sdk/go v0.2.0 + github.com/grafana/sigil-sdk/go v0.2.1-0.20260410201504-d1b27e736555 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 diff --git a/plugins/claude-code/go.sum b/plugins/claude-code/go.sum index ac5fc49..74b5358 100644 --- a/plugins/claude-code/go.sum +++ b/plugins/claude-code/go.sum @@ -17,6 +17,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/sigil-sdk/go v0.2.0 h1:RAYZsfRRdGMRY140VdNe+xvQ8e7DnVPvpViy6ErWL+k= github.com/grafana/sigil-sdk/go v0.2.0/go.mod h1:dsIfzzGlWm7XhvFxkZCD+8OLlOHPRRllG66nA/nZpy4= +github.com/grafana/sigil-sdk/go v0.2.1-0.20260410201504-d1b27e736555 h1:VA+D7yLj0UxmMc7Q3Xnu4xTbnGeXVt3KlHUnc7gXEek= +github.com/grafana/sigil-sdk/go v0.2.1-0.20260410201504-d1b27e736555/go.mod h1:jRb81AAb6ioWf/jj/nsFXeBl/b++uhFXpXRI/JYjSws= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= diff --git a/plugins/claude-code/internal/mapper/integration_test.go b/plugins/claude-code/internal/mapper/integration_test.go index f77c3e1..9f0d609 100644 --- a/plugins/claude-code/internal/mapper/integration_test.go +++ b/plugins/claude-code/internal/mapper/integration_test.go @@ -36,7 +36,7 @@ func TestIntegration_EndToEnd(t *testing.T) { } coalesced, safeOffset := Coalesce(lines) - gens := Process(coalesced, &st, Options{SessionID: "sess-integration", ContentCapture: true}, redact.New()) + gens := Process(coalesced, &st, Options{SessionID: "sess-integration"}, redact.New()) if len(gens) == 0 { t.Fatal("expected at least 1 generation") } diff --git a/plugins/claude-code/internal/mapper/mapper.go b/plugins/claude-code/internal/mapper/mapper.go index 9382edb..34e1309 100644 --- a/plugins/claude-code/internal/mapper/mapper.go +++ b/plugins/claude-code/internal/mapper/mapper.go @@ -22,10 +22,9 @@ const ( // Options controls how transcript lines are mapped to generations. type Options struct { - SessionID string // authoritative session ID from the hook input - ContentCapture bool // when true, include redacted Input/Output content - Logger *log.Logger // debug logger (nil = silent) - ExtraTags map[string]string // user-supplied tags merged into every generation; built-in keys always win + SessionID string // authoritative session ID from the hook input + Logger *log.Logger // debug logger (nil = silent) + ExtraTags map[string]string // user-supplied tags merged into every generation; built-in keys always win } func (o Options) logf(format string, args ...any) { @@ -165,7 +164,7 @@ func processUserLine(line transcript.Line, uctx *userContext, st *state.Session, st.Title = b.Text } } - if b.Type == "tool_result" && opts.ContentCapture { + if b.Type == "tool_result" { content := b.Content() if r != nil { content = r.Redact(content) @@ -250,12 +249,8 @@ func processAssistantLine(line transcript.Line, uctx *userContext, _ *state.Sess gen.ThinkingEnabled = ptrBool(true) } - if opts.ContentCapture { - gen.Input = buildInput(uctx, r) - gen.Output = buildOutput(msg.Content, r) - } else { - gen.Output = buildOutputRedacted(msg.Content) - } + gen.Input = buildInput(uctx, r) + gen.Output = buildOutput(msg.Content, r) return gen, true } @@ -359,45 +354,6 @@ func buildOutput(blocks []transcript.ContentBlock, r *redact.Redactor) []sigil.M }} } -// buildOutputRedacted builds output with tool call structure preserved -// but all content replaced with [redacted]. Used when content capture is off. -func buildOutputRedacted(blocks []transcript.ContentBlock) []sigil.Message { - var parts []sigil.Part - - for _, block := range blocks { - switch block.Type { - case "text": - parts = append(parts, sigil.Part{ - Kind: sigil.PartKindText, - Text: "[redacted]", - }) - case "thinking": - parts = append(parts, sigil.Part{ - Kind: sigil.PartKindThinking, - Thinking: "[redacted]", - }) - case "tool_use": - parts = append(parts, sigil.Part{ - Kind: sigil.PartKindToolCall, - ToolCall: &sigil.ToolCall{ - ID: block.ID, - Name: block.Name, - InputJSON: json.RawMessage(`"[redacted]"`), - }, - }) - } - } - - if len(parts) == 0 { - return nil - } - - return []sigil.Message{{ - Role: sigil.RoleAssistant, - Parts: parts, - }} -} - // truncateJSON redacts and truncates tool input JSON. // Uses Tier 1 only (RedactLightweight) to avoid Tier 2 patterns mangling // JSON structure. When truncation occurs, the result is wrapped as a JSON diff --git a/plugins/claude-code/internal/mapper/mapper_test.go b/plugins/claude-code/internal/mapper/mapper_test.go index 0400a97..5728828 100644 --- a/plugins/claude-code/internal/mapper/mapper_test.go +++ b/plugins/claude-code/internal/mapper/mapper_test.go @@ -169,32 +169,28 @@ func TestProcess_ContentModes(t *testing.T) { tests := []struct { name string - capture bool - wantInput bool - wantOutput bool + redactor *redact.Redactor + wantOutput string }{ - {"metadata only", false, false, true}, // output has [redacted] content - {"content capture", true, true, true}, + {"without redactor", nil, "Concurrency is..."}, + {"with redactor", redact.New(), "Concurrency is..."}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { st := &state.Session{} - var r *redact.Redactor - if tt.capture { - r = redact.New() - } - gens := Process(lines, st, Options{SessionID: "sess-1", ContentCapture: tt.capture}, r) + gens := Process(lines, st, Options{SessionID: "sess-1"}, tt.redactor) if len(gens) != 1 { t.Fatal("expected 1 generation") } - hasInput := gens[0].Input != nil - hasOutput := gens[0].Output != nil - if hasInput != tt.wantInput { - t.Errorf("Input present = %v, want %v", hasInput, tt.wantInput) + if gens[0].Input == nil { + t.Error("expected Input to be present") + } + if gens[0].Output == nil { + t.Fatal("expected Output to be present") } - if hasOutput != tt.wantOutput { - t.Errorf("Output present = %v, want %v", hasOutput, tt.wantOutput) + if gens[0].Output[0].Parts[0].Text != tt.wantOutput { + t.Errorf("Output text = %q, want %q", gens[0].Output[0].Parts[0].Text, tt.wantOutput) } }) } @@ -332,7 +328,7 @@ func TestProcess_ContentCaptureRedaction(t *testing.T) { } st := &state.Session{} - gens := Process(lines, st, Options{SessionID: "sess-1", ContentCapture: true}, redact.New()) + gens := Process(lines, st, Options{SessionID: "sess-1"}, redact.New()) gen := gens[0] // User prompt gets Tier 1 redaction @@ -422,7 +418,7 @@ func TestProcess_ToolResultsInInput(t *testing.T) { } st := &state.Session{} - gens := Process(lines, st, Options{SessionID: "sess-1", ContentCapture: true}, redact.New()) + gens := Process(lines, st, Options{SessionID: "sess-1"}, redact.New()) if len(gens) != 2 { t.Fatalf("got %d gens, want 2", len(gens)) @@ -603,7 +599,7 @@ func TestProcess_ToolResultContentFormats(t *testing.T) { } st := &state.Session{} - gens := Process(lines, st, Options{SessionID: "sess-1", ContentCapture: true}, redact.New()) + gens := Process(lines, st, Options{SessionID: "sess-1"}, redact.New()) if len(gens) != 2 { t.Fatalf("got %d gens, want 2", len(gens)) @@ -681,13 +677,12 @@ func TestTruncateJSON(t *testing.T) { func TestProcess_UserPromptRedaction(t *testing.T) { tests := []struct { - name string - capture bool - wantRedact bool - wantNilInput bool + name string + redactor *redact.Redactor + wantRedact bool }{ - {"with content capture", true, true, false}, - {"without content capture", false, false, true}, + {"with redactor", redact.New(), true}, + {"without redactor", nil, false}, } for _, tt := range tests { @@ -699,19 +694,11 @@ func TestProcess_UserPromptRedaction(t *testing.T) { }, "end_turn"), } - var r *redact.Redactor - if tt.capture { - r = redact.New() - } - st := &state.Session{} - gens := Process(lines, st, Options{SessionID: "sess-1", ContentCapture: tt.capture}, r) + gens := Process(lines, st, Options{SessionID: "sess-1"}, tt.redactor) - if tt.wantNilInput { - if gens[0].Input != nil { - t.Error("expected nil Input") - } - return + if gens[0].Input == nil { + t.Fatal("expected Input to be present") } input := gens[0].Input[0].Parts[0].Text @@ -722,6 +709,10 @@ func TestProcess_UserPromptRedaction(t *testing.T) { if !strings.Contains(input, "[REDACTED:grafana-cloud-token]") { t.Errorf("missing redaction marker: %q", input) } + } else { + if !strings.Contains(input, "glc_abcdefghijklmnopqrstuvwx") { + t.Errorf("expected raw token in prompt: %q", input) + } } }) }