How to Execute Local Shell Commands with Probe's Shell Plugin

Probe's shell plugin enables arbitrary command execution on the host machine through workflow steps defined with uses: shell, supporting synchronous runs, background tasks, custom shells, and environment variable injection.

The linyows/probe repository provides a built-in shell action that integrates with Probe's workflow engine to execute local commands securely. Whether you need to run build scripts, capture command output for later steps, or launch background processes, the shell plugin handles process management, validation, and result capture through a structured Go API.

Understanding the Shell Plugin Architecture

The shell plugin implementation resides in shell/client.go and exposes a request-response model centered around the shell.Req struct. This struct holds the command string, shell binary path, working directory, timeout duration, environment map, and a background execution flag.

When the workflow engine encounters a uses: shell step, it delegates to the action wrapper in actions/shell/main.go. This wrapper validates the presence of the with parameter map, registers optional lifecycle callbacks using shell.WithBefore and shell.WithAfter, and invokes shell.Execute to process the request.

The execution flow follows these distinct phases:

  1. Parameter Parsing: The parseParams function validates required fields, applies defaults via shell.NewReq (which sets /bin/sh as the default shell and 30s as the default timeout), and verifies the shell path against an allowed whitelist.
  2. Path Validation: The validateShellPath function restricts execution to known shells including /bin/sh, /bin/bash, /bin/zsh, /bin/dash, and their /usr/bin/* equivalents for security hardening.
  3. Command Execution: The Req.Do method constructs an exec.Cmd with the specified shell and -c flag, applies the working directory and environment variables, then runs either synchronously or asynchronously based on the Background flag.
  4. Result Capture: For synchronous runs, the method captures stdout, stderr, exit code, PID, and runtime duration. For background tasks, it returns immediately with exit code -1 and a temporary log file path for output tracking.

Configuring Shell Command Execution

Workflow steps using the shell plugin accept several parameters within the with map. All parameters are optional except cmd, which specifies the command string to execute.

  • cmd: The shell command string (required).
  • shell: Absolute path to the shell binary (default: /bin/sh).
  • workdir: Working directory for command execution (must exist).
  • timeout: Duration string like 10s or 5m (default: 30s).
  • env: Map of environment variables to inject.
  • background: Boolean flag to run the command asynchronously.

The plugin enforces strict validation: the working directory must exist on the filesystem, and only whitelisted shell binaries are permitted. Environment variables do not inherit from the host process unless explicitly added to the env map.

Running Commands in Workflows

Define shell execution steps in your workflow YAML using the uses: shell directive. The engine unmarshals the with map into a shell.Req and processes the action.

Basic Command Execution

name: Shell Action Demo
jobs:
  - name: Run Local Commands
    steps:
      - name: Simple echo
        uses: shell
        with:
          cmd: "echo 'Hello, Probe!'"

This minimal configuration uses /bin/sh with a 30-second timeout.

Custom Shell and Environment

      - name: Use custom shell and env
        uses: shell
        with:
          cmd: "echo \"Running as $USER on $PROJECT_DIR\""
          shell: "/bin/bash"
          env:
            USER: "probe_user"
            PROJECT_DIR: "/opt/project"
          workdir: "/tmp"
          timeout: "10s"

The shell parameter overrides the default, while the env map explicitly defines variables available to the command context.

Background Execution

      - name: Background task
        uses: shell
        with:
          cmd: "sleep 60 && echo 'done'"
          background: true

When background is set to true, the workflow continues immediately while the process runs detached. The step result includes the pid and a temporary log file path instead of stdout/stderr content.

Accessing Command Output

Probe's workflow engine exposes step results through template variables, allowing subsequent steps to access the output of previous shell commands.

jobs:
  - name: Capture and reuse output
    steps:
      - id: generate
        uses: shell
        with:
          cmd: "uuidgen"
      - id: show
        uses: shell
        with:
          cmd: "echo \"Previous UUID: {{steps.generate.result.res.stdout}}\""

The {{steps.<id>.result.res.stdout}} variable retrieves the standard output from the previous step's execution, enabling dynamic data flow between workflow stages.

Implementing Go Integration

For direct integration outside of workflow files, import the shell package and construct parameter maps matching the YAML with structure.

package main

import (
	"fmt"
	"github.com/linyows/probe/shell"
)

func main() {
	params := map[string]any{
		"cmd":     "date; echo $MY_VAR",
		"env":     map[string]string{"MY_VAR": "probe-demo"},
		"shell":   "/bin/bash",
		"workdir": "/tmp",
		"timeout": "10s",
	}

	before := shell.WithBefore(func(cmd, sh, wd string) {
		fmt.Printf("About to run: %s (shell=%s, workdir=%s)\n", cmd, sh, wd)
	})
	
	after := shell.WithAfter(func(r *shell.Result) {
		fmt.Printf("Finished (code=%d, rt=%s)\n", r.Res.Code, r.RT)
		fmt.Println("stdout:", r.Res.Stdout)
	})

	out, err := shell.Execute(params, before, after)
	if err != nil {
		panic(err)
	}
	fmt.Printf("Result map: %+v\n", out)
}

The shell.Execute function accepts the parameter map and optional callback options. Callbacks provide hooks into the execution lifecycle: WithBefore receives the resolved command, shell, and workdir before execution, while WithAfter receives the complete Result struct containing stdout, stderr, exit code, PID, runtime, and status.

Security and Validation

The shell plugin implements multiple security controls in shell/client.go to prevent arbitrary code execution vulnerabilities:

  • Shell Whitelist: The validateShellPath function restricts the shell parameter to known interpreters (/bin/sh, /bin/bash, /bin/zsh, /bin/dash, and /usr/bin/* variants).
  • Directory Validation: The validateWorkdir function ensures the specified working directory exists before execution begins.
  • Environment Isolation: The plugin passes only explicitly defined environment variables; it does not inherit the complete host environment.
  • Timeout Enforcement: All commands respect the configured timeout to prevent hanging processes.

Summary

  • The shell plugin in linyows/probe executes commands through the shell.Req struct and Req.Do method, wrapped by the action handler in actions/shell/main.go.
  • Configure commands via the with map using cmd, shell, workdir, timeout, env, and background parameters.
  • Background execution returns immediately with a PID and log file path, while synchronous execution captures stdout, stderr, and exit codes.
  • Access previous step outputs using {{steps.<id>.result.res.stdout}} in workflow templates.
  • Security controls include shell path whitelisting, working directory validation, and isolated environment variable handling.

Frequently Asked Questions

What shells are supported by Probe's shell plugin?

The plugin maintains a whitelist of allowed shells including /bin/sh, /bin/bash, /bin/zsh, /bin/dash, and their /usr/bin/* equivalents. The validateShellPath function in shell/client.go rejects any shell binary outside this list to prevent execution of arbitrary interpreters.

How do I capture the output of a shell command for use in later steps?

Assign an id to your shell step, then reference the output using the template variable {{steps.<id>.result.res.stdout}} in subsequent steps. The workflow engine stores the complete Result struct, including stdout, stderr, Code, Pid, and RT (runtime), accessible through the result field.

Can I run long-running commands without blocking the workflow?

Yes. Set background: true in the with map. When enabled, Req.Do launches the process detached from the workflow execution, returns immediately with exit code -1, and provides the process ID and a temporary log file path in the result. The workflow continues to subsequent steps while the command runs independently.

What is the default timeout for shell commands, and how do I change it?

The default timeout is 30s, set by the shell.NewReq function. Override this by specifying a duration string in the timeout parameter, such as 10s for ten seconds or 5m for five minutes. The parseTimeout function in shell/client.go parses these strings into time.Duration values.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →