Skip to content
Draft
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
7 changes: 4 additions & 3 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ Open source thrives because of the efforts of people like you 💚

A huge thank you to everyone who has contributed code, docs, ideas, testing, and feedback:

| Name | GitHub |
|---------------|----------------------------------|
| Jesse Vincent | [@obra](https://github.com/obra) |
| Name | GitHub |
|---------------|--------------------------------------|
| Jesse Vincent | [@obra](https://github.com/obra) |
| Phoenix Zerin | [@merphx](https://github.com/merphx) |

(Generated in part via `git shortlog` and [All Contributors](https://allcontributors.org/).)

Expand Down
43 changes: 41 additions & 2 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Leash Configuration Volumes
# Leash Configuration

Leash stores persistent configuration at `$XDG_CONFIG_HOME/leash/config.toml`, falling back to `~/.config/leash/config.toml` when no XDG override is present. The file controls whether host developer configuration directories (for example `~/.codex`) are mounted into the container automatically.

Expand All @@ -11,9 +11,24 @@ codex = true
claude = false
"~/devtools" = "/workspace/devtools:ro"

[leash]
# Global configuration
target_image = "myorg/leash-ubuntu:latest"
policy_file = "~/leash/policies/default.cedar"

[leash.envvars]
# Global environment variables
API_KEY = "secret"

[projects."/absolute/path/to/project"]
# Project scope overrides the global scope for the matching working directory.
codex = true
target_image = "myorg/leash-node:20"
policy_file = "./policies/project.cedar"

[projects."/absolute/path/to/project".envvars]
# Project-specific environment variables
NODE_ENV = "development"

[projects."/absolute/path/to/project".volumes]
"./.dev" = "/workspace/dev:rw"
Expand Down Expand Up @@ -73,6 +88,30 @@ For each subcommand, Leash maps the host directory `~/.<cmd>` to `/root/.<cmd>`
| `~/.local/share/opencode/snapshot` | `/root/.local/share/opencode/snapshot` | `rw` | Snapshots directory |
| `~/.local/share/opencode/storage` | `/root/.local/share/opencode/storage` | `rw` | Storage directory (excludes `bin` to avoid host/guest arch clashes) |

## Policy Files

Leash can load Cedar policy files from paths specified in configuration.

- **Global scope** (`[leash.policy_file]`) applies to every session unless overridden.
- **Project scope** (`[projects."/abs/path".policy_file]`) overrides the global setting for that directory.
- **Precedence**: CLI flag `--policy` takes highest priority, followed by `LEASH_POLICY_FILE` environment variable, then project-specific config, then global config.
- Policy file paths accept `~` expansion, environment variables, and (for project entries) relative paths.
- When no policy is specified, Leash generates a permissive runtime policy automatically.

```toml
[leash]
policy_file = "~/leash/policies/default.cedar"

[projects."/Users/alice/src/app"]
policy_file = "./policies/app-policy.cedar"

[projects."${HOME}/src/service"]
policy_file = "${XDG_CONFIG_HOME}/leash/policies/service.cedar"
```

For detailed information on writing Cedar policy files, see [design/CEDAR.md](design/CEDAR.md) — this reference is particularly useful when working with AI coding agents to generate new policy files.


## Prompt Workflow

1. On startup, Leash resolves the target host directory for the selected subcommand.
Expand Down Expand Up @@ -107,4 +146,4 @@ codex = true
"${HOME}/scratch" = false
```

When Leash runs from `/Users/alice/src/app`, it mounts `~/.codex` to `/root/.codex:rw` automatically. Running from a different directory falls back to the global setting (in this example, no mount for `codex`).
When Leash runs from `/Users/alice/src/app`, it mounts `~/.codex` to `/root/.codex:rw` automatically. Running from a different directory falls back to the global setting (in this example, no mount for `codex`).
63 changes: 63 additions & 0 deletions internal/configstore/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type Config struct {
ProjectTargetImages map[string]string
EnvVars map[string]string
ProjectEnvVars map[string]map[string]string
PolicyFile string
ProjectPolicyFiles map[string]string
}

// DecisionScope models the precedence layer that yielded an effective choice.
Expand Down Expand Up @@ -56,6 +58,7 @@ func New() Config {
ProjectTargetImages: make(map[string]string),
EnvVars: make(map[string]string),
ProjectEnvVars: make(map[string]map[string]string),
ProjectPolicyFiles: make(map[string]string),
}
}

Expand Down Expand Up @@ -125,7 +128,11 @@ func (c Config) Clone() Config {
}
out.ProjectEnvVars[projectPath] = dst
}
for projectPath, policyFile := range c.ProjectPolicyFiles {
out.ProjectPolicyFiles[projectPath] = policyFile
}
out.TargetImage = c.TargetImage
out.PolicyFile = c.PolicyFile
return out
}

Expand Down Expand Up @@ -249,6 +256,9 @@ func (c *Config) ensureInitialized() {
if c.ProjectEnvVars == nil {
c.ProjectEnvVars = make(map[string]map[string]string)
}
if c.ProjectPolicyFiles == nil {
c.ProjectPolicyFiles = make(map[string]string)
}
for key, envs := range c.ProjectEnvVars {
if envs == nil {
c.ProjectEnvVars[key] = make(map[string]string)
Expand Down Expand Up @@ -366,3 +376,56 @@ func (c *Config) UnsetProjectEnvVar(projectPath, key string) error {
c.ProjectEnvVars[projectKey] = envs
return nil
}

// SetGlobalPolicyFile records the default Cedar policy file path for leash-managed sessions.
func (c *Config) SetGlobalPolicyFile(policyFile string) {
c.PolicyFile = strings.TrimSpace(policyFile)
}

// SetProjectPolicyFile associates a policy file path with the given project path.
// Passing an empty policy file removes the override.
func (c *Config) SetProjectPolicyFile(projectPath, policyFile string) error {
key, err := normalizeProjectKey(projectPath)
if err != nil {
return err
}
c.ensureInitialized()
trimmed := strings.TrimSpace(policyFile)
if trimmed == "" {
delete(c.ProjectPolicyFiles, key)
return nil
}
c.ProjectPolicyFiles[key] = trimmed
return nil
}

// UnsetProjectPolicyFile removes any project-specific policy file override.
func (c *Config) UnsetProjectPolicyFile(projectPath string) error {
key, err := normalizeProjectKey(projectPath)
if err != nil {
return err
}
if c.ProjectPolicyFiles == nil {
return nil
}
delete(c.ProjectPolicyFiles, key)
return nil
}

// GetPolicyFile returns the effective policy file path and scope for the given project.
// It applies precedence rules: project-specific policy file overrides global policy file.
func (c *Config) GetPolicyFile(projectPath string) (string, DecisionScope, error) {
key, err := normalizeProjectKey(projectPath)
if err != nil {
return "", ScopeUnset, err
}
if c.ProjectPolicyFiles != nil {
if policyFile, ok := c.ProjectPolicyFiles[key]; ok && strings.TrimSpace(policyFile) != "" {
return strings.TrimSpace(policyFile), ScopeProject, nil
}
}
if c.PolicyFile != "" {
return c.PolicyFile, ScopeGlobal, nil
}
return "", ScopeUnset, nil
}
202 changes: 202 additions & 0 deletions internal/configstore/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -748,3 +748,205 @@ func TestSetProjectVolumeNormalizesPaths(t *testing.T) {
t.Fatalf("expected last value false, got %+v", decision)
}
}

// This test mutates config env settings while persisting files; keep it serial
// so parallel tests do not observe the temporary configuration.
func TestPolicyFilePersistenceAndPrecedence(t *testing.T) {
testSetEnv(t, "LEASH_HOME", "")
base := t.TempDir()
testSetEnv(t, "XDG_CONFIG_HOME", base)
setHome(t, filepath.Join(base, "home"))

// Set up config with both global and project-specific policy files
cfg := New()
cfg.SetGlobalPolicyFile("~/leash/default.cedar")
projectPath := filepath.Join(base, "proj")
if err := cfg.SetProjectPolicyFile(projectPath, "./policies/app.cedar"); err != nil {
t.Fatalf("SetProjectPolicyFile: %v", err)
}

// Save and verify TOML persistence
if err := Save(cfg); err != nil {
t.Fatalf("Save: %v", err)
}

_, file, err := GetConfigPath()
if err != nil {
t.Fatalf("GetConfigPath: %v", err)
}
data, err := os.ReadFile(file)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !bytes.Contains(data, []byte("policy_file = '~/leash/default.cedar'")) {
t.Fatalf("expected global policy file in config, got:\n%s", data)
}
if !bytes.Contains(data, []byte("policy_file = './policies/app.cedar'")) {
t.Fatalf("expected project policy file in config, got:\n%s", data)
}

// Load and verify roundtrip
loaded, err := Load()
if err != nil {
t.Fatalf("Load after save: %v", err)
}

// Test precedence: project-specific path should override global
projectPolicy, projectScope, err := loaded.GetPolicyFile(projectPath)
if err != nil {
t.Fatalf("GetPolicyFile(project): %v", err)
}
if projectPolicy != "./policies/app.cedar" || projectScope != ScopeProject {
t.Fatalf("expected project policy file, got policy=%q scope=%s", projectPolicy, projectScope)
}

// Test precedence: different project should get global policy
otherProject := filepath.Join(base, "other")
globalPolicy, globalScope, err := loaded.GetPolicyFile(otherProject)
if err != nil {
t.Fatalf("GetPolicyFile(other): %v", err)
}
if globalPolicy != "~/leash/default.cedar" || globalScope != ScopeGlobal {
t.Fatalf("expected global policy file, got policy=%q scope=%s", globalPolicy, globalScope)
}
}

// This test sets HOME and expands environment variables; execute serially to
// prevent shared state leaks.
func TestPolicyFileWithPathExpansions(t *testing.T) {
testSetEnv(t, "LEASH_HOME", "")
base := t.TempDir()
testSetEnv(t, "XDG_CONFIG_HOME", base)
homeDir := filepath.Join(base, "home")
setHome(t, homeDir)

srcProject := filepath.Join(homeDir, "src", "project")
if err := os.MkdirAll(srcProject, 0o755); err != nil {
t.Fatalf("mkdir project: %v", err)
}

workRoot := filepath.Join(base, "workroot")
if err := os.MkdirAll(workRoot, 0o755); err != nil {
t.Fatalf("mkdir workroot: %v", err)
}
testSetEnv(t, "WORKROOT", workRoot)

serviceProject := filepath.Join(workRoot, "service")
if err := os.MkdirAll(serviceProject, 0o755); err != nil {
t.Fatalf("mkdir service: %v", err)
}

content := `
[leash]
policy_file = "~/policies/global.cedar"

[projects."~/src/project"]
policy_file = "./policies/local.cedar"

[projects."${WORKROOT}/service"]
policy_file = "${WORKROOT}/policies/service.cedar"
`

dir, file, err := GetConfigPath()
if err != nil {
t.Fatalf("GetConfigPath: %v", err)
}
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(file, []byte(strings.TrimSpace(content)+"\n"), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}

cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}

if cfg.PolicyFile != "~/policies/global.cedar" {
t.Fatalf("expected global policy file, got %q", cfg.PolicyFile)
}

normalizedHome, err := normalizeProjectKey(srcProject)
if err != nil {
t.Fatalf("normalizeProjectKey home: %v", err)
}
homePolicy := cfg.ProjectPolicyFiles[normalizedHome]
if homePolicy != "./policies/local.cedar" {
t.Fatalf("expected home project policy for %s, got %q", normalizedHome, homePolicy)
}

normalizedService, err := normalizeProjectKey(serviceProject)
if err != nil {
t.Fatalf("normalizeProjectKey service: %v", err)
}
servicePolicy := cfg.ProjectPolicyFiles[normalizedService]
if servicePolicy != "${WORKROOT}/policies/service.cedar" {
t.Fatalf("expected service project policy for %s, got %q", normalizedService, servicePolicy)
}
}

// This test ensures empty policy file removes override; run serially.
func TestUnsetPolicyFileRemovesOverride(t *testing.T) {
testSetEnv(t, "LEASH_HOME", "")
base := t.TempDir()
testSetEnv(t, "XDG_CONFIG_HOME", base)
setHome(t, filepath.Join(base, "home"))

cfg := New()
projectPath := filepath.Join(base, "proj")

if err := cfg.SetProjectPolicyFile(projectPath, "~/policies/app.cedar"); err != nil {
t.Fatalf("SetProjectPolicyFile: %v", err)
}

policy, scope, err := cfg.GetPolicyFile(projectPath)
if err != nil {
t.Fatalf("GetPolicyFile before unset: %v", err)
}
if policy != "~/policies/app.cedar" || scope != ScopeProject {
t.Fatalf("expected project policy before unset, got policy=%q scope=%s", policy, scope)
}

if err := cfg.UnsetProjectPolicyFile(projectPath); err != nil {
t.Fatalf("UnsetProjectPolicyFile: %v", err)
}

policy, scope, err = cfg.GetPolicyFile(projectPath)
if err != nil {
t.Fatalf("GetPolicyFile after unset: %v", err)
}
if policy != "" || scope != ScopeUnset {
t.Fatalf("expected unset policy after unset, got policy=%q scope=%s", policy, scope)
}

if err := cfg.SetProjectPolicyFile(projectPath, " "); err != nil {
t.Fatalf("SetProjectPolicyFile with whitespace: %v", err)
}

policy, scope, err = cfg.GetPolicyFile(projectPath)
if err != nil {
t.Fatalf("GetPolicyFile after empty string: %v", err)
}
if policy != "" || scope != ScopeUnset {
t.Fatalf("expected unset policy after empty string, got policy=%q scope=%s", policy, scope)
}
}

// This test verifies that missing policy files in config return ScopeUnset.
func TestGetPolicyFileReturnsUnsetWhenNotConfigured(t *testing.T) {
testSetEnv(t, "LEASH_HOME", "")
base := t.TempDir()
testSetEnv(t, "XDG_CONFIG_HOME", base)
setHome(t, filepath.Join(base, "home"))

cfg := New()

policy, scope, err := cfg.GetPolicyFile(filepath.Join(base, "proj"))
if err != nil {
t.Fatalf("GetPolicyFile: %v", err)
}
if policy != "" || scope != ScopeUnset {
t.Fatalf("expected unset policy for unconfigured project, got policy=%q scope=%s", policy, scope)
}
}
Loading
Loading