Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 47 additions & 24 deletions cmd/fence/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,19 @@ var (
)

var (
debug bool
monitor bool
settingsPath string
templateName string
listTemplates bool
cmdString string
exposePorts []string
shellMode string
shellLogin bool
exitCode int
showVersion bool
linuxFeatures bool
debug bool
monitor bool
settingsPath string
templateName string
listTemplates bool
cmdString string
exposePorts []string
shellMode string
shellLogin bool
forceNewSession bool
exitCode int
showVersion bool
linuxFeatures bool
)

func main() {
Expand Down Expand Up @@ -106,6 +107,7 @@ Configuration file format:
rootCmd.Flags().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)")
rootCmd.Flags().StringVar(&shellMode, "shell", sandbox.ShellModeDefault, "Shell mode for command execution: default (bash) or user ($SHELL)")
rootCmd.Flags().BoolVar(&shellLogin, "shell-login", false, "Run shell as login shell (-lc). Use with --shell user for shell init compatibility")
rootCmd.Flags().BoolVar(&forceNewSession, "force-new-session", false, "Linux only: force bubblewrap --new-session even for interactive PTY sessions")
rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information")
rootCmd.Flags().BoolVar(&linuxFeatures, "linux-features", false, "Show available Linux security features and exit")

Expand Down Expand Up @@ -215,6 +217,8 @@ func runCommand(cmd *cobra.Command, args []string) error {
}
}

cfg = applyCLIConfigOverrides(cmd, cfg, forceNewSession)

