shell

package
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: Apache-2.0 Imports: 41 Imported by: 0

Documentation

Overview

Package shell is a virtual bash for agents: the model writes ordinary shell (pipes, &&, loops, variables, redirects, globs) and every file the script touches is a file of a vfs.Storage, so overlays, dry runs, mounts and path policy keep working. Commands are Go implementations of the usual tools (cat, grep, sed, find, jq, ...) plus the ones the caller registers: Go functions, or host programs it allows explicitly (git, kubectl).

Nothing reaches the host unless the caller registered it: no host files, no network, no host environment. A command the script calls that is not registered fails like in bash: "bash: line 1: name: command not found", exit 127.

The script sees the Storage at Options.MountPoint ("/" by default): absolute paths below it are workspace paths, other paths and climbing above it are "No such file or directory". /tmp is a memory scratch area of the Shell (never the Storage) that keeps its files from one Run to the next, within the Shell's 128 MiB memory budget; /dev/null, /dev/stdin, /dev/stdout and /dev/stderr work. A Storage holds only files, so a directory exists while a file lies below it or after mkdir created it in this Shell. Redirects collect what is written and store it with one WriteFile when they close. New builds a Shell, Register adds it to a tools.Registry as the "bash" tool, External wraps a host program as a Command.

Index

Examples

Constants

View Source
const (
	DefaultTimeout            = 60 * time.Second
	DefaultMaximumOutputBytes = 30 << 10
	DefaultToolName           = "bash"
)

Defaults of Options.

Variables

This section is empty.

Functions

func Register

func Register(registry *tools.Registry, options *Options) error

Register adds the shell as one tool (named Options.ToolName, "bash" by default) to registry. The tool takes {"command": "...", "description": "..."}; the description is for logs and is ignored. Every call runs on the same Shell, so the working directory persists between calls.

It returns an error for a nil registry, the errors of New, and an error matching tools.ErrDuplicateTool if the name is taken.

Example

Register adds the shell as one tool; the model gets the output, the exit code and whether the output was cut.

package main

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/govengers/aikido/llm"
	"github.com/govengers/aikido/tools"
	"github.com/govengers/aikido/tools/shell"
	"github.com/govengers/aikido/vfs/memory"
)

func main() {
	registry := tools.NewRegistry()
	if err := shell.Register(registry, &shell.Options{Storage: memory.New()}); err != nil {
		fmt.Println(err)
		return
	}
	result, err := registry.Dispatch(context.Background(), llm.ToolCall{
		Name:      "bash",
		Arguments: `{"command": "mkdir -p src && echo 'package main' > src/main.go && find . -name '*.go'"}`,
	}, tools.Environment{})
	if err != nil {
		fmt.Println(err)
		return
	}
	encoded, _ := json.Marshal(result.Content)
	fmt.Println(string(encoded))
}
Output:
{"output":"./src/main.go\n","exit_code":0,"truncated":false}

func Resolve

func Resolve(directory, name string) (string, error)

Resolve turns a path as a script writes it into a workspace path. A leading "/" means the workspace root, anything else is relative to directory (a workspace path, "" for the root). "." and ".." are resolved; a path that climbs above the root is vfs.ErrPathInvalid, like everything ValidatePath refuses. The root itself resolves to "".

Types

type Command

type Command struct {
	// Name is what the script types, e.g. "grep". Required, unique per shell.
	Name string
	// Summary is one short line for the tool description the model reads,
	// e.g. "search text (-r -n -l -i -c -v -E -o -w)". Required.
	Summary string
	// Run executes one invocation. It returns nil for exit status 0, an
	// ExitError for another status, and any other error for a failure the
	// shell reports on stderr with status 1. Run must honour ctx.
	//
	// Run is called concurrently: the commands of a pipeline and background
	// jobs run at once, and one Command may be registered with several
	// Shells, so it must be safe for concurrent use. The Invocation and its
	// streams belong to that call only: the shell owns Stdin, Stdout and
	// Stderr (Run must not close them) and they must not be used after Run
	// returns; a goroutine Run starts must end before Run returns. A panic is
	// reported as an internal error with status 2.
	Run func(ctx context.Context, invocation *Invocation) error
}

Command is one program the shell can run.

func Builtins

func Builtins() []Command

Builtins returns the commands every Shell has, sorted by name: file commands (cat, ls, find, mkdir, rm, mv, cp, ...), text commands (grep, sed, head, ...) and small utilities (seq, date, env, base64, ...). Each is a Go implementation that accepts a subset of the GNU flags, prints what GNU prints for them and fails loudly on any other flag. Run outside a Shell (with an Invocation built by the caller), the commands that start other commands (bash, sh, xargs, env with a command, find -exec) fail, and which knows no commands. Each call returns new values, so callers may change them.

