Skip to content
Draft
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
17 changes: 13 additions & 4 deletions plugins/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
```
Expand All @@ -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_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`) |
| `SIGIL_OTEL_PASSWORD` | no | OTLP auth password (defaults to `SIGIL_PASSWORD`) |
Expand All @@ -64,9 +65,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)
Expand Down
46 changes: 30 additions & 16 deletions plugins/claude-code/cmd/sigil-cc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func run() {
return
}

contentCapture := strings.EqualFold(os.Getenv("SIGIL_CONTENT_CAPTURE"), "true")
contentMode := resolveContentMode()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
Expand Down Expand Up @@ -115,14 +115,13 @@ 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,
SessionID: input.SessionID,
Logger: logger,
}, r)

if len(gens) == 0 {
Expand All @@ -133,6 +132,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",
Expand Down Expand Up @@ -172,7 +172,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()

Expand Down Expand Up @@ -243,7 +243,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 {
Expand All @@ -260,21 +260,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)
}
}

Expand All @@ -287,3 +284,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
}
57 changes: 29 additions & 28 deletions plugins/claude-code/cmd/sigil-cc/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,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
Expand Down Expand Up @@ -146,14 +147,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",
Expand All @@ -167,7 +168,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",
Expand All @@ -186,12 +187,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",
Expand All @@ -214,9 +215,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"}`,
Expand All @@ -243,9 +244,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"]}`,
},
Expand All @@ -267,21 +268,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())
Expand Down Expand Up @@ -322,7 +323,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{
Expand All @@ -339,7 +340,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")
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-code/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions plugins/claude-code/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-code/internal/mapper/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
54 changes: 5 additions & 49 deletions plugins/claude-code/internal/mapper/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ 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)
SessionID string // authoritative session ID from the hook input
Logger *log.Logger // debug logger (nil = silent)
}

func (o Options) logf(format string, args ...any) {
Expand Down Expand Up @@ -164,7 +163,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)
Expand Down Expand Up @@ -249,12 +248,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
}
Expand Down Expand Up @@ -353,45 +348,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
Expand Down
Loading
Loading