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
+37 -8
View File
@@ -668,10 +668,23 @@ fn merge_claude_code_settings(
/// Compute a fingerprint for the Claude Code settings so we can detect changes.
/// The `sandbox_enabled` flag is included so that toggling sandbox mode forces
/// a container recreation (re-injecting the merged settings.json). When
/// sandbox is off the historical fingerprint is preserved unchanged so that
/// upgrading triple-c does not spuriously flag every existing container for
/// recreation.
/// a container recreation (re-injecting the merged settings.json).
///
/// **This formula changed, and the change is not free.** It used to read
/// `format!("{}", bool)`; the booleans are now `Option<bool>` and it reads
/// `format!("{:?}")`, because `None` (inherit) and `Some(false)` (a deliberate
/// off) must not hash alike — conflating them leaves a container un-recreated
/// on a real change. The consequence is that **every existing project holding
/// a settings object gets a different fingerprint on first launch after this
/// upgrade, and is recreated once.** A recreation commits a snapshot layer, so
/// that is a one-off disk cost per project, paid silently.
///
/// An earlier version of this comment claimed the opposite — that "the
/// historical fingerprint is preserved unchanged so that upgrading triple-c
/// does not spuriously flag every existing container for recreation." That was
/// carried over from before the widening and was false the moment the format
/// string changed. It is recorded here because a reviewer who believed it would
/// conclude the churn cannot happen.
fn compute_claude_code_settings_fingerprint(
settings: Option<&ClaudeCodeSettings>,
sandbox_enabled: bool,
@@ -775,7 +788,7 @@ fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec<String> {
/// taken back: turning the setting off simply omits the key, the merge
/// preserves whatever was there, and the setting stays on forever. Only a
/// destructive Reset — which also deletes the OAuth login, skills and
/// transcripts — ever cleared it. Four of the five keys here were sticky that
/// transcripts — ever cleared it. Four of the six keys here were sticky that
/// way; the `sandbox` block already carried the workaround and the comment
/// explaining it, and this is the same treatment applied to the rest.
///
@@ -1150,7 +1163,22 @@ fn project_path_mounts(paths: &[crate::models::project::ProjectPath]) -> Vec<Mou
// Trimmed, because a name of `" "` targets `/workspace/ ` and a source
// of `" "` is a path the daemon will happily create at the filesystem
// root — neither is what anyone typed on purpose.
.filter(|pp| !pp.mount_name.trim().is_empty() && !pp.host_path.trim().is_empty())
.filter(|pp| {
let keep = !pp.mount_name.trim().is_empty() && !pp.host_path.trim().is_empty();
if !keep {
// Silence here means a folder the user configured simply does
// not appear in the container, with no error and no toast.
// Skipping is still right — the alternative is a project that
// cannot start — but it should leave a trace.
log::warn!(
"Skipping an unmountable project path row (host_path={:?}, mount_name={:?}): \
both are required. The project will start without it.",
pp.host_path,
pp.mount_name
);
}
keep
})
.map(|pp| Mount {
target: Some(format!("/workspace/{}", pp.mount_name)),
source: Some(pp.host_path.clone()),
@@ -2187,7 +2215,8 @@ const SCRUB_MARKER: &str = "###TRIPLE-C-SCRUBBED ";
/// Marker the scrub script prints **instead of** [`SCRUB_MARKER`] when it
/// cannot run at all, followed by what was missing.
///
/// H3: the script needs six external tools and the root filesystem's device id,
/// H3: the script needs five external tools, the root filesystem's device id,
/// and an `rm` that honours `--one-file-system` — seven prerequisites in all,
/// and on a base image that has none of them where it looked it used to run its
/// seven patterns, delete nothing, and print `###TRIPLE-C-SCRUBBED 0` — a
/// number indistinguishable from an honest "there was nothing to take". A scrub
@@ -2469,7 +2498,7 @@ pub(crate) fn snapshot_scrub_script() -> String {
/// volume was untouched and the figure was `65536` — exactly the debris that
/// really was next to the mount, so the byte accounting follows the flag too.
///
/// It is one of the six prerequisites now, and an image without it is
/// It is one of the seven prerequisites now, and an image without it is
/// [`SCRUB_UNAVAILABLE_MARKER`] rather than a scrub that runs unguarded. That
/// is a real cost — an Alpine or busybox base image stops being scrubbed and
/// keeps its debris — and it is the cheaper of the two: declining costs disk,
+33
View File
@@ -369,6 +369,15 @@ pub fn set_delta(from: &BTreeSet<String>, base: &BTreeSet<String>) -> Vec<String
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
let mut out: Vec<String> = paths
.iter()
// **The same filter `project_path_mounts` applies, and it has to be.**
// That function skips a row with an empty `host_path` or `mount_name`
// so a legacy row cannot brick the create. The consequence is that
// `/workspace/<name>` for such a row is *not* a bind mount — it is
// ordinary writable-layer content. Excluding it here would tell
// `compute_verbatim_paths` to skip staging it, and the container swap
// would then destroy whatever the user has put there. The two
// predicates must agree or a migration silently eats a directory.
.filter(|p| !p.mount_name.trim().is_empty() && !p.host_path.trim().is_empty())
.map(|p| format!("/workspace/{}", p.mount_name))
.collect();
out.sort();
@@ -1368,6 +1377,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
#[cfg(test)]
mod tests {
/// The mount filter and the migration's exclusion list must agree.
///
/// `project_path_mounts` skips a row with an empty `host_path` so a legacy
/// row cannot brick the create. That makes `/workspace/<name>` ordinary
/// writable-layer content rather than a bind mount — and if this function
/// still excluded it, `compute_verbatim_paths` would skip staging it and
/// the container swap would destroy whatever is there. A migration eating a
/// directory is the quietest kind of data loss there is.
#[test]
fn an_unmountable_row_is_not_excluded_from_the_migration_payload() {
let paths = vec![
ProjectPath { host_path: "/home/u/code".into(), mount_name: "code".into() },
// Legacy shapes that `project_path_mounts` skips.
ProjectPath { host_path: "".into(), mount_name: "data".into() },
ProjectPath { host_path: "/home/u/x".into(), mount_name: " ".into() },
];
let excluded = bind_mount_exclusions(&paths);
assert_eq!(
excluded,
vec!["/workspace/code".to_string()],
"only rows that are actually mounted may be excluded from staging"
);
}
use super::*;
use crate::models::{
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,