Example

Builtins lists the commands every Shell has, for documentation.

package main

import (
	"fmt"

	"github.com/govengers/aikido/tools/shell"
)

func main() {
	for _, command := range shell.Builtins() {
		if command.Name == "cat" || command.Name == "mkdir" {
			fmt.Printf("%s: %s\n", command.Name, command.Summary)
		}
	}
}
Output:
cat: print files (-n -b number lines, -s squeeze blanks, -A -E -T -v -e -t show ends, tabs, control bytes; - is stdin)
mkdir: create directories (-p parents, -v)

func External

func External(options *ExternalOptions) (Command, error)

External returns a Command that runs a host program, for Options.Commands. Stdin, stdout and stderr are the script's streams, so pipes work (git log | grep fix). A canceled script kills the program and its process group.

It returns an error for nil options, a Name that is empty or not one word, a Path that is not absolute or not a regular file, or a Directory that is not an absolute path to a directory.

Example

External registers a host program next to the built-in commands. Allow refuses the runs the caller does not want; here git may read but not push.

package main

import (
	"errors"
	"fmt"
	"slices"

	"github.com/govengers/aikido/tools/shell"
	"github.com/govengers/aikido/vfs/memory"
)

func main() {
	git, err := shell.External(&shell.ExternalOptions{
		Name:        "git",
		Path:        "/usr/bin/git",
		Directory:   "/srv/checkout",
		Environment: []string{"HOME=/srv/checkout", "GIT_CONFIG_NOSYSTEM=1"},
		Allow: func(arguments []string) error {
			if slices.Contains(arguments, "push") {
				return errors.New("push is not allowed")
			}
			return nil
		},
	})
	if err != nil {
		fmt.Println(err)
		return
	}
	_, _ = shell.New(&shell.Options{Storage: memory.New(), Commands: []shell.Command{git}})
}

type ExitError

type ExitError uint8

ExitError is a non-zero exit status a Command returns, like grep's 1 for "no match".

func (ExitError) Error

func (s ExitError) Error() string

type ExternalOptions

type ExternalOptions struct {
	// Name is the command name scripts use, e.g. "git". Required, one word.
	Name string
	// Path is the absolute path of the host binary, e.g. "/usr/bin/git".
	// Required; External checks that it exists.
	Path string
	// Directory is the host working directory of the program, e.g. a git
	// checkout. Required and absolute. The program sees the host file
	// system there, not the workspace, and the shell's cd does not move it
	// unless FollowWorkingDirectory is set.
	Directory string
	// FollowWorkingDirectory declares Directory the host path of the
	// workspace root (a workspace that is an osfs Storage over Directory):
	// the program then runs in the host directory that matches the shell's
	// working directory, so "cd repos/app && git status" works on that
	// checkout. A working directory that exists only in the shell (made by
	// mkdir, not on disk) fails the run.
	FollowWorkingDirectory bool
	// Environment is the complete environment of the program as
	// NAME=value; nil or empty means none. The host environment and the
	// script's variables are never passed.
	Environment []string
	// Allow, if set, is asked before every run with the arguments (without
	// the name). A non-nil error refuses the run: its text goes to stderr
	// and the exit status is 1. It is called concurrently when a script runs
	// the program in a pipeline or a background job, and from every Shell
	// the Command is registered with, so it must be safe for concurrent
	// use. The slice is a copy it may keep or change.
	Allow func(arguments []string) error
	// Timeout bounds one run; zero means none beyond the shell's own
	// timeout. A program that runs out of time is killed with its process
	// group (exit status 124).
	Timeout time.Duration
	// Summary is the line the tool description shows; empty means "host
	// program <Path>".
	Summary string
}

ExternalOptions configure a host program the shell may run.

type Invocation

type Invocation struct {
	// Arguments are the arguments after the command name, already expanded by
	// the shell (globs, variables, quotes).
	Arguments []string
	// Stdin, Stdout and Stderr are the command's streams. Output written to
	// them is bounded by the shell, not by the command.
	Stdin  io.Reader
	Stdout io.Writer
	Stderr io.Writer
	// Directory is the shell's working directory as a path of Storage
	// without leading slash: "" is "/". With the default MountPoint "/" it
	// is a workspace path; with MountPoint "/work/repo" the workspace root
	// is "work/repo".
	Directory string
	// Storage is what the script sees: the workspace at the MountPoint, with
	// /tmp and /dev/fd mounted. Commands resolve operands against Directory
	// with Resolve and never touch host files.
	Storage vfs.Storage
	// Environment holds the shell's exported variables as NAME=value.
	Environment []string
	// contains filtered or unexported fields
}

