Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions analysis/symbols_interp.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ var interpAllowedSymbols = []string{
"sync/atomic.Int64", // 🟢 atomic int64 counter; concurrency primitive, no I/O.
"time.Duration", // 🟢 numeric duration type; pure type, no side effects.
"time.Now", // 🟠 returns current time; read-only, no mutation.
"time.Since", // 🟠 returns elapsed duration since a Time; read-only, no mutation.
"time.Time", // 🟢 time value type; pure data, no side effects.

// --- mvdan.cc/sh/v3/expand --- (shell word expansion library)
Expand Down
19 changes: 19 additions & 0 deletions interp/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ type runnerConfig struct {
// Defaults to "/proc" when empty.
procPath string

// commandObserver, if non-nil, is invoked after each simple command
// dispatch with the resolved outcome. Intended for trace/telemetry
// instrumentation; see [CommandObserver] for details.
commandObserver CommandObserverFunc

// proc is the ProcProvider constructed from procPath, created once in
// New() and shared across subshells via runnerConfig value copy.
proc *builtins.ProcProvider
Expand Down Expand Up @@ -375,6 +380,20 @@ func MaxExecutionTime(d time.Duration) RunnerOption {
}
}

// CommandObserver installs a [CommandObserverFunc] invoked once per simple
// command dispatch. The observer receives the resolved command name, args,
// exit code, status, source position, and wall-clock duration. Passing nil
// clears any previously set observer.
//
// The observer is best-effort telemetry; it must not modify interpreter
// state, and its panics propagate to the caller of [Runner.Run].
func CommandObserver(fn CommandObserverFunc) RunnerOption {
return func(r *Runner) error {
r.commandObserver = fn
return nil
}
}

// Reset returns a runner to its initial state, right before the first call to
// Run or Reset.
//
Expand Down
156 changes: 156 additions & 0 deletions interp/command_observer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// 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 (
"context"
"io"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// newObserverRunner builds a Runner with stdout/stderr discarded so that
// tests can focus on CommandObserver events without asserting on shell
// output.
func newObserverRunner(t *testing.T, opts ...RunnerOption) (*Runner, func()) {
t.Helper()
allOpts := append([]RunnerOption{StdIO(nil, io.Discard, io.Discard)}, opts...)
r, err := New(allOpts...)
require.NoError(t, err)
return r, func() { _ = r.Close() }
}

func TestCommandObserverOkStatusForBuiltin(t *testing.T) {
var events []CommandEvent
r, cleanup := newObserverRunner(t,
allowAllCommandsOpt(),
CommandObserver(func(_ context.Context, e CommandEvent) {
events = append(events, e)
}),
)
defer cleanup()

require.NoError(t, r.Run(context.Background(), parseScript(t, "true")))

require.Len(t, events, 1)
assert.Equal(t, "true", events[0].Name)
assert.Equal(t, CommandStatusOk, events[0].Status)
assert.Equal(t, 0, events[0].ExitCode)
}

func TestCommandObserverOkCarriesNonZeroExitCode(t *testing.T) {
var events []CommandEvent
r, cleanup := newObserverRunner(t,
allowAllCommandsOpt(),
CommandObserver(func(_ context.Context, e CommandEvent) {
events = append(events, e)
}),
)
defer cleanup()

// `false` exits non-zero; the || keeps Run from returning an error
// so we only observe the single dispatch.
require.NoError(t, r.Run(context.Background(), parseScript(t, "false || true")))

require.GreaterOrEqual(t, len(events), 1)
var seenFalse bool
for _, e := range events {
if e.Name == "false" {
seenFalse = true
assert.Equal(t, CommandStatusOk, e.Status)
assert.Equal(t, 1, e.ExitCode)
}
}
assert.True(t, seenFalse, "expected a CommandEvent for 'false'")
}

func TestCommandObserverNotAllowedStatus(t *testing.T) {
var events []CommandEvent
// AllowedCommands restricts dispatch to just `true`; invoking anything else
// should fire an observation with status=not_allowed and ExitCode=-1.
r, cleanup := newObserverRunner(t,
AllowedCommands([]string{"rshell:true"}),
CommandObserver(func(_ context.Context, e CommandEvent) {
events = append(events, e)
}),
)
defer cleanup()

// `false` is a real rshell builtin but is not in the allowlist; the
// Runner rejects it before dispatch.
_ = r.Run(context.Background(), parseScript(t, "false"))

require.Len(t, events, 1)
assert.Equal(t, "false", events[0].Name)
assert.Equal(t, CommandStatusNotAllowed, events[0].Status)
assert.Equal(t, -1, events[0].ExitCode)
}

func TestCommandObserverUnknownStatusForNonBuiltin(t *testing.T) {
var events []CommandEvent
// Allowlisted but no rshell builtin exists for "definitely-not-a-builtin",
// so the default noExecHandler refuses it and we observe status=unknown.
r, cleanup := newObserverRunner(t,
AllowedCommands([]string{"rshell:definitely-not-a-builtin"}),
CommandObserver(func(_ context.Context, e CommandEvent) {
events = append(events, e)
}),
)
defer cleanup()

_ = r.Run(context.Background(), parseScript(t, "definitely-not-a-builtin"))

require.Len(t, events, 1)
assert.Equal(t, "definitely-not-a-builtin", events[0].Name)
assert.Equal(t, CommandStatusUnknown, events[0].Status)
assert.Equal(t, -1, events[0].ExitCode)
}

func TestCommandObserverCapturesArgs(t *testing.T) {
var events []CommandEvent
r, cleanup := newObserverRunner(t,
allowAllCommandsOpt(),
CommandObserver(func(_ context.Context, e CommandEvent) {
events = append(events, e)
}),
)
defer cleanup()

require.NoError(t, r.Run(context.Background(), parseScript(t, "echo hello world")))

require.Len(t, events, 1)
assert.Equal(t, []string{"echo", "hello", "world"}, events[0].Args)
}

func TestCommandObserverNilIsNoOp(t *testing.T) {
// Passing nil clears any previously set observer; Run must still succeed
// and not dereference a nil callback.
r, cleanup := newObserverRunner(t,
allowAllCommandsOpt(),
CommandObserver(nil),
)
defer cleanup()

require.NoError(t, r.Run(context.Background(), parseScript(t, "true")))
}

func TestCommandObserverMultipleCommandsInScript(t *testing.T) {
var names []string
r, cleanup := newObserverRunner(t,
allowAllCommandsOpt(),
CommandObserver(func(_ context.Context, e CommandEvent) {
names = append(names, e.Name)
}),
)
defer cleanup()

require.NoError(t, r.Run(context.Background(), parseScript(t, "true; true; false || true")))

// One event per dispatched command; order follows execution order.
assert.Equal(t, []string{"true", "true", "false", "true"}, names)
}
35 changes: 35 additions & 0 deletions interp/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"io"
"io/fs"
"os"
"time"

"mvdan.cc/sh/v3/expand"
"mvdan.cc/sh/v3/syntax"
Expand Down Expand Up @@ -85,6 +86,40 @@ type ReadDirHandlerFunc func(ctx context.Context, path string) ([]fs.DirEntry, e
// Any other error will halt the [Runner] and will be returned via the API.
type ExecHandlerFunc func(ctx context.Context, args []string) error

// CommandStatus describes the outcome of a single command dispatch.
type CommandStatus string

const (
// CommandStatusOk indicates the command ran (builtin or external) and its
// exit status was captured, regardless of whether the exit code was zero.
CommandStatusOk CommandStatus = "ok"
// CommandStatusNotAllowed indicates the command was rejected because it
// was not in the AllowedCommands set.
CommandStatusNotAllowed CommandStatus = "not_allowed"
// CommandStatusUnknown indicates the command was not a known builtin and
// the default noExecHandler refused to execute it. This is the signal
// that a customer attempted a command rshell does not implement.
CommandStatusUnknown CommandStatus = "unknown"
)

// CommandEvent describes a single command dispatch observed by a
// [CommandObserverFunc]. ExitCode is -1 when Status is CommandStatusNotAllowed
// or CommandStatusUnknown.
type CommandEvent struct {
Name string
Args []string
ExitCode int
Status CommandStatus
Pos syntax.Pos
Duration time.Duration
}

// CommandObserverFunc is invoked after every simple command dispatch (builtin
// or external), including commands rejected by AllowedCommands or the default
// noExecHandler. Observers must not modify interpreter state; the hook is
// informational only.
type CommandObserverFunc func(ctx context.Context, event CommandEvent)

// noExecHandler returns an [ExecHandlerFunc] that rejects all commands.
// It prints "rshell: <cmd>: unknown command" to stderr and returns exit
// code 127, without ever searching PATH or executing host binaries.
Expand Down
28 changes: 28 additions & 0 deletions interp/runner_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"os"
"path/filepath"
"sync"
"time"

"mvdan.cc/sh/v3/expand"
"mvdan.cc/sh/v3/syntax"
Expand Down Expand Up @@ -256,12 +257,33 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) {
}
name := args[0]

// observerStart records the dispatch time so the observer (if any)
// receives an accurate Duration regardless of which path r.call takes.
var observerStart time.Time
if r.commandObserver != nil {
observerStart = time.Now()
}
emitObservation := func(status CommandStatus, exitCode int) {
if r.commandObserver == nil {
return
}
r.commandObserver(ctx, CommandEvent{
Name: name,
Args: args,
ExitCode: exitCode,
Status: status,
Pos: pos,
Duration: time.Since(observerStart),
})
}

if !r.allowAllCommands && !r.allowedCommands[name] {
r.errf("rshell: %s: command not allowed\n", name)
if r.allowedCommands["help"] {
r.errf("Run 'help' to see allowed commands.\n")
}
r.exit.code = 127
emitObservation(CommandStatusNotAllowed, -1)
return
}

Expand Down Expand Up @@ -400,9 +422,15 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) {
r.exit.exiting = result.Exiting
r.breakEnclosing = result.BreakN
r.contnEnclosing = result.ContinueN
emitObservation(CommandStatusOk, int(result.Code))
return
}
r.exec(ctx, pos, args)
// Reaching here means the command was allowed by AllowedCommands but no
// rshell builtin implements it, so the default noExecHandler rejected
// execution. This is the "customer tried a command rshell does not
// implement" signal described by CommandStatusUnknown.
emitObservation(CommandStatusUnknown, -1)
}

func (r *Runner) exec(ctx context.Context, pos syntax.Pos, args []string) {
Expand Down
Loading