Skip to content

Commit cfec5f8

Browse files
feat: sandbox --best-effort for container environments (#289)
Adds graceful degradation when user namespace creation is blocked (k8s default seccomp, AppArmor). Landlock and seccomp containment layers still apply. Network scanning uses proxy-based routing. - --best-effort flag on sandbox and mcp proxy commands - --env flag for passing env vars to sandboxed processes - Pure Go netlink loopback (no ip binary dependency) - io_uring returns EPERM instead of KILL (Node.js 22 compat) - readlink syscall added to seccomp allowlist - secretDirs checks existence (container compat) - Dynamic bridge proxy port in best-effort mode - Config: sandbox.best_effort with hot-reload + enterprise merge - Docs: sandbox setup guide with deployment environments table
1 parent 2332fb1 commit cfec5f8

28 files changed

Lines changed: 657 additions & 120 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Sandbox `--best-effort` flag: gracefully degrades when user namespace creation is blocked (e.g. k8s containers with default seccomp). Landlock and seccomp containment layers still apply. Network scanning uses proxy-based routing instead of kernel-enforced namespace isolation.
12+
- Sandbox `--env` flag: pass environment variables to sandboxed processes (KEY or KEY=VALUE, repeatable). Validates against dangerous keys (LD_PRELOAD, NODE_OPTIONS, etc.) that could subvert containment.
13+
- MCP proxy `--sandbox-best-effort` flag: parity with `pipelock sandbox --best-effort` for MCP stdio wrapping mode.
14+
- Pure Go netlink loopback: sandbox uses raw netlink syscalls to bring up loopback inside network namespaces. No `ip` binary required. Works in minimal container images without iproute2.
15+
16+
### Fixed
17+
- Seccomp io_uring handling: changed from KILL_PROCESS to EPERM so runtimes like Node.js 22 that probe io_uring at startup can gracefully fall back to epoll instead of crashing.
18+
- Seccomp `readlink` syscall: added `SYS_READLINK` (nr 89) to the allowlist. Node.js/libuv uses the legacy readlink syscall directly, not readlinkat.
19+
- Sandbox secret dir validation: `secretDirs()` now only protects directories that actually exist. Prevents false validation errors in containers.
20+
- Sandbox bridge proxy dynamic port: in best-effort mode, uses dynamic port instead of hardcoded 8888.
21+
- Config reload detection: `sandbox.best_effort` changes detected during hot reload. Per-agent `best_effort` propagated through enterprise merge.
22+
- Config validation: `sandbox.best_effort` and `sandbox.strict` mutually exclusive.
23+
1024
## [2.0.0] - 2026-03-22
1125

1226
### Added

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
[![CI](https://github.com/luckyPipewrench/pipelock/actions/workflows/ci.yaml/badge.svg)](https://github.com/luckyPipewrench/pipelock/actions/workflows/ci.yaml)
88
[![Security](https://github.com/luckyPipewrench/pipelock/actions/workflows/security.yaml/badge.svg)](https://github.com/luckyPipewrench/pipelock/actions/workflows/security.yaml)
9-
[![Pipelock Security Scan](https://github.com/luckyPipewrench/pipelock/actions/workflows/pipelock.yaml/badge.svg)](https://github.com/luckyPipewrench/pipelock/actions/workflows/pipelock.yaml)
109
[![Go Report Card](https://goreportcard.com/badge/github.com/luckyPipewrench/pipelock)](https://goreportcard.com/report/github.com/luckyPipewrench/pipelock)
1110
[![GitHub Release](https://img.shields.io/github/v/release/luckyPipewrench/pipelock)](https://github.com/luckyPipewrench/pipelock/releases)
1211
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/luckyPipewrench/pipelock/badge)](https://scorecard.dev/viewer/?uri=github.com/luckyPipewrench/pipelock)
@@ -233,12 +232,13 @@ DLP runs before DNS resolution, designed to catch secrets before any DNS query l
233232

234233
See [docs/bypass-resistance.md](docs/bypass-resistance.md) for the full evasion test matrix.
235234

236-
### Process Sandbox (Linux)
235+
### Process Sandbox
237236

238-
Unprivileged process containment using Landlock LSM, network namespaces, and seccomp. No root, no Docker, no containers. The agent runs in an isolated environment with controlled filesystem access, no direct network egress, and a filtered syscall set. Works with any command — MCP servers, standalone agents, or arbitrary processes.
237+
Unprivileged process containment using OS-native kernel primitives. On Linux: Landlock LSM restricts filesystem access, seccomp filters dangerous syscalls, and network namespaces force all traffic through pipelock's scanner (no direct egress). On macOS: sandbox-exec profiles restrict filesystem and network. In containers, use `--best-effort` for Landlock + seccomp containment when namespace creation is restricted (network scanning uses proxy-based routing instead of kernel enforcement).
239238

240239
```bash
241240
pipelock sandbox --config pipelock.yaml -- python agent.py
241+
pipelock sandbox --best-effort -- python agent.py # containers
242242
pipelock mcp proxy --sandbox --config pipelock.yaml -- npx server
243243
```
244244

docs/configuration.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,32 +1405,37 @@ rules:
14051405

14061406
## Sandbox
14071407

1408-
Process containment for agent commands using Linux kernel primitives. The agent runs in a restricted namespace with controlled filesystem access, no direct network, and a filtered syscall set.
1408+
Process containment for agent commands using Linux kernel primitives. The agent runs in a restricted environment with controlled filesystem access, no direct network, and a filtered syscall set.
14091409

14101410
```yaml
14111411
sandbox:
14121412
enabled: true
1413+
best_effort: false # degrade gracefully when namespace isolation unavailable
1414+
strict: false # error if any layer unavailable (mutually exclusive with best_effort)
14131415
workspace: /home/user/project # agent working directory (default: CWD)
14141416
filesystem: # optional Landlock overrides (default policy works for most agents)
14151417
allow_read:
14161418
- /usr/share/data
1419+
- /app/ # application code in containers
14171420
allow_write:
14181421
- /tmp/agent-work
14191422
```
14201423

14211424
| Field | Default | Description |
14221425
|-------|---------|-------------|
14231426
| `enabled` | `false` | Enable sandbox containment |
1427+
| `best_effort` | `false` | Skip namespace isolation when unavailable (e.g. containers). Landlock + seccomp still apply. |
1428+
| `strict` | `false` | Error if any containment layer is unavailable. Mutually exclusive with `best_effort`. |
14241429
| `workspace` | CWD | Agent working directory (resolved to absolute at startup) |
14251430
| `filesystem.allow_read` | `[]` | Additional read-only filesystem paths |
14261431
| `filesystem.allow_write` | `[]` | Additional writable paths (workspace is always writable) |
14271432

14281433
If `filesystem` is omitted, the default Landlock policy is used (safe for Python/Node/Go agents without config). Read access grants execute (Landlock bundling). Write paths are also executable.
14291434

14301435
**Containment layers:**
1431-
- **Landlock LSM:** Restricts filesystem access to declared paths. Read-only and read-write grants are explicit. Allowlist model.
1432-
- **Network namespaces:** Agent runs in an isolated network namespace. For MCP (stdio), no network is needed. For standalone agents, traffic routes through pipelock's bridge proxy.
1433-
- **Seccomp BPF:** Syscall allowlist blocks dangerous operations (ptrace, mount, io_uring, module loading, kexec). Clone flags are filtered to prevent namespace escape.
1436+
- **Landlock LSM:** Restricts filesystem access to declared paths. Allowlist model. Protected directories (`~/.ssh`, `~/.aws`, `~/.kube`, etc.) are denied. Only dirs that exist on the system are checked.
1437+
- **Network namespaces:** Agent runs in an isolated network namespace. All traffic is kernel-forced through pipelock's bridge proxy. Raw socket bypass is impossible. For MCP (stdio), no network is needed.
1438+
- **Seccomp BPF:** Syscall allowlist (~130 safe syscalls for Go/Python/Node.js). Blocks ptrace, mount, module loading, kexec (KILL). io_uring returns EPERM (allows runtimes like Node.js 22 to fall back to epoll). Clone flags filtered to prevent namespace escape.
14341439

14351440
**Usage:**
14361441
```bash
@@ -1439,9 +1444,26 @@ pipelock mcp proxy --sandbox --config pipelock.yaml -- npx server
14391444
14401445
# Sandbox a standalone command
14411446
pipelock sandbox --config pipelock.yaml -- python agent.py
1447+
1448+
# Pass environment variables to sandboxed process
1449+
pipelock sandbox --env API_KEY --env HOME=/app -- node server.js
1450+
1451+
# Best-effort mode for containers (Landlock + seccomp, no namespace)
1452+
pipelock sandbox --best-effort -- python agent.py
1453+
1454+
# Check sandbox capabilities without launching
1455+
pipelock sandbox --dry-run --json -- python agent.py
14421456
```
14431457

1444-
**Requirements:** Linux 5.13+ (Landlock ABI v1). Unprivileged — no root, no Docker, no special capabilities. Not available on macOS or Windows (hard error with explanation).
1458+
**Environments:**
1459+
1460+
| Environment | Layers | Notes |
1461+
|-------------|--------|-------|
1462+
| Bare metal / VM (Linux) | 3/3 | Full containment: Landlock + seccomp + network namespace |
1463+
| Containers (`--best-effort`) | 2/3 | Landlock + seccomp. Network via HTTP_PROXY + NetworkPolicy. |
1464+
| macOS | sandbox-exec | Apple SBPL profiles for filesystem + network restriction |
1465+
1466+
**Requirements:** Linux 5.13+ (Landlock ABI v1). Unprivileged on bare metal. macOS 13+ for sandbox-exec. Containers may need `--best-effort` if default seccomp blocks `CLONE_NEWUSER`.
14451467

14461468
## Config Audit Scoring (v2.0)
14471469

enterprise/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,9 @@ func MergeAgentProfile(base *config.Config, profile *config.AgentProfile) (*conf
385385
if profile.Sandbox.Strict != nil {
386386
merged.Sandbox.Strict = *profile.Sandbox.Strict
387387
}
388+
if profile.Sandbox.BestEffort != nil {
389+
merged.Sandbox.BestEffort = *profile.Sandbox.BestEffort
390+
}
388391
if profile.Sandbox.Workspace != "" {
389392
merged.Sandbox.Workspace = profile.Sandbox.Workspace
390393
}

enterprise/config_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,42 @@ func TestMergeAgentProfile_SandboxFSAppendToNilBase(t *testing.T) {
911911
}
912912
}
913913

914+
func TestMergeAgentProfile_SandboxBestEffortOverride(t *testing.T) {
915+
cfg := testConfig()
916+
cfg.Sandbox.BestEffort = false
917+
918+
bestEffort := true
919+
profile := &config.AgentProfile{
920+
Sandbox: &config.AgentSandboxOverride{
921+
BestEffort: &bestEffort,
922+
},
923+
}
924+
merged, err := MergeAgentProfile(cfg, profile)
925+
if err != nil {
926+
t.Fatal(err)
927+
}
928+
if !merged.Sandbox.BestEffort {
929+
t.Error("expected sandbox.best_effort=true (explicit override)")
930+
}
931+
}
932+
933+
func TestMergeAgentProfile_SandboxBestEffortInherits(t *testing.T) {
934+
cfg := testConfig()
935+
cfg.Sandbox.BestEffort = true
936+
937+
// Profile with nil BestEffort — should inherit from base.
938+
profile := &config.AgentProfile{
939+
Sandbox: &config.AgentSandboxOverride{},
940+
}
941+
merged, err := MergeAgentProfile(cfg, profile)
942+
if err != nil {
943+
t.Fatal(err)
944+
}
945+
if !merged.Sandbox.BestEffort {
946+
t.Error("expected sandbox.best_effort=true (inherited from base)")
947+
}
948+
}
949+
914950
func TestResolvePublicKey_InvalidHex(t *testing.T) {
915951
cfg := testConfig()
916952
cfg.LicensePublicKey = "not-valid-hex"

internal/cli/mcp.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ func mcpProxyCmd() *cobra.Command {
128128
var agentName string
129129
var sandboxEnabled bool
130130
var sandboxStrict bool
131+
var sandboxBestEffort bool
131132
var sandboxWorkspace string
132133

133134
cmd := &cobra.Command{
@@ -496,15 +497,15 @@ Environment passthrough (subprocess mode only):
496497

497498
// Subprocess mode.
498499
serverCmd := args[dashIdx:]
499-
// --sandbox-strict implies --sandbox.
500-
if sandboxStrict {
500+
// --sandbox-strict and --sandbox-best-effort imply --sandbox.
501+
if sandboxStrict || sandboxBestEffort {
501502
sandboxEnabled = true
502503
}
503504
useSandbox := sandboxEnabled || cfg.Sandbox.Enabled
504505

505-
// Reject config-enabled sandbox with remote modes.
506-
if cfg.Sandbox.Enabled && (hasUpstream || hasListen) {
507-
return errors.New("sandbox.enabled cannot be used with --upstream or --listen (cannot sandbox a remote server)")
506+
// Reject sandbox with remote modes.
507+
if useSandbox && (hasUpstream || hasListen) {
508+
return errors.New("sandbox cannot be used with --upstream or --listen (cannot sandbox a remote server)")
508509
}
509510

510511
// Sandboxed MCP proxy: child in isolated namespace.
@@ -532,13 +533,19 @@ Environment passthrough (subprocess mode only):
532533
defer cancel()
533534

534535
mcpStrict := sandboxStrict || cfg.Sandbox.Strict
536+
mcpBestEffort := sandboxBestEffort || cfg.Sandbox.BestEffort
537+
538+
if mcpStrict && mcpBestEffort {
539+
return errors.New("--sandbox-strict and --sandbox-best-effort are mutually exclusive")
540+
}
535541

536542
launchCfg := sandbox.LaunchConfig{
537-
Ctx: ctx,
538-
Command: serverCmd,
539-
Workspace: workspace,
540-
Strict: mcpStrict,
541-
ExtraEnv: extraEnv,
543+
Ctx: ctx,
544+
Command: serverCmd,
545+
Workspace: workspace,
546+
Strict: mcpStrict,
547+
BestEffort: mcpBestEffort,
548+
ExtraEnv: extraEnv,
542549
}
543550
if cfg.Sandbox.FS != nil {
544551
p := sandbox.DefaultPolicy(workspace)
@@ -652,6 +659,7 @@ Environment passthrough (subprocess mode only):
652659
cmd.Flags().StringVar(&agentName, "agent", "", "agent profile name (resolves to config profile for policy/scanner)")
653660
cmd.Flags().BoolVar(&sandboxEnabled, "sandbox", false, "run child in sandbox (Landlock + seccomp + network namespace, Linux only)")
654661
cmd.Flags().BoolVar(&sandboxStrict, "sandbox-strict", false, "strict sandbox: error on missing layers, private /dev/shm, block clone3 (implies --sandbox)")
662+
cmd.Flags().BoolVar(&sandboxBestEffort, "sandbox-best-effort", false, "degrade gracefully when namespace isolation is unavailable (implies --sandbox)")
655663
cmd.Flags().StringVar(&sandboxWorkspace, "workspace", "", "sandbox workspace directory (default: current directory)")
656664
return cmd
657665
}

internal/cli/sandbox.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ func sandboxCmd() *cobra.Command {
3030
var workspace string
3131
var configFile string
3232
var strict bool
33+
var bestEffort bool
3334
var dryRun bool
3435
var jsonOutput bool
36+
var envVars []string
3537

3638
cmd := &cobra.Command{
3739
Use: "sandbox [flags] -- COMMAND [ARGS...]",
@@ -45,9 +47,14 @@ func sandboxCmd() *cobra.Command {
4547
Agent HTTP/HTTPS traffic is routed through pipelock's full scanner pipeline
4648
(DLP, SSRF, blocklist, rate limiting, entropy analysis) via a bridge proxy.
4749
50+
Use --best-effort inside containers where user namespace creation is blocked
51+
(e.g. Kubernetes with default seccomp). Landlock and seccomp are still applied;
52+
network scanning uses proxy-based routing instead of kernel-enforced isolation.
53+
4854
Examples:
4955
pipelock sandbox -- python agent.py
5056
pipelock sandbox --workspace /home/user/project -- node server.js
57+
pipelock sandbox --best-effort -- python agent.py # inside containers
5158
pipelock sandbox --config pipelock.yaml -- bash -c "curl https://example.com"`,
5259
RunE: func(cmd *cobra.Command, args []string) error {
5360
dashIdx := cmd.ArgsLenAtDash()
@@ -75,6 +82,11 @@ Examples:
7582
workspace, _ = filepath.Abs(workspace)
7683

7784
useStrict := strict || cfg.Sandbox.Strict
85+
useBestEffort := bestEffort || cfg.Sandbox.BestEffort
86+
87+
if useStrict && useBestEffort {
88+
return errors.New("--strict and --best-effort are mutually exclusive")
89+
}
7890

7991
// Dry-run: preflight check without launching.
8092
if dryRun {
@@ -141,11 +153,30 @@ Examples:
141153
_ = srv.Serve(&singleConnListener{conn: conn})
142154
}
143155

156+
// Resolve --env flags: KEY=VALUE passes as-is, bare KEY inherits from parent.
157+
var extraEnv []string
158+
for _, e := range envVars {
159+
key, _, hasValue := strings.Cut(e, "=")
160+
if key == "" {
161+
return errors.New("--env requires a non-empty variable name")
162+
}
163+
if sandbox.IsDangerousEnvKey(key) {
164+
return fmt.Errorf("--env %s is blocked: this variable can subvert sandbox containment", key)
165+
}
166+
if hasValue {
167+
extraEnv = append(extraEnv, e)
168+
} else if val, found := os.LookupEnv(e); found {
169+
extraEnv = append(extraEnv, e+"="+val)
170+
}
171+
}
172+
144173
launchCfg := sandbox.StandaloneLaunchConfig{
145174
Ctx: ctx,
146175
Command: command,
147176
Workspace: workspace,
148177
Strict: useStrict,
178+
BestEffort: useBestEffort,
179+
ExtraEnv: extraEnv,
149180
ProxyHandler: proxyHandler,
150181
}
151182

@@ -164,6 +195,8 @@ Examples:
164195
cmd.Flags().StringVar(&workspace, "workspace", "", "sandbox workspace directory (default: current directory)")
165196
cmd.Flags().StringVarP(&configFile, "config", "c", "", "config file path")
166197
cmd.Flags().BoolVar(&strict, "strict", false, "strict mode: error if any containment layer is unavailable, mount private /dev/shm, block clone3")
198+
cmd.Flags().BoolVar(&bestEffort, "best-effort", false, "degrade gracefully when namespace isolation is unavailable (e.g. inside containers)")
199+
cmd.Flags().StringArrayVar(&envVars, "env", nil, "pass environment variable to sandboxed process (KEY or KEY=VALUE, repeatable)")
167200
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "check sandbox capabilities without launching (exit 0=capabilities ok, 1=degraded, 2=error)")
168201
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output dry-run result as JSON")
169202
return cmd

internal/config/config.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -216,21 +216,23 @@ type FileSentry struct {
216216
// Sandbox config is startup-only and reload-immutable: changing these
217217
// values in a config reload has no effect on an already-running sandbox.
218218
type Sandbox struct {
219-
Enabled bool `yaml:"enabled"`
220-
Strict bool `yaml:"strict"` // error if any containment layer is unavailable
221-
Workspace string `yaml:"workspace"` // agent working dir; resolved to absolute at startup
222-
FS *SandboxFilesystem `yaml:"filesystem"`
219+
Enabled bool `yaml:"enabled"`
220+
Strict bool `yaml:"strict"` // error if any containment layer is unavailable
221+
BestEffort bool `yaml:"best_effort"` // degrade gracefully when namespace isolation unavailable (e.g. containers)
222+
Workspace string `yaml:"workspace"` // agent working dir; resolved to absolute at startup
223+
FS *SandboxFilesystem `yaml:"filesystem"`
223224
}
224225

225226
// AgentSandboxOverride controls per-agent sandbox settings.
226227
// Nil pointer fields mean "inherit from global sandbox config."
227228
// Scoped to mcp proxy --agent and agent listeners. pipelock sandbox
228229
// CLI does not support per-agent resolution.
229230
type AgentSandboxOverride struct {
230-
Enabled *bool `yaml:"enabled,omitempty"`
231-
Strict *bool `yaml:"strict,omitempty"`
232-
Workspace string `yaml:"workspace,omitempty"`
233-
FS *SandboxFilesystem `yaml:"filesystem,omitempty"`
231+
Enabled *bool `yaml:"enabled,omitempty"`
232+
Strict *bool `yaml:"strict,omitempty"`
233+
BestEffort *bool `yaml:"best_effort,omitempty"`
234+
Workspace string `yaml:"workspace,omitempty"`
235+
FS *SandboxFilesystem `yaml:"filesystem,omitempty"`
234236
}
235237

236238
// SandboxFilesystem overrides the default Landlock policy. If nil, the
@@ -2408,6 +2410,11 @@ func (c *Config) Validate() error {
24082410
}
24092411
}
24102412

2413+
// Sandbox: best_effort and strict are mutually exclusive.
2414+
if c.Sandbox.BestEffort && c.Sandbox.Strict {
2415+
return fmt.Errorf("sandbox: best_effort and strict are mutually exclusive")
2416+
}
2417+
24112418
// Sandbox: validate filesystem paths even when disabled (CLI can override enabled).
24122419
if c.Sandbox.FS != nil {
24132420
for _, p := range c.Sandbox.FS.AllowRead {
@@ -2804,6 +2811,9 @@ func sandboxChanged(old, updated *Config) bool {
28042811
if old.Sandbox.Strict != updated.Sandbox.Strict {
28052812
return true
28062813
}
2814+
if old.Sandbox.BestEffort != updated.Sandbox.BestEffort {
2815+
return true
2816+
}
28072817
if old.Sandbox.Workspace != updated.Sandbox.Workspace {
28082818
return true
28092819
}
@@ -2855,7 +2865,7 @@ func agentSandboxChanged(old, updated *AgentSandboxOverride) bool {
28552865
if old == nil {
28562866
return false
28572867
}
2858-
if !boolPtrEqual(old.Enabled, updated.Enabled) || !boolPtrEqual(old.Strict, updated.Strict) {
2868+
if !boolPtrEqual(old.Enabled, updated.Enabled) || !boolPtrEqual(old.Strict, updated.Strict) || !boolPtrEqual(old.BestEffort, updated.BestEffort) {
28592869
return true
28602870
}
28612871
if old.Workspace != updated.Workspace {

0 commit comments

Comments
 (0)