Fix review findings: secrets in snapshots, URL spoofing, migration data loss

Adversarial review of the branch produced findings across four areas.
This addresses them, plus the Windows CI environment.

Secrets. commit_container_snapshot baked the container's full env into
the per-project snapshot image, so the shared OAuth token — and the AWS
keys, git token and gateway master key — outlived revocation and were
readable via docker inspect. Verified against Engine 29.6 that a commit
body's config merges over the container's: keys cannot be dropped but
can be overwritten, so all of them now commit as KEY=. clear_claude_token
additionally rewrites images from earlier builds and reports honestly
when a tag could not be rewritten.

The recommendation to move the token out of env entirely was not taken,
with reasoning: apiKeyHelper is a different auth method that outranks
CLAUDE_CODE_OAUTH_TOKEN rather than a transport for it, and no
file-based delivery exists. The durable exposure — the image — is what
is closed here. Separately noted, not fixed: entrypoint.sh captures the
token into the scheduler's .env inside the persisted volume.

URL spoofing. Three call sites reached openUrl with container-controlled
strings, one of which the review missed (the WebLinksAddon handler).
The sign-in URL was scraped from container output with a longest-match
tie-break and no userinfo check, so claude.ai@evil.tld rendered as
"claude.ai…" in a truncating element. There is now one sanitizer in
front of every sink — scheme allowlist, no userinfo, C0/C1 and quote
rejection, host allowlist for the sign-in case, first-match — and the
origin renders un-truncated. The toast is keyed so a changed URL
remounts, closing a bait-and-switch where the user read one URL and
clicked another.

Migration. The rollback pin was best-effort: a tag failure was logged
and the migration continued past remove_container, after which the
final commit overwrote the only copy of the old system layer. It now
aborts before anything destructive and reads the tag back. /var was
destroyed while the ordinary recreate path preserves it — making the
"safe" alternative to Reset more destructive than Reset's alternative;
data-bearing subtrees are now detected and disclosed in the pre-flight
rather than copied, since tarring a live database onto a different
base's packages is a corruption risk. resume_migration now verifies the
migration-state label instead of reporting success for a container that
never swapped. dismiss actually resolves the record rather than leaving
the feature permanently refusing to migrate. Start and Reset are guarded
while a migration is live.

Lifecycle. The gateway no longer publishes on 0.0.0.0 — bind address and
advertised URL are derived together so they cannot drift. Disabling it
now stops it. App exit runs teardown concurrently under a budget with a
visible shutting-down state instead of blocking for minutes. Auto-starts
retry when Docker is not up yet, and the polling-recovery path now
reconciles, so interrupted migrations are still recovered. Auth-bridge
forwards are capped, closing a container-driven fd exhaustion.

Windows CI. build-windows failed on this branch with "linker link.exe
not found". The runner had no MSVC build tools and the workflow assumed
a hand-provisioned machine, so a bare runner registers, accepts jobs and
fails at link time after downloading the whole crate graph. The job now
installs the VC++ workload when vswhere cannot find it, matching how it
already conditionally installs Rust and Node.

192 Rust tests, 274 frontend tests, both builds clean, zero warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 19:35:39 -07:00
co-authored by Claude Opus 5
parent eb1324cb16
commit 2de00b3c55
43 changed files with 4348 additions and 346 deletions
+206 -2
View File
@@ -45,7 +45,7 @@ use bollard::models::HostConfig;
use futures_util::StreamExt;
use super::client::get_docker;
use crate::models::ProjectPath;
use crate::models::{ProjectPath, UnpreservedData};
// ─────────────────────────────────────────────────────────────────────────────
// Policy constants
@@ -81,7 +81,35 @@ pub const COPY_EXCLUSIONS: &[&str] = &["/usr/local/aws-cli", "/opt/mission-contr
/// Roots the filesystem manifest walks. Wider than [`COPY_ROOTS`] so the
/// manifest stays useful for debugging; [`compute_verbatim_paths`] applies the
/// narrower policy.
pub const MANIFEST_ROOTS: &[&str] = &["/usr/local", "/opt", "/srv", "/workspace"];
///
/// [`DATA_ROOTS`] are in here for a different reason: they are never copied,
/// but they *are* destroyed by the container swap, so the walk has to see them
/// in order to warn about them.
pub const MANIFEST_ROOTS: &[&str] = &[
"/usr/local",
"/opt",
"/srv",
"/workspace",
"/var/lib",
"/var/www",
];
/// Roots holding **state a base-image swap destroys and no replay can put
/// back**. Reported by [`unpreserved_data`], never copied.
///
/// A container running Postgres, MySQL, Redis or nginx keeps its actual data in
/// `/var/lib/<service>` or `/var/www`. Replaying the apt delta reinstalls the
/// *package* onto the new base and gets an empty data directory back — the
/// database is gone. That is worse than the ordinary recreate path, which
/// creates from the project's snapshot and therefore keeps `/var` intact.
///
/// These are deliberately **not** in [`COPY_ROOTS`]. A live database's on-disk
/// files cannot be tarred out from under a running server and restored into a
/// different base's version of the same package with any confidence — a copy
/// that half-works is worse than a warning that lets the user take a proper
/// dump first. So migration's answer is disclosure, loudly, before anything is
/// touched.
pub const DATA_ROOTS: &[&str] = &["/var/lib", "/var/www"];
/// Base-image capabilities worth telling the user they are missing, as
/// `(path, human label)`.
@@ -125,6 +153,16 @@ pub const LABEL_CREATE_IMAGE: &str = "triple-c.create-image";
pub const LABEL_MIGRATION_STATE: &str = "triple-c.migration-state";
/// Value of [`LABEL_MIGRATION_STATE`] while a migration is unfinished.
pub const MIGRATION_LABEL_IN_PROGRESS: &str = "in-progress";
/// Label stamped on the short-lived probe containers [`run_throwaway`] creates.
///
/// They are removed on every path including failure, but a hard crash of the
/// app (or of Docker) between create and remove would otherwise leave a
/// container that carries no `triple-c.*` marking at all — invisible to every
/// cleanup this app has, and unattributable by hand. The label makes
/// `docker ps -a --filter label=triple-c.probe=migration` find them.
pub const LABEL_PROBE: &str = "triple-c.probe";
/// Value of [`LABEL_PROBE`] on a migration manifest/pre-flight probe container.
pub const PROBE_LABEL_MIGRATION: &str = "migration";
// ─────────────────────────────────────────────────────────────────────────────
// Manifests
@@ -437,6 +475,72 @@ pub fn verbatim_payload_bytes(from: &Manifest, verbatim: &[String]) -> u64 {
.sum()
}
/// The reporting unit under a [`DATA_ROOTS`] entry: the first path component
/// below the root, e.g. `/var/lib/postgresql`. Directory-level, because that is
/// the granularity a user can actually act on ("dump this database"), and
/// because a per-file list of a Postgres cluster would be thousands of lines.
fn data_unit(path: &str) -> Option<String> {
for root in DATA_ROOTS {
let prefix = format!("{}/", root);
if let Some(rest) = path.strip_prefix(&prefix) {
let first = rest.split('/').next()?;
if first.is_empty() {
return None;
}
return Some(format!("{}/{}", root, first));
}
}
None
}
/// Data-bearing subtrees under [`DATA_ROOTS`] that the migration will destroy
/// and cannot restore, with the size and file count of what is at risk.
///
/// A subtree qualifies when **all** of:
/// * it is a first-level directory under a [`DATA_ROOTS`] entry,
/// * the current base image does not have that directory **at all** — if the
/// base ships it, it is the base's own machinery (`/var/lib/apt`,
/// `/var/lib/dpkg`, `/var/lib/systemd`, …) and the base's copy is the right
/// one, exactly as for `/etc`,
/// * it contains at least one regular file that neither image's dpkg database
/// owns — a package's own scaffolding is recreated by the apt replay, the
/// data written into it is not.
///
/// That pair of filters is what keeps this quiet on an ordinary container and
/// loud on one running a database: `/var/lib/postgresql` is absent from the
/// base and full of unowned files, while `/var/lib/apt/lists` is present in the
/// base and never reported.
pub fn unpreserved_data(from: &Manifest, base: &Manifest) -> Vec<UnpreservedData> {
let base_paths = base.path_set();
let mut acc: BTreeMap<String, (u64, u32)> = BTreeMap::new();
for entry in &from.paths {
let Some(unit) = data_unit(&entry.path) else {
continue;
};
if base_paths.contains(unit.as_str()) {
continue;
}
if entry.is_dir() {
continue;
}
if from.dpkg_owned.contains(&entry.path) || base.dpkg_owned.contains(&entry.path) {
continue;
}
let slot = acc.entry(unit).or_insert((0, 0));
slot.0 = slot.0.saturating_add(entry.size);
slot.1 += 1;
}
acc.into_iter()
.map(|(path, (bytes, file_count))| UnpreservedData {
path,
bytes,
file_count,
})
.collect()
}
/// Base-image capabilities the container does not have, as
/// `(concrete paths, human labels)`.
///
@@ -582,6 +686,13 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
user: Some("root".to_string()),
working_dir: Some("/".to_string()),
tty: Some(false),
// Written explicitly rather than inherited: a probe container that
// outlives a crash has to be findable, and nothing else in the app
// labels these.
labels: Some(HashMap::from([(
LABEL_PROBE.to_string(),
PROBE_LABEL_MIGRATION.to_string(),
)])),
host_config: Some(HostConfig {
// No mounts on purpose: this must observe the *image*, not the
// project's volumes, which are exactly the state migration does
@@ -1180,6 +1291,99 @@ mod tests {
assert_eq!(verbatim_payload_bytes(&from, &verbatim), 300);
}
// ── Data that migration destroys and cannot restore ─────────────────────
#[test]
fn a_database_under_var_lib_is_reported_because_nothing_replays_it() {
// The regression this exists for: replaying `postgresql` onto the new
// base reinstalls the package and gets an empty cluster. The ordinary
// recreate path keeps /var because it creates from the snapshot, so a
// silent migration would be *more* destructive than the thing it
// replaces.
let from = manifest(
&[
('d', 4096, "/var/lib/postgresql"),
('d', 4096, "/var/lib/postgresql/16/main"),
('f', 8192, "/var/lib/postgresql/16/main/PG_VERSION"),
('f', 1024, "/var/lib/postgresql/16/main/base/1/2"),
('d', 4096, "/var/www"),
('d', 4096, "/var/www/site"),
('f', 500, "/var/www/site/index.html"),
],
&[],
&[],
);
let got = unpreserved_data(&from, &Manifest::default());
assert_eq!(
got.iter().map(|d| d.path.as_str()).collect::<Vec<_>>(),
vec!["/var/lib/postgresql", "/var/www/site"]
);
assert_eq!(got[0].bytes, 9216);
assert_eq!(got[0].file_count, 2);
// And it is emphatically not in the copy set — reporting is the whole
// answer here, not a half-working copy of a live database.
assert!(!COPY_ROOTS.iter().any(|r| is_under("/var/lib/postgresql", r)));
}
#[test]
fn package_machinery_under_var_is_never_reported_as_data_at_risk() {
// /var/lib/apt exists in the base too, so it is the base's to own —
// the same rule /etc gets. Reporting apt's lists would bury the one
// line that matters under noise on every single migration.
let from = manifest(
&[
('d', 4096, "/var/lib/apt"),
('f', 900_000, "/var/lib/apt/lists/some.mirror_InRelease"),
('d', 4096, "/var/lib/dpkg"),
('f', 4096, "/var/lib/dpkg/status"),
],
&[],
&[],
);
let base = manifest(
&[
('d', 4096, "/var/lib/apt"),
('d', 4096, "/var/lib/dpkg"),
('f', 4096, "/var/lib/dpkg/status"),
],
&[],
&[],
);
assert!(unpreserved_data(&from, &base).is_empty());
}
#[test]
fn a_packages_own_scaffolding_under_var_is_not_data() {
// nginx-common ships /var/www/html/index.nginx-debian.html. The apt
// replay puts that back; only what the user wrote is at risk.
let from = manifest(
&[
('d', 4096, "/var/www/html"),
('f', 612, "/var/www/html/index.nginx-debian.html"),
],
&["/var/www/html/index.nginx-debian.html"],
&[],
);
assert!(unpreserved_data(&from, &Manifest::default()).is_empty());
}
#[test]
fn data_is_reported_per_directory_not_per_file() {
assert_eq!(
data_unit("/var/lib/mysql/ibdata1").as_deref(),
Some("/var/lib/mysql")
);
// A first-level directory is its own unit.
assert_eq!(
data_unit("/var/lib/mysql").as_deref(),
Some("/var/lib/mysql")
);
// The root itself is not: it exists in every image.
assert_eq!(data_unit("/var/lib").as_deref(), None);
assert_eq!(data_unit("/var/www").as_deref(), None);
assert_eq!(data_unit("/usr/local/bin/tool"), None);
}
// ── Bind-mount exclusion ────────────────────────────────────────────────
fn pp(mount: &str) -> ProjectPath {