mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: automatic git auth and signing for sprout agents (#528)
This commit is contained in:
@@ -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
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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\
|
||||
|
||||
@@ -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
@@ -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, ()>;
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)?);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
Executable
+97
@@ -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}"
|
||||
Reference in New Issue
Block a user