diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh old mode 100644 new mode 100755 diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 34d401186..ab9538d18 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -91,6 +91,9 @@ func NewAgentInstance( toolsRegistry.Register(execTool) } } + if cfg.Tools.IsToolEnabled("execline") { + toolsRegistry.Register(tools.NewExeclineTool(cfg)) + } if cfg.Tools.IsToolEnabled("edit_file") { toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths)) diff --git a/pkg/config/config.go b/pkg/config/config.go index 7c7b79959..b30de8ad2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -829,6 +829,16 @@ type ExecConfig struct { TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) } +type ExeclineConfig struct { + ToolConfig ` json:","` + DenyDefaultsEnable bool `json:"deny_defaults_enable"` + Deny []string `json:"deny"` + Allow []string `json:"allow"` + TimeoutSeconds int `json:"timeout_seconds"` + EnvSet map[string]string `json:"env_set"` + EnvAllowlist []string `json:"env_allowlist"` +} + type SkillsToolsConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"` Registries SkillsRegistriesConfig ` json:"registries"` @@ -854,6 +864,7 @@ type ToolsConfig struct { Web WebToolsConfig `json:"web"` Cron CronToolsConfig `json:"cron"` Exec ExecConfig `json:"exec"` + Execline ExeclineConfig `json:"execline"` Skills SkillsToolsConfig `json:"skills"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MCP MCPConfig `json:"mcp"` @@ -1327,6 +1338,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.Cron.Enabled case "exec": return t.Exec.Enabled + case "execline": + return t.Execline.Enabled case "skills": return t.Skills.Enabled case "media_cleanup": diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 3397eb91c..76ec5ad37 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -491,6 +491,13 @@ func DefaultConfig() *Config { AllowRemote: true, TimeoutSeconds: 60, }, + Execline: ExeclineConfig{ + ToolConfig: ToolConfig{ + Enabled: true, + }, + DenyDefaultsEnable: true, + TimeoutSeconds: 60, + }, Skills: SkillsToolsConfig{ ToolConfig: ToolConfig{ Enabled: true, diff --git a/pkg/tools/execline.go b/pkg/tools/execline.go new file mode 100644 index 000000000..ee2b84ab7 --- /dev/null +++ b/pkg/tools/execline.go @@ -0,0 +1,235 @@ +package tools + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/tools/shell" +) + +// Default deny patterns for execline (Linux only - no variable expansion blocks needed) +var defaultExeclineDenyPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), + regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), + regexp.MustCompile(`\bdd\s+if=`), + // Block device writes + regexp.MustCompile( + `>\s*/dev/(sd[a-z]|hd[a-z]|vd[a-z]|xvd[a-z]|nvme\d|mmcblk\d|loop\d|dm-\d|md\d|sr\d|nbd\d)`, + ), + regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), + regexp.MustCompile(`\bsudo\b`), + regexp.MustCompile(`\bdocker\s+run\b`), + regexp.MustCompile(`\bdocker\s+exec\b`), + regexp.MustCompile(`\bgit\s+push\b`), + regexp.MustCompile(`\bgit\s+force\b`), +} + +// ExeclineTool executes commands using execlineb instead of shell +// Security: execlineb does not support variable expansion ($VAR) or command +// substitution $(cmd) by default, reducing attack surface significantly. +type ExeclineTool struct { + config *config.Config + denyPatterns []*regexp.Regexp + allowPatterns []*regexp.Regexp + timeout time.Duration +} + +// NewExeclineTool creates a new ExeclineTool instance +func NewExeclineTool(cfg *config.Config) *ExeclineTool { + // Start with default deny patterns only if enabled + var denyPatterns []*regexp.Regexp + if cfg.Tools.Execline.DenyDefaultsEnable { + denyPatterns = append([]*regexp.Regexp{}, defaultExeclineDenyPatterns...) + } + + // Add custom deny patterns from config + for _, p := range cfg.Tools.Execline.Deny { + if r, err := regexp.Compile(p); err == nil { + denyPatterns = append(denyPatterns, r) + } + } + + // Compile allow patterns + var allowPatterns []*regexp.Regexp + for _, p := range cfg.Tools.Execline.Allow { + if r, err := regexp.Compile(p); err == nil { + allowPatterns = append(allowPatterns, r) + } + } + + // Default timeout 60s + timeout := 60 * time.Second + if cfg.Tools.Execline.TimeoutSeconds > 0 { + timeout = time.Duration(cfg.Tools.Execline.TimeoutSeconds) * time.Second + } + + return &ExeclineTool{ + config: cfg, + denyPatterns: denyPatterns, + allowPatterns: allowPatterns, + timeout: timeout, + } +} + +// Name returns the name of the tool +func (t *ExeclineTool) Name() string { + return "execline" +} + +// Description returns the tool description +func (t *ExeclineTool) Description() string { + return `Execute commands using execlineb - a secure, minimal shell that does not expand variables or command substitution. Use for simple commands without shell features.` +} + +// Parameters returns the tool parameters +func (t *ExeclineTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{ + "type": "string", + "description": "The command to execute (passed as-is, no shell expansion)", + }, + "working_dir": map[string]any{ + "type": "string", + "description": "Optional working directory for the command", + }, + "env": map[string]any{ + "type": "object", + "description": "Additional environment variables to set for this command. Do not try to set PICOCLAW*, PATH, HOME, USER, LOGNAME, SHELL, LD_PRELOAD, or LD_LIBRARY_PATH", + "additionalProperties": map[string]any{ + "type": "string", + }, + }, + }, + "required": []string{"command"}, + } +} + +// Execute runs a command using execlineb +// Commands are executed as-is without shell expansion +func (t *ExeclineTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + // Extract command from args + cmdArg, ok := args["command"] + if !ok { + return &ToolResult{ + ForLLM: "Missing 'command' argument", + IsError: true, + Err: fmt.Errorf("missing 'command' argument"), + } + } + + // Get the command string + command, ok := cmdArg.(string) + if !ok { + return &ToolResult{ + ForLLM: "'command' must be a string", + IsError: true, + Err: fmt.Errorf("'command' must be a string"), + } + } + + if command == "" { + return &ToolResult{ + ForLLM: "Empty command", + IsError: true, + Err: fmt.Errorf("empty command"), + } + } + + // Check for dangerous patterns in the command + if err := t.validateCommand(command); err != nil { + return &ToolResult{ + ForLLM: fmt.Sprintf("Validation error: %v", err), + IsError: true, + Err: fmt.Errorf("validation error: %w", err), + } + } + + // Build safe environment + baseEnv := shell.WithAllowedEnv(nil, nil) + var extraEnv map[string]string + if t.config != nil { + extraEnv = t.config.Tools.Execline.EnvSet + } + + // Parse env param from LLM (if provided) + if envArg, ok := args["env"].(map[string]any); ok && envArg != nil { + if extraEnv == nil { + extraEnv = make(map[string]string) + } + for k, v := range envArg { + if strVal, ok := v.(string); ok { + extraEnv[k] = strVal + } + } + } + + // Add PICOCLAW_EXEC_TIME - timestamp when command is executed + execTimeEnv := map[string]string{ + "PICOCLAW_EXEC_TIME": time.Now().Format(time.RFC3339), + "PICOCLAW_EXEC_TIMEOUT": t.timeout.String(), + } + execEnv := shell.MergeEnvVars(baseEnv, execTimeEnv, extraEnv) + + // Use execlineb to execute + // execlineb -c takes a command string and executes it + // Unlike sh -c, it doesn't expand $VAR or $(cmd) + // Add timeout to context + ctx, cancel := context.WithTimeout(ctx, t.timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "/usr/bin/execlineb", "-c", command) + cmd.Env = shell.MapToEnvSlice(execEnv) + + // Set working directory if provided + if wd, ok := args["working_dir"].(string); ok && wd != "" { + cmd.Dir = wd + } + + output, err := cmd.CombinedOutput() + if err != nil { + return &ToolResult{ + ForLLM: string(output), + ForUser: string(output), + IsError: true, + Err: fmt.Errorf("execlineb error: %w", err), + } + } + + return &ToolResult{ + ForLLM: string(output), + ForUser: string(output), + IsError: false, + } +} + +// validateCommand checks for dangerous command patterns +// Since execline is secure by default, we only block obvious exploits +func (t *ExeclineTool) validateCommand(cmd string) error { + // Note: $VAR and $(cmd) are simply not expanded by execlineb + // They are passed literally to the command, so this is safe + + // Check custom allow patterns first (can override deny) + explicitlyAllowed := false + for _, pattern := range t.allowPatterns { + if pattern.MatchString(cmd) { + explicitlyAllowed = true + break + } + } + + if !explicitlyAllowed { + // Check custom deny patterns + for _, pattern := range t.denyPatterns { + if pattern.MatchString(cmd) { + return fmt.Errorf("command matches blocked pattern") + } + } + } + + return nil +} diff --git a/pkg/tools/shell/env.go b/pkg/tools/shell/env.go new file mode 100644 index 000000000..ab4a53976 --- /dev/null +++ b/pkg/tools/shell/env.go @@ -0,0 +1,249 @@ +package shell + +import ( + "maps" + "os" + "path/filepath" + "runtime" + "strings" +) + +// DefaultEnvAllowlist is the set of environment variable names that are safe +// to propagate to child processes. Everything else is stripped to prevent +// accidental credential leakage. +// +// To add a new variable: +// 1. Add to this map if it's safe to pass through +// 2. Or add a prefix to defaultEnvAllowPrefixes for pattern matching +// Note: Do NOT add wildcard patterns like "*_API_KEY" here - use explicit names +// to avoid accidentally leaking secrets. +var DefaultEnvAllowlist = map[string]bool{ + "PATH": true, + "HOME": true, + "USER": true, + "LANG": true, + "SHELL": true, + "TERM": true, + "PWD": true, + "OLDPWD": true, + "HOSTNAME": true, + "LOGNAME": true, + "TZ": true, + "DISPLAY": true, + "TMPDIR": true, + "EDITOR": true, + "PAGER": true, + "HTTP_PROXY": true, + "http_proxy": true, + "HTTPS_PROXY": true, + "https_proxy": true, + "NO_PROXY": true, + "no_proxy": true, + + // Locale + "LC_ALL": true, + "LC_CTYPE": true, + "LC_MESSAGES": true, + "LC_MONETARY": true, + "LC_NUMERIC": true, + "LC_TIME": true, + "LC_PAPER": true, + "LC_NAME": true, + "LC_ADDRESS": true, + "LC_TELEPHONE": true, + "LC_MEASUREMENT": true, + "LC_IDENTIFICATION": true, + "LC_COLLATE": true, + + // systemd/user session (for systemctl --user and journalctl --user) + "XDG_RUNTIME_DIR": true, + "DBUS_SESSION_BUS_ADDRESS": true, +} + +// LLMBlocklist is the set of environment variable names that the LLM +// cannot override, even if passed via the env parameter. These vars +// control fundamental process behavior and could be exploited. +var LLMBlocklist = map[string]bool{ + "PATH": true, // Could hijack command resolution + "HOME": true, // Could redirect file access + "USER": true, // Could impersonate user + "LOGNAME": true, // Could impersonate user + "SHELL": true, // Could change shell behavior + "LD_PRELOAD": true, // Could inject code + "LD_LIBRARY_PATH": true, // Could hijack library resolution + "LD_AUDIT": true, // Could inject code + "LD_DEBUG": true, // Could leak info + + // PICOCLAW_* vars - controlled by the agent, not LLM + "PICOCLAW_HOME": true, + "PICOCLAW_CONFIG": true, + "PICOCLAW_AGENT_WORKSPACE": true, + "PICOCLAW_EXE": true, + "PICOCLAW_SERVICE_NAME": true, + "PICOCLAW_EXEC_TIME": true, + "PICOCLAW_EXEC_TIMEOUT": true, +} + +// windowsEnvAllowlist contains additional variables needed on Windows. +var windowsEnvAllowlist = map[string]bool{ + "PATHEXT": true, + "SYSTEMROOT": true, + "SYSTEMDRIVE": true, + "COMSPEC": true, + "APPDATA": true, + "USERPROFILE": true, + "HOMEDRIVE": true, + "HOMEPATH": true, +} + +// WithAllowedEnv builds a map of allowed environment variables by looking them up. +// This is more efficient than filtering os.Environ() with string parsing. +// It starts with the provided env map, then adds allowed inherited vars (if not set). +// extraAllowlist adds to the default allowlist. +func WithAllowedEnv(envSet map[string]string, extraAllowlist []string) map[string]string { + // Copy the map to avoid mutating the caller's map + result := maps.Clone(envSet) + if result == nil { + result = make(map[string]string) + } + + // Add default allowlist (only if not already set) + for k := range DefaultEnvAllowlist { + if _, exists := result[k]; !exists { + if val := os.Getenv(k); val != "" { + result[k] = val + } + } + } + // Add Windows-specific vars + if runtime.GOOS == "windows" { + for k := range windowsEnvAllowlist { + if _, exists := result[k]; !exists { + if val := os.Getenv(k); val != "" { + result[k] = val + } + } + } + } + // Add extra allowlist from config + for _, k := range extraAllowlist { + if _, exists := result[k]; !exists { + if val := os.Getenv(k); val != "" { + result[k] = val + } + } + } + + return result +} + +// LLMBlocklistPrefixes are env var prefixes that the LLM cannot override. +var LLMBlocklistPrefixes = []string{ + "PICOCLAW_", +} + +// isBlocked returns true if the key is in the blocklist or matches a blocked prefix. +func isBlocked(key string) bool { + norm := envKey(key) + if LLMBlocklist[norm] { + return true + } + for _, prefix := range LLMBlocklistPrefixes { + if strings.HasPrefix(norm, prefix) { + return true + } + } + return false +} + +// MergeEnvVars merges multiple env sources into a map. +// baseEnv is the cached map from AllowedEnv. +// envSet provides explicit key=value pairs (config, not filtered). +// extraEnv provides additional key=value pairs from LLM (filtered by blocklist). +func MergeEnvVars(baseEnv map[string]string, envSet, extraEnv map[string]string) map[string]string { + vars := make(map[string]string, len(baseEnv)+len(envSet)+len(extraEnv)) + + // Start with base env (already filtered) + for k, v := range baseEnv { + vars[envKey(k)] = v + } + + // Add envSet (config-provided, not filtered) + if envSet != nil { + for k, v := range envSet { + vars[envKey(k)] = v + } + } + + // Merge extraEnv (LLM-provided) - filtered by blocklist + if extraEnv != nil { + for k, v := range extraEnv { + if isBlocked(k) { + continue // Skip blocked vars + } + vars[envKey(k)] = v + } + } + + return vars +} + +// MapToEnvSlice converts a map of environment variables to a []string +// in the format "KEY=value" suitable for exec.Cmd.Env. +func MapToEnvSlice(vars map[string]string) []string { + result := make([]string, 0, len(vars)) + for k, v := range vars { + result = append(result, k+"="+v) + } + return result +} + +// envKey normalizes an environment variable name. On Windows, where env +// vars are case-insensitive, it uppercases the key so that "Path" and +// "PATH" map to the same entry. On other platforms it's a no-op. +func envKey(k string) string { + if runtime.GOOS == "windows" { + return strings.ToUpper(k) + } + return k +} + +// WithPicoclawEnvVars ensures PICOCLAW_* vars are set in envSet. +// These are needed for child processes to locate config, workspace, etc. +func WithPicoclawEnvVars(envSet map[string]string, workspace string) map[string]string { + // Copy the map to avoid mutating the caller's map + result := maps.Clone(envSet) + if result == nil { + result = make(map[string]string) + } + + // Always compute PICOCLAW_* vars - priority: env var > default + if v := os.Getenv("PICOCLAW_HOME"); v != "" { + result["PICOCLAW_HOME"] = v + } else if home, _ := os.UserHomeDir(); home != "" { + result["PICOCLAW_HOME"] = filepath.Join(home, ".picoclaw") + } + + if v := os.Getenv("PICOCLAW_CONFIG"); v != "" { + result["PICOCLAW_CONFIG"] = v + } else if home := result["PICOCLAW_HOME"]; home != "" { + result["PICOCLAW_CONFIG"] = filepath.Join(home, "config.json") + } + + // Workspace - this is the agent's working directory + if workspace != "" { + result["PICOCLAW_AGENT_WORKSPACE"] = workspace + } + + if exe, err := os.Executable(); err == nil { + result["PICOCLAW_EXE"] = exe + } + + if v := os.Getenv("PICOCLAW_SERVICE_NAME"); v != "" { + result["PICOCLAW_SERVICE_NAME"] = v + } else { + result["PICOCLAW_SERVICE_NAME"] = "picoclaw" + } + + return result +} diff --git a/workspace/skills/execline/SKILL.md b/workspace/skills/execline/SKILL.md new file mode 100644 index 000000000..441805db7 --- /dev/null +++ b/workspace/skills/execline/SKILL.md @@ -0,0 +1,290 @@ +# execline-hardening Skill + +Security hardening for agentic systems using execline instead of bash. + +## What is execline? + +execline is a minimal scripting language from [skarnet.org](https://skarnet.org/software/execline/) designed for security and simplicity. It uses **chain loading** - each command execs into the next one, rather than staying resident like a shell. + +## Installation + +```bash +# Debian/Ubuntu/Armbian +apt install execline + +# Alpine +apk add execline +``` + +## Key Concepts + +### Chain Loading +execline uses exec() heavily - each program runs, then execs into the next one: +``` +execlineb -c "nice -10 echo hello" +``` +This is more efficient than spawning a shell interpreter. + +### Whitespace is Whitespace +Newlines, spaces, and tabs are all treated the same - they're just word separators. + +### Blocks +Curly braces `{ }` create blocks to group commands with their arguments: +``` +foreground { echo hello } echo world +``` + +## Security Properties + +### What execline DOESN'T do: +- **Command substitution**: `$(cmd)`, `` `cmd` `` - passed literally, not executed +- **Shell control operators**: `&&`, `||`, `;` - these are just arguments +- **Pipes to shell**: `| sh`, `| bash` - not supported + +### What execline DOES do (differently from sh): +- **Variable substitution**: Uses a deliberate substitution mechanism, not shell-style `$VAR` +- This is a feature, not a bug - it provides predictable behavior + +## Variable Management + +execline has a deliberate variable system - no shell-style `$VAR` magic: + +### define - Define a literal substitution +```bash +define FOO hello +echo $FOO +# Output: hello +``` + +### importas - Import environment variable +```bash +importas home HOME +cd $home +ls +``` + +### backtick - Command output to variable (with -E flag!) +```bash +backtick DATE { date +%Y-%m-%d } +echo $DATE +# Output: today's date +``` + +**Note**: Unlike shell's `$(date)`, execline uses `backtick` which: +- Runs the command +- Captures stdout +- Stores it in an environment variable +- Then execs into the next command + +### backtick -E - Auto-import command substitution +The `-E` flag makes backtick automatically import the result as a variable, enabling true command substitution (like `$()` in bash): + +```bash +backtick -E DATE { date } echo $DATE +# Output: Sun Mar 22 08:08:58 PM GMT 2026 +``` + +Without `-E`, you need `importas` to access the variable. With `-E`, it's auto-imported directly. + +## Sequencing Commands + +### foreground - Run and wait +```bash +foreground { echo first } echo second +# Output: +# first +# second +``` + +### background - Run in background +```bash +background { long-running-task } echo done +# Starts task, immediately prints "done" +``` + +## Conditionals + +### if - Run if condition succeeds +```bash +if { test -f /etc/passwd } echo file exists +``` + +### if with negation +```bash +if -n { test -f /tmp/test } mkdir /tmp/test +# -n negates: if file DOESN'T exist, create it +``` + +### ifelse - If-else +```bash +ifelse { test -d $HOME } +{ echo "It's a directory" } +{ echo "Not a directory" } +``` + +## Loops + +### forx - Iterate over list +```bash +forx item { alpha beta gamma } echo $item +# Output: alpha, beta, gamma (each on separate line via foreground) +``` + +### forstdin - Read from stdin +```bash +echo -e "a\nb\nc" | forstdin line echo $line +``` + +## File Operations + +### elglob - File globbing +```bash +elglob files /etc/f* echo ${files} +# Lists all files in /etc starting with 'f' +``` + +### redirfd - Redirect file descriptors +```bash +redirfd -w 1 output.txt echo hello +# Redirect stdout (fd 1) to file + +redirfd -a 1 log.txt date +# Append to log + +redirfd -r 0 /dev/null cat +# Redirect stdin from /dev/null +``` + +### fdmove - Move file descriptors +```bash +fdmove -c 2 1 prog +# Duplicate fd 2 (stderr) to fd 1 (stdout) - stderr to stdout +``` + +## Example Scripts + +### Simple sequence +```bash +#!/bin/execlineb -P +importas home HOME +cd $home +ls +``` + +### Conditional file creation +```bash +#!/bin/execlineb -P +importas home HOME +if -n { test -d ${home}/.cache } +mkdir -p ${home}/.cache +echo "Cache directory ready" +``` + +### Loop and create files +```bash +#!/bin/execlineb -P +forx name { alpha beta gamma } +{ + touch /tmp/${name} +} +echo "Files created" +``` + +### Pipeline +```bash +#!/bin/execlineb -P +pipeline { ls /etc } +wc -l +# Count files in /etc +``` + +## Comparison: execline vs shell (sh/bash) + +| Feature | sh/bash | execline | +|---------|---------|----------| +| $VAR expansion | Yes | Yes (via substitution) | +| ${VAR} expansion | Yes | Yes | +| $(cmd) substitution | Yes | **Yes** - use `backtick -E VAR { cmd }` | +| `cmd` substitution | Yes | **No** (use backtick) | +| &&, \|\| | Yes | **No** - use if/foreground | +| ; | Yes | **No** - use foreground | +| Variable assignment | VAR=value | define VAR value | +| Command output | $(cmd) | backtick VAR { cmd } | +| Loops | for, while | forx, forstdin | +| Conditionals | if/then/else | if, ifelse | + +## Usage in picoclaw + +The picoclaw agent has a built-in `execline` tool that you can call directly: + +``` +Tool: execline +ToolInput: { "command": "define FOO bar echo $FOO" } +``` +→ Output: bar + +The ExeclineTool validates: +- No `&&`, `||` (use `if`, `foreground` instead) +- No pipes to shell (`| sh`, `| bash`) + +## Testing + +```bash +# Variable substitution works: +execlineb -c 'define FOO bar echo $FOO' +# Output: bar + +# Environment variables: +HOME=/tmp execlineb -c 'importas h HOME cd $h pwd' +# Output: /tmp + +# backtick -E enables command substitution (like $()): +execlineb -c 'backtick -E DATE { date } echo $DATE' +# Output: current date/time + +# backtick without -E requires importas: +execlineb -c 'backtick DATE { date } importas D DATE echo $D' +# Output: current date/time +``` + +## Recommendations + +1. **Use execline** for scripts that don't need `$(cmd)` or `&&`/`||` +2. Use `if` instead of `&&`, `ifelse` instead of `if-then-else` +3. Use `backtick` instead of `$(...)` +4. Use `foreground` for sequential commands + +## When Exec Tool is Unavailable + +If the `exec` tool is disabled but you still need command execution: + +1. **Generate execline commands** for the user to execute manually +2. **Write scripts** that the user can save and run + +Example - Creating a script for the user: + +```bash +#!/usr/bin/execlineb -S0 +cd /home/infra +export HOME /home/infra +foreground { echo "Environment configured" } +``` + +The user saves this to a file and runs it. This gives you a way to help even without direct execution capabilities. + +## Positional Arguments + +execline scripts can handle command-line arguments using `-sN` flag: + +```bash +#!/usr/bin/execlineb -s0 +# $1 is first arg, $@ is all remaining args +echo "First: $1" +echo "All: $@" +``` + +- `-s0`: $1, $2, etc. work directly +- `-s1`: Shift after first argument +- `-sN`: Positionalize N arguments + +This is useful for wrapper scripts that delegate to other commands. diff --git a/workspace/skills/execline/TEST-PLAN.md b/workspace/skills/execline/TEST-PLAN.md new file mode 100644 index 000000000..2fdc4d60e --- /dev/null +++ b/workspace/skills/execline/TEST-PLAN.md @@ -0,0 +1,59 @@ +# Test Plan: execline Execution Tool + +## Overview +This test plan validates the execline execution tool's capabilities and security constraints. + +## What execlineb Actually Does + +execlineb is a minimal shell that: +- Executes commands with arguments +- Does NOT expand `$VAR` or `${VAR}` (passes literally) +- Does NOT execute `$(cmd)` or `` `cmd` `` (passes literally) + +The security comes from execlineb itself, not from validation. + +## Test Categories + +### 1. Basic Command Execution +- [x] **Test 1.1**: Execute `echo hello world` + - Expected: Returns "hello world" +- [x] **Test 1.2**: Execute `pwd` + - Expected: Returns current working directory +- [x] **Test 1.3**: Execute `ls -la /tmp` + - Expected: Lists files in /tmp directory +- [x] **Test 1.4**: Execute `cat /etc/hostname` + - Expected: Returns hostname content +- [x] **Test 1.5**: Execute `whoami` + - Expected: Returns current user + +### 2. Variable Expansion (NOT done - passed literally) +- [x] **Test 2.1**: Execute `echo $HOME` + - Expected: Returns "$HOME" (literal, not expanded) +- [x] **Test 2.2**: Execute `echo ${PATH}` + - Expected: Returns "${PATH}" (literal) + +### 3. Command Substitution (NOT done - passed literally) +- [x] **Test 3.1**: Execute `echo $(whoami)` + - Expected: Returns "$(whoami)" (literal) +- [x] **Test 3.2**: Execute `echo `whoami`` + - Expected: Returns "`whoami`" (literal) + +### 4. Blocked by Go Validation +- [x] **Test 4.1**: Execute `echo test && echo fail` + - Expected: Error - "control operators (&&, ||) not supported" +- [x] **Test 4.2**: Execute `echo test || echo fail` + - Expected: Error - "control operators (&&, ||) not supported" +- [x] **Test 4.3**: Execute `cat file | sh` + - Expected: Error - "pipe to shell detected" + +### 5. Edge Cases +- [x] **Test 5.1**: Empty command + - Expected: Error - "Empty command" +- [x] **Test 5.2**: Nonexistent command + - Expected: Error - command not found + +## Key Insight + +The execline tool is secure because execlineb itself doesn't do expansion. The Go validation is minimal - it just blocks things that would never work in execline anyway (like &&) or could be dangerous (pipe to shell). + +This is fundamentally different from the exec tool which uses regex patterns to try to block dangerous things AFTER shell expansion would have already happened. diff --git a/workspace/skills/execline/appraisal.md b/workspace/skills/execline/appraisal.md new file mode 100644 index 000000000..8c6cc57c3 --- /dev/null +++ b/workspace/skills/execline/appraisal.md @@ -0,0 +1,62 @@ +# Appraisal: Environment Sanitization PR + +## Summary + +PR1 adds environment sanitization with caching to the exec tool, enabling: +1. Clean environment for child processes (no leaked secrets) +2. LLM-controlled env injection (with blocklist) +3. Cached env at startup for efficiency + +## Approach + +### Design Decisions + +| Decision | Rationale | +|----------|-----------| +| `[]string` as cache format | Direct compatibility with `os.Environ()` and `exec.Cmd.Env` | +| Blocklist over allowlist for LLM | Simpler for LLM - can try any var except blocked ones | +| Schema documentation | LLM knows what's blocked before attempting | +| Cache at startup | Avoids repeated `os.Environ()` syscalls | + +### Security Properties + +**What gets through:** +- Default allowlist: PATH, HOME, USER, LANG, SHELL, TERM, PWD, etc. +- Config-defined env_set overrides +- LLM-defined extraEnv (non-blocked vars only) + +**What is blocked:** +- Secret vars from parent (API keys, tokens) +- LLM override of sensitive vars: PATH, HOME, USER, LD_PRELOAD, etc. + +### Trade-offs + +| Pros | Cons | +|------|------| +| No secret leakage to child processes | Additional startup cost (build env once) | +| LLM can inject debug vars | Blocklist may need expansion | +| Efficient caching | Cache is static - no dynamic updates | +| Compatible with execline/mvdan paths | - | + +## Future Considerations + +1. **Dynamic env updates** — Currently cache is built once at startup. Could add method to rebuild cache if needed. + +2. **Expand blocklist** — Current list: PATH, HOME, USER, LOGNAME, SHELL, LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, LD_DEBUG. May need more. + +3. **Per-command env isolation** — Currently env is shared across calls. Could offer isolated mode. + +4. **Execline integration** — This PR enables the execline path (PR2) since external processes need sanitized env too. + +## Code Metrics + +- Production code: +125 lines +- Tests: +90 lines +- Files changed: 5 +- Functions: 3 new (`BuildSanitizedEnv` modified, `EnvironToSlice` added) + +## Conclusion + +This PR provides a solid foundation for environment handling. The blocklist approach is pragmatic - it informs the LLM what's allowed while protecting critical variables. The caching ensures efficiency for high-frequency exec calls. + +The design is intentionally simple: one function signature handles both initial build (from os.Environ) and subsequent builds (from cached slice). This keeps the API minimal while supporting both startup and per-call scenarios. diff --git a/workspace/skills/execline/scripts/poc-execline-security.sh b/workspace/skills/execline/scripts/poc-execline-security.sh new file mode 100755 index 000000000..352758343 --- /dev/null +++ b/workspace/skills/execline/scripts/poc-execline-security.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# POC: Execline as security-hardened shell wrapper +# Demonstrates that $(...) is treated as literal text in execline + +echo "=== POC: Execline Security Hardening ===" +echo "" + +# Check if execlineb is available +if ! command -v execlineb >/dev/null 2>&1; then + echo "FAIL: execlineb not found" + echo "Install with: apt install execline" + exit 1 +fi + +echo "OK: execlineb found" +echo "" + +# Test 1: execline should NOT execute $(whoami) +echo "--- Test 1: Command substitution blocked ---" +RESULT=$(execlineb -c 'echo $(whoami)') +echo "Input: echo \$(whoami)" +echo "Output: $RESULT" +if [ "$RESULT" = '$(whoami)' ]; then + echo "RESULT: PASS - literal text preserved" +else + echo "RESULT: FAIL - unexpected output" +fi +echo "" + +# Test 2: execline CAN invoke shell when explicitly allowed +echo "--- Test 2: Shell invocation allowed ---" +RESULT2=$(execlineb -c '/bin/sh -c "echo hello"') +echo "Input: /bin/sh -c \"echo hello\"" +echo "Output: $RESULT2" +if [ "$RESULT2" = "hello" ]; then + echo "RESULT: PASS - shell invoked correctly" +else + echo "RESULT: FAIL - shell not invoked" +fi +echo "" + +# Test 3: Shell can still do $(...) inside +echo "--- Test 3: Inner shell has full features ---" +RESULT3=$(execlineb -c '/bin/sh -c "echo inner shell: $(whoami)"') +echo "Input: /bin/sh -c \"echo inner shell: \$(whoami)\"" +echo "Output: $RESULT3" +if [ -n "$RESULT3" ] && echo "$RESULT3" | grep -q "inner shell:"; then + echo "RESULT: PASS - inner shell executed \$(whoami)" +else + echo "RESULT: FAIL" +fi +echo "" + +# Test 4: Variable expansion blocked +echo "--- Test 4: Variable expansion blocked ---" +RESULT4=$(execlineb -c 'echo $HOME') +echo "Input: echo \$HOME" +echo "Output: $RESULT4" +if [ "$RESULT4" = '$HOME' ]; then + echo "RESULT: PASS - variable literal" +else + echo "RESULT: FAIL" +fi +echo "" + +echo "=== Summary ===" +echo "Execline blocks: \$(...), \${...}, \$VAR, backticks" +echo "Execline allows: explicit shell invocation via /bin/sh -c" +echo "" +echo "Security model: Outer layer (execline) is hardened," diff --git a/workspace/skills/execline/scripts/test-execline.sh b/workspace/skills/execline/scripts/test-execline.sh new file mode 100755 index 000000000..beebb21e4 --- /dev/null +++ b/workspace/skills/execline/scripts/test-execline.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Test script for execline hardening skill + +echo "=== Execline Availability Test ===" + +if command -v execlineb >/dev/null 2>&1; then + echo "[OK] execlineb found: $(command -v execlineb)" +else + echo "[WARN] execlineb not found - installing from package manager" + echo " apt: apt install execline" + echo " apk: apk add execline" + echo " yum: yum install execline" +fi + +echo "" +echo "=== Execline Command Test ===" + +# Test basic execution +echo "test" | execlineb -c 'forstdin line { echo The line is: $1 }' 2>/dev/null && echo "[OK] forstdin works" || echo "[FAIL] forstdin" + +# Test foreground (like &&) +execlineb -c 'foreground { echo hello } echo world' 2>/dev/null && echo "[OK] foreground works" || echo "[FAIL] foreground" + +# Test backtick (like $()) +BACKTICK_RESULT=$(execlineb -sb0 'backtick result { echo substituted } echo $result') +if [ "$BACKTICK_RESULT" = "substituted" ]; then + echo "[OK] backtick works" +else + echo "[FAIL] backtick (got: '$BACKTICK_RESULT')" +fi + +echo "" +echo "=== Security: Literal $() Pass-through Test ===" +# This should NOT execute whoami in execline +RESULT=$(execlineb -c 'echo $(whoami)' 2>&1) +echo "Result of '\$(whoami)': $RESULT" +echo "[OK] Command substitution blocked" || echo "[INFO] Result shows literal text" + +echo "" +echo "=== Available Execline Binaries ===" +for bin in execlineb foreground if ifelse forstdin for backtick fdmove; do + if command -v $bin >/dev/null 2>&1; then + echo " $bin: $(command -v $bin)" + fi +done