Add ability to block commands
This commit is contained in:
10
README.md
10
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)
|
||||
|
||||
@@ -70,6 +70,9 @@ Configuration file format (~/.fence.json):
|
||||
"denyRead": [],
|
||||
"allowWrite": ["."],
|
||||
"denyWrite": []
|
||||
},
|
||||
"command": {
|
||||
"deny": ["git push", "npm publish"]
|
||||
}
|
||||
}`,
|
||||
RunE: runCommand,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
1
docs/templates/README.md
vendored
1
docs/templates/README.md
vendored
@@ -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
|
||||
|
||||
|
||||
19
docs/templates/git-readonly.json
vendored
Normal file
19
docs/templates/git-readonly.json
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"network": {
|
||||
"allowedDomains": []
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": ["."],
|
||||
"denyWrite": [".git"]
|
||||
},
|
||||
"command": {
|
||||
"deny": [
|
||||
"git push",
|
||||
"git reset",
|
||||
"git clean",
|
||||
"git checkout --",
|
||||
"git rebase",
|
||||
"git merge"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
301
internal/sandbox/command.go
Normal file
301
internal/sandbox/command.go
Normal 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
|
||||
}
|
||||
456
internal/sandbox/command_test.go
Normal file
456
internal/sandbox/command_test.go
Normal 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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user