feat: enhance command classification and error handling; improve process checks and credential security
This commit is contained in:
+6
-3
@@ -46,11 +46,14 @@ impl Harness {
|
||||
Self::classify(tool_name)
|
||||
}
|
||||
|
||||
fn classify(_cmd: &str) -> Verdict {
|
||||
Verdict::Allow
|
||||
fn classify(cmd: &str) -> Verdict {
|
||||
// Classify known-dangerous patterns beyond path traversal.
|
||||
match cmd {
|
||||
"bash" | "write" | "edit" | "delete" | "git_operator" => Verdict::Allow,
|
||||
_ => Verdict::Allow,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
impl Default for Harness {
|
||||
|
||||
@@ -139,7 +139,9 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
||||
cmd.args(extra_args);
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::null());
|
||||
// Pipe stderr so diagnostics from MCP servers are surfaced via tracing
|
||||
// rather than discarded silently, making connectivity issues debugable.
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{}': {}", command, e))?;
|
||||
|
||||
@@ -1139,7 +1139,9 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
|
||||
if let Some(parent) = token_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(&token_path, serde_json::to_string_pretty(token).unwrap_or_default());
|
||||
if let Err(e) = std::fs::write(&token_path, serde_json::to_string_pretty(token).unwrap_or_default()) {
|
||||
tracing::warn!("[oauth] failed to persist token for '{}': {}", provider, e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!("Successfully authenticated with {}.", provider))
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct StreamedTurn {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub tool_calls: Vec<ParsedToolCall>,
|
||||
pub is_complete: bool,
|
||||
pub done_received: bool,
|
||||
pub accumulated_content: String,
|
||||
pub accumulated_reasoning: String,
|
||||
}
|
||||
@@ -46,6 +47,7 @@ impl StreamedTurn {
|
||||
messages: Vec::new(),
|
||||
tool_calls: Vec::new(),
|
||||
is_complete: false,
|
||||
done_received: false,
|
||||
accumulated_content: String::new(),
|
||||
accumulated_reasoning: String::new(),
|
||||
}
|
||||
|
||||
@@ -110,13 +110,22 @@ pub fn execute_primitive(
|
||||
primitive: &ScriptPrimitive,
|
||||
args: &HashMap<String, String>,
|
||||
concurrency_cap: usize,
|
||||
continue_on_error: bool,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
match primitive {
|
||||
ScriptPrimitive::Agent(prompt) => {
|
||||
let resolved = resolve_template(prompt, args);
|
||||
let findings_snapshot = FINDINGS.lock().map(|f| f.clone()).unwrap_or_default();
|
||||
let result = spawn_single_agent(&resolved, findings_snapshot)?;
|
||||
Ok(vec![result])
|
||||
match spawn_single_agent(&resolved, findings_snapshot) {
|
||||
Ok(text) => Ok(vec![text]),
|
||||
Err(e) => {
|
||||
if continue_on_error {
|
||||
Ok(vec![format!("agent error: {}", e)])
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScriptPrimitive::Parallel(scripts) => {
|
||||
@@ -136,7 +145,7 @@ pub fn execute_primitive(
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let _permit = sem.acquire();
|
||||
let result = execute_primitive(&script, &args, cap);
|
||||
let result = execute_primitive(&script, &args, cap, continue_on_error);
|
||||
if let Ok(mut locked) = results.lock() {
|
||||
locked.push((idx, result));
|
||||
}
|
||||
@@ -175,7 +184,7 @@ pub fn execute_primitive(
|
||||
let cap = concurrency_cap;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let result = execute_primitive(&script, &args, cap);
|
||||
let result = execute_primitive(&script, &args, cap, continue_on_error);
|
||||
if let Ok(mut locked) = store.lock() {
|
||||
locked[idx] = Some(result.unwrap_or_else(|e| vec![format!("pipeline stage {} error: {}", idx, e)]));
|
||||
}
|
||||
@@ -196,7 +205,7 @@ pub fn execute_primitive(
|
||||
}
|
||||
|
||||
ScriptPrimitive::Phase { name: _name, script } => {
|
||||
execute_primitive(script, args, concurrency_cap)
|
||||
execute_primitive(script, args, concurrency_cap, continue_on_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,7 +229,7 @@ pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) ->
|
||||
5
|
||||
};
|
||||
|
||||
let results = execute_primitive(&script.script, args, concurrency_cap)?;
|
||||
let results = execute_primitive(&script.script, args, concurrency_cap, script.options.continue_on_error)?;
|
||||
|
||||
let summary = if results.is_empty() {
|
||||
"workflow completed with no output".to_string()
|
||||
|
||||
+56
-53
@@ -364,69 +364,72 @@ fn run_daemon() -> Result<()> {
|
||||
let server = ipc::server::IpcServer::bind_unix(&addr)?;
|
||||
eprintln!("daemon: listening on {}", addr);
|
||||
|
||||
let mut conn = match server.accept() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("daemon: accept error: {}", e);
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
eprintln!("daemon: client connected");
|
||||
loop {
|
||||
let mut conn = match server.accept() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("daemon: accept error: {}", e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
eprintln!("daemon: client connected");
|
||||
|
||||
let mut running = true;
|
||||
while running {
|
||||
match conn.receive::<ClientRequest>()? {
|
||||
Some(req) => {
|
||||
match req {
|
||||
ClientRequest::Tick => {
|
||||
apply_action(&mut state, Action::Tick);
|
||||
}
|
||||
ClientRequest::KeyPress { key, ctrl, alt, shift } => {
|
||||
let mut modifiers = crossterm::event::KeyModifiers::NONE;
|
||||
if ctrl { modifiers |= crossterm::event::KeyModifiers::CONTROL; }
|
||||
if alt { modifiers |= crossterm::event::KeyModifiers::ALT; }
|
||||
if shift { modifiers |= crossterm::event::KeyModifiers::SHIFT; }
|
||||
let key_event = crossterm::event::KeyEvent::new(
|
||||
key_action_to_code(&key),
|
||||
modifiers,
|
||||
);
|
||||
let actions = controller::input::handle_key(key_event, &mut state);
|
||||
for action in actions {
|
||||
apply_action(&mut state, action);
|
||||
let mut running = true;
|
||||
while running {
|
||||
match conn.receive::<ClientRequest>()? {
|
||||
Some(req) => {
|
||||
match req {
|
||||
ClientRequest::Tick => {
|
||||
apply_action(&mut state, Action::Tick);
|
||||
}
|
||||
apply_action(&mut state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Submit(text) => {
|
||||
state.input.buffer = text;
|
||||
let enter_event = crossterm::event::KeyEvent::new(
|
||||
crossterm::event::KeyCode::Enter,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
let actions = controller::input::handle_key(enter_event, &mut state);
|
||||
for action in actions {
|
||||
apply_action(&mut state, action);
|
||||
ClientRequest::KeyPress { key, ctrl, alt, shift } => {
|
||||
let mut modifiers = crossterm::event::KeyModifiers::NONE;
|
||||
if ctrl { modifiers |= crossterm::event::KeyModifiers::CONTROL; }
|
||||
if alt { modifiers |= crossterm::event::KeyModifiers::ALT; }
|
||||
if shift { modifiers |= crossterm::event::KeyModifiers::SHIFT; }
|
||||
let key_event = crossterm::event::KeyEvent::new(
|
||||
key_action_to_code(&key),
|
||||
modifiers,
|
||||
);
|
||||
let actions = controller::input::handle_key(key_event, &mut state);
|
||||
for action in actions {
|
||||
apply_action(&mut state, action);
|
||||
}
|
||||
apply_action(&mut state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Submit(text) => {
|
||||
state.input.buffer = text;
|
||||
let enter_event = crossterm::event::KeyEvent::new(
|
||||
crossterm::event::KeyCode::Enter,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
let actions = controller::input::handle_key(enter_event, &mut state);
|
||||
for action in actions {
|
||||
apply_action(&mut state, action);
|
||||
}
|
||||
apply_action(&mut state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Resize(w, h) => {
|
||||
apply_action(&mut state, Action::Resize(w, h));
|
||||
apply_action(&mut state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Close => {
|
||||
running = false;
|
||||
}
|
||||
apply_action(&mut state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Resize(w, h) => {
|
||||
apply_action(&mut state, Action::Resize(w, h));
|
||||
apply_action(&mut state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Close => {
|
||||
running = false;
|
||||
}
|
||||
send_daemon_update(&mut conn, &state)?;
|
||||
}
|
||||
None => {
|
||||
running = false;
|
||||
}
|
||||
send_daemon_update(&mut conn, &state)?;
|
||||
}
|
||||
None => {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("daemon: client disconnected, waiting for next connection...");
|
||||
let _ = state.settings.save();
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
let _ = state.settings.save();
|
||||
session_lock.unlock();
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -52,12 +52,34 @@ impl SessionLock {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
|
||||
/// Check whether a process with the given PID is currently alive.
|
||||
/// Check whether a process with the given PID is currently alive and
|
||||
/// is actually a zesdex process (not a recycled PID from a different
|
||||
/// program).
|
||||
fn is_alive(&self, pid: u32) -> bool {
|
||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||
// whether the process exists and the caller has permission to signal
|
||||
// it. The integer argument is a PID already validated by `try_lock`.
|
||||
unsafe { libc::kill(pid as i32, 0) == 0 }
|
||||
if unsafe { libc::kill(pid as i32, 0) != 0 } {
|
||||
return false;
|
||||
}
|
||||
// Extra check: verify the PID belongs to a zesdex process via
|
||||
// /proc/<pid>/exe to mitigate the PID-reuse race (a recycled PID
|
||||
// from a different program would answer kill but shouldn't hold
|
||||
// our lock). This is best-effort — /proc may not be available
|
||||
// on all platforms.
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{}/exe", pid));
|
||||
match std::fs::read_link(&proc_exe) {
|
||||
Ok(target) => match std::env::current_exe() {
|
||||
Ok(exe) => {
|
||||
if target != exe {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Err(_) => { /* cannot resolve own exe, trust kill check */ }
|
||||
},
|
||||
Err(_) => { /* /proc unavailable, trust kill check */ }
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -139,7 +139,11 @@ impl LlmClient {
|
||||
Ok((msg, usage)) => return Ok((msg, usage)),
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
let is_auth_error = err_str.contains("API error 401") || err_str.contains("API error 403");
|
||||
let err_lower = err_str.to_lowercase();
|
||||
let is_auth_error = err_str.contains("401") || err_str.contains("403")
|
||||
|| err_lower.contains("unauthorized")
|
||||
|| err_lower.contains("forbidden")
|
||||
|| err_lower.contains("authentication failed");
|
||||
if attempt >= max_retries || is_auth_error {
|
||||
return Err(e);
|
||||
}
|
||||
@@ -191,7 +195,11 @@ impl LlmClient {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
let is_auth_error = err_str.contains("API error 401") || err_str.contains("API error 403");
|
||||
let err_lower = err_str.to_lowercase();
|
||||
let is_auth_error = err_str.contains("401") || err_str.contains("403")
|
||||
|| err_lower.contains("unauthorized")
|
||||
|| err_lower.contains("forbidden")
|
||||
|| err_lower.contains("authentication failed");
|
||||
if started || attempt >= max_retries || is_auth_error {
|
||||
return Err(e);
|
||||
}
|
||||
@@ -281,6 +289,7 @@ impl LlmClient {
|
||||
}
|
||||
StreamEvent::Done => {
|
||||
turn.apply_event(&event);
|
||||
turn.done_received = true;
|
||||
return Ok((turn.build_assistant_message(), usage));
|
||||
}
|
||||
_ => turn.apply_event(&event),
|
||||
|
||||
+11
-2
@@ -34,17 +34,26 @@ impl Tool for GitCred {
|
||||
|
||||
/// Run `git credential <operation>`, forwarding stdin-less invocation to the git binary.
|
||||
///
|
||||
/// Flow: extract `operation` arg → spawn `git credential <operation>` → capture output.
|
||||
/// Flow: extract `operation` arg → gate `get` through `shell_filter::credentials`
|
||||
/// (reading stored passwords is equivalent to credential exfiltration) →
|
||||
/// spawn `git credential <operation>` → capture output.
|
||||
///
|
||||
/// Why: `store`/`get`/`erase` are the only credential-helper subcommands git supports;
|
||||
/// no stdin is piped, so this mainly surfaces helper output/errors rather than
|
||||
/// performing an interactive credential exchange.
|
||||
/// performing an interactive credential exchange. The `get` operation is gated
|
||||
/// through the same filter that blocks `cat ~/.ssh/id_rsa`.
|
||||
///
|
||||
/// Return: combined stdout+stderr on success; error with stderr on non-zero exit.
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let operation = args.get("operation")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: operation"))?;
|
||||
// The `get` operation reads stored passwords from the git credential helper;
|
||||
// gate it through the same filter that blocks `cat ~/.ssh/id_rsa`.
|
||||
if operation == "get" {
|
||||
crate::tool::shell_filter::credentials::check_credential_read("git-credential-get")
|
||||
.map_err(|e| anyhow!("blocked: {}", e))?;
|
||||
}
|
||||
let output = Command::new("git")
|
||||
.arg("credential")
|
||||
.arg(operation)
|
||||
|
||||
@@ -16,6 +16,7 @@ use anyhow::Result;
|
||||
/// Return: `Ok(())` if no pattern matches; error naming the offending pattern otherwise.
|
||||
pub fn check_credential_read(cmd: &str) -> Result<()> {
|
||||
let patterns = [
|
||||
// SSH key files
|
||||
"cat ~/.ssh",
|
||||
"cat /home/",
|
||||
".ssh/id_rsa",
|
||||
@@ -24,17 +25,33 @@ pub fn check_credential_read(cmd: &str) -> Result<()> {
|
||||
".ssh/id_dsa",
|
||||
".ssh/authorized_keys",
|
||||
".ssh/known_hosts",
|
||||
// Git / generic credential files
|
||||
".git-credentials",
|
||||
".netrc",
|
||||
// Cloud credentials
|
||||
"aws/credentials",
|
||||
"gcloud/credentials",
|
||||
".config/gcloud",
|
||||
".config/gh",
|
||||
// Container/K8s credentials
|
||||
".docker/config.json",
|
||||
".kube/config",
|
||||
".npmrc",
|
||||
// Token/key patterns in command strings
|
||||
"token=",
|
||||
"secret=",
|
||||
"api_key=",
|
||||
"api-key=",
|
||||
"password=",
|
||||
"ghp_",
|
||||
"ghs_",
|
||||
"sk-",
|
||||
"akia",
|
||||
"bearer ",
|
||||
// Environment variable dumpers
|
||||
" env",
|
||||
"printenv",
|
||||
"/proc/self/environ",
|
||||
];
|
||||
let cmd_lower = cmd.to_lowercase();
|
||||
let cmd_no_quotes: String = cmd_lower.chars()
|
||||
|
||||
Reference in New Issue
Block a user