Close the blockers from the fifth audit
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 10m5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 4m31s
Build App (Preview) / build-linux (pull_request) Successful in 5m21s
Build App (Preview) / build-windows (pull_request) Successful in 19m1s
Build App (Preview) / prune-previews (pull_request) Successful in 1s

Docs and disclosure. HOW-TO-USE.md's settings table still described the
pre-fix behaviour — and help_commands.rs fetches that file from GitHub
main at runtime, ahead of the embedded copy, so it would have reached
every user's Help dialog the moment this merged. The Config tab named
three settings that need a base-image update; there are four, and the
omitted one (Session recap) is the one that fails *without* the "won't
switch off" symptom the warning teaches. Both now also state the cost
nobody had written down: changing any of these recreates the container,
which commits a layer.

Two stale comments that told a reviewer the code was safe when it was
not. compute_claude_code_settings_fingerprint still claimed the
historical fingerprint is preserved so an upgrade cannot churn every
container — carried over from before the widening, false since the
format string changed. And capabilities/default.json, which is the
reviewed threat model of record, described a "Save to host…" action this
branch deletes.

Security and correctness. update_settings validated env vars and nothing
else, so the *global* default_ssh_key_path — the fallback for every
project without an override — took `/` and read-only bind-mounted the
host, which entrypoint.sh then copies into the home volume. classify_
mount_source ran canonicalize on the raw string, which resolves a
relative path against Triple-C's own cwd, so `.` and `..` were accepted
or refused depending on where the app was launched; the daemon then
refuses the mount and the project can never start. Its test passed only
because its examples did not exist under app/src-tauri.

bind_mount_exclusions still derived a path from every row while
project_path_mounts had learned to skip unmountable ones, so a legacy
row made /workspace/<name> ordinary container content that a migration
would then exclude from staging and destroy. The skip is also logged now
rather than silently dropping a folder.

The terminal's file-in path checked is_dir() but not file type, so a
dropped FIFO blocked forever with no timeout — and it is the only route
in now. The web terminal labelled sessions from a global set at request
time, so two quick opens swapped them; harmless until Shift+Enter became
type-dependent, at which point a mislabelled Claude session submitted a
half-written prompt. Opened now carries the type.

Every ~/.claude.json write goes through one atomic helper. The
awsAuthRefresh branches still truncated in place — the same corruption
the Shift+Enter block was fixed for twenty lines later, and its own
comment said so. Demonstrated: a failed write now leaves the original
byte-identical.

