config: merge /dev/null mounts by plain precedence
Two layers masking the same target cancelled each other, so a user
config and a project config both hiding .envrc left it visible. Masks
now win and lose by ordinary precedence, with emission binding an empty
directory where /dev/null cannot.

Assisted-by: Claude Opus 5 via Claude Code
change krrkrtvvwznkzpwrospvzozwrvwtqmkz
commit f118f5517f28f3b46ec1b4464f1787dcca63d651
author Alpha Chen <alpha@kejadlen.dev>
date
parent onyqqsuz
diff --git a/README.md b/README.md
index 8cc96d4..10e3901 100644
--- a/README.md
+++ b/README.md
@@ -117,21 +117,32 @@ mounts {
 }
 ```
 
-A `mounts` block holds one child node per mount, mirroring `env`. The node name is the host source path (`~` expands to the home directory), with optional properties:
+A `mounts` block holds one child node per mount, mirroring `env`. The node name is the host source path (`~` expands to the home directory, and a relative path resolves against the workspace), with optional properties:
 
 | Property | Description |
 |---|---|
 | `target` | Container path; `~` expands to the container home, a relative path resolves against the workspace mount, and omitting it derives the target from the source |
 | `writable` | `#true` allows writes (read-only by default) |
 
-When two layers define a mount with the same container target, the higher layer wins wholesale. A `/dev/null` source *masks*: it removes a mount inherited from a lower layer (say, a staple or an agent-config entry this machine or project doesn't want), and where there's nothing to remove it stays a real bind, blanking a workspace file from the agent:
+When two layers define a mount with the same container target, the higher layer wins wholesale. A `/dev/null` source follows that same rule and binds nothing readable, which hides whatever the target would otherwise hold — a mount from a lower layer, a file the image ships, or a file in your own workspace. The path still exists in the container; only its contents are gone. Hiding a directory binds a session-scoped empty directory instead, so listing the path succeeds and comes back empty:
 
 ```kdl
 mounts {
-    // Remove the inherited skills mount
+    // Hide an agent-config entry this machine doesn't want
     "/dev/null" target="/root/.pi/agent/skills"
     // Hide the repo's .envrc from the agent
     "/dev/null" target=".envrc"
+    // Same spelling for a directory; the agent sees it empty
+    "/dev/null" target="secrets"
+}
+```
+
+Precedence runs the other way too, so a project can mount back a path the user layer hides:
+
+```kdl
+// <workspace>/.ramekin/config.kdl — this repo's .envrc is fine to read
+mounts {
+    ".envrc"
 }
 ```
 
diff --git a/docs/config-redesign.md b/docs/config-redesign.md
index 74bb5e9..d28df0b 100644
--- a/docs/config-redesign.md
+++ b/docs/config-redesign.md
@@ -211,7 +211,7 @@ Layers, lowest to highest precedence:
 Merging: `env` merges per variable, overlaying the active profile's env, so
 any layer can adjust one variable without redefining the profile; mounts
 dedupe by resolved target; profiles by name; scalars last-writer-wins;
-`/dev/null` masking removes an inherited mount. A project `Dockerfile`
+a `/dev/null` source hides a target through that same precedence. A project `Dockerfile`
 stays at `.ramekin/Dockerfile`, beside the KDL.
 
 ### Session model
diff --git a/src/config.rs b/src/config.rs
index c90972d..484744d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -134,6 +134,10 @@ impl Profile {
     }
 }
 
+/// Source that hides a target: binds nothing readable over whatever the
+/// image, the workspace, or a lower layer would have put there.
+pub const MASK_SOURCE: &str = "/dev/null";
+
 /// The profile selected in the binary when no layer or flag picks one.
 const DEFAULT_PROFILE: &str = "pi";
 