Invocation is one call of a Command.

type Options

type Options struct {
	// Storage is the workspace the script sees at MountPoint. Required.
	Storage vfs.Storage
	// MountPoint is the absolute path where the workspace appears in the
	// shell, "/" when empty. Scripts start there; pwd, realpath and find
	// print paths below it. Other paths are missing, except /tmp and /dev.
	// Set it to the host directory a Storage mirrors (an osfs over that
	// directory) so built-in commands and External programs agree on every
	// absolute path. It must not be /tmp or lie in /dev; below /tmp it is
	// fine (the workspace then shows up in /tmp next to the scratch files).
	MountPoint string
	// HideHiddenPaths hides workspace files with a path segment that starts
	// with "." or "_" (.env, .git/hooks, _private), the rule of
	// agent.VFSToolOptions.HideHiddenPaths: listings, globs, ls, find and
	// grep -r leave them out, and reading, writing or deleting one fails
	// with "No such file or directory". /tmp is not affected. The rule
	// checks the path as spelled: over a Storage that follows symbolic
	// links, a visible link (public -> .env) reaches a hidden file, so pair
	// it with osfs.Options.RefuseSymlinks (the CLI does).
	HideHiddenPaths bool
	// Commands are added to the built-in commands (Builtins); a Command with
	// the name of a built-in replaces it. Names must be unique, and must not
	// be shell builtins (cd, echo, test, ...), which the interpreter runs
	// itself.
	Commands []Command
	// Environment holds the variables a script starts with, as NAME=value.
	// The host environment is never passed. HOME defaults to MountPoint.
	Environment []string
	// Timeout bounds one Run; zero means DefaultTimeout, negative disables
	// it. A script that runs out of time is stopped with exit status 124.
	Timeout time.Duration
	// MaximumOutputBytes bounds Result.Output (stdout and stderr together):
	// the first and the last half are kept, the middle is cut with a marker
	// that counts against the limit (a limit too small for the marker keeps
	// the halves without it).
	// Zero means DefaultMaximumOutputBytes, negative keeps everything.
	MaximumOutputBytes int
	// ToolName is the name Register gives the tool; empty means
	// DefaultToolName.
	ToolName string
}

Options configure a Shell. New copies them.

type Result

type Result struct {
	// Output is stdout and stderr interleaved as the script wrote them,
	// bounded by Options.MaximumOutputBytes.
	Output string
	// ExitCode is the script's exit status, as $? would show it: 127 for a
	// command that does not exist, 124 when the timeout stopped the script, 2
	// for a syntax error.
	ExitCode int
	// Truncated reports that the middle of Output was cut.
	Truncated bool
}

Result is the outcome of one Run.

type Shell

type Shell struct {
	// contains filtered or unexported fields
}

Shell runs bash scripts over a vfs.Storage. The working directory, the directories mkdir created and the files in /tmp persist from one Run to the next; variables and functions do not. /tmp, the files of <(list) and redirect output not stored yet share one memory budget of 128 MiB. Run calls are serialized, so a Shell is safe for concurrent use.

func New

func New(options *Options) (*Shell, error)

New returns a Shell over options.Storage. It returns an error for nil options, a nil Storage, an Environment entry without "=", and a Command without Name, Summary or Run, with a name used twice in Commands, with a name containing "/" or white space, or with the name of a shell builtin.

func (*Shell) Run

func (s *Shell) Run(ctx context.Context, script string) (Result, error)

Run executes script like bash -c and returns its output and exit status. A failing script is not an error: its status and messages are in the Result. Run returns an error only when ctx ends before the script does (the Result then holds the output so far) or the interpreter cannot start.

Example

A script runs over the files of a Storage; the working directory persists between runs.

package main

import (
	"context"
	"fmt"

	"github.com/govengers/aikido/tools/shell"
	"github.com/govengers/aikido/vfs/memory"
)

func main() {
	storage := memory.New()
	_ = storage.WriteFile(context.Background(), "notes/todo.md", []byte("- ship\n- test\n"), "text/markdown")
	bash, err := shell.New(&shell.Options{Storage: storage})
	if err != nil {
		fmt.Println(err)
		return
	}
	result, _ := bash.Run(context.Background(), "cd notes && cat -n todo.md && echo done > status.txt; nosuch")
	fmt.Print(result.Output)
	fmt.Println("exit", result.ExitCode)
	result, _ = bash.Run(context.Background(), "pwd; ls")
	fmt.Print(result.Output)
}
Output:
     1	- ship
     2	- test
bash: line 1: nosuch: command not found
exit 127
/notes
status.txt
todo.md

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL