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
|
- **Network Isolation**: All network access blocked by default
|
||||||
- **Domain Allowlisting**: Configure which domains are allowed
|
- **Domain Allowlisting**: Configure which domains are allowed
|
||||||
- **Filesystem Restrictions**: Control read/write access to paths
|
- **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
|
- **Violation Monitoring**: Real-time logging of blocked requests and sandbox denials
|
||||||
- **Cross-Platform**: macOS (sandbox-exec) and Linux (bubblewrap)
|
- **Cross-Platform**: macOS (sandbox-exec) and Linux (bubblewrap)
|
||||||
- **HTTP/SOCKS5 Proxies**: Built-in filtering proxies for domain control
|
- **HTTP/SOCKS5 Proxies**: Built-in filtering proxies for domain control
|
||||||
@@ -64,6 +65,7 @@ go build -o fence ./cmd/fence
|
|||||||
|
|
||||||
- `bubblewrap` (for sandboxing)
|
- `bubblewrap` (for sandboxing)
|
||||||
- `socat` (for network bridging)
|
- `socat` (for network bridging)
|
||||||
|
- `bpftrace` (optional, for filesystem violation visibility with when monitoring with `-m`)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -105,6 +107,11 @@ fence curl https://example.com
|
|||||||
# Use a custom config
|
# Use a custom config
|
||||||
fence --settings ./my-config.json npm install
|
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
|
# Run a shell command
|
||||||
fence -c "git clone https://github.com/user/repo && cd repo && npm install"
|
fence -c "git clone https://github.com/user/repo && cd repo && npm install"
|
||||||
|
|
||||||
@@ -143,6 +150,9 @@ func main() {
|
|||||||
Filesystem: fence.FilesystemConfig{
|
Filesystem: fence.FilesystemConfig{
|
||||||
AllowWrite: []string{"."},
|
AllowWrite: []string{"."},
|
||||||
},
|
},
|
||||||
|
Command: fence.CommandConfig{
|
||||||
|
Deny: []string{"git push", "npm publish"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create manager (debug=false, monitor=false)
|
// Create manager (debug=false, monitor=false)
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ Configuration file format (~/.fence.json):
|
|||||||
"denyRead": [],
|
"denyRead": [],
|
||||||
"allowWrite": ["."],
|
"allowWrite": ["."],
|
||||||
"denyWrite": []
|
"denyWrite": []
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"deny": ["git push", "npm publish"]
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
RunE: runCommand,
|
RunE: runCommand,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ Example config:
|
|||||||
"denyRead": ["/etc/passwd"],
|
"denyRead": ["/etc/passwd"],
|
||||||
"allowWrite": [".", "/tmp"],
|
"allowWrite": [".", "/tmp"],
|
||||||
"denyWrite": [".git/hooks"]
|
"denyWrite": [".git/hooks"]
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"deny": ["git push", "npm publish"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -40,6 +43,48 @@ Example config:
|
|||||||
| `denyWrite` | Paths to deny writing (takes precedence) |
|
| `denyWrite` | Paths to deny writing (takes precedence) |
|
||||||
| `allowGitConfig` | Allow writes to `.git/config` files |
|
| `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
|
## Other Options
|
||||||
|
|
||||||
| Field | Description |
|
| 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
|
- `pip-install.json`: allow PyPI; allow writes to workspace/tmp
|
||||||
- `local-dev-server.json`: allow binding and localhost outbound; 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
|
- `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
|
## 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 {
|
type Config struct {
|
||||||
Network NetworkConfig `json:"network"`
|
Network NetworkConfig `json:"network"`
|
||||||
Filesystem FilesystemConfig `json:"filesystem"`
|
Filesystem FilesystemConfig `json:"filesystem"`
|
||||||
|
Command CommandConfig `json:"command"`
|
||||||
AllowPty bool `json:"allowPty,omitempty"`
|
AllowPty bool `json:"allowPty,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +39,56 @@ type FilesystemConfig struct {
|
|||||||
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
|
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.
|
// Default returns the default configuration with all network blocked.
|
||||||
func Default() *Config {
|
func Default() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
@@ -50,6 +101,11 @@ func Default() *Config {
|
|||||||
AllowWrite: []string{},
|
AllowWrite: []string{},
|
||||||
DenyWrite: []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")
|
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
|
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 {
|
func validateDomainPattern(pattern string) error {
|
||||||
if pattern == "localhost" {
|
if pattern == "localhost" {
|
||||||
return nil
|
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.
|
// 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) {
|
func (m *Manager) WrapCommand(command string) (string, error) {
|
||||||
if !m.initialized {
|
if !m.initialized {
|
||||||
if err := m.Initialize(); err != nil {
|
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()
|
plat := platform.Detect()
|
||||||
switch plat {
|
switch plat {
|
||||||
case platform.MacOS:
|
case platform.MacOS:
|
||||||
|
|||||||
Reference in New Issue
Block a user