use anyhow::{format_err, Result};
use dialoguer::Input;
use serde::Deserialize;
use serde_json::Value;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::str::FromStr;
use crate::review::comment::common::Position;
use crate::review::comment::{Comment, Selection};
use crate::review::common::{Priority, ReviewConfidence};
const CODEX_APPROVAL_POLICY: &str = "never";
const CODEX_ALLOWED_ENV: &[&str] = &[
"ALL_PROXY",
"CODEX_HOME",
"HOME",
"HTTP_PROXY",
"HTTPS_PROXY",
"LANG",
"LC_ALL",
"LC_CTYPE",
"NIX_SSL_CERT_FILE",
"NO_PROXY",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"PATH",
"SSL_CERT_DIR",
"SSL_CERT_FILE",
"TEMP",
"TERM",
"TMP",
"TMPDIR",
];
const CODEX_SANDBOX_MODE: &str = "read-only";
const CLAUDE_ALLOWED_ENV: &[&str] = &[
"ALL_PROXY",
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"CLAUDE_CONFIG_DIR",
"HOME",
"HTTP_PROXY",
"HTTPS_PROXY",
"LANG",
"LC_ALL",
"LC_CTYPE",
"NIX_SSL_CERT_FILE",
"NO_PROXY",
"PATH",
"SSL_CERT_DIR",
"SSL_CERT_FILE",
"TEMP",
"TERM",
"TMP",
"TMPDIR",
];
const CLAUDE_PERMISSION_MODE: &str = "dontAsk";
const REVIEW_STRATEGY: &str = "package-release/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentKind {
Codex,
Claude,
}
impl AgentKind {
fn binary_name(&self) -> &'static str {
match self {
AgentKind::Codex => "codex",
AgentKind::Claude => "claude",
}
}
pub fn name(&self) -> &'static str {
match self {
AgentKind::Codex => "codex",
AgentKind::Claude => "claude",
}
}
pub fn is_installed(&self) -> bool {
is_command_available(self.binary_name())
}
pub fn from_name(name: &str) -> Option<Self> {
match name.to_ascii_lowercase().as_str() {
"codex" => Some(AgentKind::Codex),
"claude" => Some(AgentKind::Claude),
_ => None,
}
}
}
pub struct AgentRunResult {
pub model: String,
pub comments: Vec<Comment>,
pub summary: Option<String>,
pub confidence: Option<ReviewConfidence>,
}
#[derive(Debug, Deserialize)]
struct AgentOutput {
model: String,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
confidence: Option<ReviewConfidence>,
comments: Vec<AgentComment>,
}
#[derive(Debug, Deserialize)]
struct AgentComment {
comment: String,
security: Priority,
complexity: Priority,
#[serde(rename = "file")]
path: PathBuf,
#[serde(default)]
selection: Option<Selection>,
}
impl AgentComment {
fn into_comment(self) -> Comment {
Comment {
id: 0,
security: self.security,
complexity: self.complexity,
path: self.path,
message: self.comment,
selection: self.selection,
}
}
}
pub fn review_strategy() -> &'static str {
REVIEW_STRATEGY
}
pub fn select_installed_agent(preferred: Option<AgentKind>) -> Result<AgentKind> {
let mut available = Vec::new();
if is_command_available("codex") {
available.push(AgentKind::Codex);
}
if is_command_available("claude") {
available.push(AgentKind::Claude);
}
if available.is_empty() {
return Err(format_err!(
"No supported agents found. Install codex or claude."
));
}
if let Some(preferred) = preferred {
if available.contains(&preferred) {
return Ok(preferred);
}
}
if available.len() == 1 {
return Ok(available[0]);
}
println!("Select agent:");
for (index, agent) in available.iter().enumerate() {
println!(" {}. {}", index + 1, agent.name());
}
let selection: usize = Input::new()
.with_prompt("Enter number")
.validate_with(|value: &usize| {
if *value == 0 || *value > available.len() {
Err("Selection out of range.")
} else {
Ok(())
}
})
.interact_text()?;
Ok(available[selection - 1])
}
pub fn run(
agent: AgentKind,
workspace_path: &std::path::PathBuf,
display_path: &str,
file_contents: &str,
agent_model: Option<&str>,
agent_reasoning_effort: Option<&str>,
) -> Result<AgentRunResult> {
let prompt = build_prompt(display_path, file_contents);
if agent == AgentKind::Codex {
return run_codex_exec(workspace_path, &prompt, agent_model, agent_reasoning_effort);
}
log::debug!(
"Launching agent: {} (cwd: {})",
build_agent_log(agent, agent_model, agent_reasoning_effort),
workspace_path.display()
);
let mut command = Command::new(agent.binary_name());
if agent == AgentKind::Claude {
apply_claude_environment(&mut command);
apply_claude_args(&mut command);
}
let mut child = command
.current_dir(workspace_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|err| format_err!("Failed to start {}: {}", agent.binary_name(), err))?;
let stdin = child
.stdin
.as_mut()
.ok_or(format_err!("Failed to open agent stdin"))?;
if let Err(err) = stdin.write_all(prompt.as_bytes()) {
if err.kind() == std::io::ErrorKind::BrokenPipe {
let output = child.wait_with_output()?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout_raw = String::from_utf8_lossy(&output.stdout);
let stdout_trimmed = stdout_raw.trim();
let mut details = Vec::new();
if stderr.is_empty() {
details.push("stderr: <empty>".to_string());
} else {
details.push(format!("stderr: {}", truncate_for_log(&stderr, 4000)));
}
if !stdout_trimmed.is_empty() {
details.push(format!(
"stdout: {}",
truncate_for_log(stdout_trimmed, 4000)
));
}
if let Some(message) = detect_agent_failure(agent, stdout_trimmed, &stderr) {
return Err(format_err!("{}", message));
}
return Err(format_err!(
"{} terminated early (broken pipe). {}",
agent.binary_name(),
details.join(" ")
));
}
return Err(err.into());
}
let output = child.wait_with_output()?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout_raw = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
if let Some(message) = detect_agent_failure(agent, stdout_raw.trim(), &stderr) {
return Err(format_err!("{}", message));
}
let mut details = Vec::new();
if stderr.is_empty() {
details.push("stderr: <empty>".to_string());
} else {
details.push(format!("stderr: {}", truncate_for_log(&stderr, 4000)));
}
let stdout_trimmed = stdout_raw.trim();
if !stdout_trimmed.is_empty() {
details.push(format!(
"stdout: {}",
truncate_for_log(stdout_trimmed, 4000)
));
}
return Err(format_err!(
"{} exited with status {}. {}",
agent.binary_name(),
output.status,
details.join(" ")
));
}
let stdout = stdout_raw.to_string();
let output = parse_agent_output(&stdout).map_err(|err| {
if stderr.is_empty() {
err
} else {
format_err!("{}; stderr: {}", err, stderr)
}
})?;
let comments = output
.comments
.into_iter()
.map(|comment| comment.into_comment())
.collect();
Ok(AgentRunResult {
model: recorded_codex_model(agent_model, output.model),
comments,
summary: output.summary.and_then(|value| {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}),
confidence: output.confidence,
})
}
fn run_codex_exec(
workspace_path: &std::path::PathBuf,
prompt: &str,
agent_model: Option<&str>,
agent_reasoning_effort: Option<&str>,
) -> Result<AgentRunResult> {
log::debug!(
"Launching agent: {} (cwd: {})",
build_agent_log(AgentKind::Codex, agent_model, agent_reasoning_effort),
workspace_path.display()
);
let output_file = tempfile::NamedTempFile::new()?;
let output_path = output_file.path().to_path_buf();
let mut cmd = Command::new(AgentKind::Codex.binary_name());
apply_codex_environment(&mut cmd);
apply_codex_args(&mut cmd, agent_model, agent_reasoning_effort, &output_path);
cmd.arg("-");
cmd.current_dir(workspace_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|err| format_err!("Failed to start codex: {}", err))?;
let mut stdin = child
.stdin
.take()
.ok_or(format_err!("Failed to open codex stdin"))?;
stdin.write_all(prompt.as_bytes())?;
drop(stdin);
let output = child.wait_with_output()?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout_raw = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
let mut details = Vec::new();
if stderr.is_empty() {
details.push("stderr: <empty>".to_string());
} else {
details.push(format!("stderr: {}", truncate_for_log(&stderr, 4000)));
}
let stdout_trimmed = stdout_raw.trim();
if !stdout_trimmed.is_empty() {
details.push(format!(
"stdout: {}",
truncate_for_log(stdout_trimmed, 4000)
));
}
return Err(format_err!(
"codex exited with status {}. {}",
output.status,
details.join(" ")
));
}
let output_payload = std::fs::read_to_string(&output_path).unwrap_or_default();
let output_payload = if output_payload.trim().is_empty() {
stdout_raw.to_string()
} else {
output_payload
};
let output = parse_agent_output(&output_payload).map_err(|err| {
let stdout_trimmed = stdout_raw.trim();
if stdout_trimmed.is_empty() {
err
} else {
format_err!(
"{}; stdout: {}",
err,
truncate_for_log(stdout_trimmed, 4000)
)
}
})?;
let comments = output
.comments
.into_iter()
.map(|comment| comment.into_comment())
.collect();
Ok(AgentRunResult {
model: recorded_codex_model(agent_model, output.model),
comments,
summary: output.summary.and_then(|value| {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}),
confidence: output.confidence,
})
}
fn recorded_codex_model(requested_model: Option<&str>, reported_model: String) -> String {
requested_model
.map(str::trim)
.filter(|model| !model.is_empty())
.map(ToString::to_string)
.unwrap_or(reported_model)
}
fn truncate_for_log(value: &str, max_len: usize) -> String {
if value.len() <= max_len {
return value.to_string();
}
let mut truncated = value[..max_len].to_string();
truncated.push_str("…<truncated>");
truncated
}
fn apply_codex_args(
cmd: &mut Command,
agent_model: Option<&str>,
agent_reasoning_effort: Option<&str>,
output_path: &std::path::Path,
) {
cmd.arg("--ask-for-approval");
cmd.arg(CODEX_APPROVAL_POLICY);
cmd.arg("exec");
apply_codex_exec_args(cmd, agent_model, agent_reasoning_effort, output_path);
}
fn apply_codex_environment(cmd: &mut Command) {
cmd.env_clear();
apply_codex_environment_from(cmd, std::env::vars_os());
}
fn apply_codex_environment_from<I, K, V>(cmd: &mut Command, variables: I)
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<std::ffi::OsStr>,
V: AsRef<std::ffi::OsStr>,
{
apply_allowed_environment_from(cmd, variables, CODEX_ALLOWED_ENV);
}
fn apply_claude_args(cmd: &mut Command) {
cmd.arg("-p");
cmd.arg("--input-format");
cmd.arg("text");
cmd.arg("--output-format");
cmd.arg("text");
cmd.arg("--permission-mode");
cmd.arg(CLAUDE_PERMISSION_MODE);
cmd.arg("--tools");
cmd.arg("Read");
cmd.arg("--disable-slash-commands");
cmd.arg("--strict-mcp-config");
cmd.arg("--no-session-persistence");
cmd.arg("--no-chrome");
cmd.arg("--setting-sources");
cmd.arg("user");
}
fn apply_claude_environment(cmd: &mut Command) {
cmd.env_clear();
apply_claude_environment_from(cmd, std::env::vars_os());
}
fn apply_claude_environment_from<I, K, V>(cmd: &mut Command, variables: I)
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<std::ffi::OsStr>,
V: AsRef<std::ffi::OsStr>,
{
apply_allowed_environment_from(cmd, variables, CLAUDE_ALLOWED_ENV);
}
fn apply_allowed_environment_from<I, K, V>(cmd: &mut Command, variables: I, allowed_env: &[&str])
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<std::ffi::OsStr>,
V: AsRef<std::ffi::OsStr>,
{
for (key, value) in variables {
if allows_env_key(key.as_ref(), allowed_env) {
cmd.env(key, value);
}
}
}
fn allows_env_key(key: &std::ffi::OsStr, allowed_env: &[&str]) -> bool {
allowed_env
.iter()
.any(|allowed| key == std::ffi::OsStr::new(allowed))
}
fn apply_codex_exec_args(
cmd: &mut Command,
agent_model: Option<&str>,
agent_reasoning_effort: Option<&str>,
output_path: &std::path::Path,
) {
if let Some(model) = agent_model {
cmd.arg("--model");
cmd.arg(model);
}
if let Some(effort) = agent_reasoning_effort {
cmd.arg("--config");
cmd.arg(format!("model_reasoning_effort=\"{}\"", effort));
}
cmd.arg("--sandbox");
cmd.arg(CODEX_SANDBOX_MODE);
cmd.arg("--ignore-rules");
cmd.arg("--ephemeral");
cmd.arg("--skip-git-repo-check");
cmd.arg("--output-last-message");
cmd.arg(output_path);
}
fn build_agent_log(
agent: AgentKind,
agent_model: Option<&str>,
agent_reasoning_effort: Option<&str>,
) -> String {
let mut parts = vec![agent.binary_name().to_string()];
if agent == AgentKind::Codex {
parts.push("--ask-for-approval".to_string());
parts.push(CODEX_APPROVAL_POLICY.to_string());
parts.push("exec".to_string());
if let Some(model) = agent_model {
parts.push("--model".to_string());
parts.push(model.to_string());
}
if let Some(effort) = agent_reasoning_effort {
parts.push("--config".to_string());
parts.push(format!("model_reasoning_effort=\"{}\"", effort));
}
parts.push("--sandbox".to_string());
parts.push(CODEX_SANDBOX_MODE.to_string());
parts.push("--ignore-rules".to_string());
parts.push("--ephemeral".to_string());
} else if agent == AgentKind::Claude {
parts.push("-p".to_string());
parts.push("--input-format".to_string());
parts.push("text".to_string());
parts.push("--output-format".to_string());
parts.push("text".to_string());
parts.push("--permission-mode".to_string());
parts.push(CLAUDE_PERMISSION_MODE.to_string());
parts.push("--tools".to_string());
parts.push("Read".to_string());
parts.push("--disable-slash-commands".to_string());
parts.push("--strict-mcp-config".to_string());
parts.push("--no-session-persistence".to_string());
parts.push("--no-chrome".to_string());
parts.push("--setting-sources".to_string());
parts.push("user".to_string());
}
parts.join(" ")
}
fn detect_agent_failure(agent: AgentKind, stdout: &str, stderr: &str) -> Option<String> {
if agent != AgentKind::Claude {
return None;
}
let combined = format!("{} {}", stdout, stderr).to_lowercase();
let limit_markers = [
"hit your limit",
"rate limit",
"quota",
"usage limit",
"limit · resets",
];
if !limit_markers.iter().any(|marker| combined.contains(marker)) {
return None;
}
let reset_hint = extract_reset_hint(stdout)
.or_else(|| extract_reset_hint(stderr))
.unwrap_or_else(|| "reset time not provided".to_string());
Some(format!(
"Claude usage limit reached ({reset_hint}). Try again after reset or use --agent codex.",
reset_hint = reset_hint
))
}
fn extract_reset_hint(value: &str) -> Option<String> {
for part in value.split('·') {
let trimmed = part.trim();
if trimmed.to_lowercase().contains("reset") {
return Some(trimmed.to_string());
}
}
None
}
fn build_prompt(display_path: &str, file_contents: &str) -> String {
format!(
r#"You are a malicious-code reviewer for open-source dependency archives.
Your goal is to detect evidence of supply-chain compromise or malicious behavior.
This is NOT a general vulnerability audit: avoid generic "unsafe pattern" findings unless they
are used to execute hidden/encoded/remote/untrusted payloads or are unsafe-by-default.
Review ONLY the single file below. You are in read-only mode.
You may inspect other files in the package if your tool supports it, but only report issues in the target file.
Focus areas (security):
- install-time execution (preinstall/postinstall), hidden subprocess execution
- credential/secret harvesting (env vars, .npmrc, .ssh, cloud metadata, tokens)
- data exfiltration (network calls, webhooks, DNS, pastebins)
- hidden downloads or dynamic code loading (remote fetch + eval/exec, require from URL)
- obfuscation/deobfuscation used to construct or execute payloads (base64, XOR, RC4)
- persistence or environment tampering (shell profiles, PATH, startup files)
- crypto-mining or unrelated system probing
Focus areas (complexity):
- heavy obfuscation or packing, control-flow flattening
- reflection/dynamic dispatch that hides behavior
- deliberately confusing parsing/decoding pipelines that mask intent
Rules:
- Output ONLY valid JSON, no markdown, no extra keys.
- Always include a brief summary and confidence, even if there are no comments.
- If there are no concrete malicious or supply-chain indicators, return an empty comments list.
- Comments must be specific and actionable, tied to the shown code, and include evidence:
behavior + trigger + impact + why it is suspicious.
- Bundled/minified code is in scope, but only report when behavior is clearly malicious or suspicious-by-default.
- Do NOT flag common patterns (eval/new Function/dynamic require) unless tied to executing
encoded/remote/untrusted input or concealing a payload.
- Do not flag clearly intentional, explicitly signposted risky capabilities when they are consistent
with the package's apparent purpose.
- Do flag misleading, hidden, or insecure-by-default behavior, including security-sensitive actions that are implicit,
surprising, or not opt-in.
- Prefer false negatives over low-confidence findings; if uncertain, return no comments.
- Use selection only when you can point to exact lines; otherwise omit it.
- Line/character numbers are 1-based.
- Do not speculate about other files.
Return ONLY valid JSON with this schema. Do NOT include any preamble or code fences.
{{
"model": "<model name used>",
"summary": "<one or two sentence summary of the review in your own words>",
"confidence": "high|medium|low",
"comments": [
{{
"comment": "string (what is the issue and why it matters)",
"security": "critical|medium|low",
"complexity": "critical|medium|low",
"file": "{file_path}",
"selection": {{
"start": {{"line": <int>, "character": <int>}},
"end": {{"line": <int>, "character": <int>}}
}}
}}
]
}}
If no issues are found, return an empty comments list.
File path: {file_path}
--- FILE CONTENTS ---
{file_contents}
"#,
file_path = display_path,
file_contents = file_contents
)
}
fn parse_agent_output(raw: &str) -> Result<AgentOutput> {
let trimmed = raw.trim();
if let Ok(output) = serde_json::from_str::<AgentOutput>(trimmed) {
return Ok(output);
}
let extracted = extract_json_payload(raw).unwrap_or_else(|| trimmed.to_string());
if let Ok(output) = serde_json::from_str::<AgentOutput>(&extracted) {
return Ok(output);
}
let value: Value = serde_json::from_str(&extracted).map_err(|err| {
format_err!(
"Failed to parse agent JSON output: {}. Output: {}",
err,
extracted
)
})?;
parse_agent_value(value)
}
fn is_command_available(name: &str) -> bool {
std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|path| path.join(name).is_file()))
}
fn extract_json_payload(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if let Some(start) = trimmed.find("```json") {
let rest = &trimmed[start + "```json".len()..];
if let Some(end) = rest.find("```") {
return Some(rest[..end].trim().to_string());
}
}
if let Some(start) = trimmed.find("```") {
let rest = &trimmed[start + "```".len()..];
if let Some(end) = rest.find("```") {
return Some(rest[..end].trim().to_string());
}
}
let start = trimmed.find('{')?;
let end = trimmed.rfind('}')?;
if end <= start {
return None;
}
Some(trimmed[start..=end].to_string())
}
fn parse_agent_value(value: Value) -> Result<AgentOutput> {
let model = value
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let summary = value
.get("summary")
.and_then(|v| v.as_str())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let confidence_value = value
.get("confidence")
.and_then(|v| v.as_str())
.or_else(|| {
value
.get("overall_security_confidence")
.and_then(|v| v.as_str())
});
let confidence = match confidence_value {
Some(value) => ReviewConfidence::from_str(value).ok(),
None => None,
};
let comments_value = value
.get("comments")
.ok_or(format_err!("Agent output missing comments array"))?;
let comments_array = comments_value
.as_array()
.ok_or(format_err!("Agent comments is not an array"))?;
let mut comments = Vec::new();
for entry in comments_array {
let comment = entry
.get("comment")
.and_then(|v| v.as_str())
.or_else(|| entry.get("description").and_then(|v| v.as_str()))
.unwrap_or("")
.trim()
.to_string();
if comment.is_empty() {
log::warn!("Skipping agent comment without description.");
continue;
}
let path_value = entry
.get("file")
.and_then(|v| v.as_str())
.or_else(|| entry.get("path").and_then(|v| v.as_str()));
let path_value = match path_value {
Some(path_value) => path_value,
None => {
log::warn!("Skipping agent comment without file/path.");
continue;
}
};
let security = parse_priority(
entry.get("security").and_then(|v| v.as_str()),
entry.get("severity").and_then(|v| v.as_str()),
entry.get("security_finding").and_then(|v| v.as_bool()),
);
let complexity = parse_complexity(
entry.get("complexity").and_then(|v| v.as_str()),
entry.get("complexity_finding").and_then(|v| v.as_bool()),
);
let selection = parse_selection(entry);
comments.push(AgentComment {
comment,
security,
complexity,
path: PathBuf::from(path_value),
selection,
});
}
Ok(AgentOutput {
model,
summary,
confidence,
comments,
})
}
fn parse_priority(
priority_value: Option<&str>,
severity_value: Option<&str>,
security_finding: Option<bool>,
) -> Priority {
if let Some(value) = priority_value {
if let Ok(priority) = Priority::from_str(value) {
return priority;
}
}
if let Some(value) = severity_value {
let value = value.to_lowercase();
return match value.as_str() {
"critical" | "high" => Priority::Critical,
"medium" | "moderate" => Priority::Medium,
"low" | "info" => Priority::Low,
_ => Priority::Medium,
};
}
if let Some(true) = security_finding {
return Priority::Medium;
}
Priority::Low
}
fn parse_complexity(priority_value: Option<&str>, complexity_finding: Option<bool>) -> Priority {
if let Some(value) = priority_value {
if let Ok(priority) = Priority::from_str(value) {
return priority;
}
}
if let Some(true) = complexity_finding {
return Priority::Medium;
}
Priority::Low
}
fn parse_selection(entry: &Value) -> Option<Selection> {
if let Some(selection_value) = entry.get("selection") {
let start = selection_value.get("start")?;
let end = selection_value.get("end")?;
let start_line = start.get("line")?.as_i64()?;
let start_char = start.get("character")?.as_i64()?;
let end_line = end.get("line")?.as_i64()?;
let end_char = end.get("character")?.as_i64()?;
return Some(Selection {
start: Position {
line: start_line,
character: start_char,
},
end: Position {
line: end_line,
character: end_char,
},
});
}
let start_line = entry.get("line_start").and_then(|v| v.as_i64());
let end_line = entry.get("line_end").and_then(|v| v.as_i64());
if let (Some(start_line), Some(end_line)) = (start_line, end_line) {
return Some(Selection {
start: Position {
line: start_line,
character: 1,
},
end: Position {
line: end_line,
character: 1,
},
});
}
None
}
#[cfg(test)]
mod tests {
use super::{
apply_claude_args, apply_claude_environment_from, apply_codex_args,
apply_codex_environment_from, build_agent_log, recorded_codex_model, review_strategy,
AgentKind, CLAUDE_PERMISSION_MODE, CODEX_APPROVAL_POLICY, CODEX_SANDBOX_MODE,
};
#[test]
fn review_strategy_identifies_package_release_strategy() {
assert_eq!(review_strategy(), "package-release/v1");
}
#[test]
fn recorded_codex_model_prefers_requested_model() {
assert_eq!(
recorded_codex_model(Some("gpt-5.4"), "GPT-5".to_string()),
"gpt-5.4"
);
}
#[test]
fn recorded_codex_model_uses_reported_model_without_request() {
assert_eq!(recorded_codex_model(None, "GPT-5".to_string()), "GPT-5");
}
#[test]
fn codex_args_force_review_isolation() {
let mut cmd = std::process::Command::new("codex");
apply_codex_args(
&mut cmd,
Some("gpt-5.4"),
Some("high"),
std::path::Path::new("output.json"),
);
let args = cmd
.get_args()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert!(args
.windows(2)
.any(|window| window == ["--ask-for-approval", CODEX_APPROVAL_POLICY]));
assert!(args.iter().any(|arg| arg == "exec"));
assert!(args
.windows(2)
.any(|window| window == ["--sandbox", CODEX_SANDBOX_MODE]));
assert!(args.iter().any(|arg| arg == "--ignore-rules"));
assert!(args.iter().any(|arg| arg == "--ephemeral"));
assert!(
build_agent_log(AgentKind::Codex, Some("gpt-5.4"), Some("high"))
.contains("--ask-for-approval never exec")
);
assert!(
build_agent_log(AgentKind::Codex, Some("gpt-5.4"), Some("high"))
.contains("--sandbox read-only --ignore-rules --ephemeral")
);
}
#[test]
fn codex_environment_uses_allowlist() {
let mut cmd = std::process::Command::new("codex");
apply_codex_environment_from(
&mut cmd,
[
("PATH", "/usr/bin"),
("OPENAI_API_KEY", "test-openai-key"),
("HTTPS_PROXY", "http://proxy.example"),
("AWS_SECRET_ACCESS_KEY", "test-aws-secret"),
("GITHUB_TOKEN", "test-github-token"),
("SSH_AUTH_SOCK", "/tmp/ssh-agent.sock"),
],
);
let env = cmd
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().to_string(),
value.map(|value| value.to_string_lossy().to_string()),
)
})
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(
env.get("PATH").and_then(|value| value.as_deref()),
Some("/usr/bin")
);
assert_eq!(
env.get("OPENAI_API_KEY").and_then(|value| value.as_deref()),
Some("test-openai-key")
);
assert_eq!(
env.get("HTTPS_PROXY").and_then(|value| value.as_deref()),
Some("http://proxy.example")
);
assert!(!env.contains_key("AWS_SECRET_ACCESS_KEY"));
assert!(!env.contains_key("GITHUB_TOKEN"));
assert!(!env.contains_key("SSH_AUTH_SOCK"));
}
#[test]
fn claude_args_force_noninteractive_read_only_tools() {
let mut cmd = std::process::Command::new("claude");
apply_claude_args(&mut cmd);
let args = cmd
.get_args()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert!(args.iter().any(|arg| arg == "-p"));
assert!(args
.windows(2)
.any(|window| window == ["--permission-mode", CLAUDE_PERMISSION_MODE]));
assert!(args.windows(2).any(|window| window == ["--tools", "Read"]));
assert!(args.iter().any(|arg| arg == "--disable-slash-commands"));
assert!(args.iter().any(|arg| arg == "--strict-mcp-config"));
assert!(args.iter().any(|arg| arg == "--no-session-persistence"));
assert!(args.iter().any(|arg| arg == "--no-chrome"));
assert!(args
.windows(2)
.any(|window| window == ["--setting-sources", "user"]));
assert!(!args.iter().any(|arg| arg == "--bare"));
assert!(build_agent_log(AgentKind::Claude, None, None)
.contains("--permission-mode dontAsk --tools Read"));
}
#[test]
fn claude_environment_uses_allowlist() {
let mut cmd = std::process::Command::new("claude");
apply_claude_environment_from(
&mut cmd,
[
("PATH", "/usr/bin"),
("ANTHROPIC_API_KEY", "test-anthropic-key"),
("HTTPS_PROXY", "http://proxy.example"),
("OPENAI_API_KEY", "test-openai-key"),
("AWS_SECRET_ACCESS_KEY", "test-aws-secret"),
("GITHUB_TOKEN", "test-github-token"),
("SSH_AUTH_SOCK", "/tmp/ssh-agent.sock"),
],
);
let env = cmd
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().to_string(),
value.map(|value| value.to_string_lossy().to_string()),
)
})
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(
env.get("PATH").and_then(|value| value.as_deref()),
Some("/usr/bin")
);
assert_eq!(
env.get("ANTHROPIC_API_KEY")
.and_then(|value| value.as_deref()),
Some("test-anthropic-key")
);
assert_eq!(
env.get("HTTPS_PROXY").and_then(|value| value.as_deref()),
Some("http://proxy.example")
);
assert!(!env.contains_key("OPENAI_API_KEY"));
assert!(!env.contains_key("AWS_SECRET_ACCESS_KEY"));
assert!(!env.contains_key("GITHUB_TOKEN"));
assert!(!env.contains_key("SSH_AUTH_SOCK"));
}
}