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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ jobs:
with:
go-version: '1.26.6'
cache-dependency-path: go.sum
# Compatibility launches historical and candidate binaries from temporary
# paths. Ubuntu's path-scoped AppArmor userns policy cannot grant both;
# the dedicated Linux sandbox jobs exercise that security boundary.
- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- run: make test-compat

claude-model-policy:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ jobs:
- run: make test-vuln
- run: go test -race ./...
- run: make test-integ
# Compatibility launches historical and candidate binaries from temporary
# paths. Ubuntu's path-scoped AppArmor userns policy cannot grant both;
# the Linux sandbox batteries below exercise that security boundary.
- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- run: make test-compat
- run: make test-linux
- run: make test-linux-ubuntu
Expand Down
22 changes: 14 additions & 8 deletions cmd/wt/agent_sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,16 @@ func directAgentSandboxConfigForTask(eggCfg *egg.EggConfig, agentName, isolation
mounts = append(mounts, m)
}

for _, mount := range declared.Mounts {
appendMount(mount)
}
// On shared hosts egg.yaml is the administrator-authored filesystem policy.
// Prompt-derived mounts must never widen it.
if !sharedHost {
for _, mount := range declared.Mounts {
appendMount(mount)
for _, path := range mountPaths {
appendMount(sandbox.Mount{Source: path, Target: path})
}
}
for _, path := range mountPaths {
appendMount(sandbox.Mount{Source: path, Target: path})
}
if home != "" {
for _, dir := range profile.WriteRegex {
path := filepath.Join(home, dir)
Expand Down Expand Up @@ -118,10 +120,14 @@ func directAgentSandboxConfigForTask(eggCfg *egg.EggConfig, agentName, isolation
Trace: declared.Trace,
}
if sharedHost {
result.Deny = []string{"/"}
for _, path := range sharedHostSystemReadPaths() {
appendMount(sandbox.Mount{Source: path, Target: path, ReadOnly: true})
if !containsExactPath(declared.Deny, string(filepath.Separator)) {
return sandbox.Config{}, fmt.Errorf("shared-host egg config must deny the filesystem root")
}
if err := rejectSharedHostRootMount(declared.Mounts); err != nil {
return sandbox.Config{}, err
}
result.Deny = declared.Deny
result.DenyWrite = declared.DenyWrite
result.Mounts = mounts
} else {
result.Deny = declared.Deny
Expand Down
57 changes: 48 additions & 9 deletions cmd/wt/agent_sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,24 +282,38 @@ func TestAgentRuntimeCommandMatchesSupportedAgentCatalog(t *testing.T) {
}
}

func TestSharedHostDirectAgentUsesAllowlistJail(t *testing.T) {
func TestSharedHostDirectAgentPreservesAdministratorFilesystemPolicy(t *testing.T) {
t.Setenv("WT_PROVIDER_BASE_URL", "")
home := t.TempDir()
workspace := t.TempDir()
cfg, err := directAgentSandboxConfigWithPolicy("codex", "standard", home, []string{workspace}, true)
readOnlySource := t.TempDir()
deniedSecret := t.TempDir()
eggCfg := &egg.EggConfig{FS: []string{
"deny:/",
"rw:" + workspace,
"ro:" + readOnlySource,
"deny:" + deniedSecret,
}}
cfg, err := directAgentSandboxConfigForTask(eggCfg, "codex", "standard", home, workspace, nil, true)
if err != nil {
t.Fatal(err)
}
if len(cfg.Deny) != 1 || cfg.Deny[0] != "/" {
if len(cfg.Deny) == 0 || cfg.Deny[0] != "/" {
t.Fatalf("shared-host deny policy = %#v", cfg.Deny)
}
if !hasSandboxMount(cfg.Mounts, workspace) {
t.Fatalf("workspace is not writable in %#v", cfg.Mounts)
}
for _, mount := range cfg.Mounts {
if mount.Source == "/" {
t.Fatalf("host root was mounted into the jail: %#v", cfg.Mounts)
}
if !hasReadOnlySandboxMount(cfg.Mounts, readOnlySource) {
t.Fatalf("administrator read-only mount is absent from %#v", cfg.Mounts)
}
if len(cfg.Deny) != 2 {
t.Fatalf("administrator deny policy = %#v", cfg.Deny)
}
gotDenied, gotDeniedErr := os.Stat(cfg.Deny[1])
wantDenied, wantDeniedErr := os.Stat(deniedSecret)
if gotDeniedErr != nil || wantDeniedErr != nil || !os.SameFile(gotDenied, wantDenied) {
t.Fatalf("administrator deny policy = %#v", cfg.Deny)
}
}

Expand All @@ -308,8 +322,8 @@ func TestSharedHostTaskMountsValidatedWorkspaceRoots(t *testing.T) {
workDir := filepath.Join(root, "mutable", "checkout")
options := taskRunOptions{SharedHost: true, AllowedPaths: []string{root}}
mounts := taskSandboxMountPaths([]string{workDir, "/caller/widening"}, workDir, options)
if len(mounts) != 1 || mounts[0] != root {
t.Fatalf("shared-host task mounts = %#v, want only %q", mounts, root)
if len(mounts) != 0 {
t.Fatalf("shared-host task mounts widened administrator policy: %#v", mounts)
}

personal := taskSandboxMountPaths([]string{"/prompt/mount"}, workDir, taskRunOptions{})
Expand All @@ -318,6 +332,22 @@ func TestSharedHostTaskMountsValidatedWorkspaceRoots(t *testing.T) {
}
}

func TestSharedHostDirectAgentIgnoresCallerMounts(t *testing.T) {
home := t.TempDir()
workspace := t.TempDir()
callerMount := t.TempDir()
cfg, err := directAgentSandboxConfigForTask(&egg.EggConfig{FS: []string{
"deny:/",
"rw:" + workspace,
}}, "codex", "standard", home, workspace, []string{callerMount}, true)
if err != nil {
t.Fatal(err)
}
if hasSandboxMount(cfg.Mounts, callerMount) {
t.Fatalf("caller widened shared-host mounts: %#v", cfg.Mounts)
}
}

func hasSandboxMount(mounts []sandbox.Mount, source string) bool {
for _, mount := range mounts {
if mount.Source == source && !mount.ReadOnly {
Expand All @@ -326,3 +356,12 @@ func hasSandboxMount(mounts []sandbox.Mount, source string) bool {
}
return false
}

func hasReadOnlySandboxMount(mounts []sandbox.Mount, source string) bool {
for _, mount := range mounts {
if mount.Source == source && mount.ReadOnly {
return true
}
}
return false
}
78 changes: 42 additions & 36 deletions cmd/wt/egg.go
Original file line number Diff line number Diff line change
Expand Up @@ -1496,76 +1496,82 @@ func sealedSharedHostEggConfig(cfg *config.Config, source *egg.EggConfig, cwd st
if source == nil {
return nil, errors.New("shared-host egg config is required")
}
rules, canonical, err := sharedHostFilesystemRules(cfg, allowedPaths)
canonical, err := validateSharedHostWorkspacePaths(cfg, allowedPaths)
if err != nil {
return nil, err
}
resolvedCWD := canonicalSessionPath(cwd)
if !isUnderPaths(resolvedCWD, canonical) {
return nil, fmt.Errorf("working directory %q is outside this user's roost paths", cwd)
}
declared := source.ToSandboxConfig("")
if !containsExactPath(declared.Deny, string(filepath.Separator)) {
return nil, errors.New("shared-host egg config must deny the filesystem root")
}
if err := rejectSharedHostRootMount(declared.Mounts); err != nil {
return nil, err
}
// egg.yaml is the administrator-authored security policy. AllowedPaths
// authorizes the session CWD; it does not replace or synthesize mounts.
sealed := *source
sealed.FS = rules
sealed.AgentSettings = nil
sealed.FS = append([]string(nil), source.FS...)
if source.AgentSettings != nil {
sealed.AgentSettings = make(map[string]string, len(source.AgentSettings))
for name, path := range source.AgentSettings {
sealed.AgentSettings[name] = path
}
}
sealed.Env = append(egg.EnvField(nil), source.Env...)
sealed.Network.Domains = append([]string(nil), source.Network.Domains...)
sealed.Network.LocalPorts = append([]int(nil), source.Network.LocalPorts...)
return &sealed, nil
}

func sharedHostFilesystemRules(cfg *config.Config, allowedPaths []string) ([]string, []string, error) {
func containsExactPath(paths []string, target string) bool {
for _, path := range paths {
if path == target {
return true
}
}
return false
}

func rejectSharedHostRootMount(mounts []sandbox.Mount) error {
for _, mount := range mounts {
if canonicalSessionPath(mount.Source) == string(filepath.Separator) {
return errors.New("shared-host egg config must not mount the filesystem root")
}
}
return nil
}

func validateSharedHostWorkspacePaths(cfg *config.Config, allowedPaths []string) ([]string, error) {
canonical := canonicalPaths(allowedPaths)
if len(canonical) == 0 {
return nil, nil, errors.New("shared-host sessions require at least one configured workspace path")
return nil, errors.New("shared-host sessions require at least one configured workspace path")
}
stateDir := canonicalSessionPath(cfg.Dir)
hostHome, _ := os.UserHomeDir()
hostHome = canonicalSessionPath(hostHome)
for _, path := range canonical {
if path == string(filepath.Separator) {
return nil, nil, errors.New("the filesystem root cannot be a shared-roost workspace path")
return nil, errors.New("the filesystem root cannot be a shared-roost workspace path")
}
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
if err == nil {
err = errors.New("not a directory")
}
return nil, nil, fmt.Errorf("shared-roost workspace %q: %w", path, err)
return nil, fmt.Errorf("shared-roost workspace %q: %w", path, err)
}
if isUnderPaths(stateDir, []string{path}) || isUnderPaths(path, []string{stateDir}) {
return nil, nil, fmt.Errorf("shared-roost workspace %q overlaps Wingthing state", path)
return nil, fmt.Errorf("shared-roost workspace %q overlaps Wingthing state", path)
}
if hostHome != "." && isUnderPaths(hostHome, []string{path}) {
return nil, nil, fmt.Errorf("shared-roost workspace %q contains the host account home", path)
}
}

rules := []string{"deny:/"}
for _, path := range sharedHostSystemReadPaths() {
rules = append(rules, "ro:"+path)
}
for _, path := range canonical {
rules = append(rules, "rw:"+path)
}
return rules, canonical, nil
}

func sharedHostSystemReadPaths() []string {
candidates := []string{
"/usr", "/lib", "/lib64",
"/etc/ssl", "/etc/pki", "/etc/ca-certificates",
"/etc/resolv.conf", "/etc/hosts", "/etc/nsswitch.conf",
"/etc/passwd", "/etc/group", "/etc/ld.so.cache",
"/nix/store",
}
paths := make([]string, 0, len(candidates))
for _, path := range candidates {
info, err := os.Lstat(path)
if err == nil && info.Mode()&os.ModeSymlink == 0 {
paths = append(paths, path)
return nil, fmt.Errorf("shared-roost workspace %q contains the host account home", path)
}
}
return paths
return canonical, nil
}

// parseMemFlag parses a memory string like "2GB" or "512MB" into bytes.
Expand Down
5 changes: 3 additions & 2 deletions cmd/wt/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ func runTaskToWithOptions(ctx context.Context, cfg *config.Config, s *store.Stor
return err
}
if options.SharedHost {
_, canonical, err := sharedHostFilesystemRules(cfg, options.AllowedPaths)
canonical, err := validateSharedHostWorkspacePaths(cfg, options.AllowedPaths)
if err != nil {
return err
}
Expand Down Expand Up @@ -666,7 +666,8 @@ func appendNetworkEnforcementAudit(s *store.Store, taskID, event, enforcement st

func taskSandboxMountPaths(promptMounts []string, workDir string, options taskRunOptions) []string {
if options.SharedHost {
return append([]string(nil), options.AllowedPaths...)
// Shared-host mounts come exclusively from the administrator's egg.yaml.
return nil
}
mounts := append([]string(nil), promptMounts...)
return append(mounts, workDir)
Expand Down
39 changes: 26 additions & 13 deletions cmd/wt/mcp_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1231,33 +1231,46 @@ func TestSharedRoostPathBoundsFailClosed(t *testing.T) {
}
}

func TestSharedHostFilesystemPolicyIgnoresCallerWidening(t *testing.T) {
func TestSharedHostFilesystemPolicyPreservesAdministratorConfig(t *testing.T) {
stateDir := t.TempDir()
workspace := t.TempDir()
readOnlySource := t.TempDir()
writableCache := t.TempDir()
deniedSecret := t.TempDir()
cfg := &config.Config{Dir: stateDir}
source := &egg.EggConfig{
FS: []string{"rw:/", "rw:/Users/someone-else"},
FS: []string{
"deny:/",
"rw:" + workspace,
"ro:" + readOnlySource,
"rw:" + writableCache,
"deny:" + deniedSecret,
"deny-write:" + filepath.Join(workspace, "egg.yaml"),
},
AgentSettings: map[string]string{"claude": "/host/secret/settings.json"},
}
sealed, err := sealedSharedHostEggConfig(cfg, source, workspace, []string{workspace})
if err != nil {
t.Fatal(err)
}
if len(sealed.FS) == 0 || sealed.FS[0] != "deny:/" {
t.Fatalf("sealed fs = %#v", sealed.FS)
if !reflect.DeepEqual(sealed.FS, source.FS) {
t.Fatalf("sealed fs = %#v, want administrator policy %#v", sealed.FS, source.FS)
}
for _, rule := range sealed.FS {
if rule == "rw:/" || strings.Contains(rule, "someone-else") {
t.Fatalf("caller widened sealed policy with %q", rule)
}
if !reflect.DeepEqual(sealed.AgentSettings, source.AgentSettings) {
t.Fatalf("agent settings = %#v, want %#v", sealed.AgentSettings, source.AgentSettings)
}
sealed.FS[0] = "mutated"
sealed.AgentSettings["claude"] = "mutated"
if source.FS[0] != "deny:/" || source.AgentSettings["claude"] != "/host/secret/settings.json" {
t.Fatal("sealed config aliases the administrator config")
}
if !containsString(sealed.FS, "rw:"+canonicalSessionPath(workspace)) {
t.Fatalf("workspace missing from sealed fs: %#v", sealed.FS)
if _, err := sealedSharedHostEggConfig(cfg, &egg.EggConfig{FS: []string{"rw:" + workspace}}, workspace, []string{workspace}); err == nil || !strings.Contains(err.Error(), "must deny the filesystem root") {
t.Fatalf("unjailled shared-host policy error = %v", err)
}
if sealed.AgentSettings != nil {
t.Fatalf("host agent settings survived sealing: %#v", sealed.AgentSettings)
if _, err := sealedSharedHostEggConfig(cfg, &egg.EggConfig{FS: []string{"deny:/", "ro:/"}}, workspace, []string{workspace}); err == nil || !strings.Contains(err.Error(), "must not mount the filesystem root") {
t.Fatalf("host-root mount error = %v", err)
}
if _, _, err := sharedHostFilesystemRules(cfg, []string{stateDir}); err == nil {
if _, err := validateSharedHostWorkspacePaths(cfg, []string{stateDir}); err == nil {
t.Fatal("Wingthing state was accepted as a shared workspace")
}
}
Expand Down
Loading
Loading