From 47de3e431c09c67d0e9b3e8e164768ba54ab7dc2 Mon Sep 17 00:00:00 2001 From: JY Tan Date: Thu, 25 Dec 2025 19:03:01 -0800 Subject: [PATCH] Add ability to block commands --- README.md | 10 + cmd/fence/main.go | 3 + docs/configuration.md | 45 +++ docs/templates/README.md | 1 + docs/templates/git-readonly.json | 19 ++ internal/config/config.go | 68 +++++ internal/sandbox/command.go | 301 ++++++++++++++++++++ internal/sandbox/command_test.go | 456 +++++++++++++++++++++++++++++++ internal/sandbox/manager.go | 6 + 9 files changed, 909 insertions(+) create mode 100644 docs/templates/git-readonly.json create mode 100644 internal/sandbox/command.go create mode 100644 internal/sandbox/command_test.go diff --git a/README.md b/README.md index 541822e..bcca205 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Fence wraps commands in a sandbox that blocks network access by default and rest - **Network Isolation**: All network access blocked by default - **Domain Allowlisting**: Configure which domains are allowed - **Filesystem Restrictions**: Control read/write access to paths +- **Command Blocking**: Block dangerous commands (e.g., `shutdown`, `rm -rf`) with configurable deny/allow lists - **Violation Monitoring**: Real-time logging of blocked requests and sandbox denials - **Cross-Platform**: macOS (sandbox-exec) and Linux (bubblewrap) - **HTTP/SOCKS5 Proxies**: Built-in filtering proxies for domain control @@ -64,6 +65,7 @@ go build -o fence ./cmd/fence - `bubblewrap` (for sandboxing) - `socat` (for network bridging) +- `bpftrace` (optional, for filesystem violation visibility with when monitoring with `-m`) ## Quick Start @@ -105,6 +107,11 @@ fence curl https://example.com # Use a custom config fence --settings ./my-config.json npm install +# Block specific commands (via config file) +# ~/.fence.json: {"command": {"deny": ["git push", "npm publish"]}} +fence -c "git push" # blocked +fence -c "git status" # allowed + # Run a shell command fence -c "git clone https://github.com/user/repo && cd repo && npm install" @@ -143,6 +150,9 @@ func main() { Filesystem: fence.FilesystemConfig{ AllowWrite: []string{"."}, }, + Command: fence.CommandConfig{ + Deny: []string{"git push", "npm publish"}, + }, } // Create manager (debug=false, monitor=false) diff --git a/cmd/fence/main.go b/cmd/fence/main.go index 992f39f..2dfe007 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -70,6 +70,9 @@ Configuration file format (~/.fence.json): "denyRead": [], "allowWrite": ["."], "denyWrite": [] + }, + "command": { + "deny": ["git push", "npm publish"] } }`, RunE: runCommand, diff --git a/docs/configuration.md b/docs/configuration.md index 0b145be..2af53b9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,6 +14,9 @@ Example config: "denyRead": ["/etc/passwd"], "allowWrite": [".", "/tmp"], "denyWrite": [".git/hooks"] + }, + "command": { + "deny": ["git push", "npm publish"] } } ``` @@ -40,6 +43,48 @@ Example config: | `denyWrite` | Paths to deny writing (takes precedence) | | `allowGitConfig` | Allow writes to `.git/config` files | +## Command Configuration + +Block specific commands from being executed, even within command chains. + +| Field | Description | +|-------|-------------| +| `deny` | List of command prefixes to block (e.g., `["git push", "rm -rf"]`) | +| `allow` | List of command prefixes to allow, overriding `deny` | +| `useDefaults` | Enable default deny list of dangerous system commands (default: `true`) | + +Example: + +```json +{ + "command": { + "deny": ["git push", "npm publish"], + "allow": ["git push origin docs"] + } +} +``` + +### Default Denied Commands + +When `useDefaults` is `true` (the default), fence blocks these dangerous commands: + +- System control: `shutdown`, `reboot`, `halt`, `poweroff`, `init 0/6` +- Kernel manipulation: `insmod`, `rmmod`, `modprobe`, `kexec` +- Disk operations: `mkfs*`, `fdisk`, `parted`, `dd if=` +- Container escape: `docker run -v /:/`, `docker run --privileged` +- Namespace escape: `chroot`, `unshare`, `nsenter` + +To disable defaults: `"useDefaults": false` + +### Command Detection + +Fence detects blocked commands in: + +- Direct commands: `git push origin main` +- Command chains: `ls && git push` or `ls; git push` +- Pipelines: `echo test | git push` +- Shell invocations: `bash -c "git push"` or `sh -lc "ls && git push"` + ## Other Options | Field | Description | diff --git a/docs/templates/README.md b/docs/templates/README.md index 90f6dc2..39d385c 100644 --- a/docs/templates/README.md +++ b/docs/templates/README.md @@ -10,6 +10,7 @@ This directory contains Fence config templates. They are small and meant to be c - `pip-install.json`: allow PyPI; allow writes to workspace/tmp - `local-dev-server.json`: allow binding and localhost outbound; allow writes to workspace/tmp - `agent-api-only.json`: allow common LLM API domains; allow writes to workspace +- `git-readonly.json`: blocks destructive commands like `git push`, `rm -rf`, etc. ## Using a template diff --git a/docs/templates/git-readonly.json b/docs/templates/git-readonly.json new file mode 100644 index 0000000..db8e4fd --- /dev/null +++ b/docs/templates/git-readonly.json @@ -0,0 +1,19 @@ +{ + "network": { + "allowedDomains": [] + }, + "filesystem": { + "allowWrite": ["."], + "denyWrite": [".git"] + }, + "command": { + "deny": [ + "git push", + "git reset", + "git clean", + "git checkout --", + "git rebase", + "git merge" + ] + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 3940a2e..efa700d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ import ( type Config struct { Network NetworkConfig `json:"network"` Filesystem FilesystemConfig `json:"filesystem"` + Command CommandConfig `json:"command"` AllowPty bool `json:"allowPty,omitempty"` } @@ -38,6 +39,56 @@ type FilesystemConfig struct { AllowGitConfig bool `json:"allowGitConfig,omitempty"` } +// CommandConfig defines command restrictions. +type CommandConfig struct { + Deny []string `json:"deny"` + Allow []string `json:"allow"` + UseDefaults *bool `json:"useDefaults,omitempty"` +} + +// DefaultDeniedCommands returns commands that are blocked by default. +// These are system-level dangerous commands that are rarely needed by AI agents. +var DefaultDeniedCommands = []string{ + // System control - can crash/reboot the machine + "shutdown", + "reboot", + "halt", + "poweroff", + "init 0", + "init 6", + "systemctl poweroff", + "systemctl reboot", + "systemctl halt", + + // Kernel/module manipulation + "insmod", + "rmmod", + "modprobe", + "kexec", + + // Disk/partition manipulation (including common variants) + "mkfs", + "mkfs.ext2", + "mkfs.ext3", + "mkfs.ext4", + "mkfs.xfs", + "mkfs.btrfs", + "mkfs.vfat", + "mkfs.ntfs", + "fdisk", + "parted", + "dd if=", + + // Container escape vectors + "docker run -v /:/", + "docker run --privileged", + + // Chroot/namespace escape + "chroot", + "unshare", + "nsenter", +} + // Default returns the default configuration with all network blocked. func Default() *Config { return &Config{ @@ -50,6 +101,11 @@ func Default() *Config { AllowWrite: []string{}, DenyWrite: []string{}, }, + Command: CommandConfig{ + Deny: []string{}, + Allow: []string{}, + // UseDefaults defaults to true (nil = true) + }, } } @@ -112,9 +168,21 @@ func (c *Config) Validate() error { return errors.New("filesystem.denyWrite contains empty path") } + if slices.Contains(c.Command.Deny, "") { + return errors.New("command.deny contains empty command") + } + if slices.Contains(c.Command.Allow, "") { + return errors.New("command.allow contains empty command") + } + return nil } +// UseDefaultDeniedCommands returns whether to use the default deny list. +func (c *CommandConfig) UseDefaultDeniedCommands() bool { + return c.UseDefaults == nil || *c.UseDefaults +} + func validateDomainPattern(pattern string) error { if pattern == "localhost" { return nil diff --git a/internal/sandbox/command.go b/internal/sandbox/command.go new file mode 100644 index 0000000..f077a0e --- /dev/null +++ b/internal/sandbox/command.go @@ -0,0 +1,301 @@ +// Package sandbox provides sandboxing functionality for macOS and Linux. +package sandbox + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/Use-Tusk/fence/internal/config" +) + +// CommandBlockedError is returned when a command is blocked by policy. +type CommandBlockedError struct { + Command string + BlockedPrefix string + IsDefault bool +} + +func (e *CommandBlockedError) Error() string { + if e.IsDefault { + return fmt.Sprintf("command blocked by default policy: %q matches %q", e.Command, e.BlockedPrefix) + } + return fmt.Sprintf("command blocked by policy: %q matches %q", e.Command, e.BlockedPrefix) +} + +// CheckCommand checks if a command is allowed by the configuration. +// It parses shell command strings and checks each sub-command in pipelines/chains. +// Returns nil if allowed, or CommandBlockedError if blocked. +func CheckCommand(command string, cfg *config.Config) error { + if cfg == nil { + cfg = config.Default() + } + + subCommands := parseShellCommand(command) + + for _, subCmd := range subCommands { + if err := checkSingleCommand(subCmd, cfg); err != nil { + return err + } + } + + return nil +} + +// checkSingleCommand checks a single command (not a chain) against the policy. +func checkSingleCommand(command string, cfg *config.Config) error { + command = strings.TrimSpace(command) + if command == "" { + return nil + } + + // Normalize the command for matching + normalized := normalizeCommand(command) + + // Check if explicitly allowed (takes precedence over deny) + for _, allow := range cfg.Command.Allow { + if matchesPrefix(normalized, allow) { + return nil + } + } + + // Check user-defined deny list + for _, deny := range cfg.Command.Deny { + if matchesPrefix(normalized, deny) { + return &CommandBlockedError{ + Command: command, + BlockedPrefix: deny, + IsDefault: false, + } + } + } + + // Check default deny list (if enabled) + if cfg.Command.UseDefaultDeniedCommands() { + for _, deny := range config.DefaultDeniedCommands { + if matchesPrefix(normalized, deny) { + return &CommandBlockedError{ + Command: command, + BlockedPrefix: deny, + IsDefault: true, + } + } + } + } + + return nil +} + +// parseShellCommand splits a shell command string into individual commands. +// Handles: pipes (|), logical operators (&&, ||), semicolons (;), and subshells. +func parseShellCommand(command string) []string { + var commands []string + var current strings.Builder + var inSingleQuote, inDoubleQuote bool + var parenDepth int + + runes := []rune(command) + for i := 0; i < len(runes); i++ { + c := runes[i] + + // Handle quotes + if c == '\'' && !inDoubleQuote { + inSingleQuote = !inSingleQuote + current.WriteRune(c) + continue + } + if c == '"' && !inSingleQuote { + inDoubleQuote = !inDoubleQuote + current.WriteRune(c) + continue + } + + // Skip splitting inside quotes + if inSingleQuote || inDoubleQuote { + current.WriteRune(c) + continue + } + + // Handle parentheses (subshells) + if c == '(' { + parenDepth++ + current.WriteRune(c) + continue + } + if c == ')' { + parenDepth-- + current.WriteRune(c) + continue + } + + // Skip splitting inside subshells + if parenDepth > 0 { + current.WriteRune(c) + continue + } + + // Handle shell operators + switch c { + case '|': + // Check for || (or just |) + if i+1 < len(runes) && runes[i+1] == '|' { + // || + if s := strings.TrimSpace(current.String()); s != "" { + commands = append(commands, s) + } + current.Reset() + i++ // Skip second | + } else { + // Just a pipe + if s := strings.TrimSpace(current.String()); s != "" { + commands = append(commands, s) + } + current.Reset() + } + case '&': + // Check for && + if i+1 < len(runes) && runes[i+1] == '&' { + if s := strings.TrimSpace(current.String()); s != "" { + commands = append(commands, s) + } + current.Reset() + i++ // Skip second & + } else { + // Background operator - keep in current command + current.WriteRune(c) + } + case ';': + if s := strings.TrimSpace(current.String()); s != "" { + commands = append(commands, s) + } + current.Reset() + default: + current.WriteRune(c) + } + } + + // Add remaining command + if s := strings.TrimSpace(current.String()); s != "" { + commands = append(commands, s) + } + + // Handle nested shell invocations like "bash -c 'git push'" + var expanded []string + for _, cmd := range commands { + expanded = append(expanded, expandShellInvocation(cmd)...) + } + + return expanded +} + +// expandShellInvocation detects patterns like "bash -c 'cmd'" or "sh -c 'cmd'" +// and extracts the inner command for checking. +func expandShellInvocation(command string) []string { + command = strings.TrimSpace(command) + if command == "" { + return nil + } + + tokens := tokenizeCommand(command) + if len(tokens) < 3 { + return []string{command} + } + + // Check for shell -c pattern + shell := filepath.Base(tokens[0]) + isShell := shell == "sh" || shell == "bash" || shell == "zsh" || + shell == "ksh" || shell == "dash" || shell == "fish" + + if !isShell { + return []string{command} + } + + // Look for -c flag (could be combined with other flags like -lc, -ic, etc.) + for i := 1; i < len(tokens)-1; i++ { + flag := tokens[i] + // Check for -c, -lc, -ic, -ilc, etc. (any flag containing 'c') + if strings.HasPrefix(flag, "-") && strings.Contains(flag, "c") { + // Next token is the command string + innerCmd := tokens[i+1] + // Recursively parse the inner command + innerCommands := parseShellCommand(innerCmd) + // Return both the outer command and inner commands + // (we check both for safety) + result := []string{command} + result = append(result, innerCommands...) + return result + } + } + + return []string{command} +} + +// tokenizeCommand splits a command string into tokens, respecting quotes. +func tokenizeCommand(command string) []string { + var tokens []string + var current strings.Builder + var inSingleQuote, inDoubleQuote bool + + for _, c := range command { + switch { + case c == '\'' && !inDoubleQuote: + inSingleQuote = !inSingleQuote + case c == '"' && !inSingleQuote: + inDoubleQuote = !inDoubleQuote + case (c == ' ' || c == '\t') && !inSingleQuote && !inDoubleQuote: + if current.Len() > 0 { + tokens = append(tokens, current.String()) + current.Reset() + } + default: + current.WriteRune(c) + } + } + + if current.Len() > 0 { + tokens = append(tokens, current.String()) + } + + return tokens +} + +// normalizeCommand normalizes a command for matching. +// - Strips leading path from the command (e.g., /usr/bin/git -> git) +// - Collapses multiple spaces +func normalizeCommand(command string) string { + command = strings.TrimSpace(command) + if command == "" { + return "" + } + + tokens := tokenizeCommand(command) + if len(tokens) == 0 { + return command + } + + tokens[0] = filepath.Base(tokens[0]) + + return strings.Join(tokens, " ") +} + +// matchesPrefix checks if a command matches a blocked prefix. +// The prefix matches if the command starts with the prefix followed by +// end of string, a space, or other argument. +func matchesPrefix(command, prefix string) bool { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + return false + } + + prefix = normalizeCommand(prefix) + + if command == prefix { + return true + } + + if strings.HasPrefix(command, prefix+" ") { + return true + } + + return false +} diff --git a/internal/sandbox/command_test.go b/internal/sandbox/command_test.go new file mode 100644 index 0000000..d5fde3d --- /dev/null +++ b/internal/sandbox/command_test.go @@ -0,0 +1,456 @@ +package sandbox + +import ( + "testing" + + "github.com/Use-Tusk/fence/internal/config" +) + +func TestCheckCommand_BasicDeny(t *testing.T) { + cfg := &config.Config{ + Command: config.CommandConfig{ + Deny: []string{"git push", "rm -rf"}, + UseDefaults: boolPtr(false), // Disable defaults for cleaner testing + }, + } + + tests := []struct { + command string + shouldBlock bool + blockPrefix string + }{ + // Exact matches + {"git push", true, "git push"}, + {"rm -rf", true, "rm -rf"}, + + // Prefix matches + {"git push origin main", true, "git push"}, + {"rm -rf /", true, "rm -rf"}, + {"rm -rf .", true, "rm -rf"}, + + // Should NOT match + {"git status", false, ""}, + {"git pull", false, ""}, + {"rm file.txt", false, ""}, + {"rm -r dir", false, ""}, + {"echo git push", false, ""}, // git push is an argument, not a command + } + + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + err := CheckCommand(tt.command, cfg) + if tt.shouldBlock { + if err == nil { + t.Errorf("expected command %q to be blocked", tt.command) + return + } + blocked, ok := err.(*CommandBlockedError) + if !ok { + t.Errorf("expected CommandBlockedError, got %T", err) + return + } + if blocked.BlockedPrefix != tt.blockPrefix { + t.Errorf("expected block prefix %q, got %q", tt.blockPrefix, blocked.BlockedPrefix) + } + } else if err != nil { + t.Errorf("expected command %q to be allowed, got error: %v", tt.command, err) + } + }) + } +} + +func TestCheckCommand_Allow(t *testing.T) { + cfg := &config.Config{ + Command: config.CommandConfig{ + Deny: []string{"git push"}, + Allow: []string{"git push origin docs"}, + UseDefaults: boolPtr(false), + }, + } + + tests := []struct { + command string + shouldBlock bool + }{ + // Allowed by explicit allow rule + {"git push origin docs", false}, + {"git push origin docs --force", false}, + + // Still blocked (not in allow list) + {"git push origin main", true}, + {"git push", true}, + } + + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + err := CheckCommand(tt.command, cfg) + if tt.shouldBlock && err == nil { + t.Errorf("expected command %q to be blocked", tt.command) + } + if !tt.shouldBlock && err != nil { + t.Errorf("expected command %q to be allowed, got error: %v", tt.command, err) + } + }) + } +} + +func TestCheckCommand_DefaultDenyList(t *testing.T) { + // Test with defaults enabled (nil = true) + cfg := &config.Config{ + Command: config.CommandConfig{ + Deny: []string{}, + UseDefaults: nil, // defaults to true + }, + } + + tests := []struct { + command string + shouldBlock bool + }{ + // Default denied commands + {"shutdown", true}, + {"shutdown -h now", true}, + {"reboot", true}, + {"halt", true}, + {"insmod malicious.ko", true}, + {"rmmod module", true}, + {"mkfs.ext4 /dev/sda1", true}, + + // Normal commands should be allowed + {"ls", false}, + {"git status", false}, + {"npm install", false}, + } + + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + err := CheckCommand(tt.command, cfg) + if tt.shouldBlock { + if err == nil { + t.Errorf("expected command %q to be blocked by defaults", tt.command) + return + } + blocked, ok := err.(*CommandBlockedError) + if !ok { + t.Errorf("expected CommandBlockedError, got %T", err) + return + } + if !blocked.IsDefault { + t.Errorf("expected IsDefault=true for default deny list") + } + } else if err != nil { + t.Errorf("expected command %q to be allowed, got error: %v", tt.command, err) + } + }) + } +} + +func TestCheckCommand_DisableDefaults(t *testing.T) { + cfg := &config.Config{ + Command: config.CommandConfig{ + Deny: []string{}, + UseDefaults: boolPtr(false), + }, + } + + // When defaults disabled, "shutdown" should be allowed + err := CheckCommand("shutdown", cfg) + if err != nil { + t.Errorf("expected 'shutdown' to be allowed when defaults disabled, got: %v", err) + } +} + +func TestCheckCommand_ChainedCommands(t *testing.T) { + cfg := &config.Config{ + Command: config.CommandConfig{ + Deny: []string{"git push"}, + UseDefaults: boolPtr(false), + }, + } + + tests := []struct { + command string + shouldBlock bool + desc string + }{ + // Chained with && + {"ls && git push", true, "git push in && chain"}, + {"git push && ls", true, "git push at start of && chain"}, + {"ls && echo hello && git push origin main", true, "git push at end of && chain"}, + + // Chained with || + {"ls || git push", true, "git push in || chain"}, + {"git status || git push", true, "git push after ||"}, + + // Chained with ; + {"ls; git push", true, "git push after semicolon"}, + {"git push; ls", true, "git push before semicolon"}, + + // Chained with | + {"echo hello | git push", true, "git push in pipe"}, + {"git status | grep something", false, "no git push in pipe"}, + + // Multiple operators + {"ls && echo hi || git push", true, "git push in mixed chain"}, + {"ls; pwd; git push origin main", true, "git push in semicolon chain"}, + + // Safe chains + {"ls && pwd", false, "safe commands only"}, + {"git status | grep branch", false, "safe git command in pipe"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + err := CheckCommand(tt.command, cfg) + if tt.shouldBlock && err == nil { + t.Errorf("expected command %q to be blocked", tt.command) + } + if !tt.shouldBlock && err != nil { + t.Errorf("expected command %q to be allowed, got error: %v", tt.command, err) + } + }) + } +} + +func TestCheckCommand_NestedShellInvocation(t *testing.T) { + cfg := &config.Config{ + Command: config.CommandConfig{ + Deny: []string{"git push"}, + UseDefaults: boolPtr(false), + }, + } + + tests := []struct { + command string + shouldBlock bool + desc string + }{ + // bash -c patterns + {`bash -c "git push"`, true, "bash -c with git push"}, + {`bash -c 'git push origin main'`, true, "bash -c single quotes"}, + {`sh -c "git push"`, true, "sh -c with git push"}, + {`zsh -c "git push"`, true, "zsh -c with git push"}, + + // bash -c with chained commands + {`bash -c "ls && git push"`, true, "bash -c with chained git push"}, + {`sh -c 'git status; git push'`, true, "sh -c semicolon chain"}, + + // Safe bash -c + {`bash -c "git status"`, false, "bash -c with safe command"}, + {`bash -c 'ls && pwd'`, false, "bash -c with safe chain"}, + + // bash -lc (login shell) + {`bash -lc "git push"`, true, "bash -lc with git push"}, + + // Full path to shell + {`/bin/bash -c "git push"`, true, "full path bash -c"}, + {`/usr/bin/zsh -c 'git push origin main'`, true, "full path zsh -c"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + err := CheckCommand(tt.command, cfg) + if tt.shouldBlock && err == nil { + t.Errorf("expected command %q to be blocked", tt.command) + } + if !tt.shouldBlock && err != nil { + t.Errorf("expected command %q to be allowed, got error: %v", tt.command, err) + } + }) + } +} + +func TestCheckCommand_PathNormalization(t *testing.T) { + cfg := &config.Config{ + Command: config.CommandConfig{ + Deny: []string{"git push"}, + UseDefaults: boolPtr(false), + }, + } + + tests := []struct { + command string + shouldBlock bool + desc string + }{ + // Full paths should be normalized + {"/usr/bin/git push", true, "full path git"}, + {"/usr/local/bin/git push origin main", true, "full path git with args"}, + + // Relative paths + {"./git push", true, "relative path git"}, + {"../bin/git push", true, "relative parent path git"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + err := CheckCommand(tt.command, cfg) + if tt.shouldBlock && err == nil { + t.Errorf("expected command %q to be blocked", tt.command) + } + if !tt.shouldBlock && err != nil { + t.Errorf("expected command %q to be allowed, got error: %v", tt.command, err) + } + }) + } +} + +func TestCheckCommand_QuotedArguments(t *testing.T) { + cfg := &config.Config{ + Command: config.CommandConfig{ + Deny: []string{"git push"}, + UseDefaults: boolPtr(false), + }, + } + + tests := []struct { + command string + shouldBlock bool + desc string + }{ + // Quotes shouldn't affect matching + {`git push "origin" "main"`, true, "double quoted args"}, + {`git push 'origin' 'main'`, true, "single quoted args"}, + + // "git push" as a string argument to another command should NOT block + {`echo "git push"`, false, "git push as echo argument"}, + {`grep "git push" log.txt`, false, "git push in grep pattern"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + err := CheckCommand(tt.command, cfg) + if tt.shouldBlock && err == nil { + t.Errorf("expected command %q to be blocked", tt.command) + } + if !tt.shouldBlock && err != nil { + t.Errorf("expected command %q to be allowed, got error: %v", tt.command, err) + } + }) + } +} + +func TestCheckCommand_EdgeCases(t *testing.T) { + cfg := &config.Config{ + Command: config.CommandConfig{ + Deny: []string{"rm -rf"}, + UseDefaults: boolPtr(false), + }, + } + + tests := []struct { + command string + shouldBlock bool + desc string + }{ + // Empty command + {"", false, "empty command"}, + {" ", false, "whitespace only"}, + + // Command that's a prefix of blocked command + {"rm", false, "rm alone"}, + {"rm -r", false, "rm -r (not -rf)"}, + {"rm -f", false, "rm -f (not -rf)"}, + {"rm -rf", true, "rm -rf exact"}, + {"rm -rf /", true, "rm -rf with path"}, + + // Similar but different + {"rmdir", false, "rmdir (different command)"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + err := CheckCommand(tt.command, cfg) + if tt.shouldBlock && err == nil { + t.Errorf("expected command %q to be blocked", tt.command) + } + if !tt.shouldBlock && err != nil { + t.Errorf("expected command %q to be allowed, got error: %v", tt.command, err) + } + }) + } +} + +func TestParseShellCommand(t *testing.T) { + tests := []struct { + input string + expected []string + }{ + {"ls", []string{"ls"}}, + {"ls && pwd", []string{"ls", "pwd"}}, + {"ls || pwd", []string{"ls", "pwd"}}, + {"ls; pwd", []string{"ls", "pwd"}}, + {"ls | grep foo", []string{"ls", "grep foo"}}, + {"ls && pwd || echo fail; date", []string{"ls", "pwd", "echo fail", "date"}}, + + // Quotes should be preserved + {`echo "hello && world"`, []string{`echo "hello && world"`}}, + {`echo 'a; b'`, []string{`echo 'a; b'`}}, + + // Parentheses (subshells) - preserved as single unit + {"(ls && pwd)", []string{"(ls && pwd)"}}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + // parseShellCommand also expands shell invocations, so we just check basics + result := parseShellCommand(tt.input) + + // For non-shell-invocation cases, result should match expected + // (shell invocations will add extra entries) + if len(result) < len(tt.expected) { + t.Errorf("expected at least %d commands, got %d: %v", len(tt.expected), len(result), result) + } + }) + } +} + +func TestNormalizeCommand(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"git push", "git push"}, + {"/usr/bin/git push", "git push"}, + {"/usr/local/bin/git push origin main", "git push origin main"}, + {"./script.sh arg1 arg2", "script.sh arg1 arg2"}, + {" git push ", "git push"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := normalizeCommand(tt.input) + if result != tt.expected { + t.Errorf("normalizeCommand(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestMatchesPrefix(t *testing.T) { + tests := []struct { + command string + prefix string + expected bool + }{ + {"git push", "git push", true}, + {"git push origin main", "git push", true}, + {"git pushall", "git push", false}, // "pushall" is different word + {"git status", "git push", false}, + {"gitpush", "git push", false}, + } + + for _, tt := range tests { + t.Run(tt.command+"_vs_"+tt.prefix, func(t *testing.T) { + result := matchesPrefix(tt.command, tt.prefix) + if result != tt.expected { + t.Errorf("matchesPrefix(%q, %q) = %v, want %v", tt.command, tt.prefix, result, tt.expected) + } + }) + } +} + +// boolPtr returns a pointer to a bool value. +func boolPtr(b bool) *bool { + return &b +} diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 208650d..c8fdc0f 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -94,6 +94,7 @@ func (m *Manager) Initialize() error { } // WrapCommand wraps a command with sandbox restrictions. +// Returns an error if the command is blocked by policy. func (m *Manager) WrapCommand(command string) (string, error) { if !m.initialized { if err := m.Initialize(); err != nil { @@ -101,6 +102,11 @@ func (m *Manager) WrapCommand(command string) (string, error) { } } + // Check if command is blocked by policy + if err := CheckCommand(command, m.config); err != nil { + return "", err + } + plat := platform.Detect() switch plat { case platform.MacOS: