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
10 changes: 10 additions & 0 deletions internal/egg/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,16 @@ func DiscoverEggConfig(cwd string, wingDefault *EggConfig) *EggConfig {
return DefaultEggConfig()
}

// RuntimeEggConfig copies the administrator's captured session policy without
// consulting caller-writable workspace or global configuration files.
func RuntimeEggConfig(wingDefault *EggConfig) *EggConfig {
if wingDefault == nil {
wingDefault = DefaultEggConfig()
}
cfg := *wingDefault
return &cfg
}

const maxBaseDepth = 10

// ResolveEggConfig loads an egg.yaml and resolves its base chain, returning
Expand Down
183 changes: 183 additions & 0 deletions internal/eggclient/agent_sandbox_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
package eggclient

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/ehrlich-b/wingthing/internal/agent"
"github.com/ehrlich-b/wingthing/internal/config"
"github.com/ehrlich-b/wingthing/internal/egg"
"github.com/ehrlich-b/wingthing/internal/ws"
)

func TestAgentRuntimeCommandMatchesSupportedAgentCatalog(t *testing.T) {
Expand All @@ -19,3 +25,180 @@ func TestAgentRuntimeCommandMatchesSupportedAgentCatalog(t *testing.T) {
t.Fatalf("unknown command fallback = %q", got)
}
}

func TestAuthenticatedBrowserLaunchUsesRoleRootPolicy(t *testing.T) {
home := config.CanonicalProviderPath(t.TempDir())
t.Setenv("HOME", home)
t.Setenv("WINGTHING_DIR", filepath.Join(home, "state"))
root := filepath.Join(home, "eng")
sub := filepath.Join(root, "sub")
if err := os.MkdirAll(sub, 0700); err != nil {
t.Fatal(err)
}
policy := "base: none\nfs: [deny:/, ro:/usr, rw:" + root + ", deny:/opt/wingthing/support, deny:/opt/wingthing/.ssh, deny-write:./egg.yaml]\nenv: [HOME, WT_USER, WT_USER_EMAIL, WT_SESSION_ID, DISABLE_TELEMETRY=1]\nresources: {cpu: 7200s, max_fds: 2048}\nshell: /bin/bash\naudit: true\ndangerously_skip_permissions: true\nnetwork: [role.example]\n"
if err := os.WriteFile(filepath.Join(root, "egg.yaml"), []byte(policy), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "egg.yaml"), []byte("base: none\nfs: [ro:/opt/wingthing/support]\nenv: ['*']\nnetwork: ['*']\n"), 0600); err != nil {
t.Fatal(err)
}
wingDefault := &egg.EggConfig{Shell: "/bin/wing-default", Network: egg.NetworkField{Domains: []string{"wing.example"}}}
for _, shared := range []bool{false, true} {
org := "org"
if shared {
org = ""
}
wc := &config.WingConfig{Org: org, Paths: config.PathList{{Path: root, Members: []string{"eng@example.com"}}}}
for _, cwd := range []string{root, sub} {
start := ws.PTYStart{UserID: "eng", Email: "eng@example.com", OrgRole: "member", CWD: cwd}
cfg, _, err := PrepareBrowserLaunch(wc, &start, home, shared, wingDefault)
if err != nil {
t.Fatal(err)
}
if start.CWD != cwd {
t.Fatalf("cwd changed from %s to %s", cwd, start.CWD)
}
if !cfg.Audit || !cfg.DangerouslySkipPermissions || cfg.Shell != "/bin/bash" || cfg.Resources.CPU != "7200s" || cfg.Resources.MaxFDs != 2048 || !ContainsExactPath(cfg.Network.Domains, "role.example") || cfg.IsAllEnv() {
t.Fatalf("role settings lost (shared=%v cwd=%s): %#v", shared, cwd, cfg)
}
for _, env := range []string{"WT_USER", "WT_USER_EMAIL", "WT_SESSION_ID", "DISABLE_TELEMETRY=1"} {
if !ContainsExactPath(cfg.Env, env) {
t.Fatalf("missing role env %s", env)
}
}
sandbox := cfg.ToSandboxConfig(home)
for _, mount := range sandbox.Mounts {
if mount.Source == "/opt/wingthing/support" {
t.Fatalf("planted support mount admitted: %#v", cfg)
}
}
if !ContainsExactPath(sandbox.Deny, "/opt/wingthing/support") || !ContainsExactPath(sandbox.Deny, "/opt/wingthing/.ssh") || !ContainsExactPath(sandbox.DenyWrite, filepath.Join(root, "egg.yaml")) {
t.Fatalf("role denies lost: %#v", sandbox)
}
}
}
}

func TestAuthenticatedBrowserRolePolicyDiscoveryBounds(t *testing.T) {
home := config.CanonicalProviderPath(t.TempDir())
t.Setenv("HOME", home)
root := filepath.Join(home, "eng")
nested := filepath.Join(home, "other")
sub := filepath.Join(nested, "sub")
outside := filepath.Join(home, "outside")
for _, dir := range []string{root, sub, outside} {
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{home, root, sub, outside} {
if err := os.WriteFile(filepath.Join(dir, "egg.yaml"), []byte("base: none\nnetwork: ['*']\n"), 0600); err != nil {
t.Fatal(err)
}
}
wingDefault := &egg.EggConfig{Shell: "/bin/default"}
for _, shared := range []bool{false, true} {
org := "org"
if shared {
org = ""
}
wc := &config.WingConfig{Org: org, Admins: []string{"admin@example.com"}, Paths: config.PathList{{Path: root}, {Path: nested}}}
start := ws.PTYStart{UserID: "eng", Email: "eng@example.com", OrgRole: "member", CWD: sub}
if _, _, err := PrepareBrowserLaunch(wc, &start, home, shared, wingDefault); err == nil || !strings.Contains(err.Error(), "ask the wing owner") {
t.Fatalf("missing selected root policy: %v", err)
}
start.CWD = nested
if _, _, err := PrepareBrowserLaunch(wc, &start, home, shared, wingDefault); err == nil || !strings.Contains(err.Error(), "ask the wing owner") {
t.Fatalf("missing role-root policy: %v", err)
}
start.CWD = outside
if _, _, err := PrepareBrowserLaunch(wc, &start, home, shared, wingDefault); err == nil {
t.Fatal("member admitted outside configured roots")
}
start.Email = "admin@example.com"
for _, cwd := range []string{sub, outside} {
start.CWD = cwd
cfg, _, err := PrepareBrowserLaunch(wc, &start, home, shared, wingDefault)
if err != nil || cfg.Shell != wingDefault.Shell || len(cfg.Network.Domains) != 0 {
t.Fatalf("admin fallback discovered child/ancestor policy: %#v, %v", cfg, err)
}
}
}
if err := os.Symlink(filepath.Join(outside, "egg.yaml"), filepath.Join(nested, "egg.yaml")); err != nil {
t.Fatal(err)
}
wc := &config.WingConfig{Org: "org", Paths: config.PathList{{Path: root}, {Path: nested}}}
for _, role := range []string{"member", "admin"} {
start := ws.PTYStart{UserID: "eng", OrgRole: role, CWD: sub}
if _, _, err := PrepareBrowserLaunch(wc, &start, home, false, wingDefault); err == nil || !strings.Contains(err.Error(), "regular file") {
t.Fatalf("external policy symlink accepted: %v", err)
}
}
if err := os.Remove(filepath.Join(nested, "egg.yaml")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "egg.yaml"), []byte("base: none\naudit: true\nnetwork: [deepest.example]\ndangerously_skip_permissions: true\n"), 0600); err != nil {
t.Fatal(err)
}
start := ws.PTYStart{UserID: "eng", OrgRole: "member", CWD: sub}
cfg, _, err := PrepareBrowserLaunch(wc, &start, home, false, wingDefault)
if err != nil || !cfg.Audit || !cfg.DangerouslySkipPermissions || !ContainsExactPath(cfg.Network.Domains, "deepest.example") || ContainsExactPath(cfg.Network.Domains, "*") {
t.Fatalf("selected root policy lost: %#v, %v", cfg, err)
}
}

func TestPersonalBrowserLaunchDiscoversWorkspacePolicy(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("WINGTHING_DIR", filepath.Join(home, "state"))
sub := filepath.Join(home, "sub")
if err := os.MkdirAll(sub, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "egg.yaml"), []byte("base: none\nnetwork: [workspace.example]\n"), 0600); err != nil {
t.Fatal(err)
}
start := ws.PTYStart{UserID: "owner", OrgRole: "owner", CWD: sub}
admin := &egg.EggConfig{Network: egg.NetworkField{Domains: []string{"admin.example"}}}
cfg, _, err := PrepareBrowserLaunch(&config.WingConfig{}, &start, home, false, admin)
if err != nil || !ContainsExactPath(cfg.Network.Domains, "workspace.example") {
t.Fatalf("personal wing lost workspace discovery: %#v, %v", cfg, err)
}
}

func TestAuthenticatedBrowserRoleRootsUseCanonicalPaths(t *testing.T) {
home := config.CanonicalProviderPath(t.TempDir())
t.Setenv("HOME", home)
root := filepath.Join(home, "eng")
nested := filepath.Join(home, "other")
sub := filepath.Join(nested, "sub")
outside := filepath.Join(home, "outside")
for _, dir := range []string{root, sub, outside} {
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{root, nested} {
if err := os.WriteFile(filepath.Join(dir, "egg.yaml"), []byte("base: none\nshell: "+dir+"\n"), 0600); err != nil {
t.Fatal(err)
}
}
alias := filepath.Join(home, "alias")
if err := os.Symlink(nested, alias); err != nil {
t.Fatal(err)
}
escape := filepath.Join(root, "escape")
if err := os.Symlink(outside, escape); err != nil {
t.Fatal(err)
}
wc := &config.WingConfig{Org: "org", Paths: config.PathList{{Path: root}, {Path: nested}}}
start := ws.PTYStart{UserID: "eng", OrgRole: "member", CWD: filepath.Join(alias, "sub")}
cfg, _, err := PrepareBrowserLaunch(wc, &start, home, false, egg.DefaultEggConfig())
if err != nil || cfg.Shell != nested || len(cfg.FS) != 2 || !ContainsExactPath(cfg.FS, "deny-write:"+filepath.Join(nested, "egg.yaml")) {
t.Fatalf("canonical selected root not selected: %#v, %v", cfg, err)
}
start.CWD = escape
if _, _, err := PrepareBrowserLaunch(wc, &start, home, false, egg.DefaultEggConfig()); err == nil {
t.Fatal("cwd symlink escaped configured roots")
}
}
155 changes: 155 additions & 0 deletions internal/eggclient/browser_launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ package eggclient
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/ehrlich-b/wingthing/internal/config"
"github.com/ehrlich-b/wingthing/internal/egg"
"github.com/ehrlich-b/wingthing/internal/wingpolicy"
"github.com/ehrlich-b/wingthing/internal/ws"
"golang.org/x/sys/unix"
)

func BrowserEggIdentity(wc *config.WingConfig, start ws.PTYStart, home string, sharedHost bool) EggIdentity {
Expand All @@ -28,6 +31,14 @@ func PrepareBrowserLaunch(wc *config.WingConfig, start *ws.PTYStart, home string
if wingpolicy.IsMemberRole(start.OrgRole) && len(identity.AllowedPaths) == 0 {
return nil, identity, errors.New("no accessible folders on this machine")
}
if identity.UserID != "" && (identity.SharedHost || identity.OrgWing) {
if start.CWD == "" && len(identity.AllowedPaths) > 0 {
start.CWD = identity.AllowedPaths[0]
}
roots := wingpolicy.ResolvePathStrings(wc.Paths.Strings(), home)
cfg, err := LoadRoostEggConfig(start.CWD, roots, identity.AllowedPaths, wingpolicy.IsMemberRole(start.OrgRole), wingDefault)
return cfg, identity, err
}
if len(identity.AllowedPaths) > 0 && !wingpolicy.IsExactPath(wingpolicy.CanonicalSessionPath(start.CWD), identity.AllowedPaths) {
start.CWD = identity.AllowedPaths[0]
}
Expand All @@ -38,3 +49,147 @@ func PrepareBrowserLaunch(wc *config.WingConfig, start *ws.PTYStart, home string
}
return egg.DiscoverEggConfig(start.CWD, wingDefault), identity, nil
}

// LoadRoostEggConfig discovers only the administrator-configured root's policy,
// never a policy planted in a writable child directory or an ancestor.
func LoadRoostEggConfig(cwd string, roots, allowedRoots []string, member bool, wingDefault *egg.EggConfig) (*egg.EggConfig, error) {
// Validate configured spellings before canonicalization erases symlinks.
for _, root := range roots {
for path := filepath.Clean(root); ; path = filepath.Dir(path) {
info, err := os.Lstat(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("inspect configured roost path %q: %w", root, err)
}
if err == nil && info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("configured roost path %q resolves through symlink %q; configure a real directory", root, path)
}
if filepath.Dir(path) == path {
break
}
}
}
roots = wingpolicy.CanonicalPaths(roots)
for i, root := range roots {
for j, parent := range roots {
if i != j && root != parent && wingpolicy.SessionPolicyContains(parent, root) {
return nil, fmt.Errorf("nested roost paths are not supported: %s is inside %s", root, parent)
}
}
}
allowedRoots = wingpolicy.CanonicalPaths(allowedRoots)
if cwd == "" && len(allowedRoots) > 0 {
cwd = allowedRoots[0]
}
cwd = wingpolicy.CanonicalSessionPath(cwd)
root := ""
for _, candidate := range roots {
if wingpolicy.SessionPolicyContains(candidate, cwd) {
root = candidate
break
}
}
if root == "" {
if member {
return nil, fmt.Errorf("working directory %q is outside this user's roost paths", cwd)
}
return protectRoostRootPolicies(egg.RuntimeEggConfig(wingDefault), roots), nil
}
// Check the selected root's ACL before reading its policy.
if member && !wingpolicy.IsExactPath(root, allowedRoots) {
return nil, fmt.Errorf("configured roost path %q is not accessible to this user", root)
}
path := filepath.Join(root, "egg.yaml")
cfg, err := loadRoostRolePolicy(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
if member {
return nil, fmt.Errorf("no egg.yaml in %s — ask the wing owner to add a sandbox config", root)
}
return protectRoostRootPolicies(egg.RuntimeEggConfig(wingDefault), roots), nil
}
return nil, err
}
for i, rule := range cfg.FS {
mode, path, hasMode := strings.Cut(rule, ":")
if !hasMode {
path = rule
}
if !filepath.IsAbs(path) && !strings.HasPrefix(path, "~") {
cfg.FS[i] = filepath.Join(root, path)
if hasMode {
cfg.FS[i] = mode + ":" + cfg.FS[i]
}
}
}
return protectRoostRootPolicies(cfg, roots), nil
}

