diff --git a/docs/tools/forge.md b/docs/tools/forge.md index e6fcdd0c..6ec5581a 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -144,7 +144,7 @@ lets a read-only user open a PR by pushing the current `HEAD` to the magic ref `refs/for//`. Two verbs cover the workflow: ``` -hive-forge -r internal/knowledge clone # clone with token auto-injected +hive-forge -r internal/knowledge clone # clone, auth handled for you cd knowledge # add / edit / delete any files, then commit normally git add -A && git commit -m "add foo runbook" @@ -155,9 +155,13 @@ hive-forge -r internal/knowledge pr-create --agit \ ``` `clone` derives the dest dir from the repo basename (override with a -positional arg); `--branch` / `--depth` are passed through. The token -is injected into the clone's `origin` remote so `pr-create --agit` -(default remote `origin`) can push without re-auth. +positional arg); `--branch` / `--depth` are passed through. The clone +URL and the `origin` remote it leaves behind carry no credentials — +`clone` instead configures `origin`'s `credential.helper` to invoke +`hive-forge credential-helper` (a hidden verb, not meant to be run by +hand), which git calls fresh on every fetch/push. That's what lets +`pr-create --agit` (default remote `origin`) push without re-auth, +without a durable token sitting in the checkout's `.git/config`. `pr-create --agit` prints the PR URL. Re-running with the same `--topic` force-updates the existing open PR (the AGit ref is diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index eb391550..5e79fa48 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -31,10 +31,12 @@ pub struct Client { /// header the typed client sends. web: HttpClient, base: String, - /// Per-agent forge token. Kept so verbs that shell out to `git` - /// (e.g. `clone`) can assemble an authenticated push URL without - /// re-reading the token file. - token: String, + /// The `-f/--forge` label this client resolved against, `None` for + /// the default internal forge. `clone` bakes this back into the + /// `credential.helper` command it configures, so a later `git push`/ + /// `git fetch` in that checkout re-resolves the *same* forge account + /// rather than silently falling back to the internal one. + forge_label: Option, /// Default repo used when a verb doesn't carry an explicit /// `[repo]` override. pub default_repo: String, @@ -98,7 +100,7 @@ impl Client { api, web, base, - token, + forge_label, default_repo, json_mode, }) @@ -111,21 +113,26 @@ impl Client { &self.api } - /// Assemble an authenticated git URL for `repo` (e.g. - /// `internal/knowledge`) by injecting the agent's forge user + - /// token into the base URL's authority: `http://:@host/.git`. - /// The user comes from `HIVE_LABEL` (the agent's forge login), - /// falling back to `oauth2` which Forgejo also accepts as the - /// token-bearer username. Used by `clone` to clone/push. + /// The *credential-free* git URL for `repo` — no user/token in the + /// authority, so nothing durable lands in `.git/config` when this is + /// the URL `git clone` is given. Pairs with the `credential-helper` + /// verb, which `clone` configures as the repo's `credential.helper` + /// so git asks for (and gets) the token fresh from its file on every + /// fetch/push instead of it being embedded here. Replaces the old + /// `authed_git_url` (`http://:@host/.git`), which + /// left a durable token in every checkout's `.git/config` — a real + /// leak reported by atlas. #[must_use] - pub fn authed_git_url(&self, repo: &str) -> String { - let user = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "oauth2".to_owned()); - // Split scheme from authority so credentials land in the right spot. - let (scheme, host) = self - .base - .split_once("://") - .unwrap_or(("http", self.base.as_str())); - format!("{scheme}://{user}:{}@{host}/{repo}.git", self.token) + pub fn plain_git_url(&self, repo: &str) -> String { + format!("{}/{repo}.git", self.base) + } + + /// The `-f/--forge` label this client resolved against (`None` for + /// the internal forge). See the `forge_label` field doc for why + /// `clone` needs this. + #[must_use] + pub fn forge_label(&self) -> Option<&str> { + self.forge_label.as_deref() } /// True when the operator passed the global `--json` flag. @@ -331,7 +338,7 @@ pub fn index(n: u64) -> Result { /// `/` or `..`) is rejected up front with the same charset spelled out, /// rather than silently building a nonsense/traversing path and /// surfacing a confusing file error later. -fn resolve_credentials(forge_label: Option<&str>) -> Result<(String, String)> { +pub(crate) fn resolve_credentials(forge_label: Option<&str>) -> Result<(String, String)> { let Some(label) = forge_label else { let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned()); let token = read_token().context("read forge-token")?; diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 736d9942..830dad12 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -176,6 +176,13 @@ enum Verb { /// Re-run CI without an empty commit. Pass one of `--pr `, /// `--run `, or `--branch `; `--workflow` defaults to `ci.yml`. CiRerun(verbs::ci_rerun::Args), + /// Git credential-helper protocol (`get|store|erase`) — not a verb + /// you run by hand. `clone` configures each checkout's + /// `credential.helper` to invoke this, so git asks it for a token + /// fresh on every fetch/push instead of one being embedded in the + /// remote URL. + #[command(hide = true)] + CredentialHelper(verbs::credential_helper::Args), } /// Wrapper over [`run`] that owns how a failure reaches the operator. @@ -197,6 +204,14 @@ fn main() -> ExitCode { fn run() -> Result<()> { let cli = Cli::parse(); + let verb = cli.verb; + // `credential-helper` bypasses the normal client construction below: + // it needs no repo (unlike `Client::from_env`, which errors without + // one), and git may invoke it from a directory that isn't a resolved + // checkout yet (mid-`clone`, before the destination exists). + if let Verb::CredentialHelper(args) = verb { + return verbs::credential_helper::run(args, cli.forge.as_deref()); + } let client = client::Client::from_env(cli.repo, cli.json, cli.forge) .context("initialize forge client")?; // Attach the resolved repo to every verb's error uniformly here, @@ -211,7 +226,7 @@ fn run() -> Result<()> { // line 1 column 0" into "repo typo-org/repo: EOF while parsing a // value at line 1 column 0", which is diagnosable on sight. let repo = client.repo().to_owned(); - dispatch(&client, cli.verb).with_context(|| format!("repo {repo}")) + dispatch(&client, verb).with_context(|| format!("repo {repo}")) } fn dispatch(client: &client::Client, verb: Verb) -> Result<()> { @@ -253,5 +268,8 @@ fn dispatch(client: &client::Client, verb: Verb) -> Result<()> { Verb::ArtifactGet(a) => verbs::artifact_get::run(client, a), Verb::CiLog(a) => verbs::ci_log::run(client, a), Verb::CiRerun(a) => verbs::ci_rerun::run(client, a), + Verb::CredentialHelper(_) => { + unreachable!("handled in `run` before client construction") + } } } diff --git a/hive-forge/src/verbs/clone.rs b/hive-forge/src/verbs/clone.rs index 0d33ad00..9409caa0 100644 --- a/hive-forge/src/verbs/clone.rs +++ b/hive-forge/src/verbs/clone.rs @@ -4,6 +4,15 @@ //! `client::Client::from_env` for the full resolution chain). Pairs //! with `pr-create --agit`: clone, edit + commit normally, then open a //! PR via the `AGit` ref. +//! +//! The clone URL itself carries no credentials, and the resulting +//! checkout's `.git/config` never gets one either: `-c +//! credential.helper=!hive-forge credential-helper` (baked into the +//! `git clone` invocation, which persists into the new repo's config) +//! makes git ask the `credential-helper` verb for a fresh token on every +//! subsequent fetch/push instead. Previously the token itself rode in +//! the clone URL and landed, durably, in every checkout's `.git/config` — +//! a real credential leaked to every clone on disk, reported by atlas. use std::process::Command; @@ -40,9 +49,17 @@ pub fn run(client: &Client, args: Args) -> Result<()> { .map(str::to_owned) .with_context(|| format!("clone: cannot derive a destination dir from repo {repo}"))?, }; - let url = client.authed_git_url(repo); + let url = client.plain_git_url(repo); + let helper = match client.forge_label() { + Some(label) => format!("!hive-forge credential-helper -f {label}"), + None => "!hive-forge credential-helper".to_owned(), + }; - let mut git_args = vec!["clone".to_owned()]; + let mut git_args = vec![ + "clone".to_owned(), + "-c".to_owned(), + format!("credential.helper={helper}"), + ]; if let Some(depth) = args.depth { git_args.push(format!("--depth={depth}")); } diff --git a/hive-forge/src/verbs/credential_helper.rs b/hive-forge/src/verbs/credential_helper.rs new file mode 100644 index 00000000..cbda9841 --- /dev/null +++ b/hive-forge/src/verbs/credential_helper.rs @@ -0,0 +1,80 @@ +//! `credential-helper ` — implements git's +//! credential-helper protocol (`gitcredentials(7)`) so a cloned repo's +//! `.git/config` only ever holds a *reference* to this command, never +//! the forge token itself. `clone` configures the new repo's +//! `credential.helper` to invoke this subcommand; git then calls it +//! fresh on every fetch/push, reading the token from its usual +//! on-disk location (the per-agent `forge-token` file, or the +//! `--forge