From 002ecf8772b276d2b875a056a6c0f3ceb6b564dd Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Sat, 18 Apr 2026 00:31:25 +0200 Subject: [PATCH 1/5] feat(telemetry): integrate Datadog Installer lightweight tracer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps Runner.Run and command dispatch in the rshell interpreter with spans emitted via the Datadog Installer's lightweight tracer (github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry), so embedders like PAR can observe restricted-shell execution without wiring a full tracer. Span layout (operation → resource): - run → run: the Runner.Run invocation envelope, tagged with rshell.version - command → : each dispatched command, tagged with name, argc, exit_code, is_allowed, is_known, has_stdin_pipe, has_output_redirect - control_flow → if | for | for.iteration | pipeline: flattened grouping spans for the corresponding language constructs, with structural tags (branch_count/branch_taken, iteration_count/broke_early/variable_name, stage_count/exit_code) The if/elif/else handling was refactored from recursion-through-cmd() into an iterative execIfChain walk so one span covers the whole chain. Pipelines use an inPipeline flag on runnerState to suppress nested pipeline spans from the recursive N-stage pipe construction. Standalone binary wiring is gated behind the with_telemetry build tag (cmd/rshell/telemetry_{on,off}.go). In that build, DD_API_KEY and DD_SITE drive a Telemetry sender constructed with http.DefaultClient (proxy-aware). The default release binary ships the no-op variant — spans register on the global tracer but never flush, which is a bounded leak for a short-lived rshell invocation. Coverage: Go tests for the span payload (tags, nesting, parent-child linkage) plus five new byte-for-byte bash-compared scenarios stressing the iterative execIfChain walk (multi-command condition, side-effect short-circuit, 8-way elif chain, assignment in condition, exit mid-chain). Co-Authored-By: Claude Opus 4.7 (1M context) --- analysis/symbols_interp.go | 5 + cmd/rshell/main.go | 6 +- cmd/rshell/telemetry_off.go | 14 + cmd/rshell/telemetry_on.go | 38 ++ go.mod | 20 +- go.sum | 46 +- interp/api.go | 29 ++ interp/runner_exec.go | 137 ++++- interp/tracing_test.go | 485 ++++++++++++++++++ .../multi_command_cond_last_wins.yaml | 19 + .../conditions/side_effect_short_circuit.yaml | 17 + .../edge_cases/assignment_in_condition.yaml | 13 + .../if_clause/edge_cases/deep_elif_chain.yaml | 17 + .../edge_cases/exit_in_elif_condition.yaml | 15 + 14 files changed, 834 insertions(+), 27 deletions(-) create mode 100644 cmd/rshell/telemetry_off.go create mode 100644 cmd/rshell/telemetry_on.go create mode 100644 interp/tracing_test.go create mode 100644 tests/scenarios/shell/if_clause/conditions/multi_command_cond_last_wins.yaml create mode 100644 tests/scenarios/shell/if_clause/conditions/side_effect_short_circuit.yaml create mode 100644 tests/scenarios/shell/if_clause/edge_cases/assignment_in_condition.yaml create mode 100644 tests/scenarios/shell/if_clause/edge_cases/deep_elif_chain.yaml create mode 100644 tests/scenarios/shell/if_clause/edge_cases/exit_in_elif_condition.yaml diff --git a/analysis/symbols_interp.go b/analysis/symbols_interp.go index 3c52e7d5..59f6d0db 100644 --- a/analysis/symbols_interp.go +++ b/analysis/symbols_interp.go @@ -70,6 +70,11 @@ var interpAllowedSymbols = []string{ "time.Now", // 🟠 returns current time; read-only, no mutation. "time.Time", // 🟢 time value type; pure data, no side effects. + // --- github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry --- (lightweight span tracer used by the Agent Installer) + + "github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry.Span", // 🟢 pointer type used to hold a span across the if-block that starts it; no side effects from the type reference itself. + "github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry.StartSpanFromContext", // 🟠 starts a span from a parent carried on ctx; registers with the package-global tracer. No I/O here — flushing happens only if the embedding process has also called NewTelemetry. + // --- mvdan.cc/sh/v3/expand --- (shell word expansion library) "mvdan.cc/sh/v3/expand.Config", // 🟢 configuration for word expansion; pure type. diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index 91032d03..beeacee4 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -25,7 +25,11 @@ import ( const exitCodeTimeout = 124 func main() { - os.Exit(run(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) + stopTelemetry := startTelemetry() + code := run(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr) + // Flush before os.Exit — os.Exit does not run deferred calls. + stopTelemetry() + os.Exit(code) } func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int { diff --git a/cmd/rshell/telemetry_off.go b/cmd/rshell/telemetry_off.go new file mode 100644 index 00000000..dfef85ab --- /dev/null +++ b/cmd/rshell/telemetry_off.go @@ -0,0 +1,14 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build !with_telemetry + +package main + +// startTelemetry is a no-op in the default build: spans created inside interp +// register on the global tracer but are never flushed, which is a bounded +// leak acceptable for the lifetime of a short-lived rshell invocation. Build +// with `-tags with_telemetry` to include the real sender (telemetry_on.go). +func startTelemetry() func() { return func() {} } diff --git a/cmd/rshell/telemetry_on.go b/cmd/rshell/telemetry_on.go new file mode 100644 index 00000000..40840cf9 --- /dev/null +++ b/cmd/rshell/telemetry_on.go @@ -0,0 +1,38 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build with_telemetry + +package main + +import ( + "net/http" + "os" + + "github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry" +) + +// startTelemetry constructs a Telemetry sender using env-var configuration, +// so the spans the interp package registers on the global tracer actually +// get flushed to Datadog intake. Returns a stop function the caller must +// invoke before process exit to synchronously flush completed spans. +// +// Built only when the with_telemetry tag is set; the stock release binary +// uses the no-op variant in telemetry_off.go. +// +// Env vars: +// - DD_API_KEY (required; if unset, returns a no-op — no telemetry is sent) +// - DD_SITE (defaults to datadoghq.com; set to datad0g.com for staging) +// +// The HTTP client is http.DefaultClient, which respects HTTP_PROXY / +// HTTPS_PROXY / NO_PROXY env vars via Go's default transport. +func startTelemetry() func() { + apiKey := os.Getenv("DD_API_KEY") + if apiKey == "" { + return func() {} + } + tel := telemetry.NewTelemetry(http.DefaultClient, apiKey, os.Getenv("DD_SITE"), "rshell") + return tel.Stop +} diff --git a/go.mod b/go.mod index 7be42efe..fcbb372e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/DataDog/rshell go 1.25.6 require ( + github.com/DataDog/datadog-agent/pkg/fleet/installer v0.78.0 github.com/prometheus-community/pro-bing v0.8.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -14,11 +15,26 @@ require ( ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/DataDog/datadog-agent/pkg/template v0.78.0 // indirect + github.com/DataDog/datadog-agent/pkg/util/log v0.78.0 // indirect + github.com/DataDog/datadog-agent/pkg/util/scrubber v0.78.0 // indirect + github.com/DataDog/datadog-agent/pkg/version v0.78.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.2 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect + golang.org/x/time v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index a8069899..d5ee63a3 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,21 @@ +github.com/DataDog/datadog-agent/pkg/fleet/installer v0.78.0 h1:RAKiDOYMEIzwHXl7BFKWl/u6m81gKQiphjCAkgqEMs0= +github.com/DataDog/datadog-agent/pkg/fleet/installer v0.78.0/go.mod h1:iInwmRMh03UZF39vYp3BMAzfu2xEHgBQKythT/oylg0= +github.com/DataDog/datadog-agent/pkg/template v0.78.0 h1:mJ0Yy9xWhIwOBIpVMaPWNcxSbf9ajn18P4YbL3vyNJA= +github.com/DataDog/datadog-agent/pkg/template v0.78.0/go.mod h1:ZUjICHSlN0of0cmWrYk9Pof0DV0eqHSpTUK1NTnN26Y= +github.com/DataDog/datadog-agent/pkg/util/log v0.78.0 h1:dmFxkpMVX6YXvU1jV7R5eY4FOZ2nHGKWHKx+SAjAdfw= +github.com/DataDog/datadog-agent/pkg/util/log v0.78.0/go.mod h1:QZU/9s1yvOrIExqDwIhLE4cit/3LLE9iJ86FwuvY5ic= +github.com/DataDog/datadog-agent/pkg/util/scrubber v0.78.0 h1:MydZgoGPYjGt8jCwo2wOM9VvpIE7OLi8pXy8wWRL7Oo= +github.com/DataDog/datadog-agent/pkg/util/scrubber v0.78.0/go.mod h1:zM5uAINxu8o9OZGCCvqeH1E/vIsX6baiO2iPf6bs1xs= +github.com/DataDog/datadog-agent/pkg/version v0.78.0 h1:piLsyG7dP/ie6d4MQ17pTh5XPt/BV55dy8910eXiNj0= +github.com/DataDog/datadog-agent/pkg/version v0.78.0/go.mod h1:cOWF+29ZahL6qcC3KAntJEupdMdteiOGaLmIMz8zYBg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -13,13 +28,19 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc= github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -27,6 +48,15 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= @@ -34,12 +64,18 @@ golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg= diff --git a/interp/api.go b/interp/api.go index 15b8e8eb..89086bf0 100644 --- a/interp/api.go +++ b/interp/api.go @@ -26,6 +26,7 @@ import ( "mvdan.cc/sh/v3/expand" "mvdan.cc/sh/v3/syntax" + "github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry" "github.com/DataDog/rshell/allowedpaths" "github.com/DataDog/rshell/builtins" "github.com/DataDog/rshell/internal/version" @@ -117,6 +118,22 @@ type runnerState struct { stdout io.Writer stderr io.Writer + // runStdin / runStdout are the baselines captured at the start of Run() + // after any Run-level stdout wrapping. Telemetry uses them to decide + // whether a command's stdin/stdout was reassigned by a pipe or redirect. + // Propagated to subshells via the runnerState copy in subshell(). + runStdin *os.File + runStdout io.Writer + + // inPipeline is set to true on subshells created for pipeline stages and + // inherited by further subshells spawned inside them. It suppresses the + // nested rshell.pipeline spans that would otherwise fire when the + // pipeline implementation recurses through stmt() to handle N-stage + // pipelines (a|b|c is BinaryCmd(Pipe, BinaryCmd(Pipe, a, b), c)). Reset + // to false when entering a syntax.Subshell so a pipeline inside (…) gets + // its own span. + inPipeline bool + ecfg *expand.Config ectx context.Context // just so that subshell can use it again @@ -469,6 +486,10 @@ func (s ExitStatus) Error() string { return fmt.Sprintf("exit status %d", s) } // incrementally. To reuse a [Runner] without keeping the internal shell state, // call Reset. func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { + span, ctx := telemetry.StartSpanFromContext(ctx, "run") + span.SetTag("rshell.version", version.Version) + defer func() { span.Finish(retErr) }() + defer func() { if rec := recover(); rec != nil { panicOut := io.Writer(io.Discard) @@ -502,6 +523,11 @@ func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { stdoutCap := &limitWriter{w: prevStdout, limit: maxStdoutBytes} r.stdout = stdoutCap defer func() { r.stdout = prevStdout }() + // Capture the stdin/stdout baseline at Run() start (after wrapping) so + // per-command telemetry can detect pipe and redirect reassignments + // without tripping on the Run-level limitWriter wrap. + r.runStdin = r.stdin + r.runStdout = r.stdout r.startTime = time.Now() r.globReadDirCount = &atomic.Int64{} r.fillExpandConfig(ctx) @@ -678,6 +704,9 @@ func (r *Runner) subshell(background bool) *Runner { stdin: r.stdin, stdout: r.stdout, stderr: r.stderr, + runStdin: r.runStdin, + runStdout: r.runStdout, + inPipeline: r.inPipeline, filename: r.filename, exit: r.exit, lastExit: r.lastExit, diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 2f858545..113d30cd 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -17,6 +17,7 @@ import ( "mvdan.cc/sh/v3/expand" "mvdan.cc/sh/v3/syntax" + "github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry" "github.com/DataDog/rshell/allowedpaths" "github.com/DataDog/rshell/builtins" ) @@ -61,6 +62,9 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { switch cm := cm.(type) { case *syntax.Subshell: r2 := r.subshell(false) + // A pipeline inside (…) should get its own pipeline span, so + // clear the flag that suppresses nested pipeline spans. + r2.inPipeline = false r2.stmts(ctx, cm.Stmts) r.exit = r2.exit r.exit.exiting = false @@ -125,6 +129,16 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { r.stmt(ctx, cm.Y) } case syntax.Pipe: + if !r.inPipeline { + var span *telemetry.Span + span, ctx = telemetry.StartSpanFromContext(ctx, "control_flow") + span.SetResourceName("pipeline") + span.SetTag("rshell.pipeline.stage_count", countPipelineStages(cm)) + defer func() { + span.SetTag("rshell.pipeline.exit_code", int(r.exit.code)) + span.Finish(nil) + }() + } pr, pw, err := os.Pipe() if err != nil { r.exit.fatal(err) // not being able to create a pipe is rare but critical @@ -136,9 +150,11 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { rLeft := r.subshell(true) rLeft.stdout = pw rLeft.stderr = safeStderr + rLeft.inPipeline = true rRight := r.subshell(true) rRight.stdin = pr rRight.stderr = safeStderr + rRight.inPipeline = true var wg sync.WaitGroup wg.Add(1) go func() { @@ -170,22 +186,22 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { } } case *syntax.IfClause: - r.stmts(ctx, cm.Cond) - if r.exit.exiting || r.breakEnclosing > 0 || r.contnEnclosing > 0 { - break - } - if r.exit.ok() { - r.stmts(ctx, cm.Then) - } else { - r.exit = exitStatus{} - if cm.Else != nil { - r.cmd(ctx, cm.Else) - } - } + r.execIfChain(ctx, cm) case *syntax.ForClause: + span, forCtx := telemetry.StartSpanFromContext(ctx, "control_flow") + span.SetResourceName("for") + iterationCount := 0 + brokeEarly := false + var varName string + defer func() { + span.SetTag("rshell.for.variable_name", varName) + span.SetTag("rshell.for.iteration_count", iterationCount) + span.SetTag("rshell.for.broke_early", brokeEarly) + span.Finish(nil) + }() switch y := cm.Loop.(type) { case *syntax.WordIter: - name := y.Name.Value + varName = y.Name.Value items := r.Params // for i; do ... inToken := y.InPos.IsValid() @@ -194,18 +210,25 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { } for _, field := range items { - if err := ctx.Err(); err != nil { + if err := forCtx.Err(); err != nil { r.exit.fatal(err) break } - r.setVarString(name, field) - if r.loopStmtsBroken(ctx, cm.Do) { + r.setVarString(varName, field) + iterSpan, iterCtx := telemetry.StartSpanFromContext(forCtx, "control_flow") + iterSpan.SetResourceName("for.iteration") + iterSpan.SetTag("rshell.for.iteration.index", iterationCount) + broken := r.loopStmtsBroken(iterCtx, cm.Do) + iterSpan.Finish(nil) + iterationCount++ + if broken { // Excess continue at outermost loop: clamp and keep iterating // (bash treats "continue 99" in a single loop like "continue 1"). if r.contnEnclosing > 0 && !r.inLoop { r.contnEnclosing = 0 continue } + brokeEarly = true break } } @@ -251,12 +274,35 @@ func (r *Runner) loopStmtsBroken(ctx context.Context, stmts []*syntax.Stmt) bool } func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { + name := args[0] + + // Evaluate both policy checks upfront so the span tags reflect the + // independent facts about the command name regardless of which gate + // short-circuits dispatch. + isAllowed := r.allowAllCommands || r.allowedCommands[name] + fn, isKnown := builtins.Lookup(name) + + span, ctx := telemetry.StartSpanFromContext(ctx, "command") + span.SetResourceName(name) + span.SetTag("rshell.command.name", name) + span.SetTag("rshell.command.argc", len(args)-1) + span.SetTag("rshell.command.is_allowed", isAllowed) + span.SetTag("rshell.command.is_known", isKnown) + // has_stdin_pipe / has_output_redirect reflect whether the command's + // stdin/stdout were reassigned from the Runner's originals — true for + // both pipeline stages and file redirects. + span.SetTag("rshell.command.has_stdin_pipe", r.stdin != r.runStdin) + span.SetTag("rshell.command.has_output_redirect", r.stdout != r.runStdout) + defer func() { + span.SetTag("rshell.command.exit_code", int(r.exit.code)) + span.Finish(nil) + }() + if r.stop(ctx) { return } - name := args[0] - if !r.allowAllCommands && !r.allowedCommands[name] { + if !isAllowed { r.errf("rshell: %s: command not allowed\n", name) if r.allowedCommands["help"] { r.errf("Run 'help' to see allowed commands.\n") @@ -265,7 +311,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { return } - if fn, ok := builtins.Lookup(name); ok { + if isKnown { var runCmd func(context.Context, string, string, []string) (uint8, error) runCmd = func(ctx context.Context, dir string, cmdName string, cmdArgs []string) (uint8, error) { if !r.allowAllCommands && !r.allowedCommands[cmdName] { @@ -409,6 +455,59 @@ func (r *Runner) exec(ctx context.Context, pos syntax.Pos, args []string) { r.exit.fromHandlerError(r.execHandler(r.handlerCtx(ctx, pos), args)) } +// execIfChain runs an if/elif/else chain iteratively (rather than recursing +// through cmd() on each elif as the AST's Else pointer suggests) so the whole +// chain is covered by a single rshell.if span. The parser encodes "else" as a +// trailing *IfClause with no ThenPos set and an empty Cond. +func (r *Runner) execIfChain(ctx context.Context, cm *syntax.IfClause) { + span, ctx := telemetry.StartSpanFromContext(ctx, "control_flow") + span.SetResourceName("if") + branchCount := 0 + for cur := cm; cur != nil; cur = cur.Else { + branchCount++ + } + branchTaken := -2 // -2 = none matched, -1 = else, >=0 = if/elif index + defer func() { + span.SetTag("rshell.if.branch_taken", branchTaken) + span.SetTag("rshell.if.branch_count", branchCount) + span.Finish(nil) + }() + + idx := 0 + for cur := cm; cur != nil; cur = cur.Else { + // A trailing "else" branch has no "then" token. + if !cur.ThenPos.IsValid() { + branchTaken = -1 + r.stmts(ctx, cur.Then) + return + } + r.stmts(ctx, cur.Cond) + if r.exit.exiting || r.breakEnclosing > 0 || r.contnEnclosing > 0 { + return + } + if r.exit.ok() { + branchTaken = idx + r.stmts(ctx, cur.Then) + return + } + r.exit = exitStatus{} + idx++ + } +} + +// countPipelineStages walks a left-recursive Pipe-BinaryCmd tree (e.g. a|b|c +// is BinaryCmd(Pipe, BinaryCmd(Pipe, a, b), c)) and returns the total number +// of stages. +func countPipelineStages(cm *syntax.BinaryCmd) int { + n := 1 // Y is one stage + if inner, ok := cm.X.Cmd.(*syntax.BinaryCmd); ok && inner.Op == syntax.Pipe { + n += countPipelineStages(inner) + } else { + n += 1 + } + return n +} + // syncWriter wraps an io.Writer with a mutex so concurrent writes are safe. // Used to protect stderr when both sides of a pipe write to it. type syncWriter struct { diff --git a/interp/tracing_test.go b/interp/tracing_test.go new file mode 100644 index 00000000..34bb4e47 --- /dev/null +++ b/interp/tracing_test.go @@ -0,0 +1,485 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package interp + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry" + + "github.com/DataDog/rshell/internal/version" +) + +// captureTransport is an http.RoundTripper that records every request body it +// sees and returns a 200 response without making a real network call. It is +// used to intercept payloads the installer telemetry sender would otherwise +// POST to intake. +type captureTransport struct { + mu sync.Mutex + bodies [][]byte +} + +func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Body != nil { + b, _ := io.ReadAll(req.Body) + _ = req.Body.Close() + c.mu.Lock() + c.bodies = append(c.bodies, b) + c.mu.Unlock() + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(nil)), + Header: http.Header{}, + Request: req, + }, nil +} + +// newCapturingTelemetry builds a Telemetry instance whose HTTP client points +// at an in-memory RoundTripper, so tests can inspect the payload that would +// otherwise be POSTed to intake without making a real network call. +func newCapturingTelemetry(t *testing.T) (*telemetry.Telemetry, *captureTransport) { + t.Helper() + ct := &captureTransport{} + tel := telemetry.NewTelemetry( + &http.Client{Transport: ct}, + "test-api-key", + "datadoghq.com", + "rshell-test", + ) + return tel, ct +} + +// TestRunEmitsTracerSpan verifies that Run creates a "run" span tagged with +// the rshell version. +func TestRunEmitsTracerSpan(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "true")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + runSpan := findOneSpanByResource(spans, "run") + require.NotNil(t, runSpan, "expected a run span") + assert.Equal(t, version.Version, runSpan.Meta["rshell.version"]) +} + +// decodedEvent mirrors the top-level JSON shape the installer telemetry sender +// POSTs to intake. Only the fields needed by tests are listed. +type decodedEvent struct { + RequestType string `json:"request_type"` + Payload json.RawMessage `json:"payload"` +} + +type decodedTracePayload struct { + Traces [][]decodedSpan `json:"traces"` +} + +type decodedSpan struct { + Name string `json:"name"` + Resource string `json:"resource"` + TraceID uint64 `json:"trace_id"` + SpanID uint64 `json:"span_id"` + ParentID uint64 `json:"parent_id"` + Meta map[string]string `json:"meta"` + Metrics map[string]float64 `json:"metrics"` +} + +// spansForTrace decodes every captured request body and returns the spans that +// belong to the given trace ID. Filtering by trace ID isolates spans produced +// by a single test from any leftover spans that accumulated on the +// package-level global tracer across other tests. +func (c *captureTransport) spansForTrace(t *testing.T, traceID uint64) []decodedSpan { + t.Helper() + c.mu.Lock() + defer c.mu.Unlock() + var spans []decodedSpan + for _, body := range c.bodies { + var evt decodedEvent + require.NoError(t, json.Unmarshal(body, &evt)) + if evt.RequestType != "traces" { + continue + } + var pl decodedTracePayload + require.NoError(t, json.Unmarshal(evt.Payload, &pl)) + for _, trace := range pl.Traces { + for _, sp := range trace { + if sp.TraceID == traceID { + spans = append(spans, sp) + } + } + } + } + return spans +} + +// findSpanByCommand looks up a command span (operation "command") whose +// resource matches cmdName. The rshell.command.name tag is cross-checked to +// guard against a non-command span having a coincidentally matching resource. +func findSpanByCommand(spans []decodedSpan, cmdName string) *decodedSpan { + for i := range spans { + if spans[i].Name == "command" && spans[i].Resource == cmdName && + spans[i].Meta["rshell.command.name"] == cmdName { + return &spans[i] + } + } + return nil +} + +// findSpansByResource returns every span whose resource equals the given +// value. For control_flow spans (if/for/pipeline/for.iteration) and the run +// span, resource is the canonical identity of the span kind — operation name +// is shared across kinds (e.g. all control_flow spans share operation +// "control_flow"). +func findSpansByResource(spans []decodedSpan, resource string) []decodedSpan { + var out []decodedSpan + for _, sp := range spans { + if sp.Resource == resource { + out = append(out, sp) + } + } + return out +} + +func findOneSpanByResource(spans []decodedSpan, resource string) *decodedSpan { + matches := findSpansByResource(spans, resource) + if len(matches) == 0 { + return nil + } + return &matches[0] +} + +// testTraceIDCounter gives each test a unique, non-zero trace ID so the +// captured payload can be filtered regardless of what else accumulated on the +// shared global tracer before the test ran. +var testTraceIDCounter atomic.Uint64 + +func newTestTraceID() uint64 { return testTraceIDCounter.Add(1) + 1 } + +// runWithTracedContext runs prog on r under a context that carries a parent +// span with the given trace ID, then finishes the parent. Any rshell spans +// created during the run inherit the trace ID, which is what the test filters +// on. +func runWithTracedContext(t *testing.T, r *Runner, traceID uint64, script string) error { + t.Helper() + parent, ctx := telemetry.StartSpanFromIDs(context.Background(), "test.parent", + uint64ToDec(traceID), "0") + err := r.Run(ctx, parseScript(t, script)) + parent.Finish(nil) + return err +} + +func uint64ToDec(v uint64) string { + // strconv would work too — small helper to avoid the extra import. + buf := make([]byte, 0, 20) + if v == 0 { + return "0" + } + for v > 0 { + buf = append([]byte{byte('0' + v%10)}, buf...) + v /= 10 + } + return string(buf) +} + +// TestCallEmitsCommandSpan verifies that invoking a command emits an +// "command" span tagged with the command name. +func TestCallEmitsCommandSpan(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "true")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + cmd := findSpanByCommand(spans, "true") + require.NotNil(t, cmd, "expected a command span for 'true'") + assert.Equal(t, "true", cmd.Meta["rshell.command.name"]) + assert.Equal(t, "true", cmd.Meta["rshell.command.is_allowed"]) + assert.Equal(t, "true", cmd.Meta["rshell.command.is_known"]) + assert.Equal(t, "false", cmd.Meta["rshell.command.has_stdin_pipe"]) + assert.Equal(t, "false", cmd.Meta["rshell.command.has_output_redirect"]) + assert.Equal(t, float64(0), cmd.Metrics["rshell.command.argc"]) + assert.Equal(t, float64(0), cmd.Metrics["rshell.command.exit_code"]) +} + +// TestCommandSpanArgc verifies argc reflects the number of arguments passed +// (not counting argv[0]). +func TestCommandSpanArgc(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt(), StdIO(nil, io.Discard, io.Discard)) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "echo a b c")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + cmd := findSpanByCommand(spans, "echo") + require.NotNil(t, cmd) + assert.Equal(t, float64(3), cmd.Metrics["rshell.command.argc"]) +} + +// TestCommandSpanDisallowed verifies that a command blocked by AllowedCommands +// is captured with is_allowed=false and exit_code=127, and that the is_known +// tag is still populated regardless of the short-circuit. +func TestCommandSpanDisallowed(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + // Allow echo only; cat is a known builtin but not allowed. + r, err := New(AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + _ = runWithTracedContext(t, r, traceID, "cat /etc/hostname") + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + cmd := findSpanByCommand(spans, "cat") + require.NotNil(t, cmd, "expected a command span for the blocked call") + assert.Equal(t, "false", cmd.Meta["rshell.command.is_allowed"]) + assert.Equal(t, "true", cmd.Meta["rshell.command.is_known"]) + assert.Equal(t, float64(127), cmd.Metrics["rshell.command.exit_code"]) +} + +// TestCommandSpanUnknown verifies that a command not in the builtin registry +// is captured with is_known=false, and (under --allow-all-commands) also +// carries is_allowed=true with exit_code=127 from the noExecHandler. +func TestCommandSpanUnknown(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt(), StdIO(nil, io.Discard, io.Discard)) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + _ = runWithTracedContext(t, r, traceID, "definitely_not_a_real_command") + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + cmd := findSpanByCommand(spans, "definitely_not_a_real_command") + require.NotNil(t, cmd) + assert.Equal(t, "true", cmd.Meta["rshell.command.is_allowed"]) + assert.Equal(t, "false", cmd.Meta["rshell.command.is_known"]) + assert.Equal(t, float64(127), cmd.Metrics["rshell.command.exit_code"]) +} + +// TestCommandSpanNonZeroExit verifies the exit_code tag captures the actual +// exit status returned by a builtin (here, the "false" builtin returns 1). +func TestCommandSpanNonZeroExit(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + _ = runWithTracedContext(t, r, traceID, "false") + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + cmd := findSpanByCommand(spans, "false") + require.NotNil(t, cmd) + assert.Equal(t, float64(1), cmd.Metrics["rshell.command.exit_code"]) +} + +// TestCommandSpanPipeline verifies that the left stage of a pipeline has +// has_output_redirect=true (its stdout points at the pipe write end) while the +// right stage has has_stdin_pipe=true (its stdin points at the pipe read end). +func TestCommandSpanPipeline(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + var outBuf bytes.Buffer + r, err := New(allowAllCommandsOpt(), StdIO(nil, &outBuf, io.Discard)) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "echo hi | cat")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + echo := findSpanByCommand(spans, "echo") + cat := findSpanByCommand(spans, "cat") + require.NotNil(t, echo, "expected echo span") + require.NotNil(t, cat, "expected cat span") + + assert.Equal(t, "false", echo.Meta["rshell.command.has_stdin_pipe"]) + assert.Equal(t, "true", echo.Meta["rshell.command.has_output_redirect"]) + assert.Equal(t, "true", cat.Meta["rshell.command.has_stdin_pipe"]) + assert.Equal(t, "false", cat.Meta["rshell.command.has_output_redirect"]) +} + +// TestPipelineSpan verifies that a 3-stage pipeline produces a single +// flattened pipeline span with stage_count=3 and exit_code from the +// rightmost stage. +func TestPipelineSpan(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + var outBuf bytes.Buffer + r, err := New(allowAllCommandsOpt(), StdIO(nil, &outBuf, io.Discard)) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "echo hi | cat | cat")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + pipeSpans := findSpansByResource(spans, "pipeline") + require.Len(t, pipeSpans, 1, "expected exactly one pipeline span (flattened)") + assert.Equal(t, float64(3), pipeSpans[0].Metrics["rshell.pipeline.stage_count"]) + assert.Equal(t, float64(0), pipeSpans[0].Metrics["rshell.pipeline.exit_code"]) +} + +// TestIfSpanBranchTaken covers each possible branch_taken value on a single +// chain shape (if/elif/else): primary, elif, else, and none. +func TestIfSpanBranchTaken(t *testing.T) { + cases := []struct { + name string + script string + count int // expected branch_count + taken float64 // expected branch_taken + hasElse bool + }{ + {"primary", "if true; then true; elif true; then true; else true; fi", 3, 0, true}, + {"elif", "if false; then true; elif true; then true; else true; fi", 3, 1, true}, + {"else", "if false; then true; elif false; then true; else true; fi", 3, -1, true}, + {"none", "if false; then true; elif false; then true; fi", 2, -2, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, tc.script)) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + ifSpan := findOneSpanByResource(spans, "if") + require.NotNil(t, ifSpan, "expected one if span") + assert.Equal(t, float64(tc.count), ifSpan.Metrics["rshell.if.branch_count"]) + assert.Equal(t, tc.taken, ifSpan.Metrics["rshell.if.branch_taken"]) + }) + } +} + +// TestForSpan verifies for-span tags and that each iteration emits a +// rshell.for.iteration span with the expected zero-based index. +func TestForSpan(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "for x in a b c; do true; done")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + forSpan := findOneSpanByResource(spans, "for") + require.NotNil(t, forSpan) + assert.Equal(t, "x", forSpan.Meta["rshell.for.variable_name"]) + assert.Equal(t, float64(3), forSpan.Metrics["rshell.for.iteration_count"]) + assert.Equal(t, "false", forSpan.Meta["rshell.for.broke_early"]) + + iters := findSpansByResource(spans, "for.iteration") + require.Len(t, iters, 3) + indexes := make(map[float64]bool) + for _, it := range iters { + indexes[it.Metrics["rshell.for.iteration.index"]] = true + } + assert.True(t, indexes[0] && indexes[1] && indexes[2], + "expected iteration indexes 0, 1, 2; got %v", indexes) +} + +// TestForSpanBrokeEarly verifies broke_early is true when the loop exits via +// an explicit break, and iteration_count reflects how many iterations ran +// before the break. +func TestForSpanBrokeEarly(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, + "for x in a b c d; do if [ \"$x\" = b ]; then break; fi; done")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + forSpan := findOneSpanByResource(spans, "for") + require.NotNil(t, forSpan) + assert.Equal(t, "true", forSpan.Meta["rshell.for.broke_early"]) + // Two iterations ran: x=a (body continues) and x=b (break taken). + assert.Equal(t, float64(2), forSpan.Metrics["rshell.for.iteration_count"]) +} + +// TestNesting verifies that for → if → pipeline produces nested spans linked +// via ParentID, so the trace view reflects the control-flow structure. +func TestNesting(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + var outBuf bytes.Buffer + r, err := New(allowAllCommandsOpt(), StdIO(nil, &outBuf, io.Discard)) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + script := "for i in a; do if true; then echo hi | cat; fi; done" + require.NoError(t, runWithTracedContext(t, r, traceID, script)) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + + forSpan := findOneSpanByResource(spans, "for") + iterSpan := findOneSpanByResource(spans, "for.iteration") + ifSpan := findOneSpanByResource(spans, "if") + pipeSpan := findOneSpanByResource(spans, "pipeline") + echoSpan := findSpanByCommand(spans, "echo") + catSpan := findSpanByCommand(spans, "cat") + + require.NotNil(t, forSpan, "expected for span") + require.NotNil(t, iterSpan, "expected for.iteration span") + require.NotNil(t, ifSpan, "expected if span") + require.NotNil(t, pipeSpan, "expected pipeline span") + require.NotNil(t, echoSpan, "expected echo command span") + require.NotNil(t, catSpan, "expected cat command span") + + // Walk the expected parent chain: echo/cat → pipeline → if → iteration → for. + assert.Equal(t, pipeSpan.SpanID, echoSpan.ParentID, "echo's parent should be the pipeline span") + assert.Equal(t, pipeSpan.SpanID, catSpan.ParentID, "cat's parent should be the pipeline span") + assert.Equal(t, ifSpan.SpanID, pipeSpan.ParentID, "pipeline's parent should be the if span") + assert.Equal(t, iterSpan.SpanID, ifSpan.ParentID, "if's parent should be the for.iteration span") + assert.Equal(t, forSpan.SpanID, iterSpan.ParentID, "for.iteration's parent should be the for span") +} diff --git a/tests/scenarios/shell/if_clause/conditions/multi_command_cond_last_wins.yaml b/tests/scenarios/shell/if_clause/conditions/multi_command_cond_last_wins.yaml new file mode 100644 index 00000000..495cf3ee --- /dev/null +++ b/tests/scenarios/shell/if_clause/conditions/multi_command_cond_last_wins.yaml @@ -0,0 +1,19 @@ +description: When an if condition contains multiple commands, only the last command's exit status determines which branch runs. +input: + script: |+ + if false; true; then + echo then-taken + else + echo else-taken + fi + if true; false; then + echo second-then + else + echo second-else + fi +expect: + stdout: |+ + then-taken + second-else + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/if_clause/conditions/side_effect_short_circuit.yaml b/tests/scenarios/shell/if_clause/conditions/side_effect_short_circuit.yaml new file mode 100644 index 00000000..f0650a76 --- /dev/null +++ b/tests/scenarios/shell/if_clause/conditions/side_effect_short_circuit.yaml @@ -0,0 +1,17 @@ +description: Once a branch matches, later elif conditions must not run (short-circuit). Side effects in later conditions must be invisible. +input: + script: |+ + if true; then + echo matched-first + elif echo should-not-run; then + echo elif-body + elif echo also-should-not-run; then + echo second-elif-body + else + echo else-body + fi +expect: + stdout: |+ + matched-first + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/if_clause/edge_cases/assignment_in_condition.yaml b/tests/scenarios/shell/if_clause/edge_cases/assignment_in_condition.yaml new file mode 100644 index 00000000..c55198ff --- /dev/null +++ b/tests/scenarios/shell/if_clause/edge_cases/assignment_in_condition.yaml @@ -0,0 +1,13 @@ +description: A variable assigned in an if condition is visible in the matched branch body and after the if. +input: + script: |+ + if x=42; then + echo inside=$x + fi + echo after=$x +expect: + stdout: |+ + inside=42 + after=42 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/if_clause/edge_cases/deep_elif_chain.yaml b/tests/scenarios/shell/if_clause/edge_cases/deep_elif_chain.yaml new file mode 100644 index 00000000..84b82195 --- /dev/null +++ b/tests/scenarios/shell/if_clause/edge_cases/deep_elif_chain.yaml @@ -0,0 +1,17 @@ +description: Deep 8-way elif chain with only the seventh condition true exercises the iterative chain walk. +input: + script: |+ + if false; then echo 1 + elif false; then echo 2 + elif false; then echo 3 + elif false; then echo 4 + elif false; then echo 5 + elif false; then echo 6 + elif true; then echo 7 + else echo 8 + fi +expect: + stdout: |+ + 7 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/if_clause/edge_cases/exit_in_elif_condition.yaml b/tests/scenarios/shell/if_clause/edge_cases/exit_in_elif_condition.yaml new file mode 100644 index 00000000..693bff93 --- /dev/null +++ b/tests/scenarios/shell/if_clause/edge_cases/exit_in_elif_condition.yaml @@ -0,0 +1,15 @@ +description: An "exit" command inside an elif condition (reached mid-chain) terminates the script before the matching branch would be chosen. +input: + script: |+ + if false; then + echo first + elif exit 9; then + echo elif-body + else + echo else-body + fi + echo unreachable +expect: + stdout: "" + stderr: "" + exit_code: 9 From 9f6fa0a741e508cefdf36e1ff394ba62f688ff36 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Sat, 18 Apr 2026 01:51:52 +0200 Subject: [PATCH 2/5] feat(telemetry): tag run-span exit_code and outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two structured tags to the run span so operators can slice rshell invocations without parsing error.message strings: rshell.run.exit_code (int) — the interpreter's final exit code. rshell.run.outcome (string enum) — how the run ended: success any script completion (zero or non-zero exit, incl. explicit exit N) unallowed the last command dispatched was blocked by AllowedCommands unknown the last command dispatched was not in the builtin registry timeout context.DeadlineExceeded (rshell --timeout or caller) canceled context.Canceled (caller aborted the run) output_limit stdout cap (ErrOutputLimitExceeded) was hit fatal anything else (pipe creation error, internal panic, …) Only "abnormal" outcomes (timeout/canceled/output_limit/fatal) propagate retErr into span.Finish, so APM error-rate dashboards stay clean — plain non-zero exits and blocked commands no longer count as errors. Operators who care about policy rejections alert on rshell.run.outcome:unallowed instead. "unallowed" / "unknown" are resolved via a new lastDispatchStatus field on runnerState, updated inside call() and propagated up from subshells when a pipeline or (…) adopts a subshell's exit. Last-dispatch semantics mean a blocked command early in a script does not pollute outcome if a later command runs cleanly — matching the conventional shell reading of the final command's status. Co-Authored-By: Claude Opus 4.7 (1M context) --- analysis/symbols_interp.go | 3 ++ interp/api.go | 71 +++++++++++++++++++++++++++++++++++++- interp/runner_exec.go | 9 +++++ interp/tracing_test.go | 68 +++++++++++++++++++++++++++++++++++- 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/analysis/symbols_interp.go b/analysis/symbols_interp.go index 59f6d0db..3da6c1a2 100644 --- a/analysis/symbols_interp.go +++ b/analysis/symbols_interp.go @@ -20,10 +20,13 @@ var interpAllowedSymbols = []string{ "bytes.Buffer", // 🟢 in-memory byte buffer; pure data structure, no I/O. "context.Background", // 🟢 returns the empty background context; used in StdIO option where no run-scoped context is available. "context.CancelFunc", // 🟢 function type returned by WithTimeout/WithCancel; pure function type, no side effects. + "context.Canceled", // 🟢 sentinel error returned by ctx.Err() when Done was closed by CancelFunc; used to classify the run-span outcome. "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "context.DeadlineExceeded", // 🟢 sentinel error returned by ctx.Err() on deadline; used to classify the run-span outcome. "context.WithTimeout", // 🟢 derives a context with a deadline; needed for execution timeout support. "context.WithValue", // 🟢 derives a context carrying a key-value pair; pure function. "errors.As", // 🟢 error type assertion; pure function, no I/O. + "errors.Is", // 🟢 sentinel-error equivalence check; pure function, no I/O. "errors.New", // 🟢 creates a sentinel error value; pure function, no I/O. "fmt.Errorf", // 🟢 formatted error creation; pure function, no I/O. "fmt.Fprintf", // 🟠 formatted write to an io.Writer; delegates to Write, no filesystem access. diff --git a/interp/api.go b/interp/api.go index 89086bf0..24e8d980 100644 --- a/interp/api.go +++ b/interp/api.go @@ -134,6 +134,15 @@ type runnerState struct { // its own span. inPipeline bool + // lastDispatchStatus captures how the most recent call() invocation + // resolved: "dispatched" (ran a builtin or exec handler), "unallowed" + // (blocked by AllowedCommands), or "unknown" (not in the builtin + // registry). The run span reads it to classify outcomes like + // "unallowed" vs plain "success". Propagated up from subshells when a + // pipeline or (…) finishes so the top-level runner sees the last + // dispatch its execution thread observed. + lastDispatchStatus string + ecfg *expand.Config ectx context.Context // just so that subshell can use it again @@ -488,7 +497,25 @@ func (s ExitStatus) Error() string { return fmt.Sprintf("exit status %d", s) } func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { span, ctx := telemetry.StartSpanFromContext(ctx, "run") span.SetTag("rshell.version", version.Version) - defer func() { span.Finish(retErr) }() + defer func() { + span.SetTag("rshell.run.exit_code", int(r.exit.code)) + outcome := classifyRunOutcome(retErr, r.lastDispatchStatus) + span.SetTag("rshell.run.outcome", outcome) + // The run span reports whether the shell interpreter did its job. + // Script completion — even with a non-zero last command, an + // explicit `exit N`, a blocked command, or an unknown command — + // is not an interpreter-level failure and should not flag the + // span as errored. Only abnormal terminations (timeout, + // canceled, stdout cap hit, internal fatal) mark the span as an + // error for APM purposes. Consumers who want to alert on blocked + // or unknown commands can filter on rshell.run.outcome. + switch outcome { + case "success", "unallowed", "unknown": + span.Finish(nil) + default: + span.Finish(retErr) + } + }() defer func() { if rec := recover(); rec != nil { @@ -719,3 +746,45 @@ func (r *Runner) subshell(background bool) *Runner { r2.didReset = true return r2 } + +// classifyRunOutcome maps the error Run() is about to return, combined with +// the last dispatch attempted, into a stable low-cardinality enum tag on +// the run span. Outcomes: +// +// - success: script completed normally (including non-zero exit codes from +// command failures or explicit exit N). +// - unallowed: the last command dispatched was blocked by the +// AllowedCommands policy. +// - unknown: the last command dispatched was not in the builtin registry. +// - timeout / canceled: the run context's deadline or cancellation fired. +// - output_limit: the stdout cap was hit. +// - fatal: anything else (pipe creation error, internal panic, etc.). +// +// "unallowed" and "unknown" take precedence over "success" when the relevant +// dispatch was the last one observed, so operators can alert on policy +// rejections and roadmap gaps without false positives from plain non-zero +// exits. +func classifyRunOutcome(err error, lastDispatch string) string { + switch { + case errors.Is(err, ErrOutputLimitExceeded): + return "output_limit" + case errors.Is(err, context.DeadlineExceeded): + return "timeout" + case errors.Is(err, context.Canceled): + return "canceled" + } + switch lastDispatch { + case "unallowed": + return "unallowed" + case "unknown": + return "unknown" + } + if err == nil { + return "success" + } + var status ExitStatus + if errors.As(err, &status) { + return "success" + } + return "fatal" +} diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 113d30cd..4b0b9811 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -68,6 +68,9 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { r2.stmts(ctx, cm.Stmts) r.exit = r2.exit r.exit.exiting = false + if r2.lastDispatchStatus != "" { + r.lastDispatchStatus = r2.lastDispatchStatus + } case *syntax.Block: r.stmts(ctx, cm.Stmts) case *syntax.CallExpr: @@ -179,6 +182,9 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { rRight.stmt(ctx, cm.Y) r.exit = rRight.exit r.exit.exiting = false + if rRight.lastDispatchStatus != "" { + r.lastDispatchStatus = rRight.lastDispatchStatus + } pr.Close() wg.Wait() if rLeft.exit.fatalExit { @@ -303,6 +309,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } if !isAllowed { + r.lastDispatchStatus = "unallowed" r.errf("rshell: %s: command not allowed\n", name) if r.allowedCommands["help"] { r.errf("Run 'help' to see allowed commands.\n") @@ -312,6 +319,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } if isKnown { + r.lastDispatchStatus = "dispatched" var runCmd func(context.Context, string, string, []string) (uint8, error) runCmd = func(ctx context.Context, dir string, cmdName string, cmdArgs []string) (uint8, error) { if !r.allowAllCommands && !r.allowedCommands[cmdName] { @@ -448,6 +456,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { r.contnEnclosing = result.ContinueN return } + r.lastDispatchStatus = "unknown" r.exec(ctx, pos, args) } diff --git a/interp/tracing_test.go b/interp/tracing_test.go index 34bb4e47..7632661d 100644 --- a/interp/tracing_test.go +++ b/interp/tracing_test.go @@ -64,7 +64,7 @@ func newCapturingTelemetry(t *testing.T) (*telemetry.Telemetry, *captureTranspor } // TestRunEmitsTracerSpan verifies that Run creates a "run" span tagged with -// the rshell version. +// the rshell version, exit code, and a "success" outcome for a clean run. func TestRunEmitsTracerSpan(t *testing.T) { tel, ct := newCapturingTelemetry(t) @@ -80,6 +80,72 @@ func TestRunEmitsTracerSpan(t *testing.T) { runSpan := findOneSpanByResource(spans, "run") require.NotNil(t, runSpan, "expected a run span") assert.Equal(t, version.Version, runSpan.Meta["rshell.version"]) + assert.Equal(t, "success", runSpan.Meta["rshell.run.outcome"]) + assert.Equal(t, float64(0), runSpan.Metrics["rshell.run.exit_code"]) +} + +// TestRunSpanOutcome verifies the outcome classification on the run span +// across each completion mode. "success" covers any natural script +// completion (zero or non-zero exit, explicit exit N). "unallowed" and +// "unknown" surface when the last dispatched command was blocked by +// AllowedCommands or not in the builtin registry respectively, so operators +// can alert on policy rejections without false positives from plain +// non-zero exits. In every case the span must NOT be flagged as errored — +// APM error-rate signals are reserved for abnormal terminations (timeout, +// canceled, output_limit, fatal). +func TestRunSpanOutcome(t *testing.T) { + cases := []struct { + name string + script string + opts []RunnerOption + outcome string + exitCode float64 + }{ + {"natural exit 0", "true", + []RunnerOption{allowAllCommandsOpt()}, + "success", 0}, + {"explicit exit 0", "exit 0", + []RunnerOption{allowAllCommandsOpt()}, + "success", 0}, + {"last command returns non-zero", "false", + []RunnerOption{allowAllCommandsOpt()}, + "success", 1}, + {"explicit exit N", "exit 7", + []RunnerOption{allowAllCommandsOpt()}, + "success", 7}, + {"blocked by AllowedCommands as last dispatch", + "echo hi; cat /etc/hostname", + []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, + "unallowed", 127}, + {"unknown command (not in builtin registry)", + "nosuchcommand_xyz", + []RunnerOption{allowAllCommandsOpt(), StdIO(nil, io.Discard, io.Discard)}, + "unknown", 127}, + {"blocked command followed by success", + "cat /etc/hostname; echo ok", + []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, + "success", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + r, err := New(tc.opts...) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + _ = runWithTracedContext(t, r, traceID, tc.script) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + runSpan := findOneSpanByResource(spans, "run") + require.NotNil(t, runSpan) + assert.Equal(t, tc.outcome, runSpan.Meta["rshell.run.outcome"]) + assert.Equal(t, tc.exitCode, runSpan.Metrics["rshell.run.exit_code"]) + assert.Empty(t, runSpan.Meta["error.message"], + "script completion should not flag the run span as errored") + }) + } } // decodedEvent mirrors the top-level JSON shape the installer telemetry sender From 6ea393c4d6129d6ea98f57f7231ff103dd0fb3f3 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Sat, 18 Apr 2026 02:53:18 +0200 Subject: [PATCH 3/5] feat(telemetry): count policy rejections on run span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the "unallowed" / "unknown" outcome categories with two per-run counters on the run span: rshell.run.unallowed_count — commands blocked by AllowedCommands rshell.run.unknown_count — commands missing from the builtin registry Rationale: rshell does not halt on a blocked or unknown command — it prints an error, sets the call's exit to 127, and keeps executing subsequent statements. The earlier "unallowed" / "unknown" outcome, driven by the last-dispatched command's status, missed rejections that happened mid-script when the last command ran cleanly, and could also mask a clean script whose only blip was an intentional policy block. Counters capture the run-wide totals: operators alert on unallowed_count > 0 for policy violations, or unknown_count > 0 for roadmap gaps, without false positives from non-zero exits. Outcome is back to the "shell-ran-fine" taxonomy (success / timeout / canceled / output_limit / fatal) — any script completion is success. Counts are accumulated per Runner and summed into the parent when a pipeline or (…) subshell adopts a child's exit, so pipeline stages on either side of the pipe are both reflected. Co-Authored-By: Claude Opus 4.7 (1M context) --- interp/api.go | 80 +++++++++++++++++------------------------- interp/runner_exec.go | 18 +++++----- interp/tracing_test.go | 79 +++++++++++++++++++++++++++++++---------- 3 files changed, 102 insertions(+), 75 deletions(-) diff --git a/interp/api.go b/interp/api.go index 24e8d980..963e8390 100644 --- a/interp/api.go +++ b/interp/api.go @@ -134,14 +134,17 @@ type runnerState struct { // its own span. inPipeline bool - // lastDispatchStatus captures how the most recent call() invocation - // resolved: "dispatched" (ran a builtin or exec handler), "unallowed" - // (blocked by AllowedCommands), or "unknown" (not in the builtin - // registry). The run span reads it to classify outcomes like - // "unallowed" vs plain "success". Propagated up from subshells when a - // pipeline or (…) finishes so the top-level runner sees the last - // dispatch its execution thread observed. - lastDispatchStatus string + // unallowedCount / unknownCount count policy rejections observed by + // call() during this run: the number of commands blocked by + // AllowedCommands and the number of commands not in the builtin + // registry, respectively. Summed up from subshells when a pipeline or + // (…) completes so the top-level run span reports the run-wide totals. + // Kept separate so operators can alert on policy violations + // (unallowedCount > 0) independently from roadmap gaps + // (unknownCount > 0). The script as a whole may still succeed — these + // counts are informational, not an error signal. + unallowedCount int + unknownCount int ecfg *expand.Config ectx context.Context // just so that subshell can use it again @@ -499,20 +502,20 @@ func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { span.SetTag("rshell.version", version.Version) defer func() { span.SetTag("rshell.run.exit_code", int(r.exit.code)) - outcome := classifyRunOutcome(retErr, r.lastDispatchStatus) + span.SetTag("rshell.run.unallowed_count", r.unallowedCount) + span.SetTag("rshell.run.unknown_count", r.unknownCount) + outcome := classifyRunOutcome(retErr) span.SetTag("rshell.run.outcome", outcome) - // The run span reports whether the shell interpreter did its job. - // Script completion — even with a non-zero last command, an - // explicit `exit N`, a blocked command, or an unknown command — - // is not an interpreter-level failure and should not flag the - // span as errored. Only abnormal terminations (timeout, - // canceled, stdout cap hit, internal fatal) mark the span as an - // error for APM purposes. Consumers who want to alert on blocked - // or unknown commands can filter on rshell.run.outcome. - switch outcome { - case "success", "unallowed", "unknown": + // The run span reports whether the shell interpreter did its job — + // any script completion (even with a non-zero last command or an + // explicit `exit N`) is success from that point of view. Only + // abnormal terminations (timeout, canceled, stdout cap hit, + // internal fatal) flag the span as errored. Consumers distinguish + // zero vs non-zero exits via rshell.run.exit_code, and alert on + // policy rejections via rshell.run.unallowed_count > 0. + if outcome == "success" { span.Finish(nil) - default: + } else { span.Finish(retErr) } }() @@ -747,25 +750,17 @@ func (r *Runner) subshell(background bool) *Runner { return r2 } -// classifyRunOutcome maps the error Run() is about to return, combined with -// the last dispatch attempted, into a stable low-cardinality enum tag on -// the run span. Outcomes: -// -// - success: script completed normally (including non-zero exit codes from -// command failures or explicit exit N). -// - unallowed: the last command dispatched was blocked by the -// AllowedCommands policy. -// - unknown: the last command dispatched was not in the builtin registry. -// - timeout / canceled: the run context's deadline or cancellation fired. -// - output_limit: the stdout cap was hit. -// - fatal: anything else (pipe creation error, internal panic, etc.). -// -// "unallowed" and "unknown" take precedence over "success" when the relevant -// dispatch was the last one observed, so operators can alert on policy -// rejections and roadmap gaps without false positives from plain non-zero -// exits. -func classifyRunOutcome(err error, lastDispatch string) string { +// classifyRunOutcome maps the error Run() is about to return to a stable, +// low-cardinality enum tag on the run span. The outcome answers "did the +// interpreter do its job", not "did the script exit zero" — any script +// completion (including a failing last command or an explicit exit N) is +// "success". Consumers that care about the exit code read +// rshell.run.exit_code; those that care about policy rejections read +// rshell.run.unallowed_count / rshell.run.unknown_count. +func classifyRunOutcome(err error) string { switch { + case err == nil: + return "success" case errors.Is(err, ErrOutputLimitExceeded): return "output_limit" case errors.Is(err, context.DeadlineExceeded): @@ -773,15 +768,6 @@ func classifyRunOutcome(err error, lastDispatch string) string { case errors.Is(err, context.Canceled): return "canceled" } - switch lastDispatch { - case "unallowed": - return "unallowed" - case "unknown": - return "unknown" - } - if err == nil { - return "success" - } var status ExitStatus if errors.As(err, &status) { return "success" diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 4b0b9811..76dd591e 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -68,9 +68,8 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { r2.stmts(ctx, cm.Stmts) r.exit = r2.exit r.exit.exiting = false - if r2.lastDispatchStatus != "" { - r.lastDispatchStatus = r2.lastDispatchStatus - } + r.unallowedCount += r2.unallowedCount + r.unknownCount += r2.unknownCount case *syntax.Block: r.stmts(ctx, cm.Stmts) case *syntax.CallExpr: @@ -182,11 +181,13 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { rRight.stmt(ctx, cm.Y) r.exit = rRight.exit r.exit.exiting = false - if rRight.lastDispatchStatus != "" { - r.lastDispatchStatus = rRight.lastDispatchStatus - } pr.Close() wg.Wait() + // Both pipeline stages can independently hit blocked or + // unknown commands; roll both subshells' counters up to the + // parent so the run-span totals reflect the whole pipeline. + r.unallowedCount += rLeft.unallowedCount + rRight.unallowedCount + r.unknownCount += rLeft.unknownCount + rRight.unknownCount if rLeft.exit.fatalExit { r.exit.fatal(rLeft.exit.err) } @@ -309,7 +310,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } if !isAllowed { - r.lastDispatchStatus = "unallowed" + r.unallowedCount++ r.errf("rshell: %s: command not allowed\n", name) if r.allowedCommands["help"] { r.errf("Run 'help' to see allowed commands.\n") @@ -319,7 +320,6 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } if isKnown { - r.lastDispatchStatus = "dispatched" var runCmd func(context.Context, string, string, []string) (uint8, error) runCmd = func(ctx context.Context, dir string, cmdName string, cmdArgs []string) (uint8, error) { if !r.allowAllCommands && !r.allowedCommands[cmdName] { @@ -456,7 +456,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { r.contnEnclosing = result.ContinueN return } - r.lastDispatchStatus = "unknown" + r.unknownCount++ r.exec(ctx, pos, args) } diff --git a/interp/tracing_test.go b/interp/tracing_test.go index 7632661d..72731474 100644 --- a/interp/tracing_test.go +++ b/interp/tracing_test.go @@ -84,15 +84,12 @@ func TestRunEmitsTracerSpan(t *testing.T) { assert.Equal(t, float64(0), runSpan.Metrics["rshell.run.exit_code"]) } -// TestRunSpanOutcome verifies the outcome classification on the run span -// across each completion mode. "success" covers any natural script -// completion (zero or non-zero exit, explicit exit N). "unallowed" and -// "unknown" surface when the last dispatched command was blocked by -// AllowedCommands or not in the builtin registry respectively, so operators -// can alert on policy rejections without false positives from plain -// non-zero exits. In every case the span must NOT be flagged as errored — -// APM error-rate signals are reserved for abnormal terminations (timeout, -// canceled, output_limit, fatal). +// TestRunSpanOutcome verifies the outcome classification on the run span: +// any script completion (zero exit, non-zero exit, explicit exit N, or +// scripts that hit blocked/unknown commands along the way) is "success" +// — rshell treats these as the shell doing its job. The exit code lands +// in rshell.run.exit_code; blocked/unknown dispatches are counted via +// rshell.run.unallowed_count and rshell.run.unknown_count. func TestRunSpanOutcome(t *testing.T) { cases := []struct { name string @@ -113,16 +110,7 @@ func TestRunSpanOutcome(t *testing.T) { {"explicit exit N", "exit 7", []RunnerOption{allowAllCommandsOpt()}, "success", 7}, - {"blocked by AllowedCommands as last dispatch", - "echo hi; cat /etc/hostname", - []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, - "unallowed", 127}, - {"unknown command (not in builtin registry)", - "nosuchcommand_xyz", - []RunnerOption{allowAllCommandsOpt(), StdIO(nil, io.Discard, io.Discard)}, - "unknown", 127}, - {"blocked command followed by success", - "cat /etc/hostname; echo ok", + {"blocked command on the way", "cat /etc/hostname; echo ok", []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, "success", 0}, } @@ -148,6 +136,59 @@ func TestRunSpanOutcome(t *testing.T) { } } +// TestRunSpanPolicyCounters verifies the unallowed_count and unknown_count +// tags accurately tally the number of commands blocked by AllowedCommands +// and the number of commands missing from the builtin registry +// encountered during a run, including across pipeline stages. +func TestRunSpanPolicyCounters(t *testing.T) { + cases := []struct { + name string + script string + opts []RunnerOption + unallowedCount float64 + unknownCount float64 + }{ + {"no rejections", "echo a; echo b", + []RunnerOption{allowAllCommandsOpt(), StdIO(nil, io.Discard, io.Discard)}, + 0, 0}, + {"one blocked", "echo a; cat /etc/hostname", + []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, + 1, 0}, + {"two blocked, one in pipeline", + "cat x; cat y | grep z", + []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, + 3, 0}, + {"one unknown", "nosuchcmd_xyz", + []RunnerOption{allowAllCommandsOpt(), StdIO(nil, io.Discard, io.Discard)}, + 0, 1}, + {"mixed blocked and unknown", + "cat x; totally_made_up", + []RunnerOption{AllowedCommands([]string{"rshell:totally_made_up"}), StdIO(nil, io.Discard, io.Discard)}, + 1, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + r, err := New(tc.opts...) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + _ = runWithTracedContext(t, r, traceID, tc.script) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + runSpan := findOneSpanByResource(spans, "run") + require.NotNil(t, runSpan) + assert.Equal(t, tc.unallowedCount, runSpan.Metrics["rshell.run.unallowed_count"]) + assert.Equal(t, tc.unknownCount, runSpan.Metrics["rshell.run.unknown_count"]) + // These are accounting tags, not error signals. + assert.Equal(t, "success", runSpan.Meta["rshell.run.outcome"]) + assert.Empty(t, runSpan.Meta["error.message"]) + }) + } +} + // decodedEvent mirrors the top-level JSON shape the installer telemetry sender // POSTs to intake. Only the fields needed by tests are listed. type decodedEvent struct { From 0221d24c9877f566ea042c1aa1ed764f944c7dfa Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Sat, 18 Apr 2026 03:24:28 +0200 Subject: [PATCH 4/5] feat(telemetry): expand run-span command counters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the per-run command counters into a richer, more accurately scoped set, namespaced under rshell.run.commands.* so the related tags group visually in the Datadog UI: rshell.run.commands.total every call() invocation, regardless of outcome rshell.run.commands.dispatched commands that ran through a builtin fn rshell.run.commands.unallowed commands blocked by AllowedCommands rshell.run.commands.unknown commands not in the builtin registry unallowed and unknown are now counted independently. Previously a command blocked by policy short-circuited before the registry check, so a name that was both blocked and missing (e.g. `pwd` under a PAR allowlist that doesn't include it) only counted as unallowed — hiding the roadmap signal. The new logic mirrors the per-command rshell.command.is_allowed and rshell.command.is_known tags: both are independent facts about the command name, so both counters increment for a command that matches both predicates. The total counter captures every attempted dispatch (= the number of rshell.command spans produced under the run), giving operators an answer to "how many commands did this script attempt?" that is stable regardless of how the outcomes break down. All four counters are summed up from pipeline stages and (…) subshells so the run span carries run-wide totals. Co-Authored-By: Claude Opus 4.7 (1M context) --- interp/api.go | 33 ++++++++++++++++----------- interp/runner_exec.go | 25 +++++++++++++++++---- interp/tracing_test.go | 51 +++++++++++++++++++++++++----------------- 3 files changed, 72 insertions(+), 37 deletions(-) diff --git a/interp/api.go b/interp/api.go index 963e8390..8fdda9a3 100644 --- a/interp/api.go +++ b/interp/api.go @@ -134,17 +134,22 @@ type runnerState struct { // its own span. inPipeline bool - // unallowedCount / unknownCount count policy rejections observed by - // call() during this run: the number of commands blocked by - // AllowedCommands and the number of commands not in the builtin - // registry, respectively. Summed up from subshells when a pipeline or - // (…) completes so the top-level run span reports the run-wide totals. - // Kept separate so operators can alert on policy violations - // (unallowedCount > 0) independently from roadmap gaps - // (unknownCount > 0). The script as a whole may still succeed — these - // counts are informational, not an error signal. - unallowedCount int - unknownCount int + // totalCount / dispatchedCount / unallowedCount / unknownCount tally + // the call() invocations this run observed: how many command + // dispatches were attempted in total, how many ran through a + // builtin, how many were blocked by AllowedCommands, and how many + // were not in the builtin registry. The unallowed/unknown pair are + // independent facts about the command name — a command that is both + // blocked and unknown bumps both counters, matching the semantics of + // the per-command rshell.command.is_allowed / is_known tags. + // totalCount counts each call() exactly once regardless of outcome, + // so total_count = dispatched_count + (unallowed-only) + (unknown-only) + // + (unallowed AND unknown). Summed up from subshells when a + // pipeline or (…) completes. + totalCount int + dispatchedCount int + unallowedCount int + unknownCount int ecfg *expand.Config ectx context.Context // just so that subshell can use it again @@ -502,8 +507,10 @@ func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { span.SetTag("rshell.version", version.Version) defer func() { span.SetTag("rshell.run.exit_code", int(r.exit.code)) - span.SetTag("rshell.run.unallowed_count", r.unallowedCount) - span.SetTag("rshell.run.unknown_count", r.unknownCount) + span.SetTag("rshell.run.commands.total", r.totalCount) + span.SetTag("rshell.run.commands.dispatched", r.dispatchedCount) + span.SetTag("rshell.run.commands.unallowed", r.unallowedCount) + span.SetTag("rshell.run.commands.unknown", r.unknownCount) outcome := classifyRunOutcome(retErr) span.SetTag("rshell.run.outcome", outcome) // The run span reports whether the shell interpreter did its job — diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 76dd591e..aa19a63f 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -68,6 +68,8 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { r2.stmts(ctx, cm.Stmts) r.exit = r2.exit r.exit.exiting = false + r.totalCount += r2.totalCount + r.dispatchedCount += r2.dispatchedCount r.unallowedCount += r2.unallowedCount r.unknownCount += r2.unknownCount case *syntax.Block: @@ -183,9 +185,11 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { r.exit.exiting = false pr.Close() wg.Wait() - // Both pipeline stages can independently hit blocked or - // unknown commands; roll both subshells' counters up to the - // parent so the run-span totals reflect the whole pipeline. + // Roll each pipeline stage's per-run counters up to the + // parent so the run-span totals reflect commands dispatched, + // blocked, or unknown across every stage. + r.totalCount += rLeft.totalCount + rRight.totalCount + r.dispatchedCount += rLeft.dispatchedCount + rRight.dispatchedCount r.unallowedCount += rLeft.unallowedCount + rRight.unallowedCount r.unknownCount += rLeft.unknownCount + rRight.unknownCount if rLeft.exit.fatalExit { @@ -282,6 +286,7 @@ func (r *Runner) loopStmtsBroken(ctx context.Context, stmts []*syntax.Stmt) bool func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { name := args[0] + r.totalCount++ // Evaluate both policy checks upfront so the span tags reflect the // independent facts about the command name regardless of which gate @@ -309,8 +314,18 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { return } + // Increment independently — is_allowed and is_known are orthogonal + // facts about the command name, so a command that is both blocked and + // missing from the registry bumps both counters. Mirrors the semantics + // of the per-command rshell.command.is_allowed / is_known tags. if !isAllowed { r.unallowedCount++ + } + if !isKnown { + r.unknownCount++ + } + + if !isAllowed { r.errf("rshell: %s: command not allowed\n", name) if r.allowedCommands["help"] { r.errf("Run 'help' to see allowed commands.\n") @@ -320,6 +335,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } if isKnown { + r.dispatchedCount++ var runCmd func(context.Context, string, string, []string) (uint8, error) runCmd = func(ctx context.Context, dir string, cmdName string, cmdArgs []string) (uint8, error) { if !r.allowAllCommands && !r.allowedCommands[cmdName] { @@ -456,7 +472,8 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { r.contnEnclosing = result.ContinueN return } - r.unknownCount++ + // Allowed but not known: the default execHandler (noExecHandler) will + // reject with exit 127. unknownCount was already incremented above. r.exec(ctx, pos, args) } diff --git a/interp/tracing_test.go b/interp/tracing_test.go index 72731474..2a0c8829 100644 --- a/interp/tracing_test.go +++ b/interp/tracing_test.go @@ -136,35 +136,44 @@ func TestRunSpanOutcome(t *testing.T) { } } -// TestRunSpanPolicyCounters verifies the unallowed_count and unknown_count -// tags accurately tally the number of commands blocked by AllowedCommands -// and the number of commands missing from the builtin registry -// encountered during a run, including across pipeline stages. +// TestRunSpanPolicyCounters verifies the three per-run counters on the +// run span — dispatched_count (commands that actually ran a builtin), +// unallowed_count (blocked by AllowedCommands), and unknown_count +// (missing from the builtin registry) — tally correctly, including +// across pipeline stages and for commands that are both blocked and +// unknown (which bump both counters since is_allowed and is_known are +// independent facts about the command name). func TestRunSpanPolicyCounters(t *testing.T) { cases := []struct { - name string - script string - opts []RunnerOption - unallowedCount float64 - unknownCount float64 + name string + script string + opts []RunnerOption + totalCount float64 + dispatchedCount float64 + unallowedCount float64 + unknownCount float64 }{ {"no rejections", "echo a; echo b", []RunnerOption{allowAllCommandsOpt(), StdIO(nil, io.Discard, io.Discard)}, - 0, 0}, - {"one blocked", "echo a; cat /etc/hostname", + 2, 2, 0, 0}, + {"one blocked, one dispatched", "echo a; cat /etc/hostname", []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, - 1, 0}, - {"two blocked, one in pipeline", + 2, 1, 1, 0}, + {"blocked builtins across pipeline stages", "cat x; cat y | grep z", []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, - 3, 0}, - {"one unknown", "nosuchcmd_xyz", + 3, 0, 3, 0}, + {"one unknown", "echo a; nosuchcmd_xyz", []RunnerOption{allowAllCommandsOpt(), StdIO(nil, io.Discard, io.Discard)}, - 0, 1}, - {"mixed blocked and unknown", + 2, 1, 0, 1}, + {"blocked and unknown simultaneously (pwd-like)", + "echo a; pwd_no_such", + []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, + 2, 1, 1, 1}, + {"blocked builtin plus allowed-but-unknown", "cat x; totally_made_up", []RunnerOption{AllowedCommands([]string{"rshell:totally_made_up"}), StdIO(nil, io.Discard, io.Discard)}, - 1, 1}, + 2, 0, 1, 1}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -180,8 +189,10 @@ func TestRunSpanPolicyCounters(t *testing.T) { spans := ct.spansForTrace(t, traceID) runSpan := findOneSpanByResource(spans, "run") require.NotNil(t, runSpan) - assert.Equal(t, tc.unallowedCount, runSpan.Metrics["rshell.run.unallowed_count"]) - assert.Equal(t, tc.unknownCount, runSpan.Metrics["rshell.run.unknown_count"]) + assert.Equal(t, tc.totalCount, runSpan.Metrics["rshell.run.commands.total"]) + assert.Equal(t, tc.dispatchedCount, runSpan.Metrics["rshell.run.commands.dispatched"]) + assert.Equal(t, tc.unallowedCount, runSpan.Metrics["rshell.run.commands.unallowed"]) + assert.Equal(t, tc.unknownCount, runSpan.Metrics["rshell.run.commands.unknown"]) // These are accounting tags, not error signals. assert.Equal(t, "success", runSpan.Meta["rshell.run.outcome"]) assert.Empty(t, runSpan.Meta["error.message"]) From bfa0e7342242d914a1c12b8f8b3310267ea31100 Mon Sep 17 00:00:00 2001 From: AJ Stuyvenberg Date: Mon, 20 Apr 2026 12:07:19 -0400 Subject: [PATCH 5/5] Bump 3rd party deps --- LICENSE-3rdparty.csv | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 36d206a7..2bc88a8a 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -1,16 +1,32 @@ Component,Origin,License,Copyright -github.com/stretchr/testify,https://github.com/stretchr/testify,MIT,"Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors" -gopkg.in/yaml.v3,https://github.com/go-yaml/yaml,MIT AND Apache-2.0,"Copyright (c) 2006-2011 Kirill Simonov, 2011-2019 Canonical Ltd" -mvdan.cc/sh/v3,https://github.com/mvdan/sh,BSD-3-Clause,"Copyright (c) 2016, Daniel Marti" +github.com/DataDog/datadog-agent/pkg/fleet/installer,https://github.com/DataDog/datadog-agent,Apache-2.0,"Copyright 2016-present Datadog, Inc." +github.com/DataDog/datadog-agent/pkg/template,https://github.com/DataDog/datadog-agent,Apache-2.0,"Copyright 2016-present Datadog, Inc." +github.com/DataDog/datadog-agent/pkg/util/log,https://github.com/DataDog/datadog-agent,Apache-2.0,"Copyright 2016-present Datadog, Inc." +github.com/DataDog/datadog-agent/pkg/util/scrubber,https://github.com/DataDog/datadog-agent,Apache-2.0,"Copyright 2016-present Datadog, Inc." +github.com/DataDog/datadog-agent/pkg/version,https://github.com/DataDog/datadog-agent,Apache-2.0,"Copyright 2016-present Datadog, Inc." github.com/davecgh/go-spew,https://github.com/davecgh/go-spew,ISC,Copyright (c) 2012-2016 Dave Collins -github.com/inconshreveable/mousetrap,https://github.com/inconshreveable/mousetrap,Apache-2.0,Copyright 2014 Alan Shreve +github.com/ebitengine/purego,https://github.com/ebitengine/purego,Apache-2.0,Copyright 2022 The Ebitengine Authors +github.com/go-ole/go-ole,https://github.com/go-ole/go-ole,MIT,"Copyright (c) 2013-2017 Yasuhiro Matsumoto" github.com/google/uuid,https://github.com/google/uuid,BSD-3-Clause,Copyright (c) 2018 Google Inc. +github.com/inconshreveable/mousetrap,https://github.com/inconshreveable/mousetrap,Apache-2.0,Copyright 2014 Alan Shreve +github.com/lufia/plan9stats,https://github.com/lufia/plan9stats,BSD-3-Clause,"Copyright (c) 2019, KADOTA, Kyohei" github.com/pmezard/go-difflib,https://github.com/pmezard/go-difflib,BSD-3-Clause,"Copyright (c) 2013, Patrick Mezard" +github.com/power-devops/perfstat,https://github.com/power-devops/perfstat,MIT,Copyright (c) 2020 Power DevOps github.com/prometheus-community/pro-bing,https://github.com/prometheus-community/pro-bing,MIT,"Copyright 2022 The Prometheus Authors; Copyright 2016 Cameron Sparr and contributors" +github.com/shirou/gopsutil/v4,https://github.com/shirou/gopsutil,BSD-3-Clause,"Copyright (c) 2014, WAKAYAMA Shirou" github.com/spf13/cobra,https://github.com/spf13/cobra,Apache-2.0,Copyright 2013-2023 The Cobra Authors github.com/spf13/pflag,https://github.com/spf13/pflag,BSD-3-Clause,"Copyright (c) 2012 Alex Ogier, The Go Authors" +github.com/stretchr/testify,https://github.com/stretchr/testify,MIT,"Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors" +github.com/tklauser/go-sysconf,https://github.com/tklauser/go-sysconf,BSD-3-Clause,"Copyright (c) 2018-2022, Tobias Klauser" +github.com/tklauser/numcpus,https://github.com/tklauser/numcpus,Apache-2.0,"Copyright (c) 2018-2022, Tobias Klauser" +github.com/yusufpapurcu/wmi,https://github.com/yusufpapurcu/wmi,MIT,Copyright (c) 2013 Stack Exchange +go.uber.org/atomic,https://github.com/uber-go/atomic,MIT,"Copyright (c) 2016 Uber Technologies, Inc." +go.yaml.in/yaml/v3,https://github.com/yaml/go-yaml,MIT AND Apache-2.0,"Copyright (c) 2006-2011 Kirill Simonov, 2011-2019 Canonical Ltd" +golang.org/x/mod,https://github.com/golang/mod,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. golang.org/x/net,https://github.com/golang/net,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. golang.org/x/sync,https://github.com/golang/sync,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. golang.org/x/sys,https://github.com/golang/sys,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. -golang.org/x/mod,https://github.com/golang/mod,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. +golang.org/x/time,https://github.com/golang/time,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. golang.org/x/tools,https://github.com/golang/tools,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. +gopkg.in/yaml.v3,https://github.com/go-yaml/yaml,MIT AND Apache-2.0,"Copyright (c) 2006-2011 Kirill Simonov, 2011-2019 Canonical Ltd" +mvdan.cc/sh/v3,https://github.com/mvdan/sh,BSD-3-Clause,"Copyright (c) 2016, Daniel Marti"