func loadRoostRolePolicy(path string) (*egg.EggConfig, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, fmt.Errorf("stat roost egg config %q: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("roost egg config %q must be a regular file (symlinks are not allowed)", path)
}
// O_NOFOLLOW closes a symlink swap between Lstat and open; O_NONBLOCK
// avoids hanging if a regular file is replaced with a FIFO before open.
f, err := os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0)
if err != nil {
return nil, fmt.Errorf("open roost egg config %q: %w", path, err)
}
defer f.Close()
if info, err := f.Stat(); err != nil {
return nil, fmt.Errorf("stat open roost egg config %q: %w", path, err)
} else if !info.Mode().IsRegular() {
return nil, fmt.Errorf("roost egg config %q must be a regular file", path)
}
data, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("read roost egg config %q: %w", path, err)
}
cfg, err := egg.LoadEggConfigFromYAML(string(data))
if err != nil {
return nil, fmt.Errorf("roost egg config %q: %w", path, err)
}
// Named bases normally resolve to files in the state directory, which
// may itself be under a writable root. Only the built-in default is safe.
for _, field := range []struct{ name, value string }{
{"base", cfg.Base.Name}, {"base.fs", cfg.Base.FS},
{"base.network", cfg.Base.Network}, {"base.env", cfg.Base.Env},
} {
if field.value != "" && field.value != "none" && field.value != "default" {
return nil, fmt.Errorf("roost egg config %q: %s %q references a file; only none or built-in default is allowed", path, field.name, field.value)
}
}
if cfg.Base.Name == "none" {
if cfg.Base.HasMasks() {
return nil, fmt.Errorf("roost egg config %q: base masks invalid with base: none (nothing to mask)", path)
}
return cfg, nil
}
parent := egg.DefaultEggConfig()
if cfg.Base.FS == "none" {
parent.FS = nil
}
if cfg.Base.Network == "none" {
parent.Network = egg.NetworkField{}
}
if cfg.Base.Env == "none" {
parent.Env = nil
}
return egg.MergeEggConfig(parent, cfg), nil
}

func protectRoostRootPolicies(cfg *egg.EggConfig, roots []string) *egg.EggConfig {
// Copy the FS slice so fallbacks cannot mutate the captured runtime policy.
copyCfg := *cfg
copyCfg.FS = append([]string(nil), cfg.FS...)
for _, root := range roots {
rule := "deny-write:" + filepath.Join(root, "egg.yaml")
if !ContainsExactPath(copyCfg.FS, rule) {
copyCfg.FS = append(copyCfg.FS, rule)
}
}
return &copyCfg
}
Loading
Loading