manager := sandbox.NewManager(cfg, debug, monitor)
manager.SetExposedPorts(ports)
manager.SetShellOptions(shellMode, shellLogin)
Expand Down Expand Up @@ -255,11 +259,10 @@ func runCommand(cmd *cobra.Command, args []string) error {
execCmd := exec.Command("sh", "-c", sandboxedCommand) //nolint:gosec // sandboxedCommand is constructed from user input - intentional
execCmd.Env = hardenedEnv

// On Linux, bubblewrap runs with --new-session. That breaks the normal TTY
// SIGWINCH delivery behavior for interactive TUIs unless we relay it.
//
// PTY relay is only enabled for interactive sessions to avoid surprising
// behavior changes (e.g., piping output through fence).
// On Linux, PTY relay is only enabled for interactive sessions to avoid
// surprising behavior changes (e.g., piping output through fence). When PTY
// relay is active, the Linux wrapper can keep bwrap in the current session
// so the inner shell retains normal job control.
usePTY := cfg != nil &&
cfg.AllowPty &&
platform.Detect() == platform.Linux &&
Expand All @@ -273,12 +276,7 @@ func runCommand(cmd *cobra.Command, args []string) error {
// "cannot set terminal process group: Operation not permitted"
// "no job control in this shell"
isTTY := term.IsTerminal(int(os.Stdin.Fd()))
if isTTY {
execCmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pgid: 0, // child gets its own process group (pgid = child pid)
}
}
configureHostTTYChildProcessGroup(execCmd, isTTY, usePTY)

if !usePTY {
execCmd.Stdin = os.Stdin
Expand All @@ -297,7 +295,7 @@ func runCommand(cmd *cobra.Command, args []string) error {
// call tcsetpgrp. We ignore SIGTTOU so we don't get stopped when we
// later reclaim the foreground (at that point we'll be in the background
// process group).
if isTTY && execCmd.Process != nil {
if shouldManageHostTTYForeground(isTTY, usePTY) && execCmd.Process != nil {
stdinFd := int(os.Stdin.Fd())

savedFgPgrp, err := unix.IoctlGetInt(stdinFd, unix.TIOCGPGRP)
Expand Down Expand Up @@ -357,13 +355,38 @@ func runCommand(cmd *cobra.Command, args []string) error {
return nil
}

func applyCLIConfigOverrides(cmd *cobra.Command, cfg *config.Config, forceNewSessionValue bool) *config.Config {
if cfg == nil {
cfg = config.Default()
}
if cmd.Flags().Changed("force-new-session") {
value := forceNewSessionValue
cfg.ForceNewSession = &value
}
return cfg
}

func startCommand(execCmd *exec.Cmd, usePTY bool) (func(), error) {
if usePTY {
return startCommandWithPTY(execCmd)
}
return startCommandWithSignalProxy(execCmd)
}

func configureHostTTYChildProcessGroup(execCmd *exec.Cmd, isTTY bool, usePTY bool) {
if !shouldManageHostTTYForeground(isTTY, usePTY) {
return
}
execCmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pgid: 0, // child gets its own process group (pgid = child pid)
}
}

func shouldManageHostTTYForeground(isTTY bool, usePTY bool) bool {
return isTTY && !usePTY
}

func startCommandWithSignalProxy(execCmd *exec.Cmd) (func(), error) {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGWINCH)
Expand Down
43 changes: 43 additions & 0 deletions cmd/fence/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"

"github.com/Use-Tusk/fence/internal/config"
"github.com/spf13/cobra"
)

func TestBuildInitConfig_DefaultTemplate(t *testing.T) {
Expand Down Expand Up @@ -185,3 +186,45 @@ func TestStartCommandWithSignalProxy_CleanupIsIdempotent(t *testing.T) {
cleanup()
cleanup()
}

func TestConfigureHostTTYChildProcessGroup_DirectTTY(t *testing.T) {
execCmd := exec.Command("sh", "-c", "exit 0")

configureHostTTYChildProcessGroup(execCmd, true, false)

if execCmd.SysProcAttr == nil {
t.Fatal("expected SysProcAttr to be configured for direct TTY sessions")
}
if !execCmd.SysProcAttr.Setpgid {
t.Fatal("expected Setpgid to be enabled for direct TTY sessions")
}
if execCmd.SysProcAttr.Pgid != 0 {
t.Fatalf("expected Pgid=0, got %d", execCmd.SysProcAttr.Pgid)
}
}

func TestConfigureHostTTYChildProcessGroup_PTYRelay(t *testing.T) {
execCmd := exec.Command("sh", "-c", "exit 0")

configureHostTTYChildProcessGroup(execCmd, true, true)

if execCmd.SysProcAttr != nil {
t.Fatal("expected PTY relay sessions to leave SysProcAttr unset")
}
}

func TestApplyCLIConfigOverrides_NilConfigWithForceNewSessionFlag(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Bool("force-new-session", false, "")
if err := cmd.Flags().Set("force-new-session", "true"); err != nil {
t.Fatalf("failed to set force-new-session flag: %v", err)
}

cfg := applyCLIConfigOverrides(cmd, nil, true)
if cfg == nil {
t.Fatal("expected config to be initialized when nil")
}
if cfg.ForceNewSession == nil || !*cfg.ForceNewSession {
t.Fatal("expected ForceNewSession override to be applied")
}
}
5 changes: 4 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,12 +398,15 @@ SSH host patterns support wildcards anywhere:

| Field | Description |
|-------|-------------|
| `allowPty` | Enable interactive PTY behavior. On macOS this allows PTY access in sandbox policy; on Linux this enables a PTY relay mode for interactive TUIs/editors while keeping `bwrap --new-session` enabled. |
| `allowPty` | Enable interactive PTY behavior. On macOS this allows PTY access in sandbox policy; on Linux this enables a PTY relay mode for interactive TUIs/editors. |
| `forceNewSession` | Linux only. Force `bwrap --new-session` even for interactive PTY sessions. Leave unset to use Fence's default Linux PTY session policy. |

### `allowPty` notes (Linux)

- Use `allowPty: true` for interactive terminal apps (TUIs/editors) that need proper resize redraw behavior.
- PTY relay is only used when stdin/stdout are both terminals (non-interactive pipes keep the normal stdio behavior).
- By default, Linux interactive PTY sessions skip `bwrap --new-session` so shells keep normal job control.
- If you need the stricter Bubblewrap session split, set `forceNewSession: true` or pass `--force-new-session`.
- Resize handling relays `SIGWINCH` to the PTY foreground process group so terminal apps can redraw after window size changes.

## Importing from Claude Code
Expand Down
5 changes: 2 additions & 3 deletions docs/linux-bwrap-mount-sequence.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,16 @@ The mount sequence is easiest to understand as a set of phases.

### 1. Base `bwrap` Flags

Fence always starts with:
Fence starts with:

```text
bwrap
--new-session
--die-with-parent
```

These are not mounts, but they shape the rest of the runtime:

- `--new-session` isolates the sandboxed process tree into a new session
- `--new-session` is added in the normal Linux path, and also in interactive PTY sessions when `forceNewSession` is enabled
- `--die-with-parent` ensures the sandbox dies when Fence dies

### 2. Namespace Isolation
Expand Down
6 changes: 6 additions & 0 deletions docs/schema/fence.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@
},
"type": "object"
},
"forceNewSession": {
"type": [
"boolean",
"null"
]
},
"network": {
"additionalProperties": false,
"properties": {
Expand Down
17 changes: 10 additions & 7 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ import (

// Config is the main configuration for fence.
type Config struct {
Extends string `json:"extends,omitempty"`
Network NetworkConfig `json:"network"`
Filesystem FilesystemConfig `json:"filesystem"`
Devices DevicesConfig `json:"devices,omitempty"`
Command CommandConfig `json:"command"`
SSH SSHConfig `json:"ssh"`
AllowPty bool `json:"allowPty,omitempty"`
Extends string `json:"extends,omitempty"`
Network NetworkConfig `json:"network"`
Filesystem FilesystemConfig `json:"filesystem"`
Devices DevicesConfig `json:"devices,omitempty"`
Command CommandConfig `json:"command"`
SSH SSHConfig `json:"ssh"`
AllowPty bool `json:"allowPty,omitempty"`
ForceNewSession *bool `json:"forceNewSession,omitempty"`
}

// NetworkConfig defines network restrictions.
Expand Down Expand Up @@ -520,6 +521,8 @@ func Merge(base, override *Config) *Config {
result := &Config{
// AllowPty: true if either config enables it
AllowPty: base.AllowPty || override.AllowPty,
// Pointer field: override wins if set, otherwise base
ForceNewSession: mergeOptionalBool(base.ForceNewSession, override.ForceNewSession),

Network: NetworkConfig{
// Append slices (base first, then override additions)
Expand Down
18 changes: 10 additions & 8 deletions internal/config/config_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,22 @@ type cleanSSHConfig struct {

// cleanConfig is used for JSON output with fields in desired order and omitempty.
type cleanConfig struct {
Extends string `json:"extends,omitempty"`
AllowPty bool `json:"allowPty,omitempty"`
Network *cleanNetworkConfig `json:"network,omitempty"`
Filesystem *cleanFilesystemConfig `json:"filesystem,omitempty"`
Command *cleanCommandConfig `json:"command,omitempty"`
SSH *cleanSSHConfig `json:"ssh,omitempty"`
Extends string `json:"extends,omitempty"`
AllowPty bool `json:"allowPty,omitempty"`
ForceNewSession *bool `json:"forceNewSession,omitempty"`
Network *cleanNetworkConfig `json:"network,omitempty"`
Filesystem *cleanFilesystemConfig `json:"filesystem,omitempty"`
Command *cleanCommandConfig `json:"command,omitempty"`
SSH *cleanSSHConfig `json:"ssh,omitempty"`
}

// MarshalConfigJSON marshals a fence config to clean JSON, omitting empty arrays
// and with fields in a logical order (extends first).
func MarshalConfigJSON(cfg *Config) ([]byte, error) {
clean := cleanConfig{
Extends: cfg.Extends,
AllowPty: cfg.AllowPty,
Extends: cfg.Extends,
AllowPty: cfg.AllowPty,
ForceNewSession: cfg.ForceNewSession,
}

// Network config - only include if non-empty
Expand Down
5 changes: 5 additions & 0 deletions internal/config/config_file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ func TestWriteConfigFile(t *testing.T) {

func TestMarshalConfigJSON_IncludesExtendedFilesystemAndSSH(t *testing.T) {
wslInterop := false
forceNewSession := true
cfg := &Config{}
cfg.AllowPty = true
cfg.ForceNewSession = &forceNewSession
cfg.Filesystem.DefaultDenyRead = true
cfg.Filesystem.WSLInterop = &wslInterop
cfg.Filesystem.AllowRead = []string{"/workspace"}
Expand All @@ -69,6 +72,8 @@ func TestMarshalConfigJSON_IncludesExtendedFilesystemAndSSH(t *testing.T) {
require.NoError(t, err)

output := string(data)
assert.Contains(t, output, `"allowPty": true`)
assert.Contains(t, output, `"forceNewSession": true`)
assert.Contains(t, output, `"defaultDenyRead": true`)
assert.Contains(t, output, `"wslInterop": false`)
assert.Contains(t, output, `"allowRead": [`)
Expand Down
17 changes: 17 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,23 @@ func TestMerge(t *testing.T) {
}
})

t.Run("merge forceNewSession override wins", func(t *testing.T) {
base := &Config{
ForceNewSession: boolPtr(true),
}
override := &Config{
ForceNewSession: boolPtr(false),
}
result := Merge(base, override)

if result.ForceNewSession == nil {
t.Fatal("expected ForceNewSession to be non-nil")
}
if *result.ForceNewSession {
t.Error("expected ForceNewSession to be false (override wins)")
}
})

t.Run("merge command config", func(t *testing.T) {
base := &Config{
Command: CommandConfig{
Expand Down
Loading
Loading