fix(missions): stop leaving an access token in every mission checkout

`with_ambient_auth` embeds GITEA_TOKEN in the clone URL, and git persists that
URL verbatim as the `origin` remote. The checkout is bind-mounted into a
container the agents run in as root, so the token sat in a file every mission
agent could read — and it reaches every repository that token reaches, not
just the one being worked on.

The remote is now rewritten to the bare URL immediately after clone. Delivery
does not depend on the stored URL: it will build a fresh authenticated URL at
push time, which also means a rotated token starts working at once rather than
after the next clone. Best-effort and non-fatal — a checkout that keeps its
token still works, and failing a mission over it would trade a real capability
for a situation already logged.

`strip_credentials` only treats an `@` in the *authority* as a separator, so a
path containing `@` (scoped npm-style names) is left alone.

Also, two changes delivery needs:

- `--depth 1` becomes `--filter=blob:none --single-branch`. A shallow clone
  usually cannot push a new branch ("shallow update not allowed"), which is
  exactly what mission delivery must do. A partial clone keeps full history —
  so a base commit stays meaningful and a diff has something to be relative
  to — while fetching blobs on demand.
- `fetch_and_reset` deepens a pre-existing shallow checkout once, up front,
  rather than letting the push fail later with work on the line.

Fetch stderr is now redacted too; it can echo the remote URL.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-01 22:03:07 -07:00
co-authored by Claude Opus 5
parent dd8dad2ad4
commit ea3d145aac
+139 -11
View File
@@ -92,8 +92,20 @@ fn with_ambient_auth(url: &str) -> String {
}
async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
// `--filter=blob:none` rather than `--depth 1`. A shallow clone cannot
// usually push a new branch back ("shallow update not allowed"), and
// mission delivery needs exactly that. A partial clone keeps full history
// — so the base commit stays meaningful and a diff has something to be
// relative to — while fetching file contents only on demand, which is
// nearly as cheap as a shallow clone for a repo that gets read once.
let out = Command::new("git")
.args(["clone", "--depth", "1", url, &path.display().to_string()])
.args([
"clone",
"--filter=blob:none",
"--single-branch",
url,
&path.display().to_string(),
])
.output()
.await
.map_err(|e| format!("spawn git clone: {e}"))?;
@@ -107,10 +119,78 @@ async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
.collect::<String>()
));
}
scrub_remote_credentials(path, url);
ignore_agent_scaffolding(path);
Ok(())
}
/// Take the access token back out of `.git/config`.
///
/// `with_ambient_auth` embeds `GITEA_TOKEN` in the clone URL so the clone can
/// authenticate, and git then persists that URL verbatim as the `origin`
/// remote. The checkout is bind-mounted into a container the agents run in as
/// **root**, so the token sits in a file every mission agent can read, and it
/// reaches every repository that token reaches — not just this one.
///
/// Rewriting the remote to the bare URL costs one command and removes a
/// standing credential from the blast radius of any prompt injection that
/// lands in a mission. Delivery does not depend on the stored URL: it builds a
/// fresh authenticated URL at push time, which also means a rotated token
/// starts working immediately instead of after the next clone.
///
/// Best-effort and non-fatal: a checkout that keeps its token still works, and
/// failing the mission over it would trade a real capability for a marginal
/// improvement in a situation we have already logged.
fn scrub_remote_credentials(path: &std::path::Path, original_url: &str) {
if !original_url.contains('@') && !original_url.contains("oauth2:") {
// Nothing was injected (SSH remote, or no token configured).
return;
}
let bare = strip_credentials(original_url);
let out = std::process::Command::new("git")
.args([
"-C",
&path.display().to_string(),
"remote",
"set-url",
"origin",
&bare,
])
.output();
match out {
Ok(o) if o.status.success() => {}
Ok(o) => eprintln!(
"mission_workspace: could not scrub credentials from {} — the access token \
remains readable in .git/config: {}",
path.display(),
redact_token(&String::from_utf8_lossy(&o.stderr))
.chars()
.take(200)
.collect::<String>()
),
Err(e) => eprintln!(
"mission_workspace: could not scrub credentials from {} ({e}) — the access \
token remains readable in .git/config",
path.display()
),
}
}
/// `https://user:secret@host/path` → `https://host/path`.
fn strip_credentials(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_string();
};
match rest.split_once('@') {
// Only the *authority* may carry credentials; an `@` later in the path
// is an ordinary character and must not be treated as a separator.
Some((userinfo, host_and_path)) if !userinfo.contains('/') => {
format!("{scheme}://{host_and_path}")
}
_ => url.to_string(),
}
}
/// Files the agent runtime writes into its own workspace, which is pinned to
/// the repository root (`MissionRuntimeProvisioner::pin_agent_workspaces`).
///
@@ -183,16 +263,41 @@ fn redact_token(s: &str) -> String {
}
async fn fetch_and_reset(path: &std::path::Path, branch: &str) -> Result<(), String> {
// A checkout cloned before delivery existed is shallow, and a shallow repo
// cannot push a new branch. Deepen it once, here, rather than discovering
// the problem at push time when there is work on the line. `--unshallow`
// errors on a repo that is already complete, so it is only attempted when
// the marker file is present.
if path.join(".git/shallow").exists() {
let deepen = Command::new("git")
.args([
"-C",
&path.display().to_string(),
"fetch",
"--unshallow",
"origin",
])
.output()
.await;
match deepen {
Ok(o) if o.status.success() => {}
Ok(o) => eprintln!(
"mission_workspace: could not deepen shallow checkout at {} — a delivery \
push may be rejected: {}",
path.display(),
redact_token(&String::from_utf8_lossy(&o.stderr))
.chars()
.take(200)
.collect::<String>()
),
Err(e) => eprintln!(
"mission_workspace: could not deepen shallow checkout at {} ({e})",
path.display()
),
}
}
let fetch = Command::new("git")
.args([
"-C",
&path.display().to_string(),
"fetch",
"--depth",
"1",
"origin",
branch,
])
.args(["-C", &path.display().to_string(), "fetch", "origin", branch])
.output()
.await
.map_err(|e| format!("spawn git fetch: {e}"))?;
@@ -200,7 +305,7 @@ async fn fetch_and_reset(path: &std::path::Path, branch: &str) -> Result<(), Str
return Err(format!(
"git fetch origin {branch} → exit {}: {}",
fetch.status,
String::from_utf8_lossy(&fetch.stderr)
redact_token(&String::from_utf8_lossy(&fetch.stderr))
.chars()
.take(400)
.collect::<String>()
@@ -255,6 +360,29 @@ mod tests {
assert_eq!(first, second, "re-running must not append a second block");
}
#[test]
fn credentials_are_stripped_from_a_remote_url() {
assert_eq!(
strip_credentials("https://oauth2:[email protected]/o/r.git"),
"https://git.redclaw.dev/o/r.git"
);
// No credentials: unchanged.
assert_eq!(
strip_credentials("https://git.redclaw.dev/o/r.git"),
"https://git.redclaw.dev/o/r.git"
);
// SSH form has no `://` authority to rewrite.
assert_eq!(
strip_credentials("[email protected]:o/r.git"),
"[email protected]:o/r.git"
);
// An `@` inside the path is not a credential separator.
assert_eq!(
strip_credentials("https://host/scope/@org/pkg.git"),
"https://host/scope/@org/pkg.git"
);
}
/// An existing exclude file belongs to the repository; keep it.
#[test]
fn an_existing_exclude_is_preserved() {