feat: automatic git auth and signing for sprout agents (#528)

This commit is contained in:
tlongwell-block
2026-05-11 10:38:45 -04:00
committed by GitHub
parent 91f16e326f
commit 70a691517b
15 changed files with 3138 additions and 2807 deletions
+4 -1
View File
@@ -324,7 +324,10 @@ jobs:
-p sprout-relay \
-p sprout-acp \
-p sprout-mcp \
-p git-credential-nostr
-p sprout-agent \
-p sprout-dev-mcp \
-p git-credential-nostr \
-p git-sign-nostr
desktop-build-macos:
name: Desktop Build (macOS)
Generated
+4 -1
View File
@@ -1388,7 +1388,6 @@ dependencies = [
name = "git-credential-nostr"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"nostr",
"serde_json",
@@ -3865,8 +3864,11 @@ dependencies = [
name = "sprout-dev-mcp"
version = "0.1.0"
dependencies = [
"git-credential-nostr",
"git-sign-nostr",
"ignore",
"nix",
"nostr",
"rmcp",
"schemars",
"serde",
@@ -3878,6 +3880,7 @@ dependencies = [
"tokio-util",
"tracing",
"tracing-subscriber",
"zeroize",
]
[[package]]
+4 -1
View File
@@ -6,6 +6,10 @@ rust-version.workspace = true
license.workspace = true
description = "Git credential helper that produces NIP-98 auth headers for Sprout's git server"
[lib]
name = "git_credential_nostr"
path = "src/lib.rs"
[[bin]]
name = "git-credential-nostr"
path = "src/main.rs"
@@ -13,6 +17,5 @@ path = "src/main.rs"
[dependencies]
nostr = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
zeroize = { workspace = true }
base64 = "0.22"
+234
View File
@@ -0,0 +1,234 @@
//! git-credential-nostr — NIP-98 git credential helper for Sprout.
//!
//! Git calls this via the credential helper protocol (stdin/stdout).
//! We read the request, sign a kind:27235 event, and return the base64-encoded
//! event as the credential value. Git then sends:
//! Authorization: Nostr <credential>
use std::io::{self, BufRead, Write};
use base64::Engine as _;
use nostr::nips::nip98::{HttpData, HttpMethod};
use nostr::{EventBuilder, Keys, UncheckedUrl};
use zeroize::Zeroize;
// ── helpers ──────────────────────────────────────────────────────────────────
fn git_config(key: &str) -> Option<String> {
let out = std::process::Command::new("git")
.args(["config", "--get", key])
.output()
.ok()?;
if out.status.success() {
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
None
}
}
#[cfg(unix)]
fn check_keyfile_permissions(path: &str) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(path).map_err(|e| format!("cannot stat keyfile {path}: {e}"))?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o177 != 0 {
return Err(format!(
"keyfile {path} has insecure permissions (expected 0600)"
));
}
Ok(())
}
#[cfg(not(unix))]
fn check_keyfile_permissions(path: &str) -> Result<(), String> {
eprintln!("warning: cannot check keyfile permissions on this platform ({path})");
Ok(())
}
/// Max keyfile size — nsec1 is 63 bytes; hex keys are 64 bytes. 256 is generous.
const MAX_KEYFILE_BYTES: u64 = 256;
fn load_key() -> Result<String, String> {
if let Ok(val) = std::env::var("NOSTR_PRIVATE_KEY") {
if !val.is_empty() {
return Ok(val);
}
}
let path = git_config("nostr.keyfile").ok_or_else(|| {
"no nostr key configured. Set $NOSTR_PRIVATE_KEY or git config nostr.keyfile".to_string()
})?;
check_keyfile_permissions(&path)?;
let meta = std::fs::metadata(&path).map_err(|e| format!("cannot stat keyfile {path}: {e}"))?;
if !meta.is_file() {
return Err(format!("keyfile {path} is not a regular file"));
}
if meta.len() > MAX_KEYFILE_BYTES {
return Err(format!(
"keyfile {path} exceeds {MAX_KEYFILE_BYTES}-byte size limit"
));
}
let raw =
std::fs::read_to_string(&path).map_err(|e| format!("cannot read keyfile {path}: {e}"))?;
Ok(raw.trim().to_string())
}
// ── stdin parsing ─────────────────────────────────────────────────────────────
#[derive(Default)]
struct CredRequest {
has_authtype_capability: bool,
protocol: Option<String>,
host: Option<String>,
path: Option<String>,
wwwauth: Option<String>,
}
fn parse_stdin() -> CredRequest {
let stdin = io::stdin();
let mut req = CredRequest::default();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.is_empty() {
break;
}
if line == "capability[]=authtype" {
req.has_authtype_capability = true;
} else if let Some(v) = line.strip_prefix("protocol=") {
req.protocol = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("host=") {
req.host = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("path=") {
req.path = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("wwwauth[]=") {
if v.starts_with("Nostr ") && req.wwwauth.is_none() {
req.wwwauth = Some(v.to_string());
}
}
}
req
}
fn parse_method(wwwauth: &str) -> Option<HttpMethod> {
// Strip the scheme prefix ("Nostr ") if present, then split on commas.
// Handles variations: `Nostr method="GET", realm="sprout"` and
// `Nostr method="GET",realm="sprout"` (with or without space after comma).
let params = wwwauth.strip_prefix("Nostr ").unwrap_or(wwwauth);
for param in params.split(',') {
let param = param.trim();
if let Some(rest) = param.strip_prefix("method=\"") {
let end = rest.find('"')?;
return rest[..end].parse().ok();
}
}
None
}
// ── public entry point ────────────────────────────────────────────────────────
/// Run the credential helper. Returns exit code.
/// Reads from stdin, writes to stdout. Errors go to stderr only.
pub fn run() -> i32 {
match std::env::args().nth(1).as_deref() {
Some("get") | None => {}
Some(_) => return 0, // store, erase, or unknown → silent exit 0
}
let req = parse_stdin();
if !req.has_authtype_capability {
println!();
let _ = io::stdout().flush();
return 0;
}
macro_rules! require {
($opt:expr, $msg:expr) => {
match $opt {
Some(v) => v,
None => {
eprintln!("error: {}", $msg);
return 1;
}
}
};
}
// No Nostr challenge from the server — this isn't a Sprout remote.
// Exit silently so git falls through to the next credential helper.
// This check comes FIRST so non-Sprout remotes never hit validation errors.
let wwwauth = match req.wwwauth.as_deref() {
Some(v) => v,
None => return 0,
};
let method = match parse_method(wwwauth) {
Some(m) => m,
None => return 0,
};
let protocol = require!(
req.protocol.as_deref(),
"missing protocol in credential request"
);
let host = require!(req.host.as_deref(), "missing host in credential request");
let path = require!(
req.path.as_deref(),
"credential.useHttpPath must be true for NIP-98 auth"
);
let repo_path = path
.split_once("/info/refs")
.map(|(prefix, _)| prefix)
.or_else(|| path.strip_suffix("/git-upload-pack"))
.or_else(|| path.strip_suffix("/git-receive-pack"))
.unwrap_or(path);
let url = format!("{protocol}://{host}/{repo_path}");
let mut raw_key = match load_key() {
Ok(k) => k,
Err(e) => {
eprintln!("error: {e}");
return 1;
}
};
let keys = match Keys::parse(&raw_key) {
Ok(k) => k,
Err(e) => {
raw_key.zeroize();
eprintln!("error: invalid nostr private key: {e}");
return 1;
}
};
raw_key.zeroize();
let http_data = HttpData::new(UncheckedUrl::from(url.as_str()), method);
let event = match EventBuilder::http_auth(http_data).sign_with_keys(&keys) {
Ok(e) => e,
Err(e) => {
eprintln!("error: failed to sign NIP-98 event: {e}");
return 1;
}
};
let json = match serde_json::to_string(&event) {
Ok(j) => j,
Err(e) => {
eprintln!("error: failed to serialize event: {e}");
return 1;
}
};
let credential = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
println!("capability[]=authtype");
println!("authtype=Nostr");
println!("credential={credential}");
println!("ephemeral=true");
println!("quit=true");
println!();
let _ = io::stdout().flush();
0
}
+1 -233
View File
@@ -1,235 +1,3 @@
//! git-credential-nostr — NIP-98 git credential helper for Sprout.
//!
//! Git calls this binary via the credential helper protocol (stdin/stdout).
//! We read the request, sign a kind:27235 event, and return the base64-encoded
//! event as the credential value. Git then sends:
//! Authorization: Nostr <credential>
use std::io::{self, BufRead, Write};
use std::process;
use base64::Engine as _;
use nostr::nips::nip98::{HttpData, HttpMethod};
use nostr::{EventBuilder, Keys, UncheckedUrl};
use zeroize::Zeroize;
// ── helpers ──────────────────────────────────────────────────────────────────
/// Write an error to stderr and exit 1.
/// Does NOT write to stdout — git's credential protocol interprets any stdout
/// as credential data, and a bare newline could confuse the client.
fn fail(msg: &str) -> ! {
eprintln!("error: {msg}");
process::exit(1);
}
/// Read `git config <key>` from the process environment / git config.
fn git_config(key: &str) -> Option<String> {
let out = std::process::Command::new("git")
.args(["config", "--get", key])
.output()
.ok()?;
if out.status.success() {
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
None
}
}
/// Check that a file has permissions no broader than 0600.
/// On non-Unix platforms we warn and continue.
#[cfg(unix)]
fn check_keyfile_permissions(path: &str) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(path).map_err(|e| format!("cannot stat keyfile {path}: {e}"))?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o177 != 0 {
return Err(format!(
"keyfile {path} has insecure permissions (expected 0600)"
));
}
Ok(())
}
#[cfg(not(unix))]
fn check_keyfile_permissions(path: &str) -> Result<(), String> {
eprintln!("warning: cannot check keyfile permissions on this platform ({path})");
Ok(())
}
/// Load the private key: env var first, then keyfile.
/// Returns the raw key string (nsec or hex). Caller must zeroize after use.
fn load_key() -> Result<String, String> {
// 1. Environment variable — ideal for CI/CD.
if let Ok(val) = std::env::var("NOSTR_PRIVATE_KEY") {
if !val.is_empty() {
return Ok(val);
}
}
// 2. keyfile path from git config.
let path = git_config("nostr.keyfile").ok_or_else(|| {
"no nostr key configured. Set $NOSTR_PRIVATE_KEY or git config nostr.keyfile".to_string()
})?;
check_keyfile_permissions(&path)?;
let raw =
std::fs::read_to_string(&path).map_err(|e| format!("cannot read keyfile {path}: {e}"))?;
Ok(raw.trim().to_string())
}
// ── stdin parsing ─────────────────────────────────────────────────────────────
#[derive(Default)]
struct CredRequest {
has_authtype_capability: bool,
protocol: Option<String>,
host: Option<String>,
path: Option<String>,
wwwauth: Option<String>,
}
fn parse_stdin() -> CredRequest {
let stdin = io::stdin();
let mut req = CredRequest::default();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.is_empty() {
break;
}
if line == "capability[]=authtype" {
req.has_authtype_capability = true;
} else if let Some(v) = line.strip_prefix("protocol=") {
req.protocol = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("host=") {
req.host = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("path=") {
req.path = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("wwwauth[]=") {
// Only capture Nostr challenges — ignore Basic, Bearer, etc.
if v.starts_with("Nostr ") && req.wwwauth.is_none() {
req.wwwauth = Some(v.to_string());
}
}
// ignore unknown lines
}
req
}
/// Extract the HTTP method from a WWW-Authenticate value like:
/// Nostr realm="sprout", method="GET"
///
/// Splits on ", " first to isolate parameters — prevents matching inside
/// quoted values like `realm="evil method=\"DELETE\""`.
fn parse_method(wwwauth: &str) -> Option<HttpMethod> {
for param in wwwauth.split(", ") {
let param = param.trim();
if let Some(rest) = param.strip_prefix("method=\"") {
let end = rest.find('"')?;
return rest[..end].parse().ok();
}
}
None
}
// ── main ──────────────────────────────────────────────────────────────────────
fn main() {
// Git calls credential helpers with a subcommand: get, store, or erase.
// We only handle "get" — store/erase are no-ops for ephemeral credentials.
match std::env::args().nth(1).as_deref() {
Some("get") | None => {} // proceed — None for backwards compat
Some(_) => return, // store, erase, or unknown → silent exit 0
}
let req = parse_stdin();
// Old git without authtype capability — nothing we can do.
// The blank line signals "no credential available" per git's protocol.
if !req.has_authtype_capability {
println!();
let _ = io::stdout().flush();
return;
}
// Validate required fields.
let protocol = req
.protocol
.as_deref()
.unwrap_or_else(|| fail("missing protocol in credential request"));
let host = req
.host
.as_deref()
.unwrap_or_else(|| fail("missing host in credential request"));
let path = req
.path
.as_deref()
.unwrap_or_else(|| fail("credential.useHttpPath must be true for NIP-98 auth"));
let wwwauth = req
.wwwauth
.as_deref()
.unwrap_or_else(|| fail("server did not include WWW-Authenticate header"));
let method = parse_method(wwwauth)
.unwrap_or_else(|| fail("server did not include method hint in WWW-Authenticate"));
// Sign the repo root URL — strip endpoint suffixes to get the canonical form.
//
// Git's credential helper is invoked once (for the initial info/refs GET) and the
// token is reused for subsequent requests (upload-pack, receive-pack POST). The
// server verifies against the bare repo root URL.
//
// Git's credential protocol does NOT pass query strings in the `path` field, so
// we never see `?service=...` here — just the path component.
let repo_path = path
.split_once("/info/refs")
.map(|(prefix, _)| prefix)
.or_else(|| path.strip_suffix("/git-upload-pack"))
.or_else(|| path.strip_suffix("/git-receive-pack"))
.unwrap_or(path);
let url = format!("{protocol}://{host}/{repo_path}");
// Load key, sign, then zeroize.
let mut raw_key = match load_key() {
Ok(k) => k,
Err(e) => fail(&e),
};
let keys = match Keys::parse(&raw_key) {
Ok(k) => k,
Err(e) => {
raw_key.zeroize();
fail(&format!("invalid nostr private key: {e}"));
}
};
raw_key.zeroize();
let http_data = HttpData::new(UncheckedUrl::from(url.as_str()), method);
let event = match EventBuilder::http_auth(http_data).sign_with_keys(&keys) {
Ok(e) => e,
Err(e) => fail(&format!("failed to sign NIP-98 event: {e}")),
};
let json = match serde_json::to_string(&event) {
Ok(j) => j,
Err(e) => fail(&format!("failed to serialize event: {e}")),
};
let credential = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
// Output the credential response.
println!("capability[]=authtype");
println!("authtype=Nostr");
println!("credential={credential}");
println!("ephemeral=true");
println!("quit=true");
println!();
let _ = io::stdout().flush();
std::process::exit(git_credential_nostr::run());
}
@@ -170,7 +170,9 @@ fn missing_key() {
);
}
/// `wwwauth[]` present but missing `method="..."` → exit 1, stderr mentions "method hint".
/// `wwwauth[]` present but missing `method="..."` → exit 0, no credential emitted.
/// The helper gracefully declines rather than erroring, so git can fall through
/// to the next credential helper (safe for global credential.helper config).
#[test]
fn missing_method_hint() {
let input = "capability[]=authtype\n\
@@ -184,20 +186,22 @@ fn missing_method_hint() {
let nsec = fresh_nsec();
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
assert_eq!(
out.status.code(),
Some(1),
"expected exit 1 for missing method hint"
assert!(
out.status.success(),
"expected exit 0 for missing method hint (graceful decline), got {:?}",
out.status.code()
);
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stderr.contains("method hint"),
"expected 'method hint' in stderr, got:\n{stderr}"
!stdout.contains("credential="),
"should not emit credential= when method hint is missing"
);
}
/// Input without `path=` line (useHttpPath not set) → exit 1, stderr mentions "useHttpPath".
/// The relay requires the full repo-root URL for NIP-98 verification, so the
/// credential helper cannot function without the path component.
#[test]
fn missing_path() {
let input = "capability[]=authtype\n\
+4
View File
@@ -9,6 +9,10 @@ description = "NIP-GS git commit/tag signing program using Nostr secp256k1 keys"
readme = "README.md"
publish = false # internal workspace tool, not published to crates.io
[lib]
name = "git_sign_nostr"
path = "src/lib.rs"
[[bin]]
name = "git-sign-nostr"
path = "src/main.rs"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -26,7 +26,29 @@ const MARKER_FIELD_MAX: usize = 256;
pub const MAX_MCP_SERVERS: usize = 16;
const MAX_HOOK_RESULT_BYTES: usize = 16 * 1024;
const PASSTHROUGH_ENV: &[&str] = &["PATH", "HOME", "TERM", "LANG", "LC_ALL", "TMPDIR"];
const PASSTHROUGH_ENV: &[&str] = &[
// Core
"PATH",
"HOME",
"TERM",
"LANG",
"LC_ALL",
"TMPDIR",
"XDG_CONFIG_HOME",
// SSH — required for git clone/push over SSH (git@github.com:...)
"SSH_AUTH_SOCK",
"SSH_AGENT_PID",
// Git — operator-configured helpers and transport overrides
"GIT_ASKPASS",
"GIT_SSH_COMMAND",
"GIT_CONFIG_GLOBAL",
// Sprout identity — dev-mcp writes NOSTR_PRIVATE_KEY to a keyfile then
// removes it from its own env (children never see it). SPROUT_PRIVATE_KEY
// and SPROUT_RELAY_URL are kept for the sprout CLI.
"NOSTR_PRIVATE_KEY",
"SPROUT_PRIVATE_KEY",
"SPROUT_RELAY_URL",
];
type Client = RunningService<RoleClient, ()>;
+4
View File
@@ -11,6 +11,10 @@ path = "src/main.rs"
[dependencies]
sprout-cli = { path = "../sprout-cli" }
git-credential-nostr = { path = "../git-credential-nostr" }
git-sign-nostr = { path = "../git-sign-nostr" }
nostr = { workspace = true }
zeroize = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
serde = { workspace = true }
+23 -13
View File
@@ -109,12 +109,7 @@ impl ServerHandler for DevMcp {
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
fn main() -> Result<(), Box<dyn std::error::Error>> {
let argv0 = std::env::args().next().unwrap_or_default();
let cmd = Path::new(&argv0)
.file_name()
@@ -122,20 +117,35 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.unwrap_or("")
.to_ascii_lowercase();
if cmd == "rg" {
let args: Vec<String> = std::env::args().skip(1).collect();
std::process::exit(rg::run(args));
// Multicall dispatch — sync personalities exit before any runtime is built.
// No tracing, no tokio, no allocations beyond argv parsing.
match cmd.as_str() {
"rg" => std::process::exit(rg::run(std::env::args().skip(1).collect())),
"tree" => std::process::exit(tree::run(std::env::args().skip(1).collect())),
"git-credential-nostr" => std::process::exit(git_credential_nostr::run()),
"git-sign-nostr" => std::process::exit(git_sign_nostr::run()),
_ => {}
}
if cmd == "tree" {
let args: Vec<String> = std::env::args().skip(1).collect();
std::process::exit(tree::run(args));
}
// Async personalities and MCP server mode — build the runtime.
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async_main(cmd))
}
async fn async_main(cmd: String) -> Result<(), Box<dyn std::error::Error>> {
// sprout CLI needs tokio (async HTTP client).
if cmd == "sprout" {
std::process::exit(sprout_cli::run_from_args(std::env::args()).await);
}
// MCP server mode — safe to init tracing now.
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
let cwd = std::env::current_dir()?;
let shim = shim::Shim::install()?;
let state = Arc::new(shell::SharedState::new(cwd, shim)?);
+5
View File
@@ -146,6 +146,11 @@ pub async fn run(
cmd.arg("-c").arg(&p.command);
cmd.current_dir(&workdir);
cmd.env("PATH", &state.shim.path_env);
// NOSTR_PRIVATE_KEY is already removed from this process's env (shim.rs).
// SPROUT_PRIVATE_KEY is intentionally inherited — the sprout CLI needs it.
for (k, v) in &state.shim.git_env {
cmd.env(k, v);
}
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
+183 -7
View File
@@ -1,9 +1,24 @@
use nostr::ToBech32;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use zeroize::Zeroize;
/// Session-scoped shim directory providing tools and git config to shell children.
///
/// On install:
/// 1. Creates a 0700 tempdir with symlinks back to our binary (multicall)
/// 2. If `NOSTR_PRIVATE_KEY` is set: writes a 0600 keyfile, derives the pubkey,
/// builds ephemeral `GIT_CONFIG_*` env vars, then removes the env var
/// 3. Prepends the shim dir to PATH
///
/// Shell children receive `path_env`, `git_env`, and `SPROUT_PRIVATE_KEY` (for
/// the sprout CLI). `NOSTR_PRIVATE_KEY` is removed from the process env after
/// the keyfile is written — git helpers read from the keyfile only.
/// Cleaned up on drop (TempDir).
pub struct Shim {
_dir: TempDir,
pub path_env: String,
pub git_env: Vec<(String, String)>,
}
impl Shim {
@@ -14,14 +29,17 @@ impl Shim {
set_owner_only(dir.path())?;
let self_exe = std::env::current_exe()?;
let rg_link = dir.path().join("rg");
symlink(&self_exe, &rg_link)?;
let tree_link = dir.path().join("tree");
symlink(&self_exe, &tree_link)?;
let sprout_link = dir.path().join("sprout");
symlink(&self_exe, &sprout_link)?;
// Multicall symlinks — all resolve back to this binary.
for name in [
"rg",
"tree",
"sprout",
"git-credential-nostr",
"git-sign-nostr",
] {
symlink(&self_exe, &dir.path().join(name))?;
}
let original = std::env::var_os("PATH").unwrap_or_default();
let mut new_path = std::ffi::OsString::from(dir.path());
@@ -31,13 +49,171 @@ impl Shim {
}
let path_env = new_path.to_string_lossy().into_owned();
// Read and unconditionally remove NOSTR_PRIVATE_KEY from this process's
// env. The key must never leak to child processes regardless of whether
// keyfile creation succeeds.
let mut nostr_key = std::env::var("NOSTR_PRIVATE_KEY").ok();
std::env::remove_var("NOSTR_PRIVATE_KEY");
// Ephemeral git config: write key to 0600 keyfile, derive pubkey, build
// GIT_CONFIG_* env vars for nostr auth + signing.
let git_env = match nostr_key
.as_deref()
.and_then(|k| write_keyfile(dir.path(), k))
{
Some(info) => build_git_env(&info),
None => Vec::new(),
};
if let Some(ref mut k) = nostr_key {
k.zeroize();
}
Ok(Self {
_dir: dir,
path_env,
git_env,
})
}
}
struct KeyInfo {
keyfile_path: String,
pubkey_hex: String,
npub: String,
}
/// Write the nostr private key to an owner-only file in the shim dir.
/// Returns key metadata or None if key is empty/invalid.
/// Warns to stderr if the key is invalid (operator mistake).
fn write_keyfile(shim_dir: &Path, raw: &str) -> Option<KeyInfo> {
if raw.is_empty() {
return None;
}
let keys = match nostr::Keys::parse(raw) {
Ok(k) => k,
Err(e) => {
eprintln!(
"sprout-dev-mcp: warning: NOSTR_PRIVATE_KEY is set but invalid ({e}); \
git auth/signing will be disabled"
);
return None;
}
};
let pubkey_hex = keys.public_key().to_hex();
let npub = keys
.public_key()
.to_bech32()
.unwrap_or_else(|_| pubkey_hex.clone());
let keyfile = shim_dir.join(".nostr-key");
if write_keyfile_atomic(&keyfile, raw.as_bytes()).is_err() {
eprintln!(
"sprout-dev-mcp: warning: failed to write nostr keyfile; git auth/signing disabled"
);
return None;
}
let keyfile_path = match keyfile.to_str() {
Some(s) => s.to_owned(),
None => {
eprintln!("sprout-dev-mcp: warning: tempdir path is not valid UTF-8; git auth/signing disabled");
return None;
}
};
Some(KeyInfo {
keyfile_path,
pubkey_hex,
npub,
})
}
/// Write `data` to `path` with 0600 permissions set at creation time via
/// `OpenOptions::mode()` (no window where the file is world-readable).
/// Non-Unix: plain write — acceptable inside our 0700 tempdir.
#[cfg(unix)]
fn write_keyfile_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
f.write_all(data)
}
#[cfg(not(unix))]
fn write_keyfile_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
std::fs::write(path, data)
}
/// Derive a NIP-05-style email from the pubkey and relay URL.
/// Format: `<hex_pubkey>@<relay_host>` (e.g., `ab12...cd@relay.sprout.dev`).
/// Falls back to `<hex_pubkey>@sprout` if no relay URL is configured.
fn derive_git_email(pubkey_hex: &str) -> String {
let host = std::env::var("SPROUT_RELAY_URL")
.ok()
.and_then(|url| {
// Strip scheme, port, and trailing paths
let stripped = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.or_else(|| url.strip_prefix("wss://"))
.or_else(|| url.strip_prefix("ws://"))
.unwrap_or(&url);
let host_port = stripped.split('/').next()?;
// Strip port number (e.g., "localhost:3000" → "localhost")
Some(host_port.split(':').next().unwrap_or(host_port).to_owned())
})
.filter(|h| !h.is_empty() && !h.starts_with("localhost") && !h.starts_with("127."))
.unwrap_or_else(|| "sprout".to_owned());
format!("{pubkey_hex}@{host}")
}
/// Build GIT_CONFIG_COUNT/KEY/VALUE env vars for ephemeral nostr git config.
/// Composes with any existing GIT_CONFIG_COUNT in the environment. When launched
/// via sprout-agent (which clears env), the base is always 0 — composition only
/// matters when dev-mcp is run directly with pre-existing GIT_CONFIG vars.
fn build_git_env(info: &KeyInfo) -> Vec<(String, String)> {
let email = derive_git_email(&info.pubkey_hex);
let entries: Vec<(&str, String)> = vec![
// Identity — npub as display name, NIP-05-style email
("user.name", info.npub.clone()),
("user.email", email),
// Nostr credential helper is additive — it silently declines non-Sprout
// remotes (exits 0, no credential), so git falls through to system
// helpers (osxkeychain, store, etc.) for GitHub/GitLab/etc.
("credential.helper", "nostr".into()),
// Required: Sprout relay verifies NIP-98 against the full repo-root URL.
// Without useHttpPath, git only passes the host and auth is rejected.
("credential.useHttpPath", "true".into()),
("nostr.keyfile", info.keyfile_path.clone()),
("gpg.format", "x509".into()),
("gpg.x509.program", "git-sign-nostr".into()),
("commit.gpgSign", "true".into()),
("tag.gpgSign", "true".into()),
("user.signingkey", info.pubkey_hex.clone()),
];
// Compose with existing GIT_CONFIG_COUNT — don't clobber caller's config.
let base: usize = std::env::var("GIT_CONFIG_COUNT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let mut env = Vec::with_capacity(entries.len() * 2 + 1);
env.push((
"GIT_CONFIG_COUNT".into(),
(base + entries.len()).to_string(),
));
for (i, (key, val)) in entries.iter().enumerate() {
let idx = base + i;
env.push((format!("GIT_CONFIG_KEY_{idx}"), key.to_string()));
env.push((format!("GIT_CONFIG_VALUE_{idx}"), val.to_string()));
}
env
}
#[cfg(unix)]
fn set_owner_only(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
# Build a release tarball containing sprout-agent + sprout-dev-mcp.
# Usage: ./scripts/build-agent-release.sh [version]
# TARGET=aarch64-unknown-linux-musl ./scripts/build-agent-release.sh 0.1.0
# Output: dist/sprout-agent-v<version>-<target>.tar.gz
VERSION="${1:-0.1.0}"
HOST_TARGET="$(rustc -vV | sed -n 's|host: ||p')"
TARGET="${TARGET:-$HOST_TARGET}"
DIST_DIR="dist"
echo "Building sprout-agent release v${VERSION} for ${TARGET}..."
# Build release binaries — use --target only when cross-compiling.
if [[ "$TARGET" == "$HOST_TARGET" ]]; then
cargo build --release -p sprout-agent -p sprout-dev-mcp
BIN_DIR="target/release"
else
cargo build --release --target "$TARGET" -p sprout-agent -p sprout-dev-mcp
BIN_DIR="target/${TARGET}/release"
fi
# Verify binaries exist
for bin in sprout-agent sprout-dev-mcp; do
if [[ ! -f "${BIN_DIR}/${bin}" ]]; then
echo "error: ${BIN_DIR}/${bin} not found" >&2
exit 1
fi
done
# Package
mkdir -p "${DIST_DIR}"
ARCHIVE_NAME="sprout-agent-v${VERSION}-${TARGET}.tar.gz"
STAGING=$(mktemp -d)
trap 'rm -rf "${STAGING}"' EXIT
cp "${BIN_DIR}/sprout-agent" "${STAGING}/"
cp "${BIN_DIR}/sprout-dev-mcp" "${STAGING}/"
cat > "${STAGING}/README.md" << 'EOF'
# Sprout Agent
Minimal ACP agent + developer MCP toolchain.
## Contents
- `sprout-agent` — ACP-compliant agent (spawns MCP servers, calls LLMs)
- `sprout-dev-mcp` — Developer MCP server (shell, str_replace, todo, rg, tree,
sprout CLI, git-credential-nostr, git-sign-nostr)
## Quick Start
```bash
# Place both binaries on your PATH
export PATH="/path/to/this/dir:$PATH"
# Set required env vars
export SPROUT_AGENT_PROVIDER=anthropic # or openai
export ANTHROPIC_API_KEY=sk-...
export ANTHROPIC_MODEL=claude-sonnet-4-20250514
# Nostr identity (same key for git auth, signing, and relay CLI)
export NOSTR_PRIVATE_KEY=nsec1...
export SPROUT_PRIVATE_KEY=$NOSTR_PRIVATE_KEY
export SPROUT_RELAY_URL=https://your-relay.example.com
```
## Git Integration
When `NOSTR_PRIVATE_KEY` is set, the dev-mcp automatically configures git to
use nostr-based credential auth and commit signing for all shell commands.
This is ephemeral (session-scoped via `GIT_CONFIG_*` env vars) — your
persistent git config is never modified.
The nostr credential helper is additive: it silently declines non-Sprout
remotes so git falls through to your system credential helpers for GitHub,
GitLab, etc. `NOSTR_PRIVATE_KEY` is written to a 0600 keyfile and removed
from the process environment — shell commands cannot read it from env.
Set `SPROUT_PRIVATE_KEY` to the same key for the `sprout` relay CLI.
## Multicall Binary
`sprout-dev-mcp` is a multicall binary. When symlinked/invoked as:
- `rg` — ripgrep-compatible search
- `tree` — directory tree with line counts
- `sprout` — Sprout relay CLI
- `git-credential-nostr` — NIP-98 git credential helper
- `git-sign-nostr` — NIP-GS git commit/tag signing
EOF
tar -czf "${DIST_DIR}/${ARCHIVE_NAME}" -C "${STAGING}" .
echo "Built: ${DIST_DIR}/${ARCHIVE_NAME}"
ls -lh "${DIST_DIR}/${ARCHIVE_NAME}"