use std::path::Path;
use devboy_core::alias::{AliasResolverError, SecretResolver, parse_alias};
use devboy_core::secret_approval::ApprovalGatedResolver;
use secrecy::{ExposeSecret, SecretString};
use thiserror::Error;
use tracing::{debug, warn};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Substitution {
pub path: String,
pub argv_index: usize,
pub strategy: SubstitutionStrategy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubstitutionStrategy {
Argv,
Stdin,
}
#[derive(Debug)]
pub struct RewritePlan {
pub argv: Vec<String>,
pub stdin_payload: Option<SecretString>,
pub substitutions: Vec<Substitution>,
pub argv_visible: bool,
}
#[derive(Debug, Error)]
pub enum ArgvRewriteError {
#[error("failed to resolve alias `@secret:{path}`: {source_error}")]
Resolve {
path: String,
#[source]
source_error: AliasResolverError,
},
}
pub fn rewrite_argv<R, F>(
program: &str,
argv: &[String],
resolver: &ApprovalGatedResolver<R, F>,
) -> Result<RewritePlan, ArgvRewriteError>
where
R: SecretResolver,
F: Fn(&str) -> devboy_core::secret_approval::ApproveOnUsePolicy + Send + Sync,
{
let strategy = ToolStrategy::detect(program, argv);
let mut out_argv = Vec::with_capacity(argv.len());
let mut substitutions = Vec::new();
let mut stdin_pieces: Vec<SecretString> = Vec::new();
let mut argv_visible = false;
for (idx, raw) in argv.iter().enumerate() {
let Some(path) = parse_alias(raw) else {
out_argv.push(raw.clone());
continue;
};
let value = resolver
.resolve(path)
.map_err(|source_error| ArgvRewriteError::Resolve {
path: path.to_owned(),
source_error,
})?;
if strategy.uses_stdin_for(idx, argv) {
stdin_pieces.push(value);
substitutions.push(Substitution {
path: path.to_owned(),
argv_index: idx,
strategy: SubstitutionStrategy::Stdin,
});
} else {
argv_visible = true;
out_argv.push(value.expose_secret().to_owned());
substitutions.push(Substitution {
path: path.to_owned(),
argv_index: idx,
strategy: SubstitutionStrategy::Argv,
});
warn!(
program,
index = idx,
path,
"argv-substituted secret will be visible to other processes via `ps`; \
prefer a tool that accepts the value via stdin/FD"
);
}
}
let stdin_payload = if stdin_pieces.is_empty() {
None
} else {
let joined = stdin_pieces
.iter()
.map(|s| s.expose_secret().to_owned())
.collect::<Vec<_>>()
.join("\n")
+ "\n";
Some(SecretString::from(joined))
};
debug!(
program,
substitutions = substitutions.len(),
argv_visible,
"argv-secret rewrite complete"
);
Ok(RewritePlan {
argv: out_argv,
stdin_payload,
substitutions,
argv_visible,
})
}
pub fn apply_plan_to_command(plan: &RewritePlan, cmd: &mut tokio::process::Command) {
if !plan.substitutions.is_empty() {
cmd.args(&plan.argv);
}
if plan.stdin_payload.is_some() {
cmd.stdin(std::process::Stdio::piped());
}
}
struct ToolStrategy {
kind: Known,
}
#[derive(Debug, Clone, Copy)]
enum Known {
GhAuthLoginWithToken {
token_index: usize,
},
GitCredential,
Fallback,
}
impl ToolStrategy {
fn detect(program: &str, argv: &[String]) -> Self {
let basename = Path::new(program)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(program);
match basename {
"gh" => detect_gh(argv),
"git" => detect_git(argv),
_ => ToolStrategy {
kind: Known::Fallback,
},
}
}
fn uses_stdin_for(&self, argv_index: usize, _argv: &[String]) -> bool {
match self.kind {
Known::GhAuthLoginWithToken { token_index } => argv_index == token_index,
Known::GitCredential => true,
Known::Fallback => false,
}
}
}
fn detect_gh(argv: &[String]) -> ToolStrategy {
let auth_idx = argv.iter().position(|a| a == "auth");
let login_idx = argv.iter().position(|a| a == "login");
let with_token_idx = argv.iter().position(|a| a == "--with-token");
if let (Some(a), Some(l), Some(t)) = (auth_idx, login_idx, with_token_idx)
&& a < l
&& l < t
{
let token_idx = t + 1;
if token_idx < argv.len() {
let candidate = &argv[token_idx];
if parse_alias(candidate).is_some() {
return ToolStrategy {
kind: Known::GhAuthLoginWithToken {
token_index: token_idx,
},
};
}
}
}
ToolStrategy {
kind: Known::Fallback,
}
}
fn detect_git(argv: &[String]) -> ToolStrategy {
if argv.first().map(String::as_str) == Some("credential") {
return ToolStrategy {
kind: Known::GitCredential,
};
}
ToolStrategy {
kind: Known::Fallback,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
struct MapResolver {
entries: Mutex<HashMap<String, String>>,
}
impl MapResolver {
fn new(pairs: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
Self {
entries: Mutex::new(
pairs
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect(),
),
}
}
}
fn always_never(_: &str) -> devboy_core::secret_approval::ApproveOnUsePolicy {
devboy_core::secret_approval::ApproveOnUsePolicy::Never
}
fn wrap_resolver_never(
r: MapResolver,
) -> ApprovalGatedResolver<
MapResolver,
fn(&str) -> devboy_core::secret_approval::ApproveOnUsePolicy,
> {
ApprovalGatedResolver::new(
r,
std::sync::Arc::new(devboy_core::secret_approval::SessionApprovalCache::new()),
always_never,
)
}
impl SecretResolver for MapResolver {
fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
let map = self.entries.lock().unwrap();
match map.get(path) {
Some(v) => Ok(SecretString::from(v.clone())),
None => Err(AliasResolverError::NotFound {
path: path.to_owned(),
}),
}
}
}
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn no_alias_in_argv_is_a_passthrough_with_no_substitutions() {
let resolver = MapResolver::new([]);
let plan = rewrite_argv(
"any-tool",
&argv(&["--flag", "value"]),
&wrap_resolver_never(resolver),
)
.unwrap();
assert_eq!(plan.argv, vec!["--flag".to_owned(), "value".to_owned()]);
assert!(plan.stdin_payload.is_none());
assert!(plan.substitutions.is_empty());
assert!(!plan.argv_visible);
}
#[test]
fn unknown_tool_falls_back_to_argv_substitution() {
let resolver = MapResolver::new([("personal/github/pat", "ghp-fixture")]);
let plan = rewrite_argv(
"some-tool",
&argv(&["--token", "@secret:personal/github/pat"]),
&wrap_resolver_never(resolver),
)
.unwrap();
assert_eq!(
plan.argv,
vec!["--token".to_owned(), "ghp-fixture".to_owned()]
);
assert!(plan.stdin_payload.is_none());
assert_eq!(plan.substitutions.len(), 1);
assert_eq!(plan.substitutions[0].strategy, SubstitutionStrategy::Argv);
assert_eq!(plan.substitutions[0].argv_index, 1);
assert!(plan.argv_visible);
}
#[test]
fn fallback_warns_via_argv_visible_flag() {
let resolver = MapResolver::new([("a/b/c", "v")]);
let plan = rewrite_argv(
"some-tool",
&argv(&["@secret:a/b/c"]),
&wrap_resolver_never(resolver),
)
.unwrap();
assert!(plan.argv_visible);
}
#[test]
fn gh_auth_login_with_token_routes_through_stdin() {
use secrecy::ExposeSecret;
let resolver = MapResolver::new([("personal/github/pat", "ghp-fixture")]);
let plan = rewrite_argv(
"gh",
&argv(&[
"auth",
"login",
"--with-token",
"@secret:personal/github/pat",
]),
&wrap_resolver_never(resolver),
)
.unwrap();
assert_eq!(
plan.argv,
vec![
"auth".to_owned(),
"login".to_owned(),
"--with-token".to_owned()
]
);
assert!(plan.argv.iter().all(|a| !a.contains("ghp-fixture")));
let payload = plan.stdin_payload.unwrap();
assert_eq!(payload.expose_secret(), "ghp-fixture\n");
assert_eq!(plan.substitutions.len(), 1);
assert_eq!(plan.substitutions[0].strategy, SubstitutionStrategy::Stdin);
assert!(!plan.argv_visible, "stdin path must NOT mark argv visible");
}
#[test]
fn gh_with_absolute_path_still_recognised() {
let resolver = MapResolver::new([("p/g/p", "v")]);
let plan = rewrite_argv(
"/usr/local/bin/gh",
&argv(&["auth", "login", "--with-token", "@secret:p/g/p"]),
&wrap_resolver_never(resolver),
)
.unwrap();
assert!(!plan.argv_visible);
}
#[test]
fn gh_without_with_token_falls_back_to_argv() {
let resolver = MapResolver::new([("p/g/p", "v")]);
let plan = rewrite_argv(
"gh",
&argv(&["repo", "view", "--token", "@secret:p/g/p"]),
&wrap_resolver_never(resolver),
)
.unwrap();
assert!(plan.argv_visible);
assert!(plan.argv.contains(&"v".to_owned()));
}
#[test]
fn git_credential_routes_alias_args_through_stdin() {
use secrecy::ExposeSecret;
let resolver = MapResolver::new([("svc/git/cred", "credential-fixture")]);
let plan = rewrite_argv(
"git",
&argv(&["credential", "fill", "@secret:svc/git/cred"]),
&wrap_resolver_never(resolver),
)
.unwrap();
assert_eq!(plan.argv, vec!["credential".to_owned(), "fill".to_owned()]);
let payload = plan.stdin_payload.unwrap();
assert_eq!(payload.expose_secret(), "credential-fixture\n");
assert_eq!(plan.substitutions[0].strategy, SubstitutionStrategy::Stdin);
assert!(!plan.argv_visible);
}
#[test]
fn git_non_credential_uses_argv_fallback() {
let resolver = MapResolver::new([("a/b/c", "v")]);
let plan = rewrite_argv(
"git",
&argv(&["push", "--token", "@secret:a/b/c"]),
&wrap_resolver_never(resolver),
)
.unwrap();
assert!(plan.argv_visible);
}
#[test]
fn multiple_argv_aliases_each_get_an_audit_entry() {
let resolver = MapResolver::new([("a/b/c", "v1"), ("d/e/f", "v2")]);
let plan = rewrite_argv(
"tool",
&argv(&["@secret:a/b/c", "literal", "@secret:d/e/f"]),
&wrap_resolver_never(resolver),
)
.unwrap();
assert_eq!(
plan.argv,
vec!["v1".to_owned(), "literal".to_owned(), "v2".to_owned()]
);
assert_eq!(plan.substitutions.len(), 2);
assert!(plan.argv_visible);
}
#[test]
fn alias_inside_a_longer_arg_is_not_rewritten() {
let resolver = MapResolver::new([("a/b/c", "v")]);
let plan = rewrite_argv(
"tool",
&argv(&["Bearer @secret:a/b/c"]),
&wrap_resolver_never(resolver),
)
.unwrap();
assert_eq!(plan.argv, vec!["Bearer @secret:a/b/c".to_owned()]);
assert!(plan.substitutions.is_empty());
}
#[test]
fn unknown_alias_path_propagates_resolver_error() {
let resolver = MapResolver::new([]);
let err = rewrite_argv(
"tool",
&argv(&["--token", "@secret:nope/nope/nope"]),
&wrap_resolver_never(resolver),
)
.unwrap_err();
match err {
ArgvRewriteError::Resolve { path, .. } => {
assert_eq!(path, "nope/nope/nope");
}
}
}
#[cfg(unix)]
#[tokio::test]
async fn apply_plan_to_command_pipes_stdin_to_child() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let resolver = MapResolver::new([("personal/github/pat", "ghp-fixture")]);
let plan = rewrite_argv(
"gh",
&argv(&[
"auth",
"login",
"--with-token",
"@secret:personal/github/pat",
]),
&wrap_resolver_never(resolver),
)
.unwrap();
for arg in &plan.argv {
assert!(!arg.contains("ghp-fixture"));
}
let mut cmd = tokio::process::Command::new("/bin/cat");
if plan.stdin_payload.is_some() {
cmd.stdin(std::process::Stdio::piped());
}
cmd.stdout(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("spawn /bin/cat");
if let Some(secret) = &plan.stdin_payload {
let mut stdin = child.stdin.take().unwrap();
stdin
.write_all(secret.expose_secret().as_bytes())
.await
.unwrap();
stdin.shutdown().await.unwrap();
drop(stdin);
}
let mut stdout = child.stdout.take().unwrap();
let mut buf = String::new();
stdout.read_to_string(&mut buf).await.unwrap();
let _ = child.wait().await;
assert_eq!(buf, "ghp-fixture\n");
}
}