Skip to content

Commit 6cfeae9

Browse files
authored
Merge pull request #2960 from dhshah13/feat/2779-inbound-traceparent
fix(#2779): respect inbound TRACEPARENT in trace chain
2 parents 7d032b2 + db1dff4 commit 6cfeae9

9 files changed

Lines changed: 512 additions & 69 deletions

File tree

internal/cli/run.go

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -586,21 +586,21 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
586586
}
587587
}
588588

589-
// Trace identity (ADR 0050 Level 1 + security correlation). Generated here,
589+
// Trace identity (ADR 0050 Level 1 + security correlation). Resolved here,
590590
// before the pre-script, so TRACEPARENT can propagate to child processes.
591-
// The same id is reused as the security finding/audit trace id (dashed UUID)
592-
// and, dash-stripped, as the W3C telemetry trace id — one id across both.
593-
securityTraceID := security.GenerateTraceID()
594-
wTraceID := telemetry.TraceIDFromUUID(securityTraceID)
595-
rootSpanID := telemetry.NewSpanID()
591+
// An inbound TRACEPARENT (nested/instrumented invocation, issue #2779) is
592+
// adopted so the run continues the parent trace; otherwise a fresh id is
593+
// generated. Either way one id serves as both the security finding/audit
594+
// trace id (dashed UUID) and, dash-stripped, the W3C telemetry trace id.
595+
securityTraceID, traceCtx := resolveTraceIdentity(os.Getenv("TRACEPARENT"))
596596
workItemID := resolveWorkItemID()
597597