And the registration test I added yesterday could pass while the
property was false: an audit got five real unregistered commands past its
exact-string attribute match, and "exactly once" was in its name but not
its body. Mutation-checked against all six shapes.

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 18:45:11 -07:00
co-authored by Claude Opus 5
parent 4d1a5a2417
commit 016de8f641
16 changed files with 339 additions and 77 deletions
+49 -2
View File
@@ -402,7 +402,7 @@ fn validate_project_paths_update(
/// 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
/// does not unmount it. Only a *change* is held to the rule.
fn validate_mounted_host_path(
pub(crate) fn validate_mounted_host_path(
label: &str,
stored: Option<&str>,
incoming: Option<&str>,
@@ -615,6 +615,22 @@ fn classify_mount_source(host_path: &str) -> Option<UnmountableHostPath> {
});
}
// Absoluteness is judged on what the user typed, **before** resolution.
//
// `canonicalize` resolves a relative path against Triple-C's own working
// directory, so it hands back an absolute path and the `NotAbsolute` branch
// below never fires — it was reachable only when canonicalize *failed*,
// i.e. only for relative paths that happened not to exist. That made the
// verdict depend on where the app was launched from: `.` and `..` were
// accepted from the repo, refused from `/`. The daemon then refuses the
// mount outright (`invalid mount path: '..' mount path must be absolute`),
// so the project saved cleanly and could never start again — the bricking
// mode `project_path_mounts`'s filter exists to prevent, reached through
// the host-path half of the row instead of the mount-name half.
if split_host_root(&normalize_host_path(raw)).is_none() {
return Some(UnmountableHostPath::NotAbsolute);
}
let canonical = std::fs::canonicalize(raw)
.ok()
.map(|p| p.to_string_lossy().into_owned());
@@ -754,7 +770,7 @@ pub async fn update_project(
// Fields this command does not get to write, whoever is calling it.
//
// `container_id` is the one that matters: it is the handle the whole file
// command surface resolves against, `list_sibling_containers` hands the
// command surface resolves against, `list_sibling_containers` used to hand the
// webview the ids of every other container on the daemon, and a project
// save is not the place a container is adopted. It is assigned by
// `start_project_container` through `projects_store::set_container_id` and
@@ -1467,6 +1483,37 @@ mod tests {
/// 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_relative_path_is_refused_however_it_resolves_from_here() {
// The previous test for this passed by coincidence: its four examples
// did not exist under `app/src-tauri`, so `canonicalize` failed and the
// `NotAbsolute` branch fired for the wrong reason. Creating a directory
// named `project` there flipped it red.
//
// These are paths that *do* exist relative to wherever the test runs,
// so they exercise the branch that used to be unreachable. Judged on
// the typed string, the answer is the same from any working directory —
// which is the property that matters, because the daemon refuses a
// relative mount source and the project would save fine and then never
// start.
for existing in [".", "..", "src", "./src"] {
assert!(
matches!(
classify_mount_source(existing),
Some(UnmountableHostPath::NotAbsolute)
),
"{} is relative and must be refused regardless of cwd",
existing
);
}
// And the fix must not have made an absolute path unreachable.
assert!(
classify_mount_source("/usr").is_none(),
"an ordinary absolute folder must still be accepted"
);
}
#[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"] {
@@ -25,6 +25,29 @@ pub async fn update_settings(
&settings.global_custom_env_vars,
)?;
// The same for the two host paths this struct owns. `update_project`
// validated its per-project overrides and this side validated nothing,
// which left the wider hole of the two: `default_ssh_key_path` is the
// fallback for **every** project without an override
// (`container.rs`'s `create_container`), so `/` here read-only bind-mounts
// the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh`
// then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the
// home volume this release exists to bound.
//
// Grandfathered the same way project paths are: a value carried over
// unchanged still saves, so a store written before this check cannot lock
// the user out of their own settings.
crate::commands::project_commands::validate_mounted_host_path(
"SSH key path",
before.default_ssh_key_path.as_deref(),
settings.default_ssh_key_path.as_deref(),
)?;
crate::commands::project_commands::validate_mounted_host_path(
"CA certificate path",
before.ca_cert_path.as_deref(),
settings.ca_cert_path.as_deref(),
)?;
let saved = state.settings_store.update(settings)?;
// Persisting a setting is not the same as applying it. The gateway is the
@@ -216,8 +216,23 @@ pub async fn upload_host_file_to_terminal(
let meta = tokio::fs::metadata(&host_path)
.await
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
if meta.is_dir() {
return Err(format!("{} is a directory — drop individual files", host_path));
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
// regular file, reports `len() == 0`, and passes both the directory check
// and the size cap below — and `std::fs::File::open` on one blocks forever
// with no writer, with no timeout anywhere on this path. The upload then
// never returns, the toast sticks on "Adding N files…" for the session and
// the rest of the batch is abandoned. Sockets and device nodes are the same
// shape. With the Files tab's upload removed, this is the only route for
// getting a file into a container, so it is the wrong place to be clever.
if !meta.is_file() {
return Err(if meta.is_dir() {
format!("{} is a directory — drop individual files", host_path)
} else {
format!(
"{} is not a regular file — only ordinary files can be dropped into a terminal",
host_path
)
});
}
// Guard against ballooning host RAM: the file is packed into an in-memory