Resolve host paths before mounting them, and stop reading stored data as choice

Four fixes that share a shape: a value already on disk, or one spelled
around a check, being taken at face value.

`/..` bind-mounted the entire host filesystem read-write. `is_filesystem_root`
was purely lexical — trim trailing separators, refuse what was left only if it
was empty or a bare `C:` — and nothing in the file ever called `canonicalize`,
so `/..`, `/./`, `/home/..`, `/etc/../` and `C:\..` all passed. The daemon
resolves them: `docker run -v /..:/mnt/probe` mounts the host root, and the app
mounts read-*write* into a container whose agent has passwordless sudo. It is
the escalation `check_mount_name_stays_under_workspace` exists to close,
reached through the host-path half of the mount instead of the mount-name half.

`classify_mount_source` replaces it and asks the OS: `canonicalize` applies
`..`, follows symlinks, and resolves 8.3 aliases and UNC spellings on Windows.
A path that cannot be resolved — `projects.json` synced from another machine,
a folder not created yet — falls back to a lexical collapse rather than being
refused, because refusing would make such a project unsavable; the gap is
bounded, since what resolution adds is a property of paths that exist. A path
that names no location at all (`C:x`, a relative path) is refused rather than
guessed at. Same check now guards `ssh_key_path` and `ca_cert_path`, whose
read-only mounts were whole-host disclosure at /tmp/.host-ssh.

Custom env var names had no charset check anywhere, so `BASH_FUNC_stat%%` —
bash's wire format for an exported shell function, body in the value — reached
the container environment verbatim. Latent today because the image's /bin/sh is
dash, but the pre-commit scrub runs `/bin/sh -c` as root and nothing pins that.
Keys are now shell identifiers, on the project and the global list both, with
the same grandfathering the folder rows get: a stored key is admitted, a new or
edited one is not.

The blank workspace row was persisted. The comment said it was dropped on save;
the code computed the filtered list and then saved the unfiltered one, so
"+ Add folder" plus a blur stored `{"Target": "/workspace/", "Source": ""}` and
the project could never be started or recreated again. Every save in the
section now goes through one filter, and a blur that changed nothing saves
nothing.

Widening the five `ClaudeCodeSettings` booleans to `Option<bool>` reinterpreted
every stored record. They were plain `bool`s that always serialised, so every
project ever saved carries an explicit `"env_scrub": false` that nobody chose —
and under the new merge that `Some(false)` beats a global `Some(true)`, where
the old rule let the global win. Upgrading silently turned five settings off,
"strip credentials from subprocess environments" among them. Deserialisation
now goes through a shim that dates the record by the presence of the
pre-widening `enable_session_recap` key and reads its `false`s as unset. The
fields skip serialising when unset, so an older binary can still parse
`projects.json` after a downgrade — a `null` would fail to parse and take the
whole list down, since `ProjectsStore` parses all-or-nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 17:02:03 -07:00
co-authored by Claude Opus 5
parent 168b61d632
commit 9472cb3c4c
8 changed files with 1027 additions and 54 deletions
+400 -26
View File
@@ -195,7 +195,9 @@ pub(crate) fn load_secrets_for_project(project: &mut Project) {
/// entire host filesystem read-write into a container whose agent has
/// passwordless sudo. Anything short of a filesystem root is the user choosing
/// a folder — the Browse button and the free-text field lead to the same place
/// — so only the roots themselves are refused.
/// — so only the roots themselves are refused, and *root* is answered by
/// resolving the path rather than by reading it: `/..` is spelled like a folder
/// and is the root. See [`classify_mount_source`].
///
/// This is the **whole** rule set, and it belongs to `add_project`, where every
/// row is new by definition. `update_project` runs
@@ -228,17 +230,31 @@ fn validate_one_path(p: &ProjectPath) -> Result<(), String> {
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
}
check_mount_name_stays_under_workspace(&p.mount_name)?;
if p.host_path.is_empty() {
// Trimmed: a host path of spaces is not a folder, and `classify_mount_source`
// is deliberately silent about a path with nothing in it — this is the
// message that names the mount it belongs to.
if p.host_path.trim().is_empty() {
return Err(format!(
"Folder mounted at '/workspace/{}' has no host path.",
p.mount_name
));
}
if is_filesystem_root(&p.host_path) {
return Err(format!(
"'{}' is a filesystem root. Choose the project folder itself — mounting the whole drive gives the container everything on it.",
p.host_path
));
match classify_mount_source(&p.host_path) {
None => {}
Some(UnmountableHostPath::FilesystemRoot { resolved }) => {
return Err(filesystem_root_message(
&p.host_path,
&resolved,
"using it as a project folder",
));
}
Some(UnmountableHostPath::NotAbsolute) => {
return Err(format!(
"'{}' is not a full path to a folder — where it lands is decided by wherever \
Triple-C is running from rather than by you. Give the whole path.",
p.host_path
));
}
}
Ok(())
}
@@ -264,6 +280,23 @@ fn check_mount_name_stays_under_workspace(mount_name: &str) -> Result<(), String
mount_name
));
}
// **An empty name is not refused here, and that is a live residual.** It
// makes the target `/workspace/`, which the daemon normalises to
// `/workspace` — so the host folder shadows the directory the other mounts
// land in, and Docker then creates their mount points *inside it*, on the
// host. It is not refused because it cannot be: an empty name is what a
// half-filled row holds, `legacy_rows` shows those are already in
// `projects.json`, and this function runs on grandfathered rows too, so
// refusing it would make every such project unsavable — the exact
// regression [`validate_project_paths_update`] exists to prevent.
//
// Introducing one is closed at both ends: `validate_one_path` refuses an
// empty name on any new or edited row, and `WorkspaceSection` no longer
// sends half-filled or blank ones. What remains is the *stored* row, and it
// belongs where the mount is built — `docker::create_container` should skip
// a row with no mount name or no host path, which is the same filter that
// stops a stored blank row failing the create with
// `field Source must not be empty`.
if !mount_name.is_empty() && mount_name.chars().all(|c| c == '.') {
return Err(format!(
"Mount name '{}' is not a folder name — it names the directory the mount would sit in.",
@@ -363,7 +396,8 @@ fn validate_project_paths_update(
/// `/tmp/.host-ssh` and `/tmp/.host-ca` — and neither had any check at all, so
/// `/` handed the whole host filesystem to the agent to read. Read-only, so
/// this is disclosure rather than the read-write hole a `/` project folder is,
/// but the fix is the same one line.
/// but it is the same check: [`classify_mount_source`], resolved rather than
/// spelled, so `/..` and `/home/..` are refused here too.
///
/// Same grandfathering as the folder list, for the same reason: a value already
/// stored is already mounted on every start, and refusing an unrelated save
@@ -379,29 +413,239 @@ fn validate_mounted_host_path(
if stored.map(str::trim) == Some(value) {
return Ok(());
}
if is_filesystem_root(value) {
return Err(format!(
"'{}' is a filesystem root, so setting it as {} would mount the whole drive into the \
container. Choose the folder itself.",
value, label
));
match classify_mount_source(value) {
None => {}
Some(UnmountableHostPath::FilesystemRoot { resolved }) => {
return Err(filesystem_root_message(
value,
&resolved,
&format!("setting it as {}", label),
));
}
Some(UnmountableHostPath::NotAbsolute) => {
return Err(format!(
"'{}' is not a full path, so it cannot be used as {}. Give the whole path.",
value, label
));
}
}
Ok(())
}
/// Whether a host path is the root of a filesystem, in any spelling the three
/// desktop platforms produce: `/`, a Windows drive root, or a bare UNC/share
/// prefix. Trailing separators are ignored, so `C:\\` and `C:/` are the same
/// answer.
fn is_filesystem_root(host_path: &str) -> bool {
let trimmed = host_path.trim_end_matches(['/', '\\']);
if trimmed.is_empty() {
// Nothing but separators: `/`, `\\`, `//`.
return true;
/// Why a host path may not be used as the source of a bind mount.
///
/// Two answers rather than a `bool` because they need different sentences, and
/// because "is a root" is no longer a question about how the path is *spelled*
/// — the refusal has to be able to say where the path actually landed.
#[derive(Debug, PartialEq)]
enum UnmountableHostPath {
/// The path is, or resolves to, the root of a filesystem. `resolved` is
/// what it lands on, which is the same string only when a root was typed
/// outright.
FilesystemRoot { resolved: String },
/// The path does not name a location at all. Where it lands is decided by
/// whatever directory Triple-C happens to be running from, so it can be a
/// root tomorrow and a folder today, and nothing here can judge it.
NotAbsolute,
}
/// Length of a `C:` drive prefix at the head of `path`, or 0.
///
/// Duplicated from `commands::file_commands::drive_prefix_len`, together with
/// [`is_windows_style_path`] and [`normalize_host_path`] below. Those are
/// private to that module and it is not this branch's file to change; if the
/// two copies are ever merged, that one is the original and carries the wider
/// test coverage.
fn drive_prefix_len(path: &str) -> usize {
let b = path.as_bytes();
if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
2
} else {
0
}
// `C:` — a drive with no path on it.
let bytes = trimmed.as_bytes();
bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
/// Whether `path` is written in Windows form, and so whether `\` separates its
/// components. On Linux a backslash is an ordinary filename character, which is
/// why this is a question rather than an unconditional substitution.
///
/// Copy of `file_commands::is_windows_style_path` — see [`drive_prefix_len`].
fn is_windows_style_path(path: &str) -> bool {
cfg!(windows) || path.starts_with("\\\\") || drive_prefix_len(path) > 0
}
/// `path` with its separators unified and any Win32 verbatim/device prefix
/// removed — the form every rule below is expressed against.
///
/// `\\?\C:\Windows` and `\\?\UNC\server\share` name the *same locations* as
/// `C:\Windows` and `\\server\share`; the prefix only turns off Win32 path
/// parsing. Stripping it is what stops four characters being a bypass — and it
/// has to run on our own output as well, because `std::fs::canonicalize` hands
/// back exactly that spelling on Windows.
///
/// Copy of `file_commands::normalize_host_path` — see [`drive_prefix_len`].
fn normalize_host_path(path: &str) -> String {
let mut s = if is_windows_style_path(path) {
path.replace('\\', "/")
} else {
path.to_string()
};
// Slicing by byte index is safe here only because a prefix matched
// case-insensitively as ASCII is ASCII, so its end is a char boundary.
for prefix in ["//?/unc/", "//./unc/"] {
if s.len() >= prefix.len()
&& s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
{
return format!("//{}", &s[prefix.len()..]);
}
}
for prefix in ["//?/", "//./"] {
if s.len() >= prefix.len()
&& s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
{
s = s[prefix.len()..].to_string();
break;
}
}
s
}
/// A normalised absolute path split into the root it hangs off and the part
/// below it, or `None` when it names no location at all.
///
/// The three roots the desktop platforms have: `/`, a drive (`C:/`), and a UNC
/// share (`//server/share` — the share *is* the root; `//server` alone names a
/// machine and nothing on it).
fn split_host_root(norm: &str) -> Option<(&str, &str)> {
if let Some(rest) = norm.strip_prefix("//") {
let mut parts = rest.splitn(3, '/');
let server = parts.next().unwrap_or("");
let share = parts.next().unwrap_or("");
if server.is_empty() || share.is_empty() {
// `//server`, `//server/`: no share, so nothing under it is named.
return Some((norm, ""));
}
let root_len = 2 + server.len() + 1 + share.len();
return Some((&norm[..root_len], &norm[root_len..]));
}
let drive = drive_prefix_len(norm);
if drive > 0 {
// `C:x` is drive-*relative* — it means "x under the current directory
// on C:", which is a location only the process's own state decides.
return match norm[drive..].strip_prefix('/') {
Some(tail) => Some((&norm[..drive + 1], tail)),
None if norm.len() == drive => Some((norm, "")),
None => None,
};
}
norm.strip_prefix('/').map(|tail| (&norm[..1], tail))
}
/// How many named components deep `tail` ends up, with `.` dropped and `..`
/// applied — clamped at the root, because `/..` is `/` and not an error.
fn depth_below_root(tail: &str) -> usize {
let mut depth = 0usize;
for segment in tail.split('/') {
match segment {
"" | "." => {}
".." => depth = depth.saturating_sub(1),
_ => depth += 1,
}
}
depth
}
/// Whether a host path can be handed to Docker as a bind-mount source, and if
/// not, why.
///
/// ## Resolved, not spelled
///
/// This used to be `is_filesystem_root`, and it was purely lexical: trim the
/// trailing separators, say yes to what was left over only if it was empty or a
/// bare `C:`. Nothing in this file called `canonicalize`, so `/..`, `/./`,
/// `/home/..`, `/etc/../` and `C:\..` all sailed through and were passed
/// verbatim to `docker::create_container`, which builds a
/// `Mount { source, read_only: Some(false) }` out of them. Verified against the
/// daemon: `-v /..:/mnt/probe` mounts the host root. That is the whole host
/// filesystem, read-write, in a container whose agent has passwordless sudo —
/// the same escalation `check_mount_name_stays_under_workspace` exists to
/// close, reached through the host-path half of the mount instead of the
/// mount-name half.
///
/// So the answer comes from the OS where the OS can give one: `canonicalize`
/// applies `..`, follows every symlink in the path, and on Windows returns the
/// long name for an 8.3 alias and the verbatim spelling of a UNC share — all
/// things a string comparison cannot see.
///
/// ## When the path cannot be resolved
///
/// `canonicalize` fails on a path that does not exist *here*, which is an
/// ordinary state rather than an attack: `projects.json` syncs between machines
/// and names `C:\Users\jo\code` on a box that has never had a `C:`, and a
/// folder can be created after the project is. Refusing outright would make
/// every such project unsavable, which is the exact failure
/// [`validate_project_paths_update`] exists to avoid — so an unresolvable path
/// falls back to the lexical answer, with `.` and `..` collapsed by
/// [`depth_below_root`] rather than ignored.
///
/// That is a weaker guarantee, not a wrong one, and the gap is bounded: what
/// resolution adds over the lexical rule is symlinks and 8.3 aliases, and both
/// of those are properties of a path that *exists* — precisely the case where
/// `canonicalize` answers. What is left is a path that does not exist at save
/// time and is a symlink to the root by the time the container starts, i.e. the
/// user doing it to themselves after being asked.
///
/// Blocking on the filesystem here is deliberate: this runs on a save, once per
/// row, and is a single `realpath` walk.
fn classify_mount_source(host_path: &str) -> Option<UnmountableHostPath> {
let raw = host_path.trim();
if raw.is_empty() {
// Emptiness is somebody else's error message — see
// `validate_one_path`, which names the mount it belongs to.
return None;
}
// Nothing but separators, in either spelling: `/`, `//`, `\`, `\\`. Taken
// first because a lone `\` is a *relative* name on Linux, and reporting a
// Windows root as "not a full path" would be answering a question the user
// did not ask.
if raw.chars().all(|c| c == '/' || c == '\\') {
return Some(UnmountableHostPath::FilesystemRoot {
resolved: raw.to_string(),
});
}
let canonical = std::fs::canonicalize(raw)
.ok()
.map(|p| p.to_string_lossy().into_owned());
let judged = canonical.as_deref().unwrap_or(raw);
let norm = normalize_host_path(judged);
let Some((root, tail)) = split_host_root(&norm) else {
return Some(UnmountableHostPath::NotAbsolute);
};
if depth_below_root(tail) == 0 {
return Some(UnmountableHostPath::FilesystemRoot {
resolved: root.to_string(),
});
}
None
}
/// The refusal for a host path that lands on a filesystem root, naming the
/// resolved location as well as what was typed when those differ. `/..` reads
/// as a folder; `/..` *is* `/`, and a message that only quoted it back would
/// leave the user with nothing to act on.
fn filesystem_root_message(typed: &str, resolved: &str, use_for: &str) -> String {
let where_it_lands = if typed.trim() == resolved {
format!("'{}' is a filesystem root", typed)
} else {
format!("'{}' resolves to '{}', which is a filesystem root", typed, resolved)
};
format!(
"{}, so {} would mount the whole drive into the container. Choose the folder itself.",
where_it_lands, use_for
)
}
#[tauri::command]
@@ -551,6 +795,12 @@ pub async fn update_project(
project.ca_cert_path.as_deref(),
)?;
// Custom env var names had no charset check anywhere, so a key like
// `BASH_FUNC_stat%%` reached the container environment verbatim. Same
// grandfathering as the folder list, for the same reason — see
// [`crate::models::validate_env_vars_update`].
crate::models::validate_env_vars_update(&stored.custom_env_vars, &project.custom_env_vars)?;
project.container_id = stored.container_id;
project.status = stored.status;
project.created_at = stored.created_at;
@@ -1159,6 +1409,130 @@ mod tests {
assert!(validate_project_paths(&[path("C:\\Users\\u\\project", "project")]).is_ok());
}
/// Every spelling of a root that is not *spelled* like one.
///
/// The predicate this replaces trimmed trailing separators and compared
/// what was left, so `/..` — which the daemon mounts as the host root,
/// verified with `docker run -v /..:/mnt/probe` — was indistinguishable
/// from a project folder called `..`. No test in the repo contained a `.`
/// or a `..` in a host path, which is why it shipped.
#[test]
fn a_host_path_that_resolves_to_a_root_is_refused() {
let escapes = [
"/..",
"/../",
"/./",
"/.",
"/home/..",
"/etc/../",
"/tmp/../..",
// Deliberately not present on any machine, so this is the
// unresolvable path taking the lexical route.
"/no-such-dir-here/../..",
"C:\\..",
"C:\\Users\\..",
"c:/foo/..",
// Win32 verbatim spelling of a drive root.
"\\\\?\\C:\\",
"\\\\?\\C:\\..",
// A UNC share root is the root of everything on that share, which
// the old predicate accepted despite its doc comment claiming
// otherwise.
"\\\\server\\share",
"//server/share/",
];
for escape in escapes {
assert!(
validate_project_paths(&[path(escape, "everything")]).is_err(),
"host path '{}' was accepted as a project folder, which bind-mounts a whole \
filesystem read-write into a container with passwordless sudo",
escape
);
// The same value must not be reachable through the editor either.
assert!(
validate_project_paths_update(&[], &[path(escape, "everything")]).is_err(),
"host path '{}' was accepted through update_project",
escape
);
// And the two read-only mounts are the same check.
assert!(
validate_mounted_host_path("the SSH key folder", None, Some(escape)).is_err(),
"'{}' was accepted as an SSH key path, which read-only bind-mounts a whole \
filesystem at /tmp/.host-ssh",
escape
);
}
}
/// A drive-relative path (`C:x`, no separator) means "x under whatever the
/// current directory on C: happens to be" — a location decided by the
/// process rather than by the user, so it may be the drive root.
#[test]
fn a_path_that_names_no_location_is_refused_rather_than_guessed_at() {
for relative in ["C:x", "C:Users\\jo", "relative/path", "./project"] {
assert!(
validate_project_paths(&[path(relative, "project")]).is_err(),
"'{}' was accepted, though where it lands depends on Triple-C's own \
working directory",
relative
);
}
}
/// The dots that are *not* an escape have to keep working — a folder can
/// legitimately be reached through `.` or a `..` that goes back down again,
/// and the Browse button produces paths on machines this list is not
/// running on.
#[test]
fn an_ordinary_folder_is_still_accepted_however_it_is_spelled() {
for ok in [
"/home/u/./project",
"/home/u/x/../project",
"/home/u/..project",
"/home/u/project/..hidden",
"C:\\Users\\u\\x\\..\\project",
"\\\\server\\share\\project",
"\\\\?\\C:\\Users\\u\\project",
] {
assert!(
validate_project_paths(&[path(ok, "project")]).is_ok(),
"host path '{}' should be usable",
ok
);
}
}
/// The half of this that only resolution can answer.
///
/// A lexical check sees a two-component path under `/tmp` and stops. The
/// container is what plants the link — `/proc/self/mountinfo` inside a
/// Triple-C container spells the host's project paths out verbatim — so the
/// symlink is reachable, and the mount that follows it is read-write.
#[cfg(unix)]
#[test]
fn a_symlink_to_the_root_is_refused_because_resolution_is_what_answers() {
let dir = std::env::temp_dir().join(format!(
"triple-c-root-link-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let link = dir.join("innocent");
std::os::unix::fs::symlink("/", &link).unwrap();
let verdict = validate_project_paths(&[path(&link.to_string_lossy(), "project")]);
std::fs::remove_file(&link).ok();
std::fs::remove_dir(&dir).ok();
assert!(
verdict.is_err(),
"a symlink to / was accepted as a project folder; only canonicalisation can see it"
);
}
#[test]
fn duplicate_and_half_filled_rows_are_refused_but_the_blank_row_is_not() {
assert!(validate_project_paths(&[
@@ -16,6 +16,15 @@ pub async fn update_settings(
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
let before = state.settings_store.get();
// The global half of the same rule the project half gets in
// `update_project`: a global custom env var is merged into every project's
// container environment, so an unchecked name here reaches all of them.
crate::models::validate_env_vars_update(
&before.global_custom_env_vars,
&settings.global_custom_env_vars,
)?;
let saved = state.settings_store.update(settings)?;
// Persisting a setting is not the same as applying it. The gateway is the