feat(executor): per-profile opt-in for unprivileged user namespaces

This commit is contained in:
Alassane
2026-09-06 06:30:40 -04:00
committed by GitHub
parent 5f5b1f0f31
commit 9b9f347148
49 changed files with 2971 additions and 96 deletions
+42 -45
View File
@@ -42,6 +42,11 @@ type ContainerConfig struct {
Labels map[string]string
AutoRemove bool
PortBindings []PortBindingConfig
// SecurityOpt specifies Docker SecurityOpt values. Each entry is a
// "<key>=<value>" string accepted by the Docker daemon, such as
// "seccomp=<profile>" or "apparmor=<profile>". A nil or empty slice
// leaves SecurityOpt unset (nil), preserving Docker defaults.
SecurityOpt []string
}
// PortBindingConfig describes a container port to publish on the Docker host.
@@ -261,18 +266,7 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri
zap.String("image", cfg.Image),
)
// Build mounts
mounts := make([]mount.Mount, 0, len(cfg.Mounts))
for _, m := range cfg.Mounts {
mounts = append(mounts, mount.Mount{
Type: mount.TypeBind,
Source: m.Source,
Target: m.Target,
ReadOnly: m.ReadOnly,
})
}
// Container configuration
mounts := buildDockerMounts(cfg.Mounts)
exposedPorts, portBindings, err := buildDockerPortBindings(cfg.PortBindings)
if err != nil {
return "", fmt.Errorf("failed to create container %s: %w", cfg.Name, err)
@@ -287,17 +281,7 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri
ExposedPorts: exposedPorts,
}
// Host configuration
hostCfg := &container.HostConfig{
Mounts: mounts,
NetworkMode: container.NetworkMode(cfg.NetworkMode),
AutoRemove: cfg.AutoRemove,
PortBindings: portBindings,
Resources: container.Resources{
Memory: cfg.Memory,
CPUQuota: cfg.CPUQuota,
},
}
hostCfg := buildHostConfig(cfg, mounts, portBindings)
resp, err := c.cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: containerCfg,
@@ -316,6 +300,39 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri
return resp.ID, nil
}
// buildDockerMounts converts MountConfig entries to Docker SDK mount.Mount values.
func buildDockerMounts(configs []MountConfig) []mount.Mount {
mounts := make([]mount.Mount, 0, len(configs))
for _, m := range configs {
mounts = append(mounts, mount.Mount{
Type: mount.TypeBind,
Source: m.Source,
Target: m.Target,
ReadOnly: m.ReadOnly,
})
}
return mounts
}
// buildHostConfig constructs a container.HostConfig from a ContainerConfig,
// pre-built mounts, and port bindings.
func buildHostConfig(cfg ContainerConfig, mounts []mount.Mount, portBindings network.PortMap) *container.HostConfig {
hc := &container.HostConfig{
Mounts: mounts,
NetworkMode: container.NetworkMode(cfg.NetworkMode),
AutoRemove: cfg.AutoRemove,
PortBindings: portBindings,
Resources: container.Resources{
Memory: cfg.Memory,
CPUQuota: cfg.CPUQuota,
},
}
if len(cfg.SecurityOpt) > 0 {
hc.SecurityOpt = cfg.SecurityOpt
}
return hc
}
func buildDockerPortBindings(bindings []PortBindingConfig) (network.PortSet, network.PortMap, error) {
if len(bindings) == 0 {
return nil, nil, nil
@@ -759,17 +776,7 @@ func (c *Client) CreateContainerInteractive(ctx context.Context, cfg ContainerCo
zap.String("image", cfg.Image),
)
// Build mounts
mounts := make([]mount.Mount, 0, len(cfg.Mounts))
for _, m := range cfg.Mounts {
mounts = append(mounts, mount.Mount{
Type: mount.TypeBind,
Source: m.Source,
Target: m.Target,
ReadOnly: m.ReadOnly,
})
}
mounts := buildDockerMounts(cfg.Mounts)
// Container configuration with stdin attached. Mirror CreateContainer's
// handling of ContainerConfig fields — including Entrypoint — so the same
// config struct produces consistent container behavior on both paths.
@@ -793,17 +800,7 @@ func (c *Client) CreateContainerInteractive(ctx context.Context, cfg ContainerCo
Tty: false, // Important: no TTY for JSON-RPC
}
// Host configuration
hostCfg := &container.HostConfig{
Mounts: mounts,
NetworkMode: container.NetworkMode(cfg.NetworkMode),
AutoRemove: cfg.AutoRemove,
PortBindings: portBindings,
Resources: container.Resources{
Memory: cfg.Memory,
CPUQuota: cfg.CPUQuota,
},
}
hostCfg := buildHostConfig(cfg, mounts, portBindings)
resp, err := c.cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: containerCfg,
@@ -192,3 +192,56 @@ func TestRemoveContainerTreatsConcurrentRemovalAsSuccess(t *testing.T) {
// owner has already taken responsibility for reaching the desired state.
require.NoError(t, client.RemoveContainer(context.Background(), "abc", true))
}
func TestBuildHostConfig_SecurityOptNilWhenEmpty(t *testing.T) {
cfg := ContainerConfig{
Memory: 256,
CPUQuota: 100000,
NetworkMode: "bridge",
AutoRemove: true,
}
hc := buildHostConfig(cfg, nil, nil)
if hc.SecurityOpt != nil {
t.Errorf("SecurityOpt = %v, want nil", hc.SecurityOpt)
}
}
func TestBuildHostConfig_SecurityOptSetWhenProvided(t *testing.T) {
cfg := ContainerConfig{
Memory: 256,
CPUQuota: 100000,
NetworkMode: "bridge",
AutoRemove: true,
SecurityOpt: []string{
`seccomp={"defaultAction":"SCMP_ACT_ALLOW","architectures":["SCMP_ARCH_X86_64"]}`,
"apparmor=unconfined",
},
}
hc := buildHostConfig(cfg, nil, nil)
if len(hc.SecurityOpt) != 2 {
t.Fatalf("SecurityOpt = %v, want 2 entries", hc.SecurityOpt)
}
if hc.SecurityOpt[0] != cfg.SecurityOpt[0] {
t.Errorf("SecurityOpt[0] = %q, want %q", hc.SecurityOpt[0], cfg.SecurityOpt[0])
}
if hc.SecurityOpt[1] != cfg.SecurityOpt[1] {
t.Errorf("SecurityOpt[1] = %q, want %q", hc.SecurityOpt[1], cfg.SecurityOpt[1])
}
}
func TestBuildDockerMounts(t *testing.T) {
in := []MountConfig{
{Source: "/src", Target: "/dst", ReadOnly: true},
{Source: "/data", Target: "/var/data", ReadOnly: false},
}
mounts := buildDockerMounts(in)
if len(mounts) != 2 {
t.Fatalf("got %d mounts, want 2", len(mounts))
}
if mounts[0].Source != "/src" || mounts[0].Target != "/dst" || !mounts[0].ReadOnly {
t.Errorf("mount 0 = %+v, want source=/src target=/dst readOnly=true", mounts[0])
}
if mounts[1].Source != "/data" || mounts[1].Target != "/var/data" || mounts[1].ReadOnly {
t.Errorf("mount 1 = %+v, want source=/data target=/var/data readOnly=false", mounts[1])
}
}
@@ -0,0 +1,875 @@
{
"defaultAction": "SCMP_ACT_ERRNO",
"defaultErrnoRet": 1,
"archMap": [
{
"architecture": "SCMP_ARCH_X86_64",
"subArchitectures": [
"SCMP_ARCH_X86",
"SCMP_ARCH_X32"
]
},
{
"architecture": "SCMP_ARCH_AARCH64",
"subArchitectures": [
"SCMP_ARCH_ARM"
]
},
{
"architecture": "SCMP_ARCH_MIPS64",
"subArchitectures": [
"SCMP_ARCH_MIPS",
"SCMP_ARCH_MIPS64N32"
]
},
{
"architecture": "SCMP_ARCH_MIPS64N32",
"subArchitectures": [
"SCMP_ARCH_MIPS",
"SCMP_ARCH_MIPS64"
]
},
{
"architecture": "SCMP_ARCH_MIPSEL64",
"subArchitectures": [
"SCMP_ARCH_MIPSEL",
"SCMP_ARCH_MIPSEL64N32"
]
},
{
"architecture": "SCMP_ARCH_MIPSEL64N32",
"subArchitectures": [
"SCMP_ARCH_MIPSEL",
"SCMP_ARCH_MIPSEL64"
]
},
{
"architecture": "SCMP_ARCH_S390X",
"subArchitectures": [
"SCMP_ARCH_S390"
]
},
{
"architecture": "SCMP_ARCH_RISCV64",
"subArchitectures": null
},
{
"architecture": "SCMP_ARCH_LOONGARCH64",
"subArchitectures": null
}
],
"syscalls": [
{
"names": [
"accept",
"accept4",
"access",
"adjtimex",
"alarm",
"bind",
"brk",
"cachestat",
"capget",
"capset",
"chdir",
"chmod",
"chown",
"chown32",
"clock_adjtime",
"clock_adjtime64",
"clock_getres",
"clock_getres_time64",
"clock_gettime",
"clock_gettime64",
"clock_nanosleep",
"clock_nanosleep_time64",
"close",
"close_range",
"connect",
"copy_file_range",
"creat",
"dup",
"dup2",
"dup3",
"epoll_create",
"epoll_create1",
"epoll_ctl",
"epoll_ctl_old",
"epoll_pwait",
"epoll_pwait2",
"epoll_wait",
"epoll_wait_old",
"eventfd",
"eventfd2",
"execve",
"execveat",
"exit",
"exit_group",
"faccessat",
"faccessat2",
"fadvise64",
"fadvise64_64",
"fallocate",
"fanotify_mark",
"fchdir",
"fchmod",
"fchmodat",
"fchmodat2",
"fchown",
"fchown32",
"fchownat",
"fcntl",
"fcntl64",
"fdatasync",
"fgetxattr",
"flistxattr",
"flock",
"fork",
"fremovexattr",
"fsetxattr",
"fstat",
"fstat64",
"fstatat64",
"fstatfs",
"fstatfs64",
"fsync",
"ftruncate",
"ftruncate64",
"futex",
"futex_requeue",
"futex_time64",
"futex_wait",
"futex_waitv",
"futex_wake",
"futimesat",
"getcpu",
"getcwd",
"getdents",
"getdents64",
"getegid",
"getegid32",
"geteuid",
"geteuid32",
"getgid",
"getgid32",
"getgroups",
"getgroups32",
"getitimer",
"getpeername",
"getpgid",
"getpgrp",
"getpid",
"getppid",
"getpriority",
"getrandom",
"getresgid",
"getresgid32",
"getresuid",
"getresuid32",
"getrlimit",
"get_robust_list",
"getrusage",
"getsid",
"getsockname",
"getsockopt",
"get_thread_area",
"gettid",
"gettimeofday",
"getuid",
"getuid32",
"getxattr",
"getxattrat",
"inotify_add_watch",
"inotify_init",
"inotify_init1",
"inotify_rm_watch",
"io_cancel",
"ioctl",
"io_destroy",
"io_getevents",
"io_pgetevents",
"io_pgetevents_time64",
"ioprio_get",
"ioprio_set",
"io_setup",
"io_submit",
"ipc",
"kill",
"landlock_add_rule",
"landlock_create_ruleset",
"landlock_restrict_self",
"lchown",
"lchown32",
"lgetxattr",
"link",
"linkat",
"listen",
"listmount",
"listxattr",
"listxattrat",
"llistxattr",
"_llseek",
"lremovexattr",
"lseek",
"lsetxattr",
"lstat",
"lstat64",
"madvise",
"map_shadow_stack",
"membarrier",
"memfd_create",
"memfd_secret",
"mincore",
"mkdir",
"mkdirat",
"mknod",
"mknodat",
"mlock",
"mlock2",
"mlockall",
"mmap",
"mmap2",
"mprotect",
"mq_getsetattr",
"mq_notify",
"mq_open",
"mq_timedreceive",
"mq_timedreceive_time64",
"mq_timedsend",
"mq_timedsend_time64",
"mq_unlink",
"mremap",
"mseal",
"msgctl",
"msgget",
"msgrcv",
"msgsnd",
"msync",
"munlock",
"munlockall",
"munmap",
"name_to_handle_at",
"nanosleep",
"newfstatat",
"_newselect",
"open",
"openat",
"openat2",
"pause",
"pidfd_open",
"pidfd_send_signal",
"pipe",
"pipe2",
"pkey_alloc",
"pkey_free",
"pkey_mprotect",
"poll",
"ppoll",
"ppoll_time64",
"prctl",
"pread64",
"preadv",
"preadv2",
"prlimit64",
"process_mrelease",
"pselect6",
"pselect6_time64",
"pwrite64",
"pwritev",
"pwritev2",
"read",
"readahead",
"readlink",
"readlinkat",
"readv",
"recv",
"recvfrom",
"recvmmsg",
"recvmmsg_time64",
"recvmsg",
"remap_file_pages",
"removexattr",
"removexattrat",
"rename",
"renameat",
"renameat2",
"restart_syscall",
"riscv_hwprobe",
"rmdir",
"rseq",
"rt_sigaction",
"rt_sigpending",
"rt_sigprocmask",
"rt_sigqueueinfo",
"rt_sigreturn",
"rt_sigsuspend",
"rt_sigtimedwait",
"rt_sigtimedwait_time64",
"rt_tgsigqueueinfo",
"sched_getaffinity",
"sched_getattr",
"sched_getparam",
"sched_get_priority_max",
"sched_get_priority_min",
"sched_getscheduler",
"sched_rr_get_interval",
"sched_rr_get_interval_time64",
"sched_setaffinity",
"sched_setattr",
"sched_setparam",
"sched_setscheduler",
"sched_yield",
"seccomp",
"select",
"semctl",
"semget",
"semop",
"semtimedop",
"semtimedop_time64",
"send",
"sendfile",
"sendfile64",
"sendmmsg",
"sendmsg",
"sendto",
"setfsgid",
"setfsgid32",
"setfsuid",
"setfsuid32",
"setgid",
"setgid32",
"setgroups",
"setgroups32",
"setitimer",
"setpgid",
"setpriority",
"setregid",
"setregid32",
"setresgid",
"setresgid32",
"setresuid",
"setresuid32",
"setreuid",
"setreuid32",
"setrlimit",
"set_robust_list",
"setsid",
"setsockopt",
"set_thread_area",
"set_tid_address",
"setuid",
"setuid32",
"setxattr",
"setxattrat",
"shmat",
"shmctl",
"shmdt",
"shmget",
"shutdown",
"sigaltstack",
"signalfd",
"signalfd4",
"sigprocmask",
"sigreturn",
"socketcall",
"socketpair",
"splice",
"stat",
"stat64",
"statfs",
"statfs64",
"statmount",
"statx",
"symlink",
"symlinkat",
"sync",
"sync_file_range",
"syncfs",
"sysinfo",
"tee",
"tgkill",
"time",
"timer_create",
"timer_delete",
"timer_getoverrun",
"timer_gettime",
"timer_gettime64",
"timer_settime",
"timer_settime64",
"timerfd_create",
"timerfd_gettime",
"timerfd_gettime64",
"timerfd_settime",
"timerfd_settime64",
"times",
"tkill",
"truncate",
"truncate64",
"ugetrlimit",
"umask",
"uname",
"unlink",
"unlinkat",
"uretprobe",
"utime",
"utimensat",
"utimensat_time64",
"utimes",
"vfork",
"vmsplice",
"wait4",
"waitid",
"waitpid",
"write",
"writev"
],
"action": "SCMP_ACT_ALLOW"
},
{
"names": [
"process_vm_readv",
"process_vm_writev",
"ptrace"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"minKernel": "4.8"
}
},
{
"names": [
"socket"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 38,
"op": "SCMP_CMP_LT"
}
]
},
{
"names": [
"socket"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 39,
"op": "SCMP_CMP_EQ"
}
]
},
{
"names": [
"socket"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 40,
"op": "SCMP_CMP_GT"
}
]
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 0,
"op": "SCMP_CMP_EQ"
}
]
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 8,
"op": "SCMP_CMP_EQ"
}
]
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 131072,
"op": "SCMP_CMP_EQ"
}
]
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 131080,
"op": "SCMP_CMP_EQ"
}
]
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 4294967295,
"op": "SCMP_CMP_EQ"
}
]
},
{
"names": [
"sync_file_range2",
"swapcontext"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"arches": [
"ppc64le"
]
}
},
{
"names": [
"arm_fadvise64_64",
"arm_sync_file_range",
"sync_file_range2",
"breakpoint",
"cacheflush",
"set_tls"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"arches": [
"arm",
"arm64"
]
}
},
{
"names": [
"arch_prctl"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"arches": [
"amd64",
"x32"
]
}
},
{
"names": [
"modify_ldt"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"arches": [
"amd64",
"x32",
"x86"
]
}
},
{
"names": [
"s390_pci_mmio_read",
"s390_pci_mmio_write",
"s390_runtime_instr"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"arches": [
"s390",
"s390x"
]
}
},
{
"names": [
"riscv_flush_icache"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"arches": [
"riscv64"
]
}
},
{
"names": [
"open_by_handle_at"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_DAC_READ_SEARCH"
]
}
},
{
"names": [
"bpf",
"clone",
"clone3",
"fanotify_init",
"fsconfig",
"fsmount",
"fsopen",
"fspick",
"lookup_dcookie",
"lsm_get_self_attr",
"lsm_list_modules",
"lsm_set_self_attr",
"mount",
"mount_setattr",
"move_mount",
"open_tree",
"perf_event_open",
"quotactl",
"quotactl_fd",
"setdomainname",
"sethostname",
"setns",
"syslog",
"umount",
"umount2",
"unshare"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_SYS_ADMIN"
]
}
},
{
"names": [
"clone"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 2114060288,
"op": "SCMP_CMP_MASKED_EQ"
}
],
"excludes": {
"caps": [
"CAP_SYS_ADMIN"
],
"arches": [
"s390",
"s390x"
]
}
},
{
"names": [
"clone"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 1,
"value": 2114060288,
"op": "SCMP_CMP_MASKED_EQ"
}
],
"comment": "s390 parameter ordering for clone is different",
"includes": {
"arches": [
"s390",
"s390x"
]
},
"excludes": {
"caps": [
"CAP_SYS_ADMIN"
]
}
},
{
"names": [
"clone3"
],
"action": "SCMP_ACT_ERRNO",
"errnoRet": 38,
"excludes": {
"caps": [
"CAP_SYS_ADMIN"
]
}
},
{
"names": [
"reboot"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_SYS_BOOT"
]
}
},
{
"names": [
"chroot"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_SYS_CHROOT"
]
}
},
{
"names": [
"delete_module",
"init_module",
"finit_module"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_SYS_MODULE"
]
}
},
{
"names": [
"acct"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_SYS_PACCT"
]
}
},
{
"names": [
"kcmp",
"pidfd_getfd",
"process_madvise",
"process_vm_readv",
"process_vm_writev",
"ptrace"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_SYS_PTRACE"
]
}
},
{
"names": [
"iopl",
"ioperm"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_SYS_RAWIO"
]
}
},
{
"names": [
"settimeofday",
"stime",
"clock_settime",
"clock_settime64"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_SYS_TIME"
]
}
},
{
"names": [
"vhangup"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_SYS_TTY_CONFIG"
]
}
},
{
"names": [
"get_mempolicy",
"mbind",
"set_mempolicy",
"set_mempolicy_home_node"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_SYS_NICE"
]
}
},
{
"names": [
"syslog"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_SYSLOG"
]
}
},
{
"names": [
"bpf"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_BPF"
]
}
},
{
"names": [
"perf_event_open"
],
"action": "SCMP_ACT_ALLOW",
"includes": {
"caps": [
"CAP_PERFMON"
]
}
}
]
}
@@ -0,0 +1,294 @@
// Package seccomp provides the Kandev user-namespace seccomp profile.
//
// The base profile (default.json) is vendored verbatim from
// https://github.com/moby/profiles at commit v29.7.1
// (https://raw.githubusercontent.com/moby/profiles/main/seccomp/default.json).
//
// UsernsProfileJSON returns a modified copy that relaxes the namespace-related
// syscall restrictions Docker's default profile imposes on processes without
// CAP_SYS_ADMIN. Specifically:
//
// - clone: the SCMP_CMP_MASKED_EQ restriction on CLONE_NEWUSER and related
// namespace flags is removed; clone is allowed unconditionally.
// - clone3: the SCMP_ACT_ERRNO (ENOSYS) fallback is removed; clone3 is
// allowed unconditionally.
// - mount, mount_setattr, move_mount, open_tree, umount, umount2, setns,
// unshare: moved out of the CAP_SYS_ADMIN-gated group into the
// unconditional allow list.
// - pivot_root: added to the unconditional allow list. Docker's default
// profile does not list it at all, so it falls through to the profile's
// SCMP_ACT_ERRNO default. bwrap calls pivot_root after creating its
// namespace and aborts when it fails, so relaxing clone/unshare alone is
// not enough to make user-namespace sandboxing work.
//
// The profile also omits optional syscall allows that older Docker/libseccomp
// daemons cannot load. Those syscalls remain denied by the profile default.
// Apart from that compatibility filter, every other Docker restriction is
// preserved.
package seccomp
import (
"encoding/json"
"fmt"
"slices"
"sort"
_ "embed"
)
//go:embed default.json
var defaultProfileJSON []byte
const (
seccompActionAllow = "SCMP_ACT_ALLOW"
seccompActionErrno = "SCMP_ACT_ERRNO"
)
// usernsRelaxedSyscalls lists the syscalls that the Kandev userns profile
// allows unconditionally. Docker's default profile either gates them behind
// CAP_SYS_ADMIN or (pivot_root) omits them entirely. Each of these is safe
// to allow because it remains capability-gated by the kernel — seccomp merely
// no longer pre-empts the kernel check. Processes without CAP_SYS_ADMIN can create user namespaces
// and perform mounts inside them, but gain no host-level privilege.
var usernsRelaxedSyscalls = []string{
"clone",
"clone3",
"mount",
"mount_setattr",
"move_mount",
"open_tree",
"pivot_root",
"setns",
"umount",
"umount2",
"unshare",
}
// These optional allow rules were added after older Docker/libseccomp
// versions shipped. Omitting them keeps the tailored profile loadable on
// those supported daemons while retaining Docker's default deny behavior.
var legacyDaemonUnsupportedSyscalls = []string{
"futex_requeue",
"getxattrat",
"listmount",
"listxattrat",
"mseal",
"removexattrat",
"setxattrat",
"statmount",
"uretprobe",
}
// seccompRule models one entry in the "syscalls" array of a Docker seccomp
// profile. It contains only the fields we need to inspect and mutate.
type seccompRule struct {
Names []string `json:"names"`
Action string `json:"action"`
Comment string `json:"comment,omitempty"`
ErrnoRet *uint `json:"errnoRet,omitempty"`
Args []json.RawMessage `json:"args,omitempty"`
Includes *struct {
Caps []string `json:"caps,omitempty"`
Arches []string `json:"arches,omitempty"`
MinKernel string `json:"minKernel,omitempty"`
} `json:"includes,omitempty"`
Excludes *struct {
Caps []string `json:"caps,omitempty"`
Arches []string `json:"arches,omitempty"`
} `json:"excludes,omitempty"`
}
// seccompProfile is the top-level Docker seccomp profile structure.
type seccompProfile struct {
DefaultAction string `json:"defaultAction"`
DefaultErrnoRet *uint `json:"defaultErrnoRet,omitempty"`
ArchMap []json.RawMessage `json:"archMap,omitempty"`
Syscalls []json.RawMessage `json:"syscalls"`
}
// UsernsProfileJSON returns the modified seccomp profile as a JSON string
// suitable for passing as a Docker SecurityOpt value ("seccomp=<json>").
func UsernsProfileJSON() (string, error) {
var profile seccompProfile
if err := json.Unmarshal(defaultProfileJSON, &profile); err != nil {
return "", fmt.Errorf("seccomp: unmarshal default profile: %w", err)
}
processedRules, err := processSyscallRules(profile.Syscalls)
if err != nil {
return "", err
}
// Merge every relaxed namespace syscall into the unconditional allow list.
// Seeding with the full list (rather than only the names lifted out of the
// CAP_SYS_ADMIN group) is what covers syscalls the base profile never
// mentions, such as pivot_root.
finalSyscalls, err := mergeUnconditionalAllow(processedRules, usernsRelaxedSyscalls)
if err != nil {
return "", err
}
profile.Syscalls = finalSyscalls
out, err := json.Marshal(&profile)
if err != nil {
return "", fmt.Errorf("seccomp: marshal userns profile: %w", err)
}
return string(out), nil
}
// processSyscallRules returns a transformed copy of the profile's syscall
// rules. It drops the clone MASKED_EQ and clone3 ERRNO rules, and strips the
// relaxed namespace syscalls out of the CAP_SYS_ADMIN-gated rules.
func processSyscallRules(rules []json.RawMessage) ([]json.RawMessage, error) {
processed := make([]json.RawMessage, 0, len(rules))
for i, raw := range rules {
transformed, err := processSyscallRule(raw, i)
if err != nil {
return nil, err
}
processed = append(processed, transformed...)
}
return processed, nil
}
func processSyscallRule(raw json.RawMessage, index int) ([]json.RawMessage, error) {
var rule seccompRule
if err := json.Unmarshal(raw, &rule); err != nil {
return nil, fmt.Errorf("seccomp: unmarshal rule %d: %w", index, err)
}
if ruleHasName(rule, "clone") && hasMaskedEqArg(rule) {
return nil, nil
}
if ruleHasName(rule, "clone3") && rule.Action == seccompActionErrno {
return nil, nil
}
if rule.Action == seccompActionAllow && hasCap(rule, "CAP_SYS_ADMIN") {
remaining := filterSyscalls(rule.Names, usernsRelaxedSyscalls, legacyDaemonUnsupportedSyscalls)
if len(remaining) == 0 {
return nil, nil
}
sort.Strings(remaining)
return []json.RawMessage{makeRule(remaining, seccompActionAllow, capInclude("CAP_SYS_ADMIN"))}, nil
}
if rule.Action != seccompActionAllow {
return []json.RawMessage{slices.Clone(raw)}, nil
}
remaining := filterSyscalls(rule.Names, legacyDaemonUnsupportedSyscalls)
if len(remaining) == 0 {
return nil, nil
}
if len(remaining) == len(rule.Names) {
return []json.RawMessage{slices.Clone(raw)}, nil
}
rule.Names = remaining
updated, _ := json.Marshal(rule)
return []json.RawMessage{updated}, nil
}
func filterSyscalls(names []string, excluded ...[]string) []string {
remaining := make([]string, 0, len(names))
for _, name := range names {
blocked := false
for _, group := range excluded {
if slices.Contains(group, name) {
blocked = true
break
}
}
if !blocked {
remaining = append(remaining, name)
}
}
return remaining
}
// mergeUnconditionalAllow adds the namespace syscalls to the first
// unconditional allow rule, or prepends a new one. It never modifies rules.
func mergeUnconditionalAllow(rules []json.RawMessage, addedSyscalls []string) ([]json.RawMessage, error) {
final := make([]json.RawMessage, len(rules))
for i, raw := range rules {
final[i] = slices.Clone(raw)
}
if len(addedSyscalls) == 0 {
return final, nil
}
addedSyscalls = slices.Clone(addedSyscalls)
sort.Strings(addedSyscalls)
for i, raw := range final {
var rule seccompRule
if err := json.Unmarshal(raw, &rule); err != nil {
return nil, fmt.Errorf("seccomp: unmarshal rule %d: %w", i, err)
}
if isUnconditionalAllowRule(rule) {
allNames := make([]string, 0, len(rule.Names)+len(addedSyscalls))
allNames = append(allNames, rule.Names...)
allNames = append(allNames, addedSyscalls...)
sort.Strings(allNames)
allNames = slices.Compact(allNames)
final[i] = makeRule(allNames, seccompActionAllow, nil)
return final, nil
}
}
newRule := makeRule(addedSyscalls, seccompActionAllow, nil)
return append([]json.RawMessage{newRule}, final...), nil
}
func isUnconditionalAllowRule(rule seccompRule) bool {
return rule.Action == seccompActionAllow &&
rule.Includes == nil && rule.Excludes == nil &&
len(rule.Args) == 0 && len(rule.Names) > 0
}
func ruleHasName(rule seccompRule, name string) bool {
return slices.Contains(rule.Names, name)
}
func makeRule(names []string, action string, includes *struct {
Caps []string `json:"caps,omitempty"`
Arches []string `json:"arches,omitempty"`
MinKernel string `json:"minKernel,omitempty"`
}) json.RawMessage {
v := map[string]any{
"names": names,
"action": action,
}
if includes != nil {
v["includes"] = includes
}
raw, _ := json.Marshal(v)
return raw
}
func capInclude(cap string) *struct {
Caps []string `json:"caps,omitempty"`
Arches []string `json:"arches,omitempty"`
MinKernel string `json:"minKernel,omitempty"`
} {
return &struct {
Caps []string `json:"caps,omitempty"`
Arches []string `json:"arches,omitempty"`
MinKernel string `json:"minKernel,omitempty"`
}{Caps: []string{cap}}
}
func hasCap(rule seccompRule, cap string) bool {
if rule.Includes == nil || len(rule.Includes.Caps) == 0 {
return false
}
return slices.Contains(rule.Includes.Caps, cap)
}
func hasMaskedEqArg(rule seccompRule) bool {
if len(rule.Args) == 0 {
return false
}
for _, raw := range rule.Args {
var arg struct {
Op string `json:"op"`
}
if json.Unmarshal(raw, &arg) == nil && arg.Op == "SCMP_CMP_MASKED_EQ" {
return true
}
}
return false
}
@@ -0,0 +1,133 @@
package seccomp
import (
"encoding/json"
"strings"
"testing"
)
// TestUsernsProfile_StructuralInvariants verifies the profile's structure
// beyond the delta-based tests: it checks that the JSON is well-formed,
// has the correct default action, and produces a valid seccomp profile
// that Docker can accept inline.
func TestUsernsProfile_StructuralInvariants(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() = %v", err)
}
// Must be valid JSON.
var raw any
if err := json.Unmarshal([]byte(profileJSON), &raw); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
m, ok := raw.(map[string]any)
if !ok {
t.Fatal("profile is not a JSON object")
}
// Must have defaultAction: SCMP_ACT_ERRNO
da, ok := m["defaultAction"].(string)
if !ok || da != "SCMP_ACT_ERRNO" {
t.Errorf("defaultAction = %v, want SCMP_ACT_ERRNO", da)
}
// Must have archMap.
archMap, ok := m["archMap"].([]any)
if !ok || len(archMap) == 0 {
t.Error("profile has no archMap entries")
}
// Must have syscalls array.
syscalls, ok := m["syscalls"].([]any)
if !ok || len(syscalls) == 0 {
t.Fatal("profile has no syscalls array")
}
}
// TestUsernsProfile_CanInlineAsDockerSecurityOpt verifies the output
// can be used as a Docker SecurityOpt value: "seccomp=<profile>". The
// Docker daemon parses the part after "seccomp=" as either a file path
// (if it starts with /) or raw JSON.
func TestUsernsProfile_CanInlineAsDockerSecurityOpt(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() = %v", err)
}
// It must be valid JSON and not a file path.
if strings.HasPrefix(profileJSON, "/") {
t.Fatal("profile starts with /, which Docker would interpret as a file path")
}
// The Docker CLI reads the file content and puts it into the
// SecurityOpt string as raw JSON. Verify the JSON is compact
// (no newlines) so it survives serialization.
if strings.Contains(profileJSON, "\n") {
t.Log("profile contains newlines — may need compaction for Docker API")
}
// Verify it can be embedded in a SecurityOpt string.
opt := "seccomp=" + profileJSON
if !strings.HasPrefix(opt, "seccomp=") {
t.Fatal("malformed SecurityOpt prefix")
}
}
// TestUsernsProfile_UnconditionalAllowCount just smoke-checks that
// we didn't accidentally drop the entire unconditional allow list.
func TestUsernsProfile_UnconditionalAllowCount(t *testing.T) {
allowSet := unconditionalAllowSet(t, mustProfileJSON(t))
// The default profile has ~330 unconditional syscalls.
// We add 10 namespace syscalls, so should have ~340.
if len(allowSet) < 300 {
t.Errorf("unconditional allow list has %d entries, expected >300", len(allowSet))
}
}
// TestUsernsProfile_OnlyNamespaceSyscallsRelaxed verifies that exactly the
// expected set of syscalls was moved from CAP_SYS_ADMIN-gated to
// unconditional allow, and nothing else.
func TestUsernsProfile_OnlyNamespaceSyscallsRelaxed(t *testing.T) {
defaultSet := unconditionalAllowSet(t, string(defaultProfileJSON))
modifiedSet := unconditionalAllowSet(t, mustProfileJSON(t))
// New unconditional syscalls (added by the profile).
var added []string
for sc := range modifiedSet {
if !defaultSet[sc] {
added = append(added, sc)
}
}
// Verify every newly-added syscall is in the expected list.
for _, sc := range added {
found := false
for _, expected := range usernsRelaxedSyscalls {
if sc == expected {
found = true
break
}
}
if !found {
t.Errorf("syscall %q was newly allowed but not in usernsRelaxedSyscalls", sc)
}
}
// Verify every expected syscall was actually added.
for _, expected := range usernsRelaxedSyscalls {
if !modifiedSet[expected] {
t.Errorf("expected syscall %q is not in the unconditional allow list", expected)
}
}
}
func mustProfileJSON(t *testing.T) string {
t.Helper()
s, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() = %v", err)
}
return s
}
@@ -0,0 +1,343 @@
package seccomp
import (
"bytes"
"encoding/json"
"slices"
"strings"
"testing"
)
func TestProcessSyscallRulesDoesNotMutateInput(t *testing.T) {
var profile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal(defaultProfileJSON, &profile); err != nil {
t.Fatalf("unmarshal default profile: %v", err)
}
before := make([]json.RawMessage, len(profile.Syscalls))
for i, rule := range profile.Syscalls {
before[i] = bytes.Clone(rule)
}
processed, err := processSyscallRules(profile.Syscalls)
if err != nil {
t.Fatalf("processSyscallRules: %v", err)
}
if len(processed) >= len(profile.Syscalls) {
t.Fatal("processSyscallRules did not remove the clone restrictions")
}
for i, rule := range profile.Syscalls {
if !bytes.Equal(rule, before[i]) {
t.Fatalf("processSyscallRules modified input rule %d", i)
}
}
}
func TestMergeUnconditionalAllowDoesNotMutateInput(t *testing.T) {
rules := []json.RawMessage{
json.RawMessage(`{"names":["read"],"action":"SCMP_ACT_ALLOW"}`),
}
before := bytes.Clone(rules[0])
merged, err := mergeUnconditionalAllow(rules, []string{"unshare"})
if err != nil {
t.Fatalf("mergeUnconditionalAllow: %v", err)
}
if !bytes.Equal(rules[0], before) {
t.Fatal("mergeUnconditionalAllow modified input")
}
allowSet := unconditionalAllowSet(t, mustMarshalRules(t, merged))
if !allowSet["read"] || !allowSet["unshare"] {
t.Fatalf("merged allow set = %v, want read and unshare", allowSet)
}
}
func mustMarshalRules(t *testing.T, rules []json.RawMessage) string {
t.Helper()
encoded, err := json.Marshal(struct {
Syscalls []json.RawMessage `json:"syscalls"`
}{Syscalls: rules})
if err != nil {
t.Fatalf("marshal syscall rules: %v", err)
}
return string(encoded)
}
// usernsProfileAllowedSyscalls returns the set of syscalls that appear in a
// SCMP_ACT_ALLOW rule with no capability gating, no arch specifics, no
// kernel version gating, no argument restrictions, and no excludes — i.e.
// the unconditional allow list.
func unconditionalAllowSet(t *testing.T, profileJSON string) map[string]bool {
t.Helper()
var profile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal([]byte(profileJSON), &profile); err != nil {
t.Fatalf("unmarshal profile: %v", err)
}
allowSet := make(map[string]bool)
for _, raw := range profile.Syscalls {
var rule struct {
Names []string `json:"names"`
Action string `json:"action"`
Includes *json.RawMessage `json:"includes,omitempty"`
Excludes *json.RawMessage `json:"excludes,omitempty"`
Args []json.RawMessage `json:"args,omitempty"`
}
if err := json.Unmarshal(raw, &rule); err != nil {
continue
}
if rule.Action == "SCMP_ACT_ALLOW" && rule.Includes == nil && rule.Excludes == nil && len(rule.Args) == 0 {
for _, name := range rule.Names {
allowSet[name] = true
}
}
}
return allowSet
}
// capAdminGated returns the syscalls that are allowed only with CAP_SYS_ADMIN.
func capAdminGated(t *testing.T, profileJSON string) map[string]bool {
t.Helper()
var profile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal([]byte(profileJSON), &profile); err != nil {
t.Fatalf("unmarshal profile: %v", err)
}
gated := make(map[string]bool)
for _, raw := range profile.Syscalls {
var rule struct {
Names []string `json:"names"`
Action string `json:"action"`
Includes *struct {
Caps []string `json:"caps,omitempty"`
} `json:"includes,omitempty"`
}
if err := json.Unmarshal(raw, &rule); err != nil {
continue
}
if rule.Action == "SCMP_ACT_ALLOW" && rule.Includes != nil && slices.Contains(rule.Includes.Caps, "CAP_SYS_ADMIN") {
for _, name := range rule.Names {
gated[name] = true
}
}
}
return gated
}
func TestUsernsProfile_IsValidJSON(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
if !strings.HasPrefix(profileJSON, "{") {
t.Fatalf("profile does not start with {, got: %s", profileJSON[:20])
}
}
func TestUsernsProfile_NamespaceSyscalls_Unconditional(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
allowSet := unconditionalAllowSet(t, profileJSON)
capAdminSet := capAdminGated(t, profileJSON)
for _, sc := range usernsRelaxedSyscalls {
if !allowSet[sc] {
t.Errorf("namespace syscall %q is not in the unconditional allow list; in CAP_ADMIN-gated: %v", sc, capAdminSet[sc])
}
if capAdminSet[sc] {
t.Errorf("namespace syscall %q is still in the CAP_SYS_ADMIN-gated group", sc)
}
}
}
func TestUsernsProfile_DockerBlockedSyscalls_StayBlocked(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
allowSet := unconditionalAllowSet(t, profileJSON)
// Syscalls Docker blocks by default (not in the allow set, not in
// any conditional allow) must remain blocked.
//
// Note: some of these like mount, setns, unshare etc are now in the
// allow set — they are the whole point of the change. We check only
// the ones Docker denies even to CAP_SYS_ADMIN.
//
// The key check is that high-risk syscalls that Docker blocks even
// with capabilities stay blocked.
syscallsThatMustStayBlocked := []string{
"add_key",
"io_uring_enter",
"io_uring_register",
"io_uring_setup",
"kexec_file_load",
"kexec_load",
"nfsservctl",
"userfaultfd",
}
for _, sc := range syscallsThatMustStayBlocked {
if allowSet[sc] {
t.Errorf("high-risk syscall %q is now unconditionally allowed — it should remain blocked", sc)
}
}
}
func TestUsernsProfile_OmitsSyscallsUnsupportedByLegacyDaemons(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
legacySyscalls := []string{
"futex_requeue",
"getxattrat",
"listmount",
"listxattrat",
"mseal",
"removexattrat",
"setxattrat",
"statmount",
"uretprobe",
}
for _, syscall := range legacySyscalls {
if strings.Contains(profileJSON, `"`+syscall+`"`) {
t.Fatalf("profile contains syscall %q unsupported by legacy daemons", syscall)
}
}
}
func TestUsernsProfile_DefaultActionIsErrno(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
var profile struct {
DefaultAction string `json:"defaultAction"`
DefaultErrnoRet *uint `json:"defaultErrnoRet"`
}
if err := json.Unmarshal([]byte(profileJSON), &profile); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if profile.DefaultAction != "SCMP_ACT_ERRNO" {
t.Errorf("defaultAction = %q, want SCMP_ACT_ERRNO", profile.DefaultAction)
}
if profile.DefaultErrnoRet == nil || *profile.DefaultErrnoRet != 1 {
t.Errorf("defaultErrnoRet should be 1")
}
}
func TestUsernsProfile_CloneMaskedEqRemoved(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
var profile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal([]byte(profileJSON), &profile); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for i, raw := range profile.Syscalls {
var rule struct {
Names []string `json:"names"`
Args []json.RawMessage `json:"args"`
}
if err := json.Unmarshal(raw, &rule); err != nil {
continue
}
if slices.Contains(rule.Names, "clone") && len(rule.Args) > 0 {
for _, argRaw := range rule.Args {
var arg struct {
Op string `json:"op"`
}
if json.Unmarshal(argRaw, &arg) == nil && arg.Op == "SCMP_CMP_MASKED_EQ" {
t.Errorf("rule %d still has SCMP_CMP_MASKED_EQ clone restriction (index %d)", i, rule.Args)
}
}
}
}
}
func TestUsernsProfile_Clone3ErrnoRemoved(t *testing.T) {
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON() returned error: %v", err)
}
var profile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal([]byte(profileJSON), &profile); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for i, raw := range profile.Syscalls {
var rule struct {
Names []string `json:"names"`
Action string `json:"action"`
}
if err := json.Unmarshal(raw, &rule); err != nil {
continue
}
if slices.Contains(rule.Names, "clone3") && rule.Action == "SCMP_ACT_ERRNO" {
t.Errorf("rule %d still has clone3 ERRNO restriction (action=%s)", i, rule.Action)
}
}
}
func TestUsernsProfile_OnlyChangedSyscalls(t *testing.T) {
// Parse both the default and the userns profile.
var defaultProfile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal(defaultProfileJSON, &defaultProfile); err != nil {
t.Fatalf("unmarshal default: %v", err)
}
profileJSON, err := UsernsProfileJSON()
if err != nil {
t.Fatalf("UsernsProfileJSON(): %v", err)
}
var modifiedProfile struct {
Syscalls []json.RawMessage `json:"syscalls"`
}
if err := json.Unmarshal([]byte(profileJSON), &modifiedProfile); err != nil {
t.Fatalf("unmarshal modified: %v", err)
}
// Convert both to allow sets for comparison.
defaultUncond := unconditionalAllowSet(t, string(defaultProfileJSON))
modifiedUncond := unconditionalAllowSet(t, profileJSON)
// The expected changes are namespace syscalls moved from CAP_ADMIN-gated to
// unconditional allow, plus removal of optional legacy-incompatible allows.
for _, sc := range usernsRelaxedSyscalls {
if !defaultUncond[sc] && !modifiedUncond[sc] {
t.Errorf("expected syscall %q to be added to unconditional allow, but it is not present", sc)
}
}
// No other syscalls should have changed from default to unconditional allow.
for sc := range defaultUncond {
if !modifiedUncond[sc] && !slices.Contains(legacyDaemonUnsupportedSyscalls, sc) {
t.Errorf("syscall %q was in default unconditional allow but is missing from modified", sc)
}
}
for sc := range modifiedUncond {
if !defaultUncond[sc] && !slices.Contains(usernsRelaxedSyscalls, sc) {
t.Errorf("syscall %q was added to unconditional allow but is not in usernsRelaxedSyscalls", sc)
}
}
}
@@ -16,6 +16,7 @@ import (
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/docker"
"github.com/kandev/kandev/internal/agent/docker/seccomp"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
commonconfig "github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/constants"
@@ -59,6 +60,7 @@ type ContainerConfig struct {
McpProfile *mcpprofile.Context
PrepareScript string // Script to run inside container before agent starts (e.g., clone repo)
ImageTagOverride string // If set, replaces the agent runtime's default image (e.g. profile.config.image_tag)
AllowUserNamespaces bool // If true, the container is launched with a relaxed seccomp profile and apparmor=unconfined.
LocalClonePath string // Host path for file:// repository clone URLs; mounted read-only at the same path.
BootstrapNonce string // one-time nonce for agentctl handshake (set internally)
AgentctlStartupConfig commonconfig.AgentctlStartupConfig
@@ -570,6 +572,13 @@ exec /usr/local/bin/agentctl`,
},
AutoRemove: false, // We manage cleanup ourselves
}
if config.AllowUserNamespaces {
securityOpt, err := securityOptsForUserNamespaces()
if err != nil {
return docker.ContainerConfig{}, err
}
containerCfg.SecurityOpt = securityOpt
}
if scope := os.Getenv(e2eDockerScopeEnv); scope != "" {
containerCfg.Labels[e2eDockerScopeLabel] = scope
}
@@ -588,6 +597,23 @@ exec /usr/local/bin/agentctl`,
return containerCfg, nil
}
// securityOptsForUserNamespaces returns Docker SecurityOpt values that relax
// seccomp and AppArmor to allow user namespace creation by processes without
// CAP_SYS_ADMIN. An error is returned rather than swallowed: the operator
// explicitly opted the profile in, so silently launching a container without
// the relaxation would reproduce the exact bwrap failure the setting exists to
// fix, with no signal as to why.
func securityOptsForUserNamespaces() ([]string, error) {
profileJSON, err := seccomp.UsernsProfileJSON()
if err != nil {
return nil, fmt.Errorf("build user namespace seccomp profile: %w", err)
}
return []string{
"seccomp=" + profileJSON,
"apparmor=unconfined",
}, nil
}
// formatCoreutilsTimeout converts a Go duration to the single-unit format
// accepted by GNU timeout. time.Duration.String can emit compound values such
// as "10m0s", which GNU timeout rejects as an invalid interval.
@@ -205,6 +205,50 @@ func TestBuildContainerConfig_ImageDefaultsToRuntime(t *testing.T) {
}
}
// TestBuildContainerConfig_SecurityOptNilByDefault verifies AC-1: a profile
// with AllowUserNamespaces off (the default) produces SecurityOpt == nil,
// byte-identical to today's launch.
func TestBuildContainerConfig_SecurityOptNilByDefault(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
if got.SecurityOpt != nil {
t.Errorf("SecurityOpt = %v, want nil when AllowUserNamespaces is off", got.SecurityOpt)
}
}
// TestBuildContainerConfig_SecurityOptForUserNamespaces verifies AC-2: a
// profile with AllowUserNamespaces on produces seccomp and apparmor opts.
func TestBuildContainerConfig_SecurityOptForUserNamespaces(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
AllowUserNamespaces: true,
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
if len(got.SecurityOpt) != 2 {
t.Fatalf("SecurityOpt = %v, want exactly 2 entries", got.SecurityOpt)
}
assertHasSecurityOpt(t, got.SecurityOpt, "apparmor=unconfined")
if !strings.HasPrefix(got.SecurityOpt[0], "seccomp=") {
t.Errorf("SecurityOpt[0] = %q, want seccomp=<json> prefix", got.SecurityOpt[0])
}
}
func TestBuildContainerConfig_ImageTagOverrideWins(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
@@ -458,6 +502,16 @@ func assertHasMount(t *testing.T, mounts []docker.MountConfig, source, target st
t.Fatalf("missing mount source=%q target=%q readOnly=%v in %#v", source, target, readOnly, mounts)
}
func assertHasSecurityOpt(t *testing.T, opts []string, want string) {
t.Helper()
for _, opt := range opts {
if opt == want {
return
}
}
t.Fatalf("missing SecurityOpt %q in %#v", want, opts)
}
func assertHasPortBinding(t *testing.T, bindings []docker.PortBindingConfig, port int) {
t.Helper()
for _, binding := range bindings {
@@ -197,6 +197,7 @@ const (
MetadataKeyGitUserName = "git_user_name"
MetadataKeyGitUserEmail = "git_user_email"
MetadataKeyImageTagOverride = "image_tag_override"
MetadataKeyAllowUserNamespaces = "allow_user_namespaces"
MetadataKeyContainerID = "container_id"
MetadataKeySpriteName = "sprite_name"
MetadataKeySpriteState = "sprite_state"
@@ -370,6 +371,7 @@ var persistentMetadataKeys = map[string]bool{
"sprites_network_policy_rules": true,
MetadataKeyExecutorProfileID: true,
MetadataKeyImageTagOverride: true,
MetadataKeyAllowUserNamespaces: true,
MetadataKeyContainerID: true,
MetadataKeyWorktreeBranch: true,
MetadataKeyRemoteContributions: true,
@@ -294,6 +294,7 @@ func (r *DockerExecutor) buildContainerLaunchConfig(req *ExecutorCreateRequest)
McpProfile: req.McpProfile,
PrepareScript: prepareScript,
ImageTagOverride: getMetadataString(req.Metadata, MetadataKeyImageTagOverride),
AllowUserNamespaces: getMetadataString(req.Metadata, MetadataKeyAllowUserNamespaces) == boolStringTrue,
LocalClonePath: localCloneMountPath(req.Metadata),
BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches),
RemoteContributions: req.RemoteContributions,
@@ -0,0 +1,46 @@
package lifecycle
import "testing"
// TestBuildContainerLaunchConfig_AllowUserNamespacesFailsClosed pins the
// parsing of the allow_user_namespaces launch metadata key. The value relaxes
// the container's seccomp and AppArmor confinement, so anything that is not
// exactly "true" must leave it off rather than being coerced to on. Operators
// can write arbitrary strings into a profile's config map through the
// HTTP/WS settings API, and those land here verbatim.
func TestBuildContainerLaunchConfig_AllowUserNamespacesFailsClosed(t *testing.T) {
tests := []struct {
name string
value interface{}
want bool
}{
{name: "exact true enables", value: "true", want: true},
{name: "uppercase does not enable", value: "TRUE"},
{name: "mixed case does not enable", value: "True"},
{name: "numeric one does not enable", value: "1"},
{name: "yes does not enable", value: "yes"},
{name: "padded true does not enable", value: " true "},
{name: "empty string does not enable", value: ""},
{name: "false does not enable", value: "false"},
{name: "bool true does not enable", value: true},
{name: "absent key does not enable", value: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
metadata := map[string]interface{}{}
if tt.value != nil {
metadata[MetadataKeyAllowUserNamespaces] = tt.value
}
r := &DockerExecutor{logger: newTestDockerLogger()}
cfg, err := r.buildContainerLaunchConfig(&ExecutorCreateRequest{Metadata: metadata})
if err != nil {
t.Fatalf("buildContainerLaunchConfig() error = %v", err)
}
if cfg.AllowUserNamespaces != tt.want {
t.Errorf("AllowUserNamespaces = %v for %#v, want %v",
cfg.AllowUserNamespaces, tt.value, tt.want)
}
})
}
}
@@ -3,6 +3,8 @@ package handlers
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/models"
@@ -11,6 +13,48 @@ import (
"go.uber.org/zap"
)
const allowUserNamespacesProfileConfigKey = "allow_user_namespaces"
// operatorOnlyConfigKeys are executor profile Config keys that the
// agent-exposed MCP tools (create_executor_profile, update_executor_profile)
// must reject. These keys can only be set through the operator HTTP/WS API.
// This is a narrow security guard: it prevents self-enablement of container
// security relaxations from within a task, without widening the surface onto
// the operator path (which already exposes prepare_script — strictly more
// powerful).
var operatorOnlyConfigKeys = []string{
allowUserNamespacesProfileConfigKey,
}
// rejectOperatorConfigKeys returns an error if the config map contains any
// key that is reserved for the operator path. The error names the offending
// key and returns ws.ErrorCodeValidation.
func rejectOperatorConfigKeys(config map[string]string) error {
var offending []string
for _, key := range operatorOnlyConfigKeys {
if _, ok := config[key]; ok {
offending = append(offending, key)
}
}
if len(offending) > 0 {
return fmt.Errorf("config key(s) reserved for operator: %s", strings.Join(offending, ", "))
}
return nil
}
func preserveOperatorConfigKeys(config, current map[string]string) map[string]string {
preserved := make(map[string]string, len(config)+len(operatorOnlyConfigKeys))
for key, value := range config {
preserved[key] = value
}
for _, key := range operatorOnlyConfigKeys {
if value, ok := current[key]; ok {
preserved[key] = value
}
}
return preserved
}
func (h *Handlers) handleListExecutors(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
executors, err := h.taskSvc.ListExecutors(ctx)
if err != nil {
@@ -73,6 +117,10 @@ func (h *Handlers) handleCreateExecutorProfile(ctx context.Context, msg *ws.Mess
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil)
}
if err := rejectOperatorConfigKeys(req.Config); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
profile, err := h.taskSvc.CreateExecutorProfile(ctx, &service.CreateExecutorProfileRequest{
ExecutorID: req.ExecutorID,
Name: req.Name,
@@ -106,13 +154,29 @@ func (h *Handlers) handleUpdateExecutorProfile(ctx context.Context, msg *ws.Mess
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "profile_id is required", nil)
}
if err := rejectOperatorConfigKeys(req.Config); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
}
// MCP updates rewrite the full profile row, so every update needs the
// profile version observed before the request's fields are applied.
current, err := h.taskSvc.GetExecutorProfile(ctx, req.ProfileID)
if err != nil {
h.logger.Error("failed to load executor profile before MCP update", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update executor profile: "+err.Error(), nil)
}
if req.Config != nil {
req.Config = preserveOperatorConfigKeys(req.Config, current.Config)
}
expectedUpdatedAt := &current.UpdatedAt
profile, err := h.taskSvc.UpdateExecutorProfile(ctx, req.ProfileID, &service.UpdateExecutorProfileRequest{
Name: req.Name,
McpPolicy: req.McpPolicy,
Config: req.Config,
PrepareScript: req.PrepareScript,
CleanupScript: req.CleanupScript,
EnvVars: req.EnvVars,
Name: req.Name,
McpPolicy: req.McpPolicy,
Config: req.Config,
PrepareScript: req.PrepareScript,
CleanupScript: req.CleanupScript,
EnvVars: req.EnvVars,
ExpectedUpdatedAt: expectedUpdatedAt,
})
if err != nil {
h.logger.Error("failed to update executor profile", zap.Error(err))
@@ -0,0 +1,239 @@
package handlers
import (
"context"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
"github.com/kandev/kandev/internal/task/service"
ws "github.com/kandev/kandev/pkg/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type blockingExecutorRepository struct {
repository.ExecutorRepository
firstUpdateStarted chan struct{}
releaseFirstUpdate chan struct{}
gateMu sync.Mutex
firstUpdate bool
}
func (r *blockingExecutorRepository) blockFirstUpdate() {
r.gateMu.Lock()
if r.firstUpdate {
r.gateMu.Unlock()
return
}
r.firstUpdate = true
r.gateMu.Unlock()
close(r.firstUpdateStarted)
<-r.releaseFirstUpdate
}
func (r *blockingExecutorRepository) UpdateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error {
r.blockFirstUpdate()
return r.ExecutorRepository.UpdateExecutorProfile(ctx, profile)
}
func (r *blockingExecutorRepository) UpdateExecutorProfileIfUnmodified(
ctx context.Context,
profile *models.ExecutorProfile,
expectedUpdatedAt time.Time,
) error {
r.blockFirstUpdate()
return r.ExecutorRepository.UpdateExecutorProfileIfUnmodified(ctx, profile, expectedUpdatedAt)
}
func TestRejectOperatorConfigKeys_RejectsReservedKey(t *testing.T) {
err := rejectOperatorConfigKeys(map[string]string{
allowUserNamespacesProfileConfigKey: "true",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), allowUserNamespacesProfileConfigKey)
}
func TestRejectOperatorConfigKeys_AcceptsNormalKeys(t *testing.T) {
err := rejectOperatorConfigKeys(map[string]string{
"image_tag": "kandev/agent:latest",
})
assert.NoError(t, err)
}
func TestRejectOperatorConfigKeys_AcceptsEmptyConfig(t *testing.T) {
err := rejectOperatorConfigKeys(map[string]string{})
assert.NoError(t, err)
}
func TestRejectOperatorConfigKeys_AcceptsNilConfig(t *testing.T) {
err := rejectOperatorConfigKeys(nil)
assert.NoError(t, err)
}
func TestExecutorProfileHandlersRejectUserNamespacesAndPreserveOperatorConfig(t *testing.T) {
ctx := context.Background()
svc, _ := newTestTaskService(t)
executor, err := svc.CreateExecutor(ctx, &service.CreateExecutorRequest{
Name: "Docker", Type: models.ExecutorTypeLocalDocker, Status: models.ExecutorStatusActive, Resumable: true,
})
require.NoError(t, err)
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
create := makeWSMessage(t, ws.ActionMCPCreateExecutorProfile, map[string]any{
"executor_id": executor.ID, "name": "Blocked", "config": map[string]string{
allowUserNamespacesProfileConfigKey: "true", "image_tag": "kandev-agent:e2e",
},
})
response, err := h.handleCreateExecutorProfile(ctx, create)
require.NoError(t, err)
assertWSError(t, response, ws.ErrorCodeValidation)
assert.Contains(t, string(response.Payload), allowUserNamespacesProfileConfigKey)
profiles, err := svc.ListExecutorProfiles(ctx, executor.ID)
require.NoError(t, err)
assert.Empty(t, profiles)
profile, err := svc.CreateExecutorProfile(ctx, &service.CreateExecutorProfileRequest{
ExecutorID: executor.ID, Name: "Operator", Config: map[string]string{
allowUserNamespacesProfileConfigKey: "true",
},
})
require.NoError(t, err)
update := makeWSMessage(t, ws.ActionMCPUpdateExecutorProfile, map[string]any{
"profile_id": profile.ID, "config": map[string]string{allowUserNamespacesProfileConfigKey: "false"},
})
response, err = h.handleUpdateExecutorProfile(ctx, update)
require.NoError(t, err)
assertWSError(t, response, ws.ErrorCodeValidation)
stored, err := svc.GetExecutorProfile(ctx, profile.ID)
require.NoError(t, err)
assert.Equal(t, "true", stored.Config[allowUserNamespacesProfileConfigKey])
name := "Renamed by MCP"
update = makeWSMessage(t, ws.ActionMCPUpdateExecutorProfile, map[string]any{
"profile_id": profile.ID, "name": name,
})
response, err = h.handleUpdateExecutorProfile(ctx, update)
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeResponse, response.Type)
stored, err = svc.GetExecutorProfile(ctx, profile.ID)
require.NoError(t, err)
assert.Equal(t, name, stored.Name)
assert.Equal(t, "true", stored.Config[allowUserNamespacesProfileConfigKey])
}
func TestHandleCreateExecutorProfileAcceptsNormalConfig(t *testing.T) {
ctx := context.Background()
svc, _ := newTestTaskService(t)
executor, err := svc.CreateExecutor(ctx, &service.CreateExecutorRequest{
Name: "Docker", Type: models.ExecutorTypeLocalDocker, Status: models.ExecutorStatusActive, Resumable: true,
})
require.NoError(t, err)
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
response, err := h.handleCreateExecutorProfile(ctx, makeWSMessage(t, ws.ActionMCPCreateExecutorProfile, map[string]any{
"executor_id": executor.ID, "name": "Allowed", "config": map[string]string{"image_tag": "kandev-agent:e2e"},
}))
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeResponse, response.Type)
profiles, err := svc.ListExecutorProfiles(ctx, executor.ID)
require.NoError(t, err)
require.Len(t, profiles, 1)
assert.Equal(t, "kandev-agent:e2e", profiles[0].Config["image_tag"])
}
func TestHandleUpdateExecutorProfilePreservesOperatorConfigDuringOrdinaryConfigUpdate(t *testing.T) {
ctx := context.Background()
svc, _ := newTestTaskService(t)
executor, err := svc.CreateExecutor(ctx, &service.CreateExecutorRequest{
Name: "Docker", Type: models.ExecutorTypeLocalDocker, Status: models.ExecutorStatusActive, Resumable: true,
})
require.NoError(t, err)
profile, err := svc.CreateExecutorProfile(ctx, &service.CreateExecutorProfileRequest{
ExecutorID: executor.ID,
Name: "Operator",
Config: map[string]string{
allowUserNamespacesProfileConfigKey: "true",
"image_tag": "kandev-agent:old",
},
})
require.NoError(t, err)
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
response, err := h.handleUpdateExecutorProfile(ctx, makeWSMessage(
t, ws.ActionMCPUpdateExecutorProfile, map[string]any{
"profile_id": profile.ID,
"config": map[string]string{"image_tag": "kandev-agent:new"},
},
))
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeResponse, response.Type)
stored, err := svc.GetExecutorProfile(ctx, profile.ID)
require.NoError(t, err)
assert.Equal(t, "true", stored.Config[allowUserNamespacesProfileConfigKey])
assert.Equal(t, "kandev-agent:new", stored.Config["image_tag"])
}
func TestHandleUpdateExecutorProfileUsesCASWhenConfigIsOmitted(t *testing.T) {
ctx := context.Background()
_, repo, eventBus := newTestTaskServiceWithEventBus(t)
blockingRepo := &blockingExecutorRepository{
ExecutorRepository: repo,
firstUpdateStarted: make(chan struct{}),
releaseFirstUpdate: make(chan struct{}),
}
svc := service.NewService(service.Repos{Executors: blockingRepo}, eventBus, testLogger(t).WithFields(), service.RepositoryDiscoveryConfig{})
executor, err := svc.CreateExecutor(ctx, &service.CreateExecutorRequest{
Name: "Docker", Type: models.ExecutorTypeLocalDocker, Status: models.ExecutorStatusActive, Resumable: true,
})
require.NoError(t, err)
profile, err := svc.CreateExecutorProfile(ctx, &service.CreateExecutorProfileRequest{
ExecutorID: executor.ID,
Name: "Operator",
Config: map[string]string{},
})
require.NoError(t, err)
h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()}
updateMessage := makeWSMessage(t, ws.ActionMCPUpdateExecutorProfile, map[string]any{
"profile_id": profile.ID,
"name": "MCP name",
})
resultCh := make(chan struct {
response *ws.Message
err error
}, 1)
go func() {
response, updateErr := h.handleUpdateExecutorProfile(ctx, updateMessage)
resultCh <- struct {
response *ws.Message
err error
}{response: response, err: updateErr}
}()
select {
case <-blockingRepo.firstUpdateStarted:
case <-time.After(5 * time.Second):
t.Fatal("MCP update did not reach the write barrier")
}
operatorEnabled := map[string]string{allowUserNamespacesProfileConfigKey: "true"}
_, err = svc.UpdateExecutorProfile(ctx, profile.ID, &service.UpdateExecutorProfileRequest{Config: operatorEnabled})
require.NoError(t, err)
close(blockingRepo.releaseFirstUpdate)
var result struct {
response *ws.Message
err error
}
select {
case result = <-resultCh:
case <-time.After(5 * time.Second):
t.Fatal("MCP update did not complete after the write barrier was released")
}
require.NoError(t, result.err)
assertWSError(t, result.response, ws.ErrorCodeInternalError)
stored, err := svc.GetExecutorProfile(ctx, profile.ID)
require.NoError(t, err)
assert.Equal(t, "true", stored.Config[allowUserNamespacesProfileConfigKey])
}
@@ -735,7 +735,14 @@ func (e *Executor) resolveModelSwitchExecutorConfig(
running *models.ExecutorRunning,
) (executorConfig, error) {
if running == nil || running.Runtime != agentruntime.RuntimeKubernetes {
return e.resolveExecutorConfig(ctx, session.ExecutorID, task.WorkspaceID, nil), nil
metadata := cloneMetadata(task.Metadata)
if session.ExecutorProfileID != "" {
if metadata == nil {
metadata = make(map[string]interface{})
}
metadata[lifecycle.MetadataKeyExecutorProfileID] = session.ExecutorProfileID
}
return e.resolveExecutorConfig(ctx, session.ExecutorID, task.WorkspaceID, metadata), nil
}
metadata := cloneMetadata(session.Metadata)
if metadata == nil {
@@ -718,3 +718,54 @@ func TestSwitchModelFallback_PreservesRestrictedMCPMode(t *testing.T) {
})
}
}
func TestSwitchModelFallback_PreservesExecutorProfileMetadata(t *testing.T) {
repo := newMockRepository()
repo.tasks["task-profile"] = &models.Task{
ID: "task-profile",
WorkspaceID: "workspace-1",
Title: "Profile task",
}
repo.sessions["session-profile"] = &models.TaskSession{
ID: "session-profile",
TaskID: "task-profile",
AgentProfileID: "agent-profile",
ExecutorID: "exec-local-docker",
ExecutorProfileID: "profile-userns",
State: models.TaskSessionStateRunning,
}
repo.executors["exec-local-docker"] = &models.Executor{
ID: "exec-local-docker", Type: models.ExecutorTypeLocalDocker,
}
repo.executorProfiles["profile-userns"] = &models.ExecutorProfile{
ID: "profile-userns",
ExecutorID: "exec-local-docker",
Config: map[string]string{
lifecycle.MetadataKeyAllowUserNamespaces: "true",
},
}
var capturedReq *LaunchAgentRequest
manager := &mockAgentManager{
getExecutionIDForSessionFunc: func(context.Context, string) (string, error) {
return "execution-old", nil
},
launchAgentFunc: func(_ context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) {
capturedReq = req
return &LaunchAgentResponse{AgentExecutionID: "execution-new"}, nil
},
}
exec := newTestExecutor(t, manager, repo)
if _, err := exec.SwitchModel(
context.Background(), "task-profile", "session-profile", "new-model", "continue",
); err != nil {
t.Fatalf("SwitchModel: %v", err)
}
if capturedReq == nil {
t.Fatal("fallback did not call LaunchAgent")
}
if got, _ := capturedReq.Metadata[lifecycle.MetadataKeyAllowUserNamespaces].(string); got != "true" {
t.Fatalf("user namespace metadata = %q, want profile value true", got)
}
}
@@ -1330,6 +1330,9 @@ func (m *mockRepository) GetExecutorProfile(ctx context.Context, id string) (*mo
func (m *mockRepository) UpdateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error {
return nil
}
func (m *mockRepository) UpdateExecutorProfileIfUnmodified(ctx context.Context, profile *models.ExecutorProfile, expectedUpdatedAt time.Time) error {
return nil
}
func (m *mockRepository) DeleteExecutorProfile(ctx context.Context, id string) error { return nil }
func (m *mockRepository) ListExecutorProfiles(ctx context.Context, executorID string) ([]*models.ExecutorProfile, error) {
return nil, nil
@@ -127,6 +127,15 @@ func (e *Executor) resolveExecutorConfig(ctx context.Context, executorID, worksp
metadata = make(map[string]interface{})
}
// Authoritative keys belong to the profile, so clear any task-supplied
// value up front; applyProfile re-applies the profile's own value below.
// Doing it here rather than only inside applyProfile is what makes the
// guarantee hold for launches that attach no profile, carry a stale
// profile ID, or hit a profile lookup error — task metadata is
// caller-writable through POST/PATCH /api/v1/tasks and task.create /
// task.update, none of which filter keys.
clearAuthoritativeMetadataKeys(metadata)
// When no executor ID is resolved, check if the metadata carries an
// executor profile. The profile references a specific executor, so we
// can derive the full config from it (critical for review-watch tasks
@@ -263,6 +272,16 @@ var profileConfigAuthoritativeKeys = []string{
// the profile value wins unconditionally — including when it is empty,
// which the reader treats as disabled.
lifecycle.MetadataKeySSHReclaimTaskDir,
lifecycle.MetadataKeyAllowUserNamespaces,
}
// clearAuthoritativeMetadataKeys blanks every profile-owned key in the
// launch metadata. The reader-side helpers treat an empty value exactly
// as an absent one, so this is the "no profile said otherwise" state.
func clearAuthoritativeMetadataKeys(metadata map[string]interface{}) {
for _, k := range profileConfigAuthoritativeKeys {
metadata[k] = ""
}
}
var kubernetesProfileConfigAuthoritativeKeys = []string{
@@ -87,6 +87,20 @@ func TestResolveExecutorConfig_AuthoritativeSSHKeys_ClobberTaskMetadata(t *testi
taskValue: "bash --norc",
wantMetadata: "",
},
{
name: "userns_profile_wins_when_set",
key: lifecycle.MetadataKeyAllowUserNamespaces,
profileValue: "true",
taskValue: "false",
wantMetadata: "true",
},
{
name: "empty_userns_profile_clobbers_task",
key: lifecycle.MetadataKeyAllowUserNamespaces,
profileValue: "",
taskValue: "true",
wantMetadata: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -139,6 +153,54 @@ func TestResolveExecutorConfig_PassthroughKeys_DontClobberTaskMetadata(t *testin
}
}
// TestResolveExecutorConfig_AuthoritativeKeysClearedWithoutProfile pins
// AC-6 for the launch paths that attach no executor profile. The
// authoritative-key projection only runs inside applyProfile, so before
// this test a task whose metadata carried allow_user_namespaces="true"
// kept it whenever no profile was selected, the profile ID was stale, or
// the profile lookup failed — and DockerExecutor.buildContainerLaunchConfig
// reads that key verbatim, launching a relaxed container from purely
// task-supplied metadata. Task metadata is caller-writable through
// POST/PATCH /api/v1/tasks (task_http_handlers.go:924, :1576) and
// task.create/task.update over WS, none of which filter keys.
func TestResolveExecutorConfig_AuthoritativeKeysClearedWithoutProfile(t *testing.T) {
cases := []struct {
name string
profileID string
}{
{name: "no_profile_selected", profileID: ""},
{name: "profile_id_does_not_resolve", profileID: "prof-missing"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
for _, key := range []string{
lifecycle.MetadataKeyAllowUserNamespaces,
lifecycle.MetadataKeySSHWorkdirRoot,
lifecycle.MetadataKeySSHShell,
} {
t.Run(key, func(t *testing.T) {
repo := newMockRepository()
exec := newTestExecutor(t, &mockAgentManager{}, repo)
repo.executors["exec-docker-1"] = &models.Executor{
ID: "exec-docker-1",
Type: models.ExecutorTypeLocalDocker,
}
metadata := map[string]interface{}{key: "true"}
if tc.profileID != "" {
metadata["executor_profile_id"] = tc.profileID
}
cfg := exec.resolveExecutorConfig(context.Background(), "exec-docker-1", "ws-1", metadata)
if got, _ := cfg.Metadata[key].(string); got != "" {
t.Fatalf("metadata[%q] = %q, want it cleared when no profile supplies it", key, got)
}
})
}
})
}
}
func TestResolveExecutorConfig_KubernetesProfileConfigClobbersTaskMetadata(t *testing.T) {
repo := newMockRepository()
exec := newTestExecutor(t, &mockAgentManager{}, repo)
@@ -160,13 +222,11 @@ func TestResolveExecutorConfig_KubernetesProfileConfigClobbersTaskMetadata(t *te
for key := range profileConfig {
metadata[key] = "hostile-task-override"
}
cfg := exec.resolveExecutorConfig(context.Background(), "exec-k8s", "ws-1", metadata)
got := make(map[string]string, len(profileConfig))
for key := range profileConfig {
got[key], _ = cfg.Metadata[key].(string)
}
if !reflect.DeepEqual(got, profileConfig) {
t.Fatalf("Kubernetes launch metadata = %#v, want authoritative profile config %#v", got, profileConfig)
}
@@ -203,13 +263,11 @@ func TestResolveExecutorConfig_KubernetesProfileKeysDoNotClobberNonKubernetesMet
for key, value := range want {
metadata[key] = value
}
cfg := exec.resolveExecutorConfig(context.Background(), "exec-docker", "ws-1", metadata)
got := make(map[string]string, len(want))
for key := range want {
got[key], _ = cfg.Metadata[key].(string)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("non-Kubernetes launch metadata = %#v, want task metadata unchanged %#v", got, want)
}
@@ -486,6 +486,7 @@ func TestWSCreateProfileValidatesAndCreates(t *testing.T) {
resp, err := h.wsCreateProfile(context.Background(),
wsWorkflowRequest(t, ws.ActionExecutorProfileCreate, map[string]any{
"executor_id": "ex-local", "name": "Fresh", "cleanup_script": "echo bye",
"config": map[string]string{"allow_user_namespaces": "true"},
}))
require.NoError(t, err)
var created dto.ExecutorProfileDTO
@@ -494,6 +495,7 @@ func TestWSCreateProfileValidatesAndCreates(t *testing.T) {
require.Equal(t, "ex-local", created.ExecutorID)
require.Len(t, repo.createdProfile, 1)
require.Equal(t, "echo bye", repo.createdProfile[0].CleanupScript)
require.Equal(t, "true", repo.createdProfile[0].Config["allow_user_namespaces"])
}
func TestWSCreateProfileSurfacesUnknownExecutor(t *testing.T) {
@@ -767,6 +767,9 @@ func (m *mockRepository) GetExecutorProfile(ctx context.Context, id string) (*mo
func (m *mockRepository) UpdateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error {
return nil
}
func (m *mockRepository) UpdateExecutorProfileIfUnmodified(ctx context.Context, profile *models.ExecutorProfile, expectedUpdatedAt time.Time) error {
return nil
}
func (m *mockRepository) DeleteExecutorProfile(ctx context.Context, id string) error { return nil }
func (m *mockRepository) ListExecutorProfiles(ctx context.Context, executorID string) ([]*models.ExecutorProfile, error) {
return nil, nil
@@ -526,6 +526,7 @@ type ExecutorRepository interface {
CreateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error
GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error)
UpdateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error
UpdateExecutorProfileIfUnmodified(ctx context.Context, profile *models.ExecutorProfile, expectedUpdatedAt time.Time) error
DeleteExecutorProfile(ctx context.Context, id string) error
ListExecutorProfiles(ctx context.Context, executorID string) ([]*models.ExecutorProfile, error)
ListAllExecutorProfiles(ctx context.Context) ([]*models.ExecutorProfile, error)
@@ -100,6 +100,35 @@ func (r *Repository) UpdateExecutorProfile(ctx context.Context, profile *models.
return nil
}
// UpdateExecutorProfileIfUnmodified updates a profile only when it still has
// the timestamp observed by the caller. This prevents stale partial updates
// from replacing concurrently changed profile configuration.
func (r *Repository) UpdateExecutorProfileIfUnmodified(ctx context.Context, profile *models.ExecutorProfile, expectedUpdatedAt time.Time) error {
profile.UpdatedAt = time.Now().UTC()
configJSON, err := json.Marshal(profile.Config)
if err != nil {
return fmt.Errorf("failed to serialize profile config: %w", err)
}
envVarsJSON, err := json.Marshal(profile.EnvVars)
if err != nil {
return fmt.Errorf("failed to serialize profile env_vars: %w", err)
}
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE executor_profiles SET name = ?, mcp_policy = ?, config = ?, prepare_script = ?, cleanup_script = ?, env_vars = ?, updated_at = ?
WHERE id = ? AND updated_at = ?
`), profile.Name, profile.McpPolicy, string(configJSON), profile.PrepareScript, profile.CleanupScript, string(envVarsJSON), profile.UpdatedAt, profile.ID, expectedUpdatedAt)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("executor profile changed or not found: %s", profile.ID)
}
return nil
}
func (r *Repository) DeleteExecutorProfile(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM executor_profiles WHERE id = ?`), id)
if err != nil {
@@ -236,6 +236,43 @@ func TestUpdateExecutorProfileWritesMutableFieldsAndReportsMissing(t *testing.T)
}
}
func TestUpdateExecutorProfileIfUnmodifiedRejectsStaleProfile(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedExecutorForProfiles(t, repo, "executor-profile-cas")
if err := repo.CreateExecutorProfile(ctx, &models.ExecutorProfile{
ID: "profile-cas", ExecutorID: "executor-profile-cas", Name: "Before",
Config: map[string]string{"allow_user_namespaces": "true"},
}); err != nil {
t.Fatalf("CreateExecutorProfile: %v", err)
}
stale, err := repo.GetExecutorProfile(ctx, "profile-cas")
if err != nil {
t.Fatalf("GetExecutorProfile(stale): %v", err)
}
fresh, err := repo.GetExecutorProfile(ctx, "profile-cas")
if err != nil {
t.Fatalf("GetExecutorProfile(fresh): %v", err)
}
fresh.Config["allow_user_namespaces"] = ""
if err := repo.UpdateExecutorProfile(ctx, fresh); err != nil {
t.Fatalf("UpdateExecutorProfile(fresh): %v", err)
}
stale.Config["image_tag"] = "agent:stale"
if err := repo.UpdateExecutorProfileIfUnmodified(ctx, stale, stale.UpdatedAt); err == nil {
t.Fatal("stale update succeeded; want optimistic-lock conflict")
}
got, err := repo.GetExecutorProfile(ctx, "profile-cas")
if err != nil {
t.Fatalf("GetExecutorProfile(after conflict): %v", err)
}
if got.Config["allow_user_namespaces"] != "" || got.Config["image_tag"] != "" {
t.Fatalf("stale update mutated profile: %#v", got.Config)
}
}
func TestDeleteExecutorProfileRemovesRowAndReportsMissing(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
@@ -1,6 +1,8 @@
package service
import (
"time"
v1 "github.com/kandev/kandev/pkg/api/v1"
"github.com/kandev/kandev/internal/task/models"
@@ -296,12 +298,13 @@ type CreateExecutorProfileRequest struct {
// UpdateExecutorProfileRequest contains the data for updating an executor profile
type UpdateExecutorProfileRequest struct {
Name *string `json:"name,omitempty"`
McpPolicy *string `json:"mcp_policy,omitempty"`
Config map[string]string `json:"config,omitempty"`
PrepareScript *string `json:"prepare_script,omitempty"`
CleanupScript *string `json:"cleanup_script,omitempty"`
EnvVars []models.ProfileEnvVar `json:"env_vars,omitempty"`
Name *string `json:"name,omitempty"`
McpPolicy *string `json:"mcp_policy,omitempty"`
Config map[string]string `json:"config,omitempty"`
PrepareScript *string `json:"prepare_script,omitempty"`
CleanupScript *string `json:"cleanup_script,omitempty"`
EnvVars []models.ProfileEnvVar `json:"env_vars,omitempty"`
ExpectedUpdatedAt *time.Time `json:"-"`
}
// CreateEnvironmentRequest contains the data for creating an environment
@@ -1862,8 +1862,14 @@ func (s *Service) UpdateExecutorProfile(ctx context.Context, id string, req *Upd
}
profile.EnvVars = req.EnvVars
}
if err := s.executors.UpdateExecutorProfile(ctx, profile); err != nil {
return nil, err
var updateErr error
if req.ExpectedUpdatedAt != nil {
updateErr = s.executors.UpdateExecutorProfileIfUnmodified(ctx, profile, *req.ExpectedUpdatedAt)
} else {
updateErr = s.executors.UpdateExecutorProfile(ctx, profile)
}
if updateErr != nil {
return nil, updateErr
}
s.publishExecutorProfileEvent(ctx, events.ExecutorProfileUpdated, profile)
return profile, nil
@@ -70,6 +70,7 @@ import type { Executor, ExecutorProfile } from "@/lib/types/http";
import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api";
import { executorProfileDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets";
import { buildSaveConfig } from "@/components/settings/profile-edit/serialize-executor-config";
import { useUserNamespacesFormState } from "@/components/settings/profile-edit/use-user-namespaces-form-state";
import { useProfileRuntimeFormState } from "@/components/settings/profile-edit/use-profile-runtime-form-state";
import { KubernetesProfileSections } from "@/components/settings/kubernetes-profile-sections";
import { KubernetesReadOnlyNotice } from "@/components/settings/kubernetes-read-only-notice";
@@ -291,6 +292,7 @@ export function useProfileFormState(executor: Executor, profile: ExecutorProfile
const [prepareScript, setPrepareScript] = useState(profile.prepare_script ?? "");
const [cleanupScript, setCleanupScript] = useState(profile.cleanup_script ?? "");
const runtime = useProfileRuntimeFormState(executor, profile);
const userNamespaces = useUserNamespacesFormState(profile.config);
const { envVarRows, addEnvVar, removeEnvVar, updateEnvVar, resetEnvVars } = useEnvVarRows(
profile.env_vars,
);
@@ -323,11 +325,12 @@ export function useProfileFormState(executor: Executor, profile: ExecutorProfile
setPrepareScript(profile.prepare_script ?? "");
setCleanupScript(profile.cleanup_script ?? "");
runtime.resetRuntime();
userNamespaces.resetUserNamespaces();
resetEnvVars(profile.env_vars);
setSpritesSecretId(deriveSpritesSecretId(profile.env_vars));
remoteAuth.reset();
gitIdentity.reset();
}, [gitIdentity, profile, remoteAuth, resetEnvVars, runtime]);
}, [gitIdentity, profile, remoteAuth, resetEnvVars, runtime, userNamespaces]);
return {
...runtime,
@@ -339,6 +342,10 @@ export function useProfileFormState(executor: Executor, profile: ExecutorProfile
setPrepareScript,
cleanupScript,
setCleanupScript,
allowUserNamespaces: userNamespaces.allowUserNamespaces,
setAllowUserNamespaces: userNamespaces.setAllowUserNamespaces,
resetUserNamespaces: userNamespaces.resetUserNamespaces,
isLocalDocker: executor.type === "local_docker",
envVarRows,
addEnvVar,
removeEnvVar,
@@ -412,6 +419,9 @@ function ExecutorSpecificSections({ executor, profile, form, secrets }: ProfileE
onDockerfileChange={form.setDockerfile}
imageTag={form.imageTag}
onImageTagChange={form.setImageTag}
allowsUserNamespaces={form.isLocalDocker}
allowUserNamespaces={form.allowUserNamespaces}
onAllowUserNamespacesChange={form.setAllowUserNamespaces}
/>
)}
{form.isKubernetes && (
@@ -33,6 +33,7 @@ import {
import { ScriptCard } from "@/components/settings/profile-edit/script-card";
import {
DockerfileBuildCard,
UserNamespacesCard,
type DockerBuildSuccess,
} from "@/components/settings/profile-edit/docker-sections";
import { SpritesApiKeyCard } from "@/components/settings/profile-edit/sprites-api-key-card";
@@ -128,6 +129,7 @@ type BuildProfileConfigInput = {
isRemote: boolean;
isSprites: boolean;
isDocker: boolean;
isLocalDocker: boolean;
networkPolicyRules: NetworkPolicyRule[];
remoteCredentials: string[];
configBundleIds: string[];
@@ -138,13 +140,13 @@ type BuildProfileConfigInput = {
gitUserEmail: string;
dockerfile: string;
imageTag: string;
allowUserNamespaces: boolean;
};
function buildProfileConfig(input: BuildProfileConfigInput): Record<string, string> | undefined {
const {
isRemote,
isSprites,
isDocker,
networkPolicyRules,
remoteCredentials,
configBundleIds,
@@ -153,8 +155,6 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record<string, stri
localGitIdentity,
gitUserName,
gitUserEmail,
dockerfile,
imageTag,
} = input;
const config: Record<string, string> = {};
if (isSprites && networkPolicyRules.length > 0) {
@@ -184,22 +184,22 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record<string, stri
config.git_user_email = effectiveEmail;
}
}
applyDockerCreateConfig(config, isDocker, dockerfile, imageTag);
applyDockerCreateConfig(config, input);
return Object.keys(config).length > 0 ? config : undefined;
}
function applyDockerCreateConfig(
config: Record<string, string>,
isDocker: boolean,
dockerfile: string,
imageTag: string,
input: BuildProfileConfigInput,
): void {
if (!isDocker) return;
if (dockerfile.trim()) {
config.dockerfile = dockerfile;
if (input.isDocker && input.dockerfile.trim()) {
config.dockerfile = input.dockerfile;
}
if (imageTag.trim()) {
config.image_tag = imageTag.trim();
if (input.isDocker && input.imageTag.trim()) {
config.image_tag = input.imageTag.trim();
}
if (input.isLocalDocker && input.allowUserNamespaces) {
config.allow_user_namespaces = "true";
}
}
@@ -221,6 +221,7 @@ function useCreateRemoteFlags(executorType: ExecutorType) {
return {
isRemote,
isDocker: executorType === "local_docker" || executorType === "remote_docker",
isLocalDocker: executorType === "local_docker",
isSprites: executorType === "sprites",
};
}
@@ -301,6 +302,7 @@ function useCreateProfileFormState(executorType: ExecutorType) {
const remoteAuth = useCreateRemoteAuthState();
const [dockerfile, setDockerfile] = useState("");
const [imageTag, setImageTag] = useState("");
const [allowUserNamespaces, setAllowUserNamespaces] = useState(false);
const [builtDockerImage, setBuiltDockerImage] = useState<DockerBuildSuccess | null>(null);
const flags = useCreateRemoteFlags(executorType);
const gitIdentity = useCreateGitIdentityState(flags.isRemote);
@@ -368,6 +370,8 @@ function useCreateProfileFormState(executorType: ExecutorType) {
setDockerfile,
imageTag,
setImageTag,
allowUserNamespaces,
setAllowUserNamespaces,
recordDockerBuildSuccess,
dockerImageBuilt,
gitUserName: gitIdentity.gitUserName,
@@ -376,6 +380,7 @@ function useCreateProfileFormState(executorType: ExecutorType) {
setGitUserEmail: gitIdentity.setGitUserEmail,
isRemote: flags.isRemote,
isDocker: flags.isDocker,
isLocalDocker: flags.isLocalDocker,
isSprites: flags.isSprites,
mcpPolicyErrorKey,
buildEnvVars,
@@ -438,15 +443,24 @@ function CreateProfileSections({
/>
)}
{form.isDocker && (
<DockerfileBuildCard
dockerfile={form.dockerfile}
baselineDockerfile=""
onDockerfileChange={form.setDockerfile}
imageTag={form.imageTag}
baselineImageTag=""
onImageTagChange={form.setImageTag}
onBuildSuccess={form.recordDockerBuildSuccess}
/>
<>
<DockerfileBuildCard
dockerfile={form.dockerfile}
baselineDockerfile=""
onDockerfileChange={form.setDockerfile}
imageTag={form.imageTag}
baselineImageTag=""
onImageTagChange={form.setImageTag}
onBuildSuccess={form.recordDockerBuildSuccess}
/>
{form.isLocalDocker && (
<UserNamespacesCard
enabled={form.allowUserNamespaces}
baselineEnabled={false}
onChange={form.setAllowUserNamespaces}
/>
)}
</>
)}
<CreateRemoteCredentialsSection executorType={executorType} form={form} secrets={secrets} />
{form.isSprites && (
@@ -560,6 +574,7 @@ function buildCreateProfilePayload(form: ReturnType<typeof useCreateProfileFormS
isRemote: form.isRemote,
isSprites: form.isSprites,
isDocker: form.isDocker,
isLocalDocker: form.isLocalDocker,
networkPolicyRules: form.networkPolicyRules,
remoteCredentials: form.remoteCredentials,
configBundleIds: form.configBundleIds,
@@ -570,6 +585,7 @@ function buildCreateProfilePayload(form: ReturnType<typeof useCreateProfileFormS
gitUserEmail: form.gitUserEmail,
dockerfile: form.dockerfile,
imageTag: form.imageTag,
allowUserNamespaces: form.allowUserNamespaces,
}),
prepare_script: form.prepareScript,
cleanup_script: form.cleanupScript,
@@ -1,12 +1,20 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { IconPlayerPlay, IconLoader2, IconCheck, IconX, IconTrash } from "@tabler/icons-react";
import {
IconPlayerPlay,
IconLoader2,
IconCheck,
IconX,
IconTrash,
IconInfoCircle,
} from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Switch } from "@kandev/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { ScriptEditor } from "@/components/settings/profile-edit/script-editor";
import {
@@ -414,6 +422,51 @@ function ContainerRow({
);
}
export function UserNamespacesCard({
enabled,
baselineEnabled = false,
onChange,
}: {
enabled: boolean;
baselineEnabled?: boolean;
onChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
const isDirty = enabled !== baselineEnabled;
return (
<SettingsCard isDirty={isDirty}>
<SettingsCardHeader
title={t("executors:allowUserNamespacesTitle")}
description={t("executors:allowUserNamespacesDescription")}
actions={
<label
htmlFor="allow-user-namespaces"
className="inline-flex min-h-11 min-w-11 cursor-pointer items-center justify-center md:min-h-0 md:min-w-0"
>
<Switch
id="allow-user-namespaces"
aria-label={t("executors:allowUserNamespacesTitle")}
checked={enabled}
onCheckedChange={onChange}
data-settings-dirty={isDirty}
/>
</label>
}
/>
<CardContent>
<p className="text-sm text-muted-foreground flex items-start gap-2">
<IconInfoCircle className="mt-0.5 h-4 w-4 shrink-0" />
{t("executors:allowUserNamespacesNote")}
</p>
<p className="mt-2 flex items-start gap-2 text-sm text-amber-600 dark:text-amber-400">
<IconInfoCircle className="mt-0.5 h-4 w-4 shrink-0" />
{t("executors:allowUserNamespacesWarning")}
</p>
</CardContent>
</SettingsCard>
);
}
export function DockerContainersCard({ profileId }: { profileId: string }) {
const { t } = useTranslation();
const { containers, loading, refresh } = useDockerProfileContainers(profileId);
@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { McpPolicyCard } from "./mcp-policy-card";
import { ProfileDetailsCard } from "./profile-details-card";
import { UserNamespacesCard } from "./docker-sections";
afterEach(cleanup);
const MCP_POLICY_LABEL = "MCP policy JSON";
@@ -47,4 +48,19 @@ describe("executor profile dirty fields", () => {
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Edited" } });
expect(onNameChange).toHaveBeenCalledWith("Edited");
});
it("keeps an already-enabled user namespace switch clean until it changes", () => {
const { container, rerender } = render(
<UserNamespacesCard enabled baselineEnabled onChange={vi.fn()} />,
);
const toggle = container.querySelector("#allow-user-namespaces");
expect(screen.getByRole("switch", { name: "User namespace support" })).toBe(toggle);
expect(toggle?.getAttribute(DIRTY_ATTRIBUTE)).toBe("false");
rerender(<UserNamespacesCard enabled={false} baselineEnabled onChange={vi.fn()} />);
expect(container.querySelector("#allow-user-namespaces")?.getAttribute(DIRTY_ATTRIBUTE)).toBe(
"true",
);
});
});
@@ -11,6 +11,7 @@ import {
import {
DockerfileBuildCard,
DockerContainersCard,
UserNamespacesCard,
} from "@/components/settings/profile-edit/docker-sections";
import { NetworkPoliciesCard } from "@/components/settings/profile-edit/sprites-sections";
import { SpritesInstancesCard } from "@/components/settings/sprites-settings";
@@ -21,6 +22,9 @@ type DockerSectionsProps = {
onDockerfileChange: (v: string) => void;
imageTag: string;
onImageTagChange: (v: string) => void;
allowsUserNamespaces: boolean;
allowUserNamespaces: boolean;
onAllowUserNamespacesChange: (v: boolean) => void;
};
export function DockerSections({
@@ -29,6 +33,9 @@ export function DockerSections({
onDockerfileChange,
imageTag,
onImageTagChange,
allowsUserNamespaces,
allowUserNamespaces,
onAllowUserNamespacesChange,
}: DockerSectionsProps) {
return (
<>
@@ -40,6 +47,13 @@ export function DockerSections({
baselineImageTag={profile.config?.image_tag ?? ""}
onImageTagChange={onImageTagChange}
/>
{allowsUserNamespaces && (
<UserNamespacesCard
enabled={allowUserNamespaces}
baselineEnabled={profile.config?.allow_user_namespaces === "true"}
onChange={onAllowUserNamespacesChange}
/>
)}
<DockerContainersCard profileId={profile.id} />
</>
);
@@ -22,8 +22,10 @@ function form(overrides: Partial<ExecutorProfileConfigForm> = {}): ExecutorProfi
gitUserName: "",
gitUserEmail: "",
isDocker: false,
isLocalDocker: false,
dockerfile: "",
imageTag: "",
allowUserNamespaces: false,
isSSH: false,
sshShell: "",
sshReclaimTaskDir: false,
@@ -87,6 +89,33 @@ describe("buildSaveConfig", () => {
expect(config).toEqual({ remote_credentials: '["codex-auth"]' });
});
it("persists allowUserNamespaces when enabled on a Docker profile", () => {
const config = buildSaveConfig(
form({ isDocker: true, isLocalDocker: true, allowUserNamespaces: true }),
);
expect(config.allow_user_namespaces).toBe("true");
});
it("removes allowUserNamespaces key when disabled", () => {
const config = buildSaveConfig(
form({ isDocker: true, isLocalDocker: true, allowUserNamespaces: false }),
);
expect(config.allow_user_namespaces).toBeUndefined();
});
it("removes allowUserNamespaces when not a Docker profile", () => {
const config = buildSaveConfig(form({ isDocker: false, allowUserNamespaces: true }));
expect(config.allow_user_namespaces).toBeUndefined();
});
it("removes allowUserNamespaces from a remote Docker profile", () => {
const config = buildSaveConfig(
form({ isDocker: true, isLocalDocker: false, allowUserNamespaces: true }),
{ allow_user_namespaces: "true" },
);
expect(config.allow_user_namespaces).toBeUndefined();
});
});
describe("buildSaveConfig ssh_reclaim_task_dir", () => {
@@ -28,8 +28,10 @@ export type ExecutorProfileConfigForm = {
gitUserName: string;
gitUserEmail: string;
isDocker: boolean;
isLocalDocker: boolean;
dockerfile: string;
imageTag: string;
allowUserNamespaces: boolean;
isSSH: boolean;
sshShell: string;
sshReclaimTaskDir: boolean;
@@ -56,6 +58,11 @@ export function buildSaveConfig(
setTextConfig(config, "git_user_email", form.isRemote ? gitEmail.trim() : "");
setTextConfig(config, "dockerfile", form.isDocker ? form.dockerfile : "");
setTextConfig(config, "image_tag", form.isDocker ? form.imageTag.trim() : "");
setTextConfig(
config,
"allow_user_namespaces",
form.isLocalDocker && form.allowUserNamespaces ? "true" : "",
);
setTextConfig(config, "ssh_shell", form.isSSH ? form.sshShell.trim() : "");
setBoolConfig(config, "ssh_reclaim_task_dir", form.isSSH, form.sshReclaimTaskDir);
return config;
@@ -0,0 +1,17 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useUserNamespacesFormState } from "./use-user-namespaces-form-state";
describe("useUserNamespacesFormState", () => {
it("discards a changed setting back to the profile baseline", () => {
const { result } = renderHook(() =>
useUserNamespacesFormState({ allow_user_namespaces: "true" }),
);
act(() => result.current.setAllowUserNamespaces(false));
expect(result.current.allowUserNamespaces).toBe(false);
act(() => result.current.resetUserNamespaces());
expect(result.current.allowUserNamespaces).toBe(true);
});
});
@@ -0,0 +1,11 @@
import { useCallback, useState } from "react";
export function useUserNamespacesFormState(config?: Record<string, string>) {
const baselineEnabled = config?.allow_user_namespaces === "true";
const [allowUserNamespaces, setAllowUserNamespaces] = useState(baselineEnabled);
const resetUserNamespaces = useCallback(() => {
setAllowUserNamespaces(baselineEnabled);
}, [baselineEnabled]);
return { allowUserNamespaces, setAllowUserNamespaces, resetUserNamespaces };
}
+1 -1
View File
@@ -22,7 +22,7 @@ const SCOPED_CONTAINER_CLEANUP_EMPTY_POLLS = 8;
const E2E_DOCKERFILE = `FROM node:22-slim
RUN apt-get update \\
&& apt-get install -y --no-install-recommends git ca-certificates curl \\
&& apt-get install -y --no-install-recommends git ca-certificates curl bubblewrap \\
&& rm -rf /var/lib/apt/lists/*
COPY --chmod=0755 fake-lsp-server.mjs /usr/local/bin/kotlin-lsp
RUN rm -f /usr/local/bin/npx
+43
View File
@@ -92,3 +92,46 @@ export async function waitForDockerContainerRemoved(
})
.toBe(false);
}
export type DockerExecResult = { status: number | null; stdout: string; stderr: string };
export function dockerExec(containerID: string, ...command: string[]): DockerExecResult {
const result = spawnSync("docker", ["exec", containerID, ...command], { encoding: "utf8" });
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
}
/**
* Returns true when the container can perform a full user-namespace map,
* which requires both the seccomp/AppArmor relaxation AND functioning
* sub-id mapping (newuidmap/newgidmap setuid binaries or /etc/subuid
* entries). Without this bwrap will fail with "setting up uid map:
* Permission denied" even when namespace creation is allowed.
*
* The probe uses `unshare --map-root-user` which goes one step beyond
* `unshare --user`: it also maps the UID inside the new namespace, the
* operation bwrap needs. On hosts where user namespaces are allowed but
* mapping is unavailable, `unshare --user` succeeds but this probe fails.
*/
export function dockerHasSubuidMapping(containerID: string): boolean {
const result = spawnSync(
"docker",
["exec", containerID, "sh", "-c", "unshare --map-root-user --user true 2>/dev/null"],
{ encoding: "utf8", stdio: "pipe" },
);
return result.status === 0;
}
export function dockerSecurityOpt(containerID: string): string[] | null {
const result = spawnSync(
"docker",
["inspect", "--format", "{{json .HostConfig.SecurityOpt}}", containerID],
{
encoding: "utf8",
},
);
if (result.status !== 0)
throw new Error(
`failed to inspect Docker SecurityOpt for ${containerID}: ${result.stderr.trim()}`,
);
return JSON.parse(result.stdout) as string[] | null;
}
@@ -0,0 +1,91 @@
import { test, expect } from "../../fixtures/docker-test-base";
import { E2E_IMAGE_TAG } from "../../fixtures/docker-probe";
import { dockerExec, dockerHasSubuidMapping, dockerSecurityOpt } from "../../helpers/docker";
import { waitForLatestSessionDone } from "../../helpers/session";
// Both probes run through `sh -c` on purpose. `docker exec <container> bwrap
// ...` execs bwrap as the exec'd process itself, and writing
// /proc/self/uid_map from that process is refused ("setting up uid map:
// Permission denied") even when user namespaces are fully permitted. One shell
// layer makes bwrap a forked child, which is how a real agent runtime spawns
// it, and the probe then reflects the container's actual capability.
const BWRAP_PROBE = "bwrap --unshare-user --dev-bind / / true";
const UNSHARE_PROBE = "unshare --user true";
test.describe("Docker executor user namespace sandbox", () => {
test("enables bwrap user namespaces only for opted-in profiles", async ({
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const { executors } = await apiClient.listExecutors();
const executor = executors.find((item) => item.type === "local_docker");
expect(executor?.id).toBeTruthy();
const [enabledProfile, disabledProfile] = await Promise.all([
apiClient.createExecutorProfile(executor!.id, {
name: "User namespaces enabled",
config: { image_tag: E2E_IMAGE_TAG, allow_user_namespaces: "true" },
env_vars: seedData.gitConfigEnvVars,
prepare_script: "",
cleanup_script: "",
}),
apiClient.createExecutorProfile(executor!.id, {
name: "User namespaces disabled",
config: { image_tag: E2E_IMAGE_TAG },
env_vars: seedData.gitConfigEnvVars,
prepare_script: "",
cleanup_script: "",
}),
]);
try {
const launch = async (title: string, profileID: string) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: profileID,
},
);
await waitForLatestSessionDone(apiClient, task.id, 1, `Waiting for ${title}`);
const environment = await apiClient.getTaskEnvironment(task.id);
expect(environment?.container_id).toBeTruthy();
return environment!.container_id!;
};
const enabledContainer = await launch("Userns enabled", enabledProfile.id);
const enabledSecurityOpt = dockerSecurityOpt(enabledContainer);
expect(enabledSecurityOpt).toHaveLength(2);
expect(enabledSecurityOpt).toContain("apparmor=unconfined");
expect(enabledSecurityOpt![0]).toMatch(/^seccomp=\{/);
const enabledUnshare = dockerExec(enabledContainer, "sh", "-c", UNSHARE_PROBE);
expect(enabledUnshare.status, `${enabledUnshare.stdout}${enabledUnshare.stderr}`).toBe(0);
// bwrap needs /etc/subuid + /etc/subgid entries (or newuidmap/newgidmap
// setuid binaries), which are absent from most CI Docker hosts. When the
// container lacks sub-id mapping support, skip the assertion rather than
// failing — this matches the plan's "manual smoke" designation for bwrap.
if (dockerHasSubuidMapping(enabledContainer)) {
const enabledBwrap = dockerExec(enabledContainer, "sh", "-c", BWRAP_PROBE);
expect(enabledBwrap.status, `${enabledBwrap.stdout}${enabledBwrap.stderr}`).toBe(0);
}
const disabledContainer = await launch("Userns disabled", disabledProfile.id);
expect(dockerSecurityOpt(disabledContainer)).toBeNull();
const disabledUnshare = dockerExec(disabledContainer, "sh", "-c", UNSHARE_PROBE);
expect(disabledUnshare.status).not.toBe(0);
expect(disabledUnshare.stderr).toContain("Operation not permitted");
const disabledBwrap = dockerExec(disabledContainer, "sh", "-c", BWRAP_PROBE);
expect(disabledBwrap.status).not.toBe(0);
// Assert the reason, not just the failure: without this the probe also
// "fails" when bwrap is missing or blocked for an unrelated reason.
expect(disabledBwrap.stderr).toContain("No permissions to create new namespace");
} finally {
await Promise.all([
apiClient.deleteExecutorProfile(enabledProfile.id).catch(() => {}),
apiClient.deleteExecutorProfile(disabledProfile.id).catch(() => {}),
]);
}
});
});
@@ -206,6 +206,54 @@ test.describe("Docker executor profile persistence", () => {
}
});
test("user namespace support persists through Save and reload", async ({
testPage,
apiClient,
}) => {
const exec = await apiClient.createExecutor("e2e-userns-persistence", "local_docker");
const profile = await apiClient.createExecutorProfile(exec.id, {
name: "userns",
config: { image_tag: "kandev-agent:e2e" },
});
await testPage.route("**/api/v1/docker/containers?*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: '{"containers":[]}',
});
});
try {
await testPage.goto(`/settings/executors/${profile.id}`);
const toggle = testPage.getByRole("switch", { name: "User namespace support" });
await expect(toggle).not.toBeChecked();
await toggle.click();
await testPage
.getByTestId("settings-floating-save")
.getByRole("button", { name: "Save changes" })
.click();
await expect(testPage.getByText("Profile saved")).toBeVisible();
await expect
.poll(async () => (await apiClient.getExecutorProfile(exec.id, profile.id)).config)
.toMatchObject({ allow_user_namespaces: "true" });
await testPage.reload();
await expect(toggle).toBeChecked();
await toggle.click();
await testPage
.getByTestId("settings-floating-save")
.getByRole("button", { name: "Save changes" })
.click();
await expect
.poll(async () => (await apiClient.getExecutorProfile(exec.id, profile.id)).config)
.not.toHaveProperty("allow_user_namespaces");
} finally {
await apiClient.deleteExecutor(exec.id).catch(() => {});
}
});
/**
* POST /api/v1/docker/build is admin-gated on the backend, so a member must
* not be shown an enabled control that can only end in a 403. Auth is
@@ -0,0 +1,53 @@
import { test, expect } from "../../fixtures/test-base";
test.describe("User namespace support on mobile", () => {
test("enables and persists the Docker profile setting with a touch-sized control", async ({
testPage,
apiClient,
}) => {
const exec = await apiClient.createExecutor("e2e-userns-mobile", "local_docker");
const profile = await apiClient.createExecutorProfile(exec.id, {
name: "userns mobile",
config: { image_tag: "kandev-agent:e2e" },
});
await testPage.route("**/api/v1/docker/containers?*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: '{"containers":[]}',
});
});
try {
await testPage.goto(`/settings/executors/${profile.id}`);
const toggle = testPage.getByRole("switch", { name: "User namespace support" });
const touchTarget = testPage.locator('label[for="allow-user-namespaces"]');
await expect(toggle).not.toBeChecked();
await expect(touchTarget).toBeVisible();
const targetBox = await touchTarget.boundingBox();
expect(targetBox).not.toBeNull();
expect(targetBox!.height).toBeGreaterThanOrEqual(44);
await touchTarget.tap();
await expect(toggle).toBeChecked();
await testPage
.getByTestId("settings-floating-save")
.getByRole("button", { name: "Save changes" })
.tap();
await expect(testPage.getByText("Profile saved")).toBeVisible();
await expect
.poll(async () => (await apiClient.getExecutorProfile(exec.id, profile.id)).config)
.toMatchObject({ allow_user_namespaces: "true" });
await testPage.reload();
await expect(toggle).toBeChecked();
const horizontalOverflow = await testPage.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
expect(horizontalOverflow).toBe(0);
} finally {
await apiClient.deleteExecutor(exec.id).catch(() => {});
}
});
});
+5 -1
View File
@@ -394,5 +394,9 @@
"usesTheLocalDockerDaemonOn": "Uses the local Docker daemon on this machine.",
"value": "Value",
"verifyingConnection": "Verifying connection...",
"profileNameExample": "Production, Development"
"profileNameExample": "Production, Development",
"allowUserNamespacesTitle": "User namespace support",
"allowUserNamespacesDescription": "Allow unprivileged user namespace creation and mount operations inside containers. Enables sandboxing tools like bwrap.",
"allowUserNamespacesNote": "This setting only affects newly created containers. Existing task environments must be reset for the change to take effect.",
"allowUserNamespacesWarning": "This disables Docker's default AppArmor restrictions for opted-in containers. Enable it only for trusted agent runtimes that need user-namespace sandboxing."
}
+5 -1
View File
@@ -394,5 +394,9 @@
"usesTheLocalDockerDaemonOn": "Ũśēś ţĥē ĺōćàĺ Ďōćķēŕ ďàēḿōń ōń ţĥĩś ḿàćĥĩńē.",
"value": "Vàĺũē",
"verifyingConnection": "Vēŕĩƒŷĩńĝ ćōńńēćţĩōń...",
"profileNameExample": "Ƥŕōďũćţĩōń, Ďēvēĺōƥḿēńţ"
"profileNameExample": "Ƥŕōďũćţĩōń, Ďēvēĺōƥḿēńţ",
"allowUserNamespacesTitle": "Ũśēŕ ńàḿēśƥàćē śũƥƥōŕţ",
"allowUserNamespacesDescription": "Àĺĺōŵ ũńƥŕĩvĩĺēĝēď ũśēŕ ńàḿēśƥàćē ćŕēàţĩōń àńď ḿōũńţ ōƥēŕàţĩōńś ĩńśĩďē ćōńţàĩńēŕś. Ēńàƀĺēś śàńďƀōxĩńĝ ţōōĺś ĺĩķē ƀŵŕàƥ.",
"allowUserNamespacesNote": "Ţĥĩś śēţţĩńĝ ōńĺŷ àƒƒēćţś ńēŵĺŷ ćŕēàţēď ćōńţàĩńēŕś. Ēxĩśţĩńĝ ţàśķ ēńvĩŕōńḿēńţś ḿũśţ ƀē ŕēśēţ ƒōŕ ţĥē ćĥàńĝē ţō ţàķē ēƒƒēćţ.",
"allowUserNamespacesWarning": "⟦This disables Docker's default AppArmor restrictions for opted-in containers. Enable it only for trusted agent runtimes that need user-namespace sandboxing.⟧"
}
+5 -1
View File
@@ -394,5 +394,9 @@
"portableConfigInfoTitle": "Configuração portátil do agente",
"portableConfigNotFound": "Não encontrado",
"portableConfigNotFoundHint": "Isto é uma sugestão de edição. Pode guardar o perfil e adicionar o ficheiro mais tarde.",
"portableConfigTitle": "Configuração do agente"
"portableConfigTitle": "Configuração do agente",
"allowUserNamespacesTitle": "Suporte para namespaces de utilizador",
"allowUserNamespacesDescription": "Permitir a criação de namespaces de utilizador sem privilégios e operações de montagem dentro de contentores. Ativa ferramentas de sandbox como bwrap.",
"allowUserNamespacesNote": "Esta definição só afeta contentores recém-criados. Os ambientes de tarefas existentes devem ser repostos para a alteração ter efeito.",
"allowUserNamespacesWarning": "Isto desativa as restrições AppArmor predefinidas do Docker para contentores selecionados. Ative apenas para ambientes de agentes fidedignos que necessitem de sandboxing com namespaces de utilizador."
}
+5 -1
View File
@@ -394,5 +394,9 @@
"portableConfigInfoTitle": "可迁移的智能体配置",
"portableConfigNotFound": "未找到",
"portableConfigNotFoundHint": "这只是编辑提示。你可以先保存配置,稍后再添加该文件。",
"portableConfigTitle": "智能体配置"
"portableConfigTitle": "智能体配置",
"allowUserNamespacesTitle": "用户命名空间支持",
"allowUserNamespacesDescription": "允许在容器内创建无特权的用户命名空间和执行挂载操作。启用类似 bwrap 的沙箱工具。",
"allowUserNamespacesNote": "此设置仅影响新创建的容器。现有任务环境必须重置才能使更改生效。",
"allowUserNamespacesWarning": "这会为选择启用的容器停用 Docker 默认的 AppArmor 限制。仅为需要用户命名空间沙箱的可信代理运行环境启用。"
}
+5 -1
View File
@@ -394,5 +394,9 @@
"portableConfigInfoTitle": "可遷移的代理程式設定檔",
"portableConfigNotFound": "未找到",
"portableConfigNotFoundHint": "這只是編輯提示。你可以先儲存設定,稍後再新增該檔案。",
"portableConfigTitle": "代理程式設定檔"
"portableConfigTitle": "代理程式設定檔",
"allowUserNamespacesTitle": "使用者命名空間支援",
"allowUserNamespacesDescription": "允許在容器內建立無特權的使用者命名空間和執行掛載操作。啟用如 bwrap 的沙盒工具。",
"allowUserNamespacesNote": "此設定僅影響新建立的容器。現有任務環境必須重設才能使變更生效。",
"allowUserNamespacesWarning": "這會為選擇啟用的容器停用 Docker 預設的 AppArmor 限制。只應為需要使用者命名空間沙盒的可信代理執行環境啟用。"
}
+5 -1
View File
@@ -394,5 +394,9 @@
"portableConfigInfoTitle": "可遷移的代理程式設定檔",
"portableConfigNotFound": "未找到",
"portableConfigNotFoundHint": "這只是編輯提示。你可以先儲存設定,稍後再新增該檔案。",
"portableConfigTitle": "代理程式設定檔"
"portableConfigTitle": "代理程式設定檔",
"allowUserNamespacesTitle": "使用者命名空間支援",
"allowUserNamespacesDescription": "允許在容器內建立無特權的使用者命名空間和執行掛載操作。啟用如 bwrap 的沙盒工具。",
"allowUserNamespacesNote": "此設定僅影響新建立的容器。現有任務環境必須重設才能使變更生效。",
"allowUserNamespacesWarning": "這會為選擇啟用的容器停用 Docker 預設的 AppArmor 限制。僅應為需要使用者命名空間沙盒的可信代理執行環境啟用。"
}
@@ -0,0 +1,90 @@
# Executor Container Security Options for User Namespace Support
| Metadata | Value |
|---|---|
| **Date** | 2026-08-18 |
| **Status** | accepted |
| **Tags** | backend, security, docker |
| **Replaces** | (none — no prior ADR covers this boundary) |
## Context
Kandev's Local Docker executor launches every task container with no `SecurityOpt` set, so Docker applies its defaults: the default seccomp profile plus the `docker-default` AppArmor profile. Both deny `CLONE_NEWUSER` to processes without `CAP_SYS_ADMIN` — which is every process in a Kandev task container, since Kandev never adds that capability.
Any agent runtime that sandboxes its own file edits via user namespaces therefore fails. The observed symptom is Codex's `apply_patch` → `bwrap: No permissions to create a new namespace`. This stalled four board tasks on 2026-08-17/18. The interim mitigation was pinning agent profiles away from the affected runtime; this ADR records the durable fix, after which pinning can be reverted.
Empirical probe results on the target host confirmed two independent blocking layers:
1. **seccomp** — Docker's default profile gates `clone` on a `SCMP_CMP_MASKED_EQ` check over the namespace flags and puts `unshare`, `mount`, `umount2`, and `setns` in a `CAP_SYS_ADMIN`-gated group. It does not name `pivot_root`, so the default action denies it.
2. **AppArmor** — the host runs `apparmor_restrict_unprivileged_userns=1` (Ubuntu 24.04 kernel), and `docker-default` carries no `userns` allow rule plus an explicit `deny mount,`.
Relaxing only one is not enough: seccomp alone fails because AppArmor blocks mount operations inside user namespaces, and AppArmor alone fails because seccomp blocks namespace creation.
## Decision
Add a per-executor-profile boolean `allow_user_namespaces` that, when enabled, emits two Docker `SecurityOpt` entries at container creation time:
- `seccomp=<Kandev-tailored seccomp profile>`
- `apparmor=unconfined`
The profile is a modified copy of Docker's default seccomp profile (vendored from the moby/profiles repository at v29.7.1). The modifications are:
- `clone`: the `SCMP_CMP_MASKED_EQ` namespace-flag restriction for non-CAP_SYS_ADMIN processes is removed.
- `clone3`: the `SCMP_ACT_ERRNO` (ENOSYS) fallback for non-CAP_SYS_ADMIN processes is removed.
- `mount`, `mount_setattr`, `move_mount`, `open_tree`, `setns`, `umount`, `umount2`, `unshare`: moved from the `CAP_SYS_ADMIN`-gated allow list into the unconditional allow list.
- `pivot_root`: added to the unconditional allow list. Docker's default profile does not name it in any rule, so it falls through to the profile's `SCMP_ACT_ERRNO` default. `bwrap` calls `pivot_root` immediately after creating its namespace and aborts with `bwrap: pivot_root: Operation not permitted` when it is denied, so relaxing `clone`/`unshare` alone leaves the motivating use case broken. Verified against a live daemon: with `pivot_root` denied, `unshare -U true` succeeds but `bwrap --unshare-user --dev-bind / / true` exits 1; with it allowed, both exit 0.
- Compatibility: nine optional syscall allows that older Docker/libseccomp daemons cannot load are removed. These syscalls remain denied by the profile default action.
Apart from that compatibility filter, every other syscall restriction in Docker's default profile is preserved. `kexec_load`, `bpf`, `perf_event_open`, `add_key`, and ~50 other syscalls Docker blocks for good reason remain blocked.
The setting is:
- **Off by default** — every existing profile produces byte-identical launch config.
- **Per-profile** — stored as a `config` entry (`allow_user_namespaces: "true"`), not a typed DB column.
- **Authoritative** — the profile value unconditionally overwrites any task-supplied metadata value, preventing self-escalation. Launch-config resolution clears the key before applying the profile, so the guarantee also holds for launches that attach no executor profile, carry a stale profile ID, or hit a profile lookup error; task metadata is caller-writable through the task REST/WS API and is not key-filtered there.
- **Gated from the MCP surface** — the `create_executor_profile` and `update_executor_profile` MCP handlers reject the key with a validation error. The operator HTTP/WS API remains unguarded; this is accepted because the same operator surface already exposes `prepare_script` (arbitrary shell at every launch), a strictly more powerful primitive.
- **Create-time only** — `SecurityOpt` cannot be changed on an existing container. Operators must reset task environments after enabling it.
## Rationale
### Why not free-form `security_opt` list?
Maximum flexibility, but it hands every profile editor `seccomp=unconfined`, `apparmor=unconfined`, and `privileged` — the setting becomes an arbitrary container-escape switch rather than a single, reviewed relaxation.
### Why not `seccomp=unconfined`?
It would restore `kexec_load`, `bpf`, `perf_event_open`, `add_key`, `open_by_handle_at`, and ~50 other syscalls. The tailored profile is strictly better: it only relaxes namespace-related syscalls and removes optional names for older daemon compatibility.
### Why not `--privileged` or `CapAdd: SYS_ADMIN`?
These grant real host privilege. User namespaces grant privilege only within the new namespace. Strictly worse for the same outcome.
### Why not daemon-wide `userns-remap` / `HostConfig.UsernsMode`?
A different feature (remapping container root to host subuids); does not enable nested namespace creation, and is daemon-global rather than per-profile.
### Why not a Kandev-authored AppArmor profile instead of `apparmor=unconfined`?
Strictly better security, but loading it needs host root and `apparmor_parser`, and Kandev's control plane is frequently itself a container. Left as a documented future escape hatch.
### Why `apparmor=unconfined` for the AppArmor half?
`apparmor_restrict_unprivileged_unconfined=0` on the target hosts means an unconfined process is permitted to create user namespaces. The `apparmor=unconfined` SecurityOpt is what makes this work. On hosts without AppArmor, `apparmor=unconfined` is a no-op.
## Consequences
### Positive
- Agent runtimes that use user namespaces (Codex's `apply_patch`, bwrap-based sandboxing) work inside Kandev container executors without `CAP_SYS_ADMIN` or `--privileged`.
- Default-off means zero risk for profiles that don't need this.
- The tailored seccomp profile preserves all other Docker security hardening and keeps compatibility-filtered calls denied by default.
- The profile is vendored and version-pinned; tests assert the namespace additions and compatibility removals.
### Negative
- `apparmor=unconfined` is the coarse half of the fix. It removes `deny mount,` and all other docker-default AppArmor restrictions — a broader relaxation than the user namespace itself needs. This is mitigated by default-off + explicit per-profile opt-in.
- User namespaces are historically the single richest source of Linux LPE CVEs. The exposure is kernel attack surface, not a direct grant: privilege obtained inside the namespace does not map to host privilege. Capabilities (still no `CAP_SYS_ADMIN`), cgroups, and namespace isolation remain in force.
- The setting can still be set through the operator HTTP/WS API by anything that can reach the backend port. This is accepted because the same surface already exposes `prepare_script`.
### Risks
- Create-time-only: toggling the profile affects only newly created containers. Operators must be aware of this (stated in UI copy and docs).
- The seccomp base profile is vendored from moby/profiles. A periodic manual refresh on moby upgrades is needed; automating it is out of scope.
## Future Work
- A Kandev-authored AppArmor profile (and a `docker_apparmor_profile` key to name a site-loaded one instead of `unconfined`).
- Installing `bwrap` / `newuidmap` into Kandev-provided agent images.
+1
View File
@@ -185,6 +185,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-18-session-mcp-tool-definition-details | [Bound MCP Tool Definition Details to Current Sessions](2026-08-18-session-mcp-tool-definition-details.md) | accepted | backend, agentctl, frontend, protocol, security | 2026-08-18 |
| 2026-08-17-separate-task-activity-from-summary-freshness | [Separate Task Activity From Summary Freshness](2026-08-17-separate-task-activity-from-summary-freshness.md) | accepted (amended 2026-08-29) | backend, frontend, protocol | 2026-08-17 |
| 2026-08-17-release-pr-ruleset-bypass | [Give Stable Release PRs an Administrator Token Bypass](2026-08-17-release-pr-ruleset-bypass.md) | accepted | infra, workflow, security | 2026-08-17 |
| 2026-08-18-executor-userns-security-options | [Executor Container Security Options for User Namespace Support](2026-08-18-executor-userns-security-options.md) | accepted | backend, security, docker | 2026-08-18 |
| 2026-08-18-never-started-agent-stall-terminal | [Treat Never-Started Agent Stalls as Terminal](2026-08-18-never-started-agent-stall-terminal.md) | accepted | backend, frontend, protocol | 2026-08-18 |
| 2026-08-18-plugin-task-row-metadata | [Keep Task Row Plugin Metadata Generic](2026-08-18-plugin-task-row-metadata.md) | accepted | frontend | 2026-08-18 |
| 2026-08-20-settings-prompt-editor-provider-ownership | [Settings Prompt Editors Own Monaco Completion Providers](2026-08-20-settings-prompt-editor-provider-ownership.md) | accepted | frontend | 2026-08-20 |
+7 -1
View File
@@ -272,9 +272,15 @@ Kandev passes each agent definition's CPU and memory limits to Docker. These are
</details>
### User namespace support
Profiles can enable **User namespace support** under the Dockerfile build card. When enabled, the container is launched with a tailored seccomp profile that relaxes namespace-related syscall restrictions, plus `apparmor=unconfined`. This allows agent runtimes that sandbox file edits via user namespaces (e.g., Codex's `apply_patch` → bwrap) to work inside the container.
The setting is **off by default**, only available on Docker profiles, and affects **newly created containers only**. Existing task environments must be reset for the change to take effect. See the [security ADR](../decisions/2026-08-18-executor-userns-security-options.md) for details on the exact syscall changes and security model.
### Credentials and security
> **Trust boundary:** A container is useful but not a hostile-code sandbox. The daemon has host-level power, bind mounts expose sources, agents can use injected secrets, and the default image has outbound network access. Kandev does not mount the Docker socket automatically.
> **Trust boundary:** A container is useful but not a hostile-code sandbox. The daemon has host-level power, bind mounts expose sources, agents can use injected secrets, and the default image has outbound network access. Kandev does not mount the Docker socket automatically. The User namespace support option relaxes container isolation. See the [security ADR](../decisions/2026-08-18-executor-userns-security-options.md).
<details>
<summary>Docker credential and security details</summary>
+1 -1
View File
@@ -48,7 +48,7 @@ An executor decides where the agent process runs. It does not reduce permissions
|---|---|---|
| Worktree | A separate Git checkout | Isolates file state, not the host account, credentials, processes, ports, or network |
| Local | The selected folder and Kandev host account | The agent can affect the same host resources its process can reach |
| Local Docker | A container plus explicitly mounted paths and credentials | A Docker socket or daemon API can grant host-level control; mounts remain readable in the container |
| Local Docker | A container plus explicitly mounted paths and credentials | A Docker socket or daemon API can grant host-level control; mounts remain readable in the container. User namespace support (per-profile opt-in) relaxes seccomp and AppArmor. See [executor ADR](../decisions/2026-08-18-executor-userns-security-options.md) for the exact boundary |
| Kubernetes | A namespaced Pod plus cluster admission, workload identity, network, and storage policy | The Kandev API identity can create/exec/forward/delete Pods, while the administrator-authored Pod template can request privileged or host-integrated access |
| SSH | The configured remote account and host | Remote directories and credentials require manual lifecycle review |
| Sprites | A remote sandbox and its injected credentials | Destroying the sandbox can remove unpushed work; network and token scope still matter |