use super::traits::{Tool, ToolResult};
use crate::config::GoogleAgentsCliConfig;
use crate::security::SecurityPolicy;
use crate::security::policy::ToolOperation;
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
use tokio::process::Command;
const SAFE_ENV_VARS: &[&str] = &[
"PATH",
"HOME",
"TERM",
"LANG",
"LC_ALL",
"LC_CTYPE",
"USER",
"SHELL",
"TMPDIR",
"GOOGLE_APPLICATION_CREDENTIALS",
"GOOGLE_CLOUD_PROJECT",
"GOOGLE_CLOUD_LOCATION",
"GOOGLE_GENAI_USE_VERTEXAI",
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
"GEMINI_ENTERPRISE_APP_ID",
];
const ALLOWED_TOP_LEVEL_COMMANDS: &[&str] = &[
"cmd-info",
"create",
"data-ingestion",
"deploy",
"eval",
"infra",
"info",
"install",
"lint",
"login",
"playground",
"publish",
"run",
"scaffold",
"setup",
"update",
];
pub struct GoogleAgentsCliTool {
security: Arc<SecurityPolicy>,
config: GoogleAgentsCliConfig,
}
impl GoogleAgentsCliTool {
pub fn new(security: Arc<SecurityPolicy>, config: GoogleAgentsCliConfig) -> Self {
Self { security, config }
}
}
#[async_trait]
impl Tool for GoogleAgentsCliTool {
fn name(&self) -> &str {
"google_agents_cli"
}
fn description(&self) -> &str {
"Run Google Agents CLI (agents-cli) lifecycle commands for ADK/A2A agents: setup, create, scaffold, install, lint, run, eval, deploy, publish, infra, data-ingestion, playground, update, login --status, and info. Use for Google Agent Platform workflows, not as a generic shell."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"command": {
"type": "array",
"items": { "type": "string" },
"description": "Arguments after `agents-cli`, e.g. [\"run\"], [\"deploy\", \"--no-wait\"], [\"eval\", \"run\"], or [\"publish\", \"gemini-enterprise\", \"--list\"]. If omitted with `prompt`, defaults to [\"run\"]."
},
"prompt": {
"type": "string",
"description": "Prompt appended to `agents-cli run`. Use this instead of embedding long ADK project prompts in command tokens."
},
"working_directory": {
"type": "string",
"description": "Working directory within the workspace (must be inside workspace_dir). For most commands this should be an agents-cli project root."
},
"allow_interactive": {
"type": "boolean",
"description": "Allow interactive flags such as --interactive/-i. Defaults to false because Revka tools run non-interactively."
}
}
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
if self.security.is_rate_limited() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Rate limit exceeded: too many actions in the last hour".into()),
});
}
if let Err(error) = self
.security
.enforce_tool_operation(ToolOperation::Act, "google_agents_cli")
{
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error),
});
}
let prompt = args.get("prompt").and_then(|v| v.as_str());
let mut cli_args = match normalize_command(&args) {
Ok(command) => command,
Err(error) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error),
});
}
};
if cli_args.is_empty() {
if prompt.is_some() {
cli_args.push("run".to_string());
} else {
return Err(anyhow::anyhow!(
"Missing 'command' parameter (or provide 'prompt' to default to agents-cli run)"
));
}
}
let allow_interactive = args
.get("allow_interactive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if let Err(error) = validate_command(&cli_args, allow_interactive) {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error),
});
}
if let Some(text) = prompt {
if text.contains('\0') {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("prompt contains a NUL byte".into()),
});
}
if cli_args.first().is_some_and(|cmd| cmd == "run") {
cli_args.push(text.to_string());
} else {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'prompt' is only valid with `command = [\"run\", ...]`".into()),
});
}
}
let work_dir = if let Some(wd) = args.get("working_directory").and_then(|v| v.as_str()) {
match validate_working_directory(wd, &self.security.workspace_dir) {
Ok(path) => path,
Err(error) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error.to_string()),
});
}
}
} else {
self.security.workspace_dir.clone()
};
if !self.security.record_action() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Rate limit exceeded: action budget exhausted".into()),
});
}
let agents_cli_bin = if cfg!(target_os = "windows") {
"agents-cli.exe"
} else {
"agents-cli"
};
let mut cmd = Command::new(agents_cli_bin);
cmd.args(&cli_args);
cmd.env_clear();
for var in SAFE_ENV_VARS {
if let Ok(val) = std::env::var(var) {
cmd.env(var, val);
}
}
for var in &self.config.env_passthrough {
let trimmed = var.trim();
if !trimmed.is_empty() {
if let Ok(val) = std::env::var(trimmed) {
cmd.env(trimmed, val);
}
}
}
cmd.current_dir(&work_dir);
let timeout = Duration::from_secs(self.config.timeout_secs);
cmd.kill_on_drop(true);
let result = tokio::time::timeout(timeout, cmd.output()).await;
match result {
Ok(Ok(output)) => {
let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
truncate_to_bytes(
&mut stdout,
self.config.max_output_bytes,
"\n... [output truncated]",
);
truncate_to_bytes(
&mut stderr,
self.config.max_output_bytes,
"\n... [stderr truncated]",
);
Ok(ToolResult {
success: output.status.success(),
output: stdout,
error: if stderr.is_empty() {
None
} else {
Some(stderr)
},
})
}
Ok(Err(e)) => {
let err_msg = e.to_string();
let msg = if err_msg.contains("No such file or directory")
|| err_msg.contains("not found")
|| err_msg.contains("cannot find")
{
"Google Agents CLI ('agents-cli') not found in PATH. Install with: uvx google-agents-cli setup".into()
} else {
format!("Failed to execute agents-cli: {e}")
};
Ok(ToolResult {
success: false,
output: String::new(),
error: Some(msg),
})
}
Err(_) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Google Agents CLI timed out after {}s and was killed",
self.config.timeout_secs
)),
}),
}
}
}
fn normalize_command(args: &serde_json::Value) -> Result<Vec<String>, String> {
match args.get("command") {
None | Some(serde_json::Value::Null) => Ok(Vec::new()),
Some(serde_json::Value::String(command)) => Ok(vec![command.to_owned()]),
Some(serde_json::Value::Array(command)) => {
let mut parts = Vec::with_capacity(command.len());
for part in command {
let Some(text) = part.as_str() else {
return Err("agents-cli command tokens must be strings".into());
};
parts.push(text.to_owned());
}
Ok(parts)
}
Some(_) => Err("agents-cli command must be a string or array of strings".into()),
}
}
fn truncate_to_bytes(text: &mut String, max_bytes: usize, marker: &str) {
if text.len() <= max_bytes {
return;
}
let mut b = max_bytes.min(text.len());
while b > 0 && !text.is_char_boundary(b) {
b -= 1;
}
text.truncate(b);
text.push_str(marker);
}
fn validate_working_directory(
wd: &str,
workspace: &std::path::Path,
) -> anyhow::Result<std::path::PathBuf> {
let wd_path = std::path::PathBuf::from(wd);
let candidate = if wd_path.is_absolute() {
wd_path
} else {
workspace.join(wd_path)
};
let canonical_wd = match candidate.canonicalize() {
Ok(p) => p,
Err(_) => {
return Err(anyhow::anyhow!(
"working_directory '{}' does not exist or is not accessible",
wd
));
}
};
let canonical_ws = match workspace.canonicalize() {
Ok(p) => p,
Err(_) => {
return Err(anyhow::anyhow!(
"workspace directory '{}' does not exist or is not accessible",
workspace.display()
));
}
};
if !canonical_wd.starts_with(&canonical_ws) {
return Err(anyhow::anyhow!(
"working_directory '{}' is outside the workspace '{}'",
wd,
workspace.display()
));
}
Ok(canonical_wd)
}
fn validate_command(args: &[String], allow_interactive: bool) -> Result<(), String> {
let Some(first) = args.first().map(|s| s.trim()) else {
return Err("agents-cli command must not be empty".into());
};
if first.is_empty() {
return Err("agents-cli command must not start with an empty token".into());
}
if !ALLOWED_TOP_LEVEL_COMMANDS.contains(&first) {
return Err(format!(
"Unsupported agents-cli command '{}'. Allowed commands: {}",
first,
ALLOWED_TOP_LEVEL_COMMANDS.join(", ")
));
}
for arg in args {
if arg.is_empty() {
return Err("agents-cli command contains an empty token".into());
}
if arg.trim() != arg {
return Err(
"agents-cli command tokens must not include leading or trailing whitespace".into(),
);
}
if arg.contains('\0') {
return Err("agents-cli command contains a NUL byte".into());
}
if !allow_interactive && (arg == "-i" || arg == "--interactive") {
return Err("Interactive agents-cli flags are disabled by default".into());
}
}
if first == "login"
&& !args.iter().any(|arg| arg == "--status" || arg == "status")
&& !allow_interactive
{
return Err(
"Use `agents-cli login --status`; interactive login must be done outside Revka".into(),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::GoogleAgentsCliConfig;
use crate::security::{AutonomyLevel, SecurityPolicy};
fn test_config() -> GoogleAgentsCliConfig {
GoogleAgentsCliConfig::default()
}
fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
Arc::new(SecurityPolicy {
autonomy,
workspace_dir: std::env::temp_dir(),
..SecurityPolicy::default()
})
}
#[test]
fn google_agents_cli_tool_name() {
let tool =
GoogleAgentsCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
assert_eq!(tool.name(), "google_agents_cli");
}
#[test]
fn google_agents_cli_tool_schema_has_command_and_prompt() {
let tool =
GoogleAgentsCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
let schema = tool.parameters_schema();
assert!(schema["properties"]["command"].is_object());
assert!(schema["properties"]["prompt"].is_object());
}
#[tokio::test]
async fn google_agents_cli_blocks_rate_limited() {
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
workspace_dir: std::env::temp_dir(),
max_actions_per_hour: 0,
..SecurityPolicy::default()
});
let tool = GoogleAgentsCliTool::new(security, test_config());
let result = tool
.execute(json!({ "command": ["login", "--status"] }))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("Rate limit"));
}
#[tokio::test]
async fn google_agents_cli_blocks_readonly() {
let tool = GoogleAgentsCliTool::new(test_security(AutonomyLevel::ReadOnly), test_config());
let result = tool
.execute(json!({ "command": ["login", "--status"] }))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("read-only mode"));
}
#[tokio::test]
async fn google_agents_cli_missing_command_and_prompt() {
let tool =
GoogleAgentsCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
let err = tool.execute(json!({})).await.unwrap_err();
assert!(err.to_string().contains("Missing 'command'"));
}
#[tokio::test]
async fn google_agents_cli_rejects_interactive_login() {
let tool =
GoogleAgentsCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
let result = tool
.execute(json!({ "command": ["login", "--interactive"] }))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("Interactive"));
}
#[tokio::test]
async fn google_agents_cli_rejects_non_string_command_tokens() {
let tool =
GoogleAgentsCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
let result = tool
.execute(json!({ "command": ["run", 1] }))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("tokens must be strings"));
}
#[tokio::test]
async fn google_agents_cli_rejects_path_outside_workspace() {
let tmp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Full,
workspace_dir: tmp.path().to_path_buf(),
..SecurityPolicy::default()
});
let tool = GoogleAgentsCliTool::new(security, test_config());
let result = tool
.execute(json!({
"command": ["login", "--status"],
"working_directory": outside.path().to_string_lossy()
}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("outside the workspace"));
}
#[tokio::test]
async fn google_agents_cli_accepts_relative_path_inside_workspace() {
let tmp = tempfile::tempdir().unwrap();
let child = tmp.path().join("agent-project");
std::fs::create_dir(&child).unwrap();
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Full,
workspace_dir: tmp.path().to_path_buf(),
..SecurityPolicy::default()
});
let tool = GoogleAgentsCliTool::new(security, test_config());
let result = tool
.execute(json!({
"command": ["login", "--status"],
"working_directory": "agent-project"
}))
.await
.unwrap();
assert!(
!result
.error
.unwrap_or_default()
.contains("outside the workspace")
);
}
#[test]
fn google_agents_cli_env_passthrough_defaults() {
let config = GoogleAgentsCliConfig::default();
assert!(config.env_passthrough.is_empty());
}
#[test]
fn google_agents_cli_safe_env_includes_enterprise_publish_id() {
assert!(SAFE_ENV_VARS.contains(&"GEMINI_ENTERPRISE_APP_ID"));
}
#[test]
fn google_agents_cli_default_config_values() {
let config = GoogleAgentsCliConfig::default();
assert!(!config.enabled);
assert_eq!(config.timeout_secs, 600);
assert_eq!(config.max_output_bytes, 2_097_152);
}
#[test]
fn google_agents_cli_accepts_string_command_shape() {
let command = normalize_command(&json!({ "command": "info" })).unwrap();
assert_eq!(command, vec!["info"]);
}
#[test]
fn google_agents_cli_rejects_command_object_shape() {
let error = normalize_command(&json!({ "command": { "name": "info" } })).unwrap_err();
assert!(error.contains("string or array"));
}
#[test]
fn google_agents_cli_truncates_stderr_without_splitting_utf8() {
let mut stderr = "éééé".to_string();
truncate_to_bytes(&mut stderr, 5, "\n... [stderr truncated]");
assert_eq!(stderr, "éé\n... [stderr truncated]");
}
#[test]
fn google_agents_cli_accepts_current_info_command() {
assert!(validate_command(&["info".to_string()], false).is_ok());
}
#[test]
fn google_agents_cli_accepts_public_lifecycle_commands() {
for command in [
"setup",
"create",
"scaffold",
"install",
"lint",
"run",
"eval",
"deploy",
"publish",
"infra",
"data-ingestion",
"playground",
"update",
"login",
"info",
] {
let args = if command == "login" {
vec![command.to_string(), "--status".to_string()]
} else {
vec![command.to_string()]
};
assert!(
validate_command(&args, false).is_ok(),
"{command} should be allowed"
);
}
}
}