@@ -154,6 +158,13 @@ pub struct ResolvedMount {
 }
 
 impl ResolvedMount {
+    /// A mount that hides its target instead of binding anything readable.
+    /// Merging treats it like any other mount; only emission cares, because
+    /// what a mask binds depends on whether it hides a file or a directory.
+    pub fn is_mask(&self) -> bool {
+        self.source == Path::new(MASK_SOURCE)
+    }
+
     /// Label for display in `config` output (target, with ` (ro)` suffix when read-only).
     pub fn display_target(&self) -> String {
         if self.writable {
@@ -271,12 +282,6 @@ impl ScopedConfig {
     }
 }
 
-/// Mask source: a mount whose source is `/dev/null` *removes* an inherited
-/// mount at the same target instead of binding anything. With no inherited
-/// mount to remove, it stays a real `/dev/null` bind, which blanks a file
-/// that exists in the image or workspace.
-const MASK_SOURCE: &str = "/dev/null";
-
 impl ScopedConfig {
     /// Load all configuration layers for the given workspace.
     ///
@@ -335,7 +340,7 @@ impl ScopedConfig {
             if !files.is_empty() {
                 let mut builder = LayerBuilder::new(Scope::User, Some(config_dir.to_path_buf()));
                 for file in &files {
-                    builder.add_file(file, workspace_target)?;
+                    builder.add_file(file, workspace, workspace_target)?;
                 }
                 builders.push(builder);
             }
@@ -345,7 +350,7 @@ impl ScopedConfig {
         let project_path = workspace.join(".ramekin/config.kdl");
         if project_path.exists() {
             let mut builder = LayerBuilder::new(Scope::Project, Some(project_path.clone()));
-            builder.add_file(&project_path, workspace_target)?;
+            builder.add_file(&project_path, workspace, workspace_target)?;
             builders.push(builder);
         }
 
@@ -411,7 +416,7 @@ impl ScopedConfig {
             mounts: profile
                 .mounts
                 .iter()
-                .filter_map(|m| m.resolve(workspace_target))
+                .filter_map(|m| m.resolve(workspace, workspace_target))
                 .collect(),
             env: profile.env.clone(),
             caches: Vec::new(),
@@ -428,35 +433,43 @@ impl ScopedConfig {
 
     /// Return merged mounts from all layers, de-duplicated by container target.
     ///
-    /// Higher-precedence layers override mounts with the same target, and a
-    /// `/dev/null` source masks (removes) a mount inherited from a lower
-    /// layer. Output order is lexicographic by target, which puts parent
-    /// paths before any child path (`/root/.pi/agent` precedes
+    /// Higher-precedence layers override mounts with the same target,
+    /// including a `/dev/null` mount, which binds nothing readable over the
+    /// target and so hides whatever the image or the workspace has there. A
+    /// project wanting a path a lower layer hid mounts it back, like any
+    /// other override. Output order is lexicographic by target, which puts
+    /// parent paths before any child path (`/root/.pi/agent` precedes
     /// `/root/.pi/agent/AGENTS.md`). Docker processes mount declarations in
     /// order; a parent declared after its child would shadow the child. The
     /// deterministic ordering also keeps repeat runs identical.
     pub fn merged_mounts(&self) -> Vec<ScopedValue<&ResolvedMount>> {
-        let mut by_target: BTreeMap<&str, (ScopedValue<&ResolvedMount>, bool)> = BTreeMap::new();
+        let mut by_target: BTreeMap<&str, ScopedValue<&ResolvedMount>> = BTreeMap::new();
         for layer in &self.layers {
             for mount in &layer.mounts {
-                let inherited = by_target.contains_key(mount.target.as_str());
                 by_target.insert(
                     mount.target.as_str(),
-                    (
-                        ScopedValue {
-                            scope: layer.scope,
-                            value: mount,
-                        },
-                        inherited,
-                    ),
+                    ScopedValue {
+                        scope: layer.scope,
+                        value: mount,
+                    },
                 );
             }
         }
-        by_target
-            .into_values()
-            .filter(|(sv, inherited)| !(*inherited && sv.value.source == Path::new(MASK_SOURCE)))
-            .map(|(sv, _)| sv)
-            .collect()
+        by_target.into_values().collect()
+    }
+
+    /// The host source a mask at `target` hides, when a layer declared one.
+    ///
+    /// Emission needs the shape of what a mask covers, which the merged
+    /// output no longer carries: the mask won precedence, so the mount it
+    /// overrode is gone from the result.
+    pub fn overridden_source(&self, target: &str) -> Option<&Path> {
+        self.layers
+            .iter()
+            .rev()
+            .flat_map(|layer| layer.mounts.iter())
+            .find(|mount| mount.target == target && !mount.is_mask())
+            .map(|mount| mount.source.as_path())
     }
 
     /// Merge caches from all layers, de-duplicated by name, so a higher layer
@@ -553,16 +566,22 @@ impl LayerBuilder {
         }
     }
 
-    fn add_file(&mut self, file: &Path, workspace_target: &str) -> Result<()> {
+    fn add_file(&mut self, file: &Path, workspace: &Path, workspace_target: &str) -> Result<()> {
         let raw = parse_file(file)?;
-        self.add(raw, file, workspace_target)
+        self.add(raw, file, workspace, workspace_target)
     }
 
-    fn add(&mut self, raw: RawConfig, file: &Path, workspace_target: &str) -> Result<()> {
+    fn add(
+        &mut self,
+        raw: RawConfig,
+        file: &Path,
+        workspace: &Path,
+        workspace_target: &str,
+    ) -> Result<()> {
         for mount in &raw.mounts {
             // Mounts with a missing host source are skipped entirely, so
             // they don't participate in duplicate detection either.
-            let Some(resolved) = mount.resolve(workspace_target) else {
+            let Some(resolved) = mount.resolve(workspace, workspace_target) else {
                 continue;
             };
             ensure!(
@@ -946,13 +965,22 @@ fn binary_mounts(agent: Agent) -> Vec<ResolvedMount> {
 }
 
 impl Mount {
-    /// Expand tildes and derive the container target path.
+    /// Expand tildes and derive the host source and container target paths.
+    ///
+    /// A relative source resolves against the workspace directory, the way a
+    /// relative target resolves against the workspace mount, so a committed
+    /// project config can name a file in its own repo without a host path.
     ///
     /// Returns `None` if the source does not exist on the host. Files and
     /// devices (such as `/dev/null`) resolve like directories — Docker binds
     /// them all the same way.
-    pub fn resolve(&self, workspace_target: &str) -> Option<ResolvedMount> {
+    pub fn resolve(&self, workspace: &Path, workspace_target: &str) -> Option<ResolvedMount> {
         let expanded = PathBuf::from(shellexpand::tilde(&self.source).as_ref());
+        let expanded = if expanded.is_relative() {
+            workspace.join(expanded)
+        } else {
+            expanded
+        };
         if !expanded.exists() {
             return None;
         }
@@ -996,6 +1024,9 @@ mod tests {
     use super::*;
 
     const WS: &str = "/workspace/test-slug";
+    /// Host workspace for tests that resolve mounts; only relative sources
+    /// touch it, and those tests point it at a temp dir instead.
+    const WS_HOST: &str = "/nonexistent-workspace";
 
     /// A ScopedConfig with the given layers and an inert trivial profile,
     /// for tests that only exercise merging.
@@ -1106,8 +1137,12 @@ mod tests {
         let mut builder = LayerBuilder::new(Scope::User, None);
         let a = parse_config(r#"env { FOO "1" }"#).unwrap();
         let b = parse_config(r#"env { FOO "2" }"#).unwrap();
-        builder.add(a, Path::new("a.kdl"), WS).unwrap();
-        let err = builder.add(b, Path::new("b.kdl"), WS).unwrap_err();
+        builder
+            .add(a, Path::new("a.kdl"), Path::new(WS_HOST), WS)
+            .unwrap();
+        let err = builder
+            .add(b, Path::new("b.kdl"), Path::new(WS_HOST), WS)
+            .unwrap_err();
         assert!(err.to_string().contains("FOO"), "{err}");
     }
 
@@ -1116,8 +1151,12 @@ mod tests {
         let mut builder = LayerBuilder::new(Scope::User, None);
         let a = parse_config(r#"mounts { "/tmp" target="/t" }"#).unwrap();
         let b = parse_config(r#"mounts { "/dev/null" target="/t" }"#).unwrap();
-        builder.add(a, Path::new("a.kdl"), WS).unwrap();
-        let err = builder.add(b, Path::new("b.kdl"), WS).unwrap_err();
+        builder
+            .add(a, Path::new("a.kdl"), Path::new(WS_HOST), WS)
+            .unwrap();
+        let err = builder
+            .add(b, Path::new("b.kdl"), Path::new(WS_HOST), WS)
+            .unwrap_err();
         assert!(err.to_string().contains("/t"), "{err}");
     }
 
@@ -1126,8 +1165,12 @@ mod tests {
         let mut builder = LayerBuilder::new(Scope::User, None);
         let a = parse_config(r#"mounts { "/nonexistent-a" target="/t" }"#).unwrap();
         let b = parse_config(r#"mounts { "/tmp" target="/t" }"#).unwrap();
-        builder.add(a, Path::new("a.kdl"), WS).unwrap();
-        builder.add(b, Path::new("b.kdl"), WS).unwrap();
+        builder
+            .add(a, Path::new("a.kdl"), Path::new(WS_HOST), WS)
+            .unwrap();
+        builder
+            .add(b, Path::new("b.kdl"), Path::new(WS_HOST), WS)
+            .unwrap();
         let layer = builder.build();
         assert_eq!(layer.mounts.len(), 1);
         assert_eq!(layer.mounts[0].source, PathBuf::from("/tmp"));
@@ -1166,7 +1209,7 @@ mod tests {
             target: None,
             writable: false,
         };
-        assert!(mount.resolve(WS).is_none());
+        assert!(mount.resolve(Path::new(WS_HOST), WS).is_none());
     }
 
     #[test]
@@ -1176,11 +1219,27 @@ mod tests {
             target: Some(".envrc".into()),
             writable: false,
         };
-        let resolved = mount.resolve(WS).unwrap();
+        let resolved = mount.resolve(Path::new(WS_HOST), WS).unwrap();
         assert_eq!(resolved.source, PathBuf::from("/dev/null"));
         assert_eq!(resolved.target, "/workspace/test-slug/.envrc");
     }
 
+    #[test]
+    fn relative_source_resolves_against_the_workspace() {
+        let workspace = tempfile::tempdir().unwrap();
+        fs_err::write(workspace.path().join(".envrc"), "export FOO=1").unwrap();
+        let mount = Mount {
+            source: ".envrc".into(),
+            target: None,
+            writable: false,
+        };
+
+        let resolved = mount.resolve(workspace.path(), WS).unwrap();
+
+        assert_eq!(resolved.source, workspace.path().join(".envrc"));
+        assert_eq!(resolved.target, "/workspace/test-slug/.envrc");
+    }
+
     #[test]
     fn resolve_expands_tilde_in_explicit_target() {
         let mount = Mount {
@@ -1188,7 +1247,7 @@ mod tests {
             target: Some("~/downloads".into()),
             writable: true,
         };
-        let resolved = mount.resolve(WS).unwrap();
+        let resolved = mount.resolve(Path::new(WS_HOST), WS).unwrap();
         assert_eq!(resolved.target, "/root/downloads");
     }
 
@@ -1199,7 +1258,7 @@ mod tests {
             target: None,
             writable: true,
         };
-        let resolved = mount.resolve(WS).unwrap();
+        let resolved = mount.resolve(Path::new(WS_HOST), WS).unwrap();
         assert_eq!(resolved.target, "/tmp");
     }
 
@@ -1235,7 +1294,9 @@ mod tests {
     fn cache_target_resolves_against_the_workspace() {
         let raw = parse_config(r#"cache { target "target" }"#).unwrap();
         let mut builder = LayerBuilder::new(Scope::Project, None);
-        builder.add(raw, Path::new("c.kdl"), WS).unwrap();
+        builder
+            .add(raw, Path::new("c.kdl"), Path::new(WS_HOST), WS)
+            .unwrap();
         let layer = builder.build();
         assert_eq!(layer.caches[0].target, "/workspace/test-slug/target");
     }
@@ -1269,7 +1330,9 @@ mod tests {
         );
 
         let mut builder = LayerBuilder::new(Scope::Project, None);
-        builder.add(raw, Path::new("c.kdl"), WS).unwrap();
+        builder
+            .add(raw, Path::new("c.kdl"), Path::new(WS_HOST), WS)
+            .unwrap();
         assert_eq!(
             builder.build().caches[0].target,
             "/workspace/test-slug/target"
@@ -1415,7 +1478,7 @@ mod tests {
     }
 
     #[test]
-    fn dev_null_mask_removes_inherited_mount() {
+    fn dev_null_overrides_an_inherited_mount() {
         let config = scoped(vec![
             layer(
                 Scope::Binary,
@@ -1427,7 +1490,33 @@ mod tests {
             ),
         ]);
         let merged = config.merged_mounts();
-        assert!(merged.is_empty(), "mask should remove the inherited mount");
+        assert_eq!(merged.len(), 1);
+        assert_eq!(merged[0].value.source, PathBuf::from("/dev/null"));
+        assert_eq!(merged[0].scope, Scope::Project);
+    }
+
+    #[test]
+    fn a_higher_layer_mounts_back_what_a_lower_one_hid() {
+        let config = scoped(vec![
+            layer(
+                Scope::User,
+                vec![mount("/dev/null", "/workspace/test-slug/.envrc", false)],
+            ),
+            layer(
+                Scope::Project,
+                vec![mount(
+                    "/host/workspace/.envrc",
+                    "/workspace/test-slug/.envrc",
+                    false,
+                )],
+            ),
+        ]);
+        let merged = config.merged_mounts();
+        assert_eq!(merged.len(), 1);
+        assert_eq!(
+            merged[0].value.source,
+            PathBuf::from("/host/workspace/.envrc")
+        );
     }
 
     #[test]
@@ -1441,6 +1530,23 @@ mod tests {
         assert_eq!(merged[0].value.source, PathBuf::from("/dev/null"));
     }
 
+    #[test]
+    fn stacked_masks_still_blank_the_file() {
+        let config = scoped(vec![
+            layer(
+                Scope::User,
+                vec![mount("/dev/null", "/workspace/test-slug/.envrc", false)],
+            ),
+            layer(
+                Scope::Project,
+                vec![mount("/dev/null", "/workspace/test-slug/.envrc", false)],
+            ),
+        ]);
+        let merged = config.merged_mounts();
+        assert_eq!(merged.len(), 1, "stacked masks must not cancel each other");
+        assert_eq!(merged[0].value.source, PathBuf::from("/dev/null"));
+    }
+
     #[test]
     fn mount_overriding_mask_survives() {
         let config = scoped(vec![
diff --git a/src/main.rs b/src/main.rs
index b0c2004..078be79 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -572,6 +572,20 @@ impl Ramekin {
         mounts
     }
 
+    /// Host path a mask at `target` hides, when ramekin can name one: a
+    /// path inside the workspace maps back to the host repo, and anywhere
+    /// else the mount a lower layer declared is what the mask covers.
+    /// Neither exists for a path the image alone supplies.
+    fn hidden_host_path(&self, target: &str) -> Option<PathBuf> {
+        if let Some(rel) = target
+            .strip_prefix(&self.workspace_target)
+            .and_then(|rest| rest.strip_prefix('/'))
+        {
+            return Some(self.workspace.join(rel));
+        }
+        self.config.overridden_source(target).map(Path::to_path_buf)
+    }
+
     /// Merge config mounts with the forced ones (session plumbing and
     /// caches), ordered lexicographically by target so parents precede
     /// children.
@@ -643,14 +657,22 @@ impl Ramekin {
             println!();
             println!("Mounts");
             let scopes: BTreeSet<_> = merged_mounts.iter().map(|sv| sv.scope).collect();
+            // Masks over a directory bind a session-scoped empty dir, so show
+            // that rather than the /dev/null the config file spells.
+            let empty_placeholder = self.cache_dir.join("sessions/<session>/empty");
             for scope in scopes {
                 println!("  {}", scope_label(scope));
                 for sv in merged_mounts.iter().filter(|sv| sv.scope == scope) {
-                    println!(
-                        "    {} → {}",
-                        sv.value.source.display(),
-                        sv.value.display_target()
-                    );
+                    let hides_a_dir = sv.value.is_mask()
+                        && self
+                            .hidden_host_path(&sv.value.target)
+                            .is_some_and(|path| path.is_dir());
+                    let source = if hides_a_dir {
+                        &empty_placeholder
+                    } else {
+                        &sv.value.source
+                    };
+                    println!("    {} → {}", source.display(), sv.value.display_target());
                 }
             }
         }
@@ -816,7 +838,15 @@ impl Ramekin {
             fs_err::create_dir_all(&mount.source).into_diagnostic()?;
             forced_mounts.push(mount);
         }
-        let all_mounts = self.final_mounts(&forced_mounts);
+        // A mask over a directory needs an empty directory to bind, not
+        // /dev/null; the dir is session-scoped so nothing can write into it
+        // and have it outlive the run.
+        let empty_dir = session_dir.join("empty");
+        fs_err::create_dir_all(&empty_dir).into_diagnostic()?;
+        let emitted = elide_directory_masks(&self.final_mounts(&forced_mounts), &empty_dir, |t| {
+            self.hidden_host_path(t)
+        });
+        let all_mounts: Vec<&config::ResolvedMount> = emitted.iter().collect();
         let env_vars = self.config.merged_env();
         let compose = generate_compose(ComposeParams {
             dockerfile: &dockerfile,
@@ -1101,6 +1131,31 @@ struct ComposeParams<'a> {
     agent_args: &'a [String],
 }
 
+/// Bind an empty directory for masks that hide a directory.
+///
+/// `/dev/null` binds over a file and blanks it, but over a directory Docker
+/// refuses the mount and the run dies at startup. An empty directory elides
+/// the contents while keeping the target the shape callers expect, so
+/// listing it succeeds and comes back empty.
+fn elide_directory_masks(
+    mounts: &[&config::ResolvedMount],
+    empty_dir: &Path,
+    hidden_host_path: impl Fn(&str) -> Option<PathBuf>,
+) -> Vec<config::ResolvedMount> {
+    mounts
+        .iter()
+        .map(|mount| {
+            let mut mount = (*mount).clone();
+            let hides_a_dir = mount.is_mask()
+                && hidden_host_path(&mount.target).is_some_and(|path| path.is_dir());
+            if hides_a_dir {
+                mount.source = empty_dir.to_path_buf();
+            }
+            mount
+        })
+        .collect()
+}
+
 /// Generate a Docker Compose config with all volume mounts.
 fn generate_compose(params: ComposeParams) -> String {
     let ComposeParams {
@@ -1217,6 +1272,54 @@ mod tests {
         );
     }
 
+    #[test]
+    fn directory_mask_binds_an_empty_dir() {
+        let workspace = tempfile::tempdir().unwrap();
+        fs_err::create_dir_all(workspace.path().join("node_modules")).unwrap();
+        let mask = config::ResolvedMount {
+            source: PathBuf::from(config::MASK_SOURCE),
+            target: "/workspace/slug/node_modules".to_string(),
+            writable: false,
+        };
+
+        let emitted = elide_directory_masks(&[&mask], Path::new("/session/empty"), |_| {
+            Some(workspace.path().join("node_modules"))
+        });
+
+        assert_eq!(emitted[0].source, PathBuf::from("/session/empty"));
+        assert_eq!(emitted[0].target, mask.target);
+    }
+
+    #[test]
+    fn file_mask_still_binds_dev_null() {
+        let workspace = tempfile::tempdir().unwrap();
+        fs_err::write(workspace.path().join(".envrc"), "export FOO=1").unwrap();
+        let mask = config::ResolvedMount {
+            source: PathBuf::from(config::MASK_SOURCE),
+            target: "/workspace/slug/.envrc".to_string(),
+            writable: false,
+        };
+
+        let emitted = elide_directory_masks(&[&mask], Path::new("/session/empty"), |_| {
+            Some(workspace.path().join(".envrc"))
+        });
+
+        assert_eq!(emitted[0].source, PathBuf::from(config::MASK_SOURCE));
+    }
+
+    #[test]
+    fn a_mask_over_nothing_nameable_binds_dev_null() {
+        let mask = config::ResolvedMount {
+            source: PathBuf::from(config::MASK_SOURCE),
+            target: "/root/.config/nowhere".to_string(),
+            writable: false,
+        };
+
+        let emitted = elide_directory_masks(&[&mask], Path::new("/session/empty"), |_| None);
+
+        assert_eq!(emitted[0].source, PathBuf::from(config::MASK_SOURCE));
+    }
+
     #[test]
     fn generate_compose_long_form_binds() {
         let mount = config::ResolvedMount {