Add ability to block commands

This commit is contained in:
JY Tan
2025-12-25 19:03:01 -08:00
parent 6159bdd38a
commit 47de3e431c
9 changed files with 909 additions and 0 deletions

301
internal/sandbox/command.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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: