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