From a71ff7870b54de2d18ee6c568131edf5ab321651 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Mon, 19 Jan 2026 03:20:01 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat(config):=20add=20policy=5Ffile=20direc?= =?UTF-8?q?tive=20to=20config.toml=20=F0=9F=93=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support global and project-specific Cedar policy file paths in config.toml, providing consistency with target_image and envvars configuration. - Add PolicyFile and ProjectPolicyFiles fields to Config struct - Implement GetPolicyFile, SetGlobalPolicyFile, SetProjectPolicyFile methods - Parse policy_file from [leash] and [projects] sections in TOML - Update runner to respect config policy with correct precedence: CLI flag > env var > project config > global config - Document policy_file directive in CONFIG.md with link to CEDAR.md Co-Authored-By: opencode --- docs/CONFIG.md | 40 +++++++++++++++++++- internal/configstore/config.go | 63 ++++++++++++++++++++++++++++++++ internal/configstore/loadsave.go | 20 ++++++++++ internal/runner/runner.go | 43 ++++++++++++++-------- 4 files changed, 149 insertions(+), 17 deletions(-) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 3832f05..5486819 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -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. @@ -11,9 +11,24 @@ codex = true claude = false "~/devtools" = "/workspace/devtools:ro" +[leash] +# Global configuration +target_image = "ubuntu:22.04" +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 = "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" @@ -73,6 +88,27 @@ For each subcommand, Leash maps the host directory `~/.` to `/root/.` | `~/.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" +``` + +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. @@ -107,4 +143,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`). \ No newline at end of file diff --git a/internal/configstore/config.go b/internal/configstore/config.go index a69babb..17d9616 100644 --- a/internal/configstore/config.go +++ b/internal/configstore/config.go @@ -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. @@ -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), } } @@ -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 } @@ -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) @@ -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 +} diff --git a/internal/configstore/loadsave.go b/internal/configstore/loadsave.go index 21a25e9..08c9995 100644 --- a/internal/configstore/loadsave.go +++ b/internal/configstore/loadsave.go @@ -139,6 +139,10 @@ func decodeConfig(data []byte, path string, cfg *Config) error { if s, ok := value.(string); ok { cfg.ProjectTargetImages[normalizedKey] = strings.TrimSpace(s) } + case "policy_file": + if s, ok := value.(string); ok { + cfg.ProjectPolicyFiles[normalizedKey] = strings.TrimSpace(s) + } case "envvars": envTable, ok := value.(map[string]any) if !ok { @@ -203,6 +207,9 @@ func decodeConfig(data []byte, path string, cfg *Config) error { if target, ok := leash["target_image"].(string); ok { cfg.TargetImage = strings.TrimSpace(target) } + if policyFile, ok := leash["policy_file"].(string); ok { + cfg.PolicyFile = strings.TrimSpace(policyFile) + } if envTable, ok := leash["envvars"].(map[string]any); ok { for key, rawVal := range envTable { strVal, err := toString(rawVal) @@ -318,6 +325,9 @@ func buildPersisted(cfg Config) persistedConfig { for key := range cfg.ProjectTargetImages { projectKeys[key] = struct{}{} } + for key := range cfg.ProjectPolicyFiles { + projectKeys[key] = struct{}{} + } for key := range cfg.ProjectEnvVars { projectKeys[key] = struct{}{} } @@ -365,6 +375,9 @@ func buildPersisted(cfg Config) persistedConfig { if img := strings.TrimSpace(cfg.ProjectTargetImages[key]); img != "" { entry["target_image"] = img } + if policyFile := strings.TrimSpace(cfg.ProjectPolicyFiles[key]); policyFile != "" { + entry["policy_file"] = policyFile + } if len(volumeEntries) > 0 { entry["volumes"] = volumeEntries } @@ -384,6 +397,13 @@ func buildPersisted(cfg Config) persistedConfig { result.Leash["target_image"] = img } + if policyFile := strings.TrimSpace(cfg.PolicyFile); policyFile != "" { + if result.Leash == nil { + result.Leash = make(map[string]any) + } + result.Leash["policy_file"] = policyFile + } + if len(cfg.EnvVars) > 0 { envCopy := make(map[string]string, len(cfg.EnvVars)) for key, value := range cfg.EnvVars { diff --git a/internal/runner/runner.go b/internal/runner/runner.go index d8726e1..dfaabb6 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -881,21 +881,6 @@ func loadConfig(callerDir string, opts options) (config, map[string]configstore. cfg.privateDir = filepath.Join(workDir, "private") - envPolicy := strings.TrimSpace(os.Getenv("LEASH_POLICY_FILE")) - // Do not default to docs/example.cedar anymore. If LEASH_POLICY_FILE is - // unset, we allow the runtime to generate a permissive policy from Cedar. - if envPolicy != "" { - resolvedPolicy, err := resolvePolicyPath(callerDir, envPolicy) - if err != nil { - return config{}, nil, err - } - cfg.policyPath = resolvedPolicy - cfg.policyOverride = true - } else { - cfg.policyPath = "" - cfg.policyOverride = false - } - cfgData, err := configstore.Load() if err != nil { return config{}, nil, fmt.Errorf("load leash config: %w", err) @@ -920,6 +905,34 @@ func loadConfig(callerDir string, opts options) (config, map[string]configstore. cfg.targetImageDevFile = "" } + envPolicy := strings.TrimSpace(os.Getenv("LEASH_POLICY_FILE")) + policyFromConfig := "" + if policyFile, _, policyErr := cfgData.GetPolicyFile(callerDir); policyErr == nil && strings.TrimSpace(policyFile) != "" { + policyFromConfig = strings.TrimSpace(policyFile) + } + + // Precedence: env var > config.toml + // Do not default to docs/example.cedar anymore. If LEASH_POLICY_FILE is + // unset, we allow the runtime to generate a permissive policy from Cedar. + if envPolicy != "" { + resolvedPolicy, err := resolvePolicyPath(callerDir, envPolicy) + if err != nil { + return config{}, nil, err + } + cfg.policyPath = resolvedPolicy + cfg.policyOverride = true + } else if policyFromConfig != "" { + resolvedPolicy, err := resolvePolicyPath(callerDir, policyFromConfig) + if err != nil { + return config{}, nil, err + } + cfg.policyPath = resolvedPolicy + cfg.policyOverride = true + } else { + cfg.policyPath = "" + cfg.policyOverride = false + } + envTarget := strings.TrimSpace(os.Getenv("LEASH_TARGET_IMAGE")) if envTarget == "" { envTarget = strings.TrimSpace(os.Getenv("TARGET_IMAGE")) From defdf5b8b97b730177b2112af8378de87bc200d6 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Mon, 9 Feb 2026 15:34:57 +1300 Subject: [PATCH 2/8] =?UTF-8?q?Don't=20forget=20the=20tests!=20?= =?UTF-8?q?=F0=9F=99=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/configstore/config_test.go | 251 ++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) diff --git a/internal/configstore/config_test.go b/internal/configstore/config_test.go index 900c961..a9a5b68 100644 --- a/internal/configstore/config_test.go +++ b/internal/configstore/config_test.go @@ -748,3 +748,254 @@ 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 TestPolicyFileConfigPrecedence(t *testing.T) { + testSetEnv(t, "LEASH_HOME", "") + base := t.TempDir() + testSetEnv(t, "XDG_CONFIG_HOME", base) + setHome(t, filepath.Join(base, "home")) + + project := filepath.Join(base, "proj") + cfg := New() + cfg.SetGlobalPolicyFile("~/policies/global.cedar") + if err := cfg.SetProjectPolicyFile(project, "./policies/project.cedar"); err != nil { + t.Fatalf("SetProjectPolicyFile: %v", err) + } + if err := Save(cfg); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + policyFile, scope, err := loaded.GetPolicyFile(project) + if err != nil { + t.Fatalf("GetPolicyFile(project): %v", err) + } + if policyFile != "./policies/project.cedar" || scope != ScopeProject { + t.Fatalf("expected project policy file, got policyFile=%q scope=%s", policyFile, scope) + } + + otherPolicyFile, otherScope, err := loaded.GetPolicyFile(filepath.Join(base, "other")) + if err != nil { + t.Fatalf("GetPolicyFile(other): %v", err) + } + if otherPolicyFile != "~/policies/global.cedar" || otherScope != ScopeGlobal { + t.Fatalf("expected global policy file, got policyFile=%q scope=%s", otherPolicyFile, otherScope) + } + + _, file, err := GetConfigPath() + if err != nil { + t.Fatalf("GetConfigPath: %v", err) + } + data, err := os.ReadFile(file) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + contents := string(data) + if !strings.Contains(contents, "policy_file = '~/policies/global.cedar'") { + t.Fatalf("expected global policy file in config, got:\n%s", contents) + } + if !strings.Contains(contents, "policy_file = './policies/project.cedar'") { + t.Fatalf("expected project policy file in config, got:\n%s", contents) + } +} + +// This test writes config files to a temporary home and should be serial to +// avoid leaking env overrides to concurrent tests. +func TestPolicyFileSaveRoundTrip(t *testing.T) { + testSetEnv(t, "LEASH_HOME", "") + base := t.TempDir() + testSetEnv(t, "XDG_CONFIG_HOME", base) + setHome(t, filepath.Join(base, "home")) + + 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) + } + 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) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load after save: %v", err) + } + + globalPolicy, globalScope, err := loaded.GetPolicyFile("") + if err != nil { + t.Fatalf("GetPolicyFile(global): %v", err) + } + if globalPolicy != "~/leash/default.cedar" || globalScope != ScopeGlobal { + t.Fatalf("expected global policy file, got policy=%q scope=%s", globalPolicy, globalScope) + } + + 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) + } +} + +// 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) + } +} From 273acd79fd570d7151099dc0711ec76a34dd51da Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Mon, 9 Feb 2026 02:45:28 +0000 Subject: [PATCH 3/8] =?UTF-8?q?test(config):=20fix=20policy=20query=20in?= =?UTF-8?q?=20roundtrip=20test=20=F0=9F=94=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test was attempting to query GetPolicyFile with an empty string to check global policy, but normalizeProjectKey rejects empty paths. Fixed by querying a different project path to verify global policy fallback behaviour works correctly. Co-Authored-By: opencode --- internal/configstore/config_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/configstore/config_test.go b/internal/configstore/config_test.go index a9a5b68..358ba6f 100644 --- a/internal/configstore/config_test.go +++ b/internal/configstore/config_test.go @@ -843,7 +843,9 @@ func TestPolicyFileSaveRoundTrip(t *testing.T) { t.Fatalf("Load after save: %v", err) } - globalPolicy, globalScope, err := loaded.GetPolicyFile("") + // Query a different project to verify it gets the global policy + otherProject := filepath.Join(base, "other") + globalPolicy, globalScope, err := loaded.GetPolicyFile(otherProject) if err != nil { t.Fatalf("GetPolicyFile(global): %v", err) } From 2af92015696f19f8d63fabaf91bc60ff03c25b97 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Mon, 23 Feb 2026 11:35:59 +1300 Subject: [PATCH 4/8] =?UTF-8?q?Add=20Phoenix=20Zerin=20to=20contributors?= =?UTF-8?q?=20=F0=9F=AA=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: opencode --- CONTRIBUTORS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index a70ea94..70748b1 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -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/).) From b4a279df26edc5eb576751a064f17b48babd6213 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Mon, 23 Feb 2026 12:03:51 +1300 Subject: [PATCH 5/8] =?UTF-8?q?feat(config):=20add=20environment=20variabl?= =?UTF-8?q?e=20expansion=20to=20policy=5Ffile=20paths=20=F0=9F=A7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the documented feature that policy file paths support environment variable expansion. Previously, the documentation promised this functionality but resolvePolicyPath only handled tilde and relative paths. Changes: - Add os.ExpandEnv() to resolvePolicyPath before tilde expansion - Add comprehensive tests covering env var expansion scenarios - Add example to CONFIG.md showing env var usage - Document code duplication decision with migration guidance This matches the pattern used in resolveVolumeHost (mounts.go:325). Co-Authored-By: opencode --- docs/CONFIG.md | 3 + internal/runner/runner.go | 9 +++ internal/runner/runner_config_test.go | 102 ++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 5486819..3b46110 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -104,6 +104,9 @@ 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. diff --git a/internal/runner/runner.go b/internal/runner/runner.go index dfaabb6..ce5213f 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -1047,11 +1047,20 @@ func envOrDefault(key, fallback string) string { return fallback } +// resolvePolicyPath expands environment variables, tilde, and relative paths. +// Note: This duplicates logic from configstore.expandLeadingTilde and +// configstore.resolveVolumeHost to avoid cross-package coupling. If this pattern +// appears a third time, consider extracting a shared path resolution utility. func resolvePolicyPath(base, candidate string) (string, error) { path := strings.TrimSpace(candidate) if path == "" { return "", errors.New("policy path must not be empty") } + + // Expand environment variables first (e.g., ${HOME}, ${XDG_CONFIG_HOME}) + path = os.ExpandEnv(path) + + // Then expand tilde prefix switch { case path == "~": home, err := os.UserHomeDir() diff --git a/internal/runner/runner_config_test.go b/internal/runner/runner_config_test.go index eee72f4..86e8d73 100644 --- a/internal/runner/runner_config_test.go +++ b/internal/runner/runner_config_test.go @@ -375,3 +375,105 @@ func TestLogDevImageSelectionsEmitsMessage(t *testing.T) { t.Fatalf("expected only target override to be logged, got %q", output) } } + +func TestResolvePolicyPath(t *testing.T) { + tests := []struct { + name string + base string + candidate string + envVars map[string]string + want string + wantErr bool + }{ + { + name: "empty path returns error", + base: "/project", + candidate: "", + wantErr: true, + }, + { + name: "whitespace-only path returns error", + base: "/project", + candidate: " ", + wantErr: true, + }, + { + name: "absolute path unchanged", + base: "/project", + candidate: "/etc/policies/policy.cedar", + want: "/etc/policies/policy.cedar", + }, + { + name: "relative path joined to base", + base: "/project", + candidate: "./policies/local.cedar", + want: "/project/policies/local.cedar", + }, + { + name: "tilde expands to home", + base: "/project", + candidate: "~", + want: os.Getenv("HOME"), + }, + { + name: "tilde slash expands to home subpath", + base: "/project", + candidate: "~/policies/default.cedar", + want: filepath.Join(os.Getenv("HOME"), "policies/default.cedar"), + }, + { + name: "environment variable expands", + base: "/project", + candidate: "${TEST_POLICY_DIR}/policy.cedar", + envVars: map[string]string{"TEST_POLICY_DIR": "/var/policies"}, + want: "/var/policies/policy.cedar", + }, + { + name: "multiple environment variables expand", + base: "/project", + candidate: "${TEST_ROOT}/${TEST_SUBDIR}/policy.cedar", + envVars: map[string]string{"TEST_ROOT": "/root", "TEST_SUBDIR": "policies"}, + want: "/root/policies/policy.cedar", + }, + { + name: "env var then tilde expansion", + base: "/project", + candidate: "${HOME}/policies/default.cedar", + envVars: map[string]string{"HOME": "/home/testuser"}, + want: "/home/testuser/policies/default.cedar", + }, + { + name: "env var in relative path", + base: "/project", + candidate: "./${TEST_SUBDIR}/policy.cedar", + envVars: map[string]string{"TEST_SUBDIR": "policies"}, + want: "/project/policies/policy.cedar", + }, + { + name: "whitespace trimmed before expansion", + base: "/project", + candidate: " ~/policies/default.cedar ", + want: filepath.Join(os.Getenv("HOME"), "policies/default.cedar"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set environment variables for this test + for k, v := range tt.envVars { + setEnv(t, k, v) + } + + got, err := resolvePolicyPath(tt.base, tt.candidate) + if (err != nil) != tt.wantErr { + t.Fatalf("resolvePolicyPath() error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil { + return + } + if got != tt.want { + t.Errorf("resolvePolicyPath() = %q, want %q", got, tt.want) + } + }) + } +} From 69e5af7b64c1b2f224947c0685d9c8cd5e5a2ea2 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Mon, 23 Feb 2026 12:16:01 +1300 Subject: [PATCH 6/8] =?UTF-8?q?test(config):=20consolidate=20duplicate=20p?= =?UTF-8?q?olicy=20file=20tests=20=F0=9F=A7=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged TestPolicyFileConfigPrecedence and TestPolicyFileSaveRoundTrip into a single comprehensive test TestPolicyFilePersistenceAndPrecedence. Both tests were covering the same functionality: - Save/Load roundtrip - TOML persistence verification - Global vs project scope precedence The consolidated test maintains all coverage while removing duplication. Co-Authored-By: opencode --- internal/configstore/config_test.go | 83 ++++++----------------------- 1 file changed, 16 insertions(+), 67 deletions(-) diff --git a/internal/configstore/config_test.go b/internal/configstore/config_test.go index 358ba6f..af8d3d3 100644 --- a/internal/configstore/config_test.go +++ b/internal/configstore/config_test.go @@ -751,74 +751,21 @@ func TestSetProjectVolumeNormalizesPaths(t *testing.T) { // This test mutates config env settings while persisting files; keep it serial // so parallel tests do not observe the temporary configuration. -func TestPolicyFileConfigPrecedence(t *testing.T) { - testSetEnv(t, "LEASH_HOME", "") - base := t.TempDir() - testSetEnv(t, "XDG_CONFIG_HOME", base) - setHome(t, filepath.Join(base, "home")) - - project := filepath.Join(base, "proj") - cfg := New() - cfg.SetGlobalPolicyFile("~/policies/global.cedar") - if err := cfg.SetProjectPolicyFile(project, "./policies/project.cedar"); err != nil { - t.Fatalf("SetProjectPolicyFile: %v", err) - } - if err := Save(cfg); err != nil { - t.Fatalf("Save: %v", err) - } - - loaded, err := Load() - if err != nil { - t.Fatalf("Load: %v", err) - } - - policyFile, scope, err := loaded.GetPolicyFile(project) - if err != nil { - t.Fatalf("GetPolicyFile(project): %v", err) - } - if policyFile != "./policies/project.cedar" || scope != ScopeProject { - t.Fatalf("expected project policy file, got policyFile=%q scope=%s", policyFile, scope) - } - - otherPolicyFile, otherScope, err := loaded.GetPolicyFile(filepath.Join(base, "other")) - if err != nil { - t.Fatalf("GetPolicyFile(other): %v", err) - } - if otherPolicyFile != "~/policies/global.cedar" || otherScope != ScopeGlobal { - t.Fatalf("expected global policy file, got policyFile=%q scope=%s", otherPolicyFile, otherScope) - } - - _, file, err := GetConfigPath() - if err != nil { - t.Fatalf("GetConfigPath: %v", err) - } - data, err := os.ReadFile(file) - if err != nil { - t.Fatalf("ReadFile: %v", err) - } - contents := string(data) - if !strings.Contains(contents, "policy_file = '~/policies/global.cedar'") { - t.Fatalf("expected global policy file in config, got:\n%s", contents) - } - if !strings.Contains(contents, "policy_file = './policies/project.cedar'") { - t.Fatalf("expected project policy file in config, got:\n%s", contents) - } -} - -// This test writes config files to a temporary home and should be serial to -// avoid leaking env overrides to concurrent tests. -func TestPolicyFileSaveRoundTrip(t *testing.T) { +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) } @@ -838,21 +785,13 @@ func TestPolicyFileSaveRoundTrip(t *testing.T) { 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) } - // Query a different project to verify it gets the global policy - otherProject := filepath.Join(base, "other") - globalPolicy, globalScope, err := loaded.GetPolicyFile(otherProject) - if err != nil { - t.Fatalf("GetPolicyFile(global): %v", err) - } - if globalPolicy != "~/leash/default.cedar" || globalScope != ScopeGlobal { - t.Fatalf("expected global policy file, got policy=%q scope=%s", globalPolicy, globalScope) - } - + // Test precedence: project-specific path should override global projectPolicy, projectScope, err := loaded.GetPolicyFile(projectPath) if err != nil { t.Fatalf("GetPolicyFile(project): %v", err) @@ -860,6 +799,16 @@ func TestPolicyFileSaveRoundTrip(t *testing.T) { 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 From 9cfba53a6d4b59e431c0b7a9029dee6688106246 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Mon, 23 Feb 2026 12:46:18 +1300 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20use=20realistic=20target=5Fimage=20?= =?UTF-8?q?examples=20=F0=9F=AA=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace vanilla base images (ubuntu:22.04, node:20) with custom image examples that include leash-entry, reflecting actual usage per CUSTOM-DOCKER-IMAGES.md guidance. Co-Authored-By: opencode --- docs/CONFIG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 3b46110..4efa338 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -13,7 +13,7 @@ claude = false [leash] # Global configuration -target_image = "ubuntu:22.04" +target_image = "myorg/leash-ubuntu:latest" policy_file = "~/leash/policies/default.cedar" [leash.envvars] @@ -23,7 +23,7 @@ API_KEY = "secret" [projects."/absolute/path/to/project"] # Project scope overrides the global scope for the matching working directory. codex = true -target_image = "node:20" +target_image = "myorg/leash-node:20" policy_file = "./policies/project.cedar" [projects."/absolute/path/to/project".envvars] From b4cb1e0e229bf6e848dbbc7014d168d226594060 Mon Sep 17 00:00:00 2001 From: Phoenix Zerin Date: Mon, 23 Feb 2026 12:52:42 +1300 Subject: [PATCH 8/8] =?UTF-8?q?Doc=20comment=20shuffle=20=F0=9F=95=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runner/runner.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index ce5213f..8a21862 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -912,8 +912,6 @@ func loadConfig(callerDir string, opts options) (config, map[string]configstore. } // Precedence: env var > config.toml - // Do not default to docs/example.cedar anymore. If LEASH_POLICY_FILE is - // unset, we allow the runtime to generate a permissive policy from Cedar. if envPolicy != "" { resolvedPolicy, err := resolvePolicyPath(callerDir, envPolicy) if err != nil { @@ -929,6 +927,8 @@ func loadConfig(callerDir string, opts options) (config, map[string]configstore. cfg.policyPath = resolvedPolicy cfg.policyOverride = true } else { + // Do not default to docs/example.cedar anymore. If policy file config is + // unset, we allow the runtime to generate a permissive policy from Cedar. cfg.policyPath = "" cfg.policyOverride = false }