598598
// 2c. Run pre-script on the host (if configured).
599599
if h.PreScript != "" {
600600
preStart := time.Now()
601601
printer.StepStart("Running pre-script: " + h.PreScript)
602602
preCmd := exec.Command(h.PreScript)
603-
preCmd.Env = childScriptEnv(h.RunnerEnv, telemetry.TraceParent(wTraceID, rootSpanID))
603+
preCmd.Env = childScriptEnv(h.RunnerEnv, telemetry.TraceParentWithFlags(traceCtx.TraceID, traceCtx.RootSpanID, traceCtx.Flags))
604604
preCmd.Stdout = os.Stdout
605605
preCmd.Stderr = os.Stderr
606606
if err := preCmd.Run(); err != nil {
@@ -626,7 +626,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
626626
// order — it runs last and the summary captures the whole run.
627627
var lastExitCode int
628628
var transcriptErrorOverride bool
629-
rec := telemetry.New(runDir, wTraceID, rootSpanID, agentName, workItemID, runStart)
629+
rec := telemetry.New(runDir, traceCtx, agentName, workItemID, runStart)
630630
defer func() { rec.Finalize(telemetryExitCode(lastExitCode, runErr)) }()
631631

632632
createStart := time.Now()
@@ -675,7 +675,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
675675
printer.StepStart("Running post-script: " + h.PostScript)
676676
postCmd := exec.Command(h.PostScript)
677677
postCmd.Dir = runDir
678-
postCmd.Env = childScriptEnv(h.RunnerEnv, telemetry.TraceParent(wTraceID, rootSpanID))
678+
postCmd.Env = childScriptEnv(h.RunnerEnv, telemetry.TraceParentWithFlags(traceCtx.TraceID, traceCtx.RootSpanID, traceCtx.Flags))
679679
postCmd.Stdout = os.Stdout
680680
postCmd.Stderr = os.Stderr
681681
if err := postCmd.Run(); err != nil {
@@ -1681,12 +1681,46 @@ func telemetryExitCode(lastExitCode int, runErr error) int {
16811681
return lastExitCode
16821682
}
16831683

1684+
// resolveTraceIdentity resolves the run's trace identity (issue #2779). A
1685+
// valid inbound W3C TRACEPARENT is adopted: its trace-id becomes both the
1686+
// security trace id (re-dashed UUID) and the W3C telemetry trace-id, its
1687+
// span-id becomes the root span's remote parent, and its trace-flags carry
1688+
// the upstream sampling decision forward. Without a valid inbound value a
1689+
// fresh identity is generated, exactly as Level 1 always did. TRACESTATE is
1690+
// intentionally untouched — it passes through os.Environ to child scripts.
1691+
func resolveTraceIdentity(inbound string) (securityTraceID string, tc telemetry.TraceContext) {
1692+
if traceID, parentSpanID, flags, ok := telemetry.ParseTraceParent(inbound); ok {
1693+
return telemetry.UUIDFromTraceID(traceID), telemetry.TraceContext{
1694+
TraceID: traceID,
1695+
RootSpanID: telemetry.NewSpanID(),
1696+
ParentSpanID: parentSpanID,
1697+
Flags: flags,
1698+
}
1699+
}
1700+
securityTraceID = security.GenerateTraceID()
1701+
return securityTraceID, telemetry.TraceContext{
1702+
TraceID: telemetry.TraceIDFromUUID(securityTraceID),
1703+
RootSpanID: telemetry.NewSpanID(),
1704+
Flags: "01",
1705+
}
1706+
}
1707+
16841708
// childScriptEnv builds the environment for a host-side child script (pre- or
16851709
// post-script): the harness RunnerEnv layered over the process environment,
1686-
// plus the W3C TRACEPARENT for trace propagation (ADR 0050 Level 1). An empty
1687-
// traceparent (telemetry disabled) is omitted rather than emitted blank.
1710+
// plus the W3C TRACEPARENT for trace propagation (ADR 0050 Level 1). Any
1711+
// TRACEPARENT already present — inherited from the process environment or
1712+
// set in runner_env — is filtered out first: env lookups resolve the first
1713+
// match, so a stale value would shadow fullsend's own, and fullsend's trace
1714+
// identity never derives from runner_env (issue #2779). An empty traceparent
1715+
// (telemetry disabled) is omitted rather than emitted blank.
16881716
func childScriptEnv(runnerEnv map[string]string, traceparent string) []string {
1689-
env := append(os.Environ(), envToList(runnerEnv)...)
1717+
merged := append(os.Environ(), envToList(runnerEnv)...)
1718+
env := make([]string, 0, len(merged)+1)
1719+
for _, e := range merged {
1720+
if !strings.HasPrefix(e, "TRACEPARENT=") {
1721+
env = append(env, e)
1722+
}
1723+
}
16901724
if traceparent != "" {
16911725
env = append(env, "TRACEPARENT="+traceparent)
16921726
}
@@ -1857,9 +1891,11 @@ func refreshOIDCToken(ctx context.Context, sandboxName, oidcURL, oidcAuth string
18571891
// inside the sandbox. It finds known context files (including SKILL.md in
18581892
// skill directories) in the repo directory and passes them as arguments.
18591893
func buildScanContextCommand(repoDir, traceID string) string {
1860-
// Defense-in-depth: validate traceID before shell interpolation even though
1861-
// GenerateTraceID() only produces safe hex characters.
1862-
if !security.IsValidTraceID(traceID) {
1894+
// Defense-in-depth: validate traceID before shell interpolation. Uses
1895+
// IsShellSafeTraceID (not IsValidTraceID) because the id may have been
1896+
// adopted from an inbound W3C traceparent (issue #2779), so it is not
1897+
// necessarily UUID v4.
1898+
if !security.IsShellSafeTraceID(traceID) {
18631899
// Should never happen with internal generation, but fail safely.
18641900
traceID = "invalid-trace-id"
18651901
}
@@ -2196,10 +2232,10 @@ func scanOutputFiles(outputDir, traceID string, printer *ui.Printer) error {
21962232

21972233
// injectTraceID appends the FULLSEND_TRACE_ID to the sandbox .env file.
21982234
func injectTraceID(sandboxName, traceID string) error {
2199-
if !security.IsValidTraceID(traceID) {
2235+
if !security.IsShellSafeTraceID(traceID) {
22002236
return fmt.Errorf("invalid trace ID format: %q", traceID)
22012237
}
2202-
// Safe: IsValidTraceID() above ensures traceID matches UUID v4 format only.
2238+
// Safe: IsShellSafeTraceID() above ensures traceID is only hex and dashes.
22032239
cmd := fmt.Sprintf("echo 'export FULLSEND_TRACE_ID=%s' >> %s/.env", traceID, sandbox.SandboxWorkspace)
22042240
_, _, _, err := sandbox.Exec(sandboxName, cmd, 10*time.Second)
22052241
return err

internal/cli/run_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,16 @@ func TestBuildScanContextCommand_SourcesEnv(t *testing.T) {
414414
assert.Contains(t, cmd, "-exec fullsend scan context")
415415
}
416416

417+
func TestBuildScanContextCommand_AcceptsAdoptedTraceID(t *testing.T) {
418+
// A trace id adopted from an inbound W3C traceparent (issue #2779) is
419+
// dashed hex but not UUID v4; it must survive validation, not be replaced
420+
// with the "invalid-trace-id" sentinel.
421+
traceID := "4f3a9c1b-2d8e-0a7c-1f0b-1e2d3c4a5b6d"
422+
cmd := buildScanContextCommand("/sandbox/workspace/repo", traceID)
423+
assert.Contains(t, cmd, "FULLSEND_TRACE_ID='"+traceID+"'")
424+
assert.NotContains(t, cmd, "invalid-trace-id")
425+
}
426+
417427
func TestCopyFile(t *testing.T) {
418428
t.Run("copies content and preserves permissions", func(t *testing.T) {
419429
src := filepath.Join(t.TempDir(), "source")

internal/cli/telemetry_run_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,137 @@ func TestChildScriptEnv_EmptyTraceparentOmitted(t *testing.T) {
121121
}
122122
}
123123

124+
func TestChildScriptEnv_FiltersPreExistingTraceparent(t *testing.T) {
125+
// A parent process may already export TRACEPARENT (issue #2779). Most
126+
// runtimes resolve the FIRST match in the environment, so the stale value
127+
// must be filtered out — exactly one TRACEPARENT entry, fullsend's own.
128+
t.Setenv("TRACEPARENT", "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1-bbbbbbbbbbbbbbbb-01")
129+
const fullsendTP = "00-4f3a9c1b2d8e4a7c9f0b1e2d3c4a5b6d-a1b2c3d4e5f60718-01"
130+
131+
env := childScriptEnv(map[string]string{}, fullsendTP)
132+
133+
traceparents := 0
134+
for _, e := range env {
135+
if strings.HasPrefix(e, "TRACEPARENT=") {
136+
traceparents++
137+
assert.Equal(t, "TRACEPARENT="+fullsendTP, e, "must be fullsend's value, not the parent's")
138+
}
139+
}
140+
assert.Equal(t, 1, traceparents, "exactly one TRACEPARENT entry after filtering")
141+
}
142+
143+
func TestChildScriptEnv_EmptyTraceparentFiltersExisting(t *testing.T) {
144+
// Even with telemetry disabled (empty traceparent), a stale inherited
145+
// TRACEPARENT must not leak through to child scripts.
146+
t.Setenv("TRACEPARENT", "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1-bbbbbbbbbbbbbbbb-01")
147+
148+
env := childScriptEnv(map[string]string{}, "")
149+
for _, e := range env {
150+
assert.False(t, strings.HasPrefix(e, "TRACEPARENT="), "stale TRACEPARENT must be filtered even when disabled")
151+
}
152+
}
153+
154+
func TestChildScriptEnv_FiltersRunnerEnvTraceparent(t *testing.T) {
155+
// A harness-provided runner_env.TRACEPARENT would land before fullsend's
156+
// appended value and win first-match resolution — same shadowing bug as
157+
// the inherited process env, so it gets the same filter. fullsend's
158+
// trace identity never derives from runner_env; honoring it would only
159+
// desync child scripts from the recorded trace.
160+
const fullsendTP = "00-4f3a9c1b2d8e4a7c9f0b1e2d3c4a5b6d-a1b2c3d4e5f60718-01"
161+
runnerEnv := map[string]string{
162+
"TRACEPARENT": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1-bbbbbbbbbbbbbbbb-01",
163+
"FOO": "bar",
164+
}
165+
166+
env := childScriptEnv(runnerEnv, fullsendTP)
167+
168+
traceparents := 0
169+
hasFoo := false
170+
for _, e := range env {
171+
if strings.HasPrefix(e, "TRACEPARENT=") {
172+
traceparents++
173+
assert.Equal(t, "TRACEPARENT="+fullsendTP, e, "must be fullsend's value, not runner_env's")
174+
}
175+
if e == "FOO=bar" {
176+
hasFoo = true
177+
}
178+
}
179+
assert.Equal(t, 1, traceparents, "exactly one TRACEPARENT entry")
180+
assert.True(t, hasFoo, "other runner_env entries preserved")
181+
}
182+
183+
func TestChildScriptEnv_EmptyTraceparentFiltersRunnerEnv(t *testing.T) {
184+
// Telemetry disabled: a runner_env TRACEPARENT must not leak either.
185+
env := childScriptEnv(map[string]string{"TRACEPARENT": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1-bbbbbbbbbbbbbbbb-01"}, "")
186+
for _, e := range env {
187+
assert.False(t, strings.HasPrefix(e, "TRACEPARENT="), "runner_env TRACEPARENT must be filtered when disabled")
188+
}
189+
}
190+
191+
func TestChildScriptEnv_PreservesTracestate(t *testing.T) {
192+
// W3C tracestate carries vendor context alongside traceparent and must
193+
// pass through to child scripts untouched.
194+
t.Setenv("TRACESTATE", "vendor=abc123,other=def456")
195+
const tp = "00-4f3a9c1b2d8e4a7c9f0b1e2d3c4a5b6d-a1b2c3d4e5f60718-01"
196+
197+
env := childScriptEnv(map[string]string{}, tp)
198+
199+
found := false
200+
for _, e := range env {
201+
if e == "TRACESTATE=vendor=abc123,other=def456" {
202+
found = true
203+
}
204+
}
205+
assert.True(t, found, "TRACESTATE must pass through to child scripts")
206+
}
207+
208+
func TestResolveTraceIdentity(t *testing.T) {
209+
const (
210+
tid = "4f3a9c1b2d8e4a7c9f0b1e2d3c4a5b6d"
211+
sid = "a1b2c3d4e5f60718"
212+
)
213+
reSpanID := regexp.MustCompile(`^[0-9a-f]{16}$`)
214+
215+
t.Run("adopts valid inbound sampled traceparent", func(t *testing.T) {
216+
securityID, tc := resolveTraceIdentity("00-" + tid + "-" + sid + "-01")
217+
218+
assert.Equal(t, tid, tc.TraceID, "inbound trace-id adopted")
219+
assert.Equal(t, sid, tc.ParentSpanID, "inbound span-id becomes the root span's remote parent")
220+
assert.Equal(t, "01", tc.Flags)
221+
assert.Equal(t, "4f3a9c1b-2d8e-4a7c-9f0b-1e2d3c4a5b6d", securityID, "security id derived from inbound trace-id")
222+
assert.Equal(t, tid, telemetry.TraceIDFromUUID(securityID), "security id must round-trip to the W3C id")
223+
assert.True(t, security.IsShellSafeTraceID(securityID))
224+
225+
assert.Regexp(t, reSpanID, tc.RootSpanID, "fresh root span id")
226+
assert.NotEqual(t, sid, tc.RootSpanID, "root span is a child, not the inbound span itself")
227+
})
228+
229+
t.Run("preserves unsampled flag", func(t *testing.T) {
230+
_, tc := resolveTraceIdentity("00-" + tid + "-" + sid + "-00")
231+
assert.Equal(t, "00", tc.Flags, "upstream unsampled decision preserved")
232+
})
233+
234+
t.Run("adopted non-v4 trace-id is shell-safe", func(t *testing.T) {
235+
// version nibble 0, variant nibble 1 — valid W3C, not UUID v4.
236+
securityID, _ := resolveTraceIdentity("00-4f3a9c1b2d8e0a7c1f0b1e2d3c4a5b6d-" + sid + "-01")
237+
assert.Equal(t, "4f3a9c1b-2d8e-0a7c-1f0b-1e2d3c4a5b6d", securityID)
238+
assert.True(t, security.IsShellSafeTraceID(securityID))
239+
assert.False(t, security.IsValidTraceID(securityID), "adopted id is not v4 — needs the shell-safe validator")
240+
})
241+
242+
for _, inbound := range []string{"", "not-a-traceparent", "00-" + tid + "-" + sid, "ff-" + tid + "-" + sid + "-01"} {
243+
t.Run("falls back to fresh identity for "+fmt.Sprintf("%q", inbound), func(t *testing.T) {
244+
securityID, tc := resolveTraceIdentity(inbound)
245+
246+
assert.True(t, security.IsValidTraceID(securityID), "fresh id is a v4 UUID")
247+
assert.Equal(t, telemetry.TraceIDFromUUID(securityID), tc.TraceID, "unified trace id (Level 1 invariant)")
248+
assert.Empty(t, tc.ParentSpanID, "local trace root has no remote parent")
249+
assert.Equal(t, "01", tc.Flags, "fresh traces are sampled")
250+
assert.Regexp(t, reSpanID, tc.RootSpanID)
251+
})
252+
}
253+
}
254+
124255
func TestAgentSpanEndAttrs(t *testing.T) {
125256
var m agentruntime.RunMetrics
126257
m.Model = "claude-opus-4-6"

internal/security/trace.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ func IsValidTraceID(id string) bool {
3131
return reTraceID.MatchString(id)
3232
}
3333

34+
// reShellSafeTraceID matches any dashed lowercase-hex string in UUID shape
35+
// (8-4-4-4-12), without the UUID v4 version/variant requirement of reTraceID.
36+
var reShellSafeTraceID = regexp.MustCompile(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)
37+
38+
// IsShellSafeTraceID returns true if the trace ID consists only of lowercase
39+
// hex and dashes in UUID shape — the property that makes it safe for shell
40+
// interpolation. Unlike IsValidTraceID it accepts any version/variant, because
41+
// a trace id adopted from an inbound W3C traceparent (issue #2779) is not
42+
// necessarily UUID v4. The charset restriction is the entire safety argument;
43+
// version bits carry no interpolation risk.
44+
func IsShellSafeTraceID(id string) bool {
45+
return reShellSafeTraceID.MatchString(id)
46+
}
47+
3448
// seedHash is the well-known genesis hash for the first entry in a chain.
3549
const seedHash = "0000000000000000000000000000000000000000000000000000000000000000"
3650

internal/security/trace_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,37 @@ func TestGenerateTraceID(t *testing.T) {
1414
}
1515
}
1616

17+
func TestIsShellSafeTraceID(t *testing.T) {
18+
// A generated UUID v4 passes both the strict and the shell-safe validator.
19+
id := GenerateTraceID()
20+
if !IsShellSafeTraceID(id) {
21+
t.Errorf("generated trace ID %q should be shell-safe", id)
22+
}
23+
24+
// A trace id adopted from an inbound W3C traceparent is dashed hex but
25+
// generally not UUID v4: shell-safe must accept it, strict must reject it.
26+
adopted := "4f3a9c1b-2d8e-0a7c-1f0b-1e2d3c4a5b6d" // version 0, variant 0
27+
if !IsShellSafeTraceID(adopted) {
28+
t.Error("adopted non-v4 trace ID should be shell-safe")
29+
}
30+
if IsValidTraceID(adopted) {
31+
t.Error("adopted non-v4 trace ID should NOT pass strict v4 validation")
32+
}
33+
34+
for _, bad := range []string{
35+
"zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz", // non-hex
36+
"4F3A9C1B-2D8E-4A7C-9F0B-1E2D3C4A5B6D", // uppercase
37+
"4f3a9c1b-2d8e", // wrong length
38+
"4f3a9c1b2d8e4a7c9f0b1e2d3c4a5b6d", // undashed
39+
"", // empty
40+
"4f3a9c1b-2d8e-4a7c-9f0b-1e2d3c4a5b6d; rm -rf /", // injection attempt
41+
} {
42+
if IsShellSafeTraceID(bad) {
43+
t.Errorf("IsShellSafeTraceID(%q) must be false", bad)
44+
}
45+
}
46+
}
47+
1748
func TestAppendFindingHashChain(t *testing.T) {
1849
dir := t.TempDir()
1950
path := filepath.Join(dir, "findings.jsonl")

0 commit comments

Comments
 (0)