Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 164 additions & 7 deletions src-rust/crates/cli/src/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,8 @@ pub fn apply_to_config(config: &mut claurst_core::config::Config, brief: &Sessio
/// at push time. Only the helper *script* (which references the env var by name)
/// is stored in `.git/config`; the token value stays in the process
/// environment. Returns `Ok(true)` when a helper was installed, `Ok(false)` when
/// there is no token or no repo.
/// there is no token or no repo, and an error when the repo's origin cannot be
/// safely scoped.
pub fn configure_git_auth(workspace: &Path) -> anyhow::Result<bool> {
if !git_token_present() {
return Ok(false);
Expand All @@ -280,11 +281,30 @@ pub fn configure_git_auth(workspace: &Path) -> anyhow::Result<bool> {
return Ok(false);
}

// Credential helper (git's protocol): on `get`, print username + password.
// `$COVEN_GIT_TOKEN` is expanded by the shell at push time, so the literal
// token never touches `.git/config`.
let helper = "!f() { test \"$1\" = get && \
printf 'username=x-access-token\\npassword=%s\\n' \"$COVEN_GIT_TOKEN\"; }; f";
let repo_path = origin_github_repo_path(workspace)
.context("headless git credentials require an HTTPS GitHub origin")?;
let repo_path = shell_single_quote(&repo_path);

// Credential helper (git's protocol): only answer `get` requests for the
// existing GitHub origin path. `$COVEN_GIT_TOKEN` is expanded by the shell
// at push time, so the literal token never touches `.git/config`.
let helper = format!(
"!f() {{ \
test \"$1\" = get || exit 0; \
protocol=; host=; path=; \
while IFS= read -r line; do \
test -z \"$line\" && break; \
case \"$line\" in \
protocol=*) protocol=${{line#protocol=}} ;; \
host=*) host=${{line#host=}} ;; \
path=*) path=${{line#path=}} ;; \
esac; \
done; \
case \"$path\" in {repo_path}|{repo_path}.git) ;; *) exit 0 ;; esac; \
test \"$protocol\" = https && test \"$host\" = github.com && \
printf 'username=x-access-token\\npassword=%s\\n' \"$COVEN_GIT_TOKEN\"; \
}}; f"
);

// Reset any inherited helper chain, then install ours as the sole helper.
run_git(
Expand All @@ -300,13 +320,39 @@ pub fn configure_git_auth(workspace: &Path) -> anyhow::Result<bool> {
.context("failed to reset credential.helper")?;
run_git(
workspace,
&["config", "--local", "--add", "credential.helper", helper],
&["config", "--local", "credential.useHttpPath", "true"],
)
.context("failed to enable path-aware git credentials")?;
run_git(
workspace,
&["config", "--local", "--add", "credential.helper", &helper],
)
.context("failed to install env-backed credential.helper")?;

Ok(true)
}

fn origin_github_repo_path(workspace: &Path) -> Option<String> {
let origin = git_stdout(workspace, &["remote", "get-url", "origin"])?;
github_repo_path_from_url(&origin)
}

fn github_repo_path_from_url(url: &str) -> Option<String> {
let path = url.strip_prefix("https://github.com/")?;
let path = path.strip_suffix(".git").unwrap_or(path);
let mut parts = path.split('/');
let owner = parts.next()?;
let repo = parts.next()?;
if owner.is_empty() || repo.is_empty() || parts.next().is_some() {
return None;
}
Some(format!("{owner}/{repo}"))
}

fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}

fn run_git(workspace: &Path, args: &[&str]) -> anyhow::Result<()> {
let status = std::process::Command::new("git")
.args(args)
Expand Down Expand Up @@ -1036,6 +1082,17 @@ mod tests {
.status()
.unwrap()
.success());
assert!(std::process::Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/OpenCoven/coven-code.git"
])
.current_dir(ws)
.status()
.unwrap()
.success());

let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// Distinctive token value we assert never lands in .git/config.
Expand All @@ -1054,12 +1111,112 @@ mod tests {
config.contains("$COVEN_GIT_TOKEN"),
"helper should reference the env var, not inline the token: {config}"
);
assert!(
config.contains("OpenCoven/coven-code"),
"helper should be scoped to the existing GitHub origin path: {config}"
);
assert!(
config.contains("useHttpPath = true"),
"git should pass credential paths to the helper: {config}"
);
assert!(
!config.contains(token),
"token value must never be written to .git/config: {config}"
);
}

#[test]
fn configure_git_auth_helper_rejects_non_origin_credentials() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path();
assert!(std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(ws)
.status()
.unwrap()
.success());
assert!(std::process::Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/OpenCoven/coven-code.git"
])
.current_dir(ws)
.status()
.unwrap()
.success());

let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let token = "ghs_HEADLESS_TEST_TOKEN_do_not_leak";
std::env::set_var(GIT_TOKEN_ENV, token);
configure_git_auth(ws).expect("configure ok");

let output = std::process::Command::new("git")
.args(["credential", "fill"])
.current_dir(ws)
.env(GIT_TOKEN_ENV, token)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", "true")
.env("SSH_ASKPASS", "true")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.expect("spawn git credential fill");
let mut output = output;
{
use std::io::Write;
let stdin = output.stdin.as_mut().expect("stdin open");
stdin
.write_all(b"protocol=https\nhost=evil.example\npath=attacker/repo.git\n\n")
.expect("write credential request");
}
let output = output.wait_with_output().expect("credential fill exits");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains(token),
"helper must not return token for non-origin credentials: {stdout}"
);
std::env::remove_var(GIT_TOKEN_ENV);
}

#[test]
fn configure_git_auth_rejects_unscopable_origin() {
let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let token = "ghs_HEADLESS_TEST_TOKEN_do_not_leak";

for origin in [None, Some("https://example.com/attacker/repo.git")] {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path();
assert!(std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(ws)
.status()
.unwrap()
.success());
if let Some(origin) = origin {
assert!(std::process::Command::new("git")
.args(["remote", "add", "origin", origin])
.current_dir(ws)
.status()
.unwrap()
.success());
}

std::env::set_var(GIT_TOKEN_ENV, token);
let result = configure_git_auth(ws);
std::env::remove_var(GIT_TOKEN_ENV);

let error = result.expect_err("an unscopable origin must be a configuration error");
assert!(
error.to_string().contains("HTTPS GitHub origin"),
"unexpected error: {error:#}"
);
}
}

#[test]
fn configure_git_auth_is_a_noop_without_a_token() {
let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
Expand Down