Skip to content

Commit 5094bd9

Browse files
committed
fix(cli): prevent self-destruction via --all commands (#600)
When honryu (or any agent) runs `kild stop --all` or `kild destroy --all` from inside its own session, it now skips itself and prints a note. Explicit single-branch commands (`kild stop honryu`) still work but print a warning. Changes: - Add `resolve_self_branch()` helper using $KILD_SESSION_BRANCH + CWD fallback - Filter calling session from stop --all and destroy --all loops - Add self-targeting warnings for explicit stop/destroy commands - Add unit tests for resolve_self_branch() Fixes #600
1 parent 3174613 commit 5094bd9

3 files changed

Lines changed: 157 additions & 2 deletions

File tree

crates/kild/src/commands/destroy.rs

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,21 @@ pub(crate) fn handle_destroy_command(
2323
.get_one::<String>("branch")
2424
.ok_or("Branch argument is required (or use --all)")?;
2525

26+
// Warn if destroying own session
27+
if let Some(self_br) = super::helpers::resolve_self_branch()
28+
&& self_br == branch.as_str()
29+
{
30+
eprintln!(
31+
"{} You are about to destroy your own session ({}).",
32+
color::warning("Warning:"),
33+
color::ice(branch),
34+
);
35+
eprintln!(
36+
" {}",
37+
color::hint("This will kill the agent and remove the session."),
38+
);
39+
}
40+
2641
info!(
2742
event = "cli.destroy_started",
2843
branch = branch,
@@ -108,10 +123,25 @@ pub(crate) fn handle_destroy_command(
108123
fn handle_destroy_all(force: bool) -> Result<(), Box<dyn std::error::Error>> {
109124
info!(event = "cli.destroy_all_started", force = force);
110125

111-
let sessions = session_ops::list_sessions()?;
126+
let self_branch = super::helpers::resolve_self_branch();
127+
128+
let mut sessions = session_ops::list_sessions()?;
129+
130+
// Filter out the calling session to prevent self-destruction
131+
let skipped_self = if let Some(ref self_br) = self_branch {
132+
let before = sessions.len();
133+
sessions.retain(|s| s.branch.as_ref() != self_br.as_str());
134+
before > sessions.len()
135+
} else {
136+
false
137+
};
112138

113139
if sessions.is_empty() {
114-
println!("No kilds to destroy.");
140+
if skipped_self {
141+
println!("No other kilds to destroy (skipped self).");
142+
} else {
143+
println!("No kilds to destroy.");
144+
}
115145
info!(
116146
event = "cli.destroy_all_completed",
117147
destroyed = 0,
@@ -120,6 +150,19 @@ fn handle_destroy_all(force: bool) -> Result<(), Box<dyn std::error::Error>> {
120150
return Ok(());
121151
}
122152

153+
if skipped_self && let Some(ref self_br) = self_branch {
154+
info!(
155+
event = "cli.destroy_all_self_skipped",
156+
branch = self_br.as_str()
157+
);
158+
eprintln!(
159+
"{} Skipping self ({}) — use `kild destroy {}` explicitly.",
160+
color::warning("Note:"),
161+
color::ice(self_br),
162+
self_br,
163+
);
164+
}
165+
123166
// Confirmation prompt unless --force is specified
124167
if !force {
125168
use std::io::{self, Write};

crates/kild/src/commands/helpers.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,25 @@ use kild_core::{events, session_ops};
99
use super::json_types::JsonError;
1010
use crate::color;
1111

12+
/// Resolve the branch name of the calling session, if running inside one.
13+
///
14+
/// Tries `$KILD_SESSION_BRANCH` first (reliable for claude/codex agents),
15+
/// then falls back to CWD-based worktree path matching (universal).
16+
/// Returns `None` when called from outside any kild session.
17+
pub(crate) fn resolve_self_branch() -> Option<String> {
18+
// Fast path: env var is set for claude and codex daemon sessions
19+
if let Ok(branch) = std::env::var("KILD_SESSION_BRANCH")
20+
&& !branch.is_empty()
21+
{
22+
return Some(branch);
23+
}
24+
25+
// Fallback: match CWD against session worktree paths
26+
let cwd = std::env::current_dir().ok()?;
27+
let session = session_ops::find_session_by_worktree_path(&cwd).ok()??;
28+
Some(session.branch.to_string())
29+
}
30+
1231
/// Print a JSON error object to stdout for --json mode.
1332
/// Returns the error wrapped in Box for chaining with `return Err(...)`.
1433
pub fn print_json_error(error: &dyn std::fmt::Display, code: &str) -> Box<dyn std::error::Error> {
@@ -220,6 +239,61 @@ pub fn resolve_open_mode(matches: &clap::ArgMatches) -> kild_core::OpenMode {
220239
mod tests {
221240
use super::*;
222241

242+
/// # Safety helper — save/restore env var around test
243+
unsafe fn set_env(key: &str, val: &str) {
244+
unsafe { std::env::set_var(key, val) };
245+
}
246+
unsafe fn remove_env(key: &str) {
247+
unsafe { std::env::remove_var(key) };
248+
}
249+
unsafe fn restore_env(key: &str, prev: Option<String>) {
250+
match prev {
251+
Some(v) => unsafe { std::env::set_var(key, v) },
252+
None => unsafe { std::env::remove_var(key) },
253+
}
254+
}
255+
256+
#[test]
257+
fn resolve_self_branch_from_env_var() {
258+
let key = "KILD_SESSION_BRANCH";
259+
let prev = std::env::var(key).ok();
260+
// SAFETY: test-only, single-threaded test runner
261+
unsafe { set_env(key, "honryu") };
262+
263+
let result = resolve_self_branch();
264+
assert_eq!(result.as_deref(), Some("honryu"));
265+
266+
unsafe { restore_env(key, prev) };
267+
}
268+
269+
#[test]
270+
fn resolve_self_branch_empty_env_var() {
271+
let key = "KILD_SESSION_BRANCH";
272+
let prev = std::env::var(key).ok();
273+
// SAFETY: test-only, single-threaded test runner
274+
unsafe { set_env(key, "") };
275+
276+
let result = resolve_self_branch();
277+
// Empty env var falls through; CWD is unlikely to match a session
278+
assert_eq!(result, None);
279+
280+
unsafe { restore_env(key, prev) };
281+
}
282+
283+
#[test]
284+
fn resolve_self_branch_no_env_var() {
285+
let key = "KILD_SESSION_BRANCH";
286+
let prev = std::env::var(key).ok();
287+
// SAFETY: test-only, single-threaded test runner
288+
unsafe { remove_env(key) };
289+
290+
let result = resolve_self_branch();
291+
// No env var and CWD is not a session worktree → None
292+
assert_eq!(result, None);
293+
294+
unsafe { restore_env(key, prev) };
295+
}
296+
223297
#[test]
224298
fn test_load_config_with_warning_returns_valid_config() {
225299
// When config loads (successfully or with fallback), should return a valid config

crates/kild/src/commands/stop.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ pub(crate) fn handle_stop_command(matches: &ArgMatches) -> Result<(), Box<dyn st
2626
.get_one::<String>("branch")
2727
.ok_or("Branch argument is required (or use --all)")?;
2828

29+
// Warn if stopping own session (prevents accidental self-destruction)
30+
if let Some(self_br) = super::helpers::resolve_self_branch()
31+
&& self_br == branch.as_str()
32+
{
33+
eprintln!(
34+
"{} You are about to stop your own session ({}).",
35+
color::warning("Warning:"),
36+
color::ice(branch),
37+
);
38+
eprintln!(
39+
" {}",
40+
color::hint("This will kill the agent running this command."),
41+
);
42+
}
43+
2944
info!(event = "cli.stop_started", branch = branch);
3045

3146
match session_ops::stop_session(branch) {
@@ -100,18 +115,41 @@ fn handle_stop_teammate(branch: &str, pane_id: &str) -> Result<(), Box<dyn std::
100115
fn handle_stop_all() -> Result<(), Box<dyn std::error::Error>> {
101116
info!(event = "cli.stop_all_started");
102117

118+
let self_branch = super::helpers::resolve_self_branch();
119+
103120
let sessions = session_ops::list_sessions()?;
104121
let mut active = Vec::new();
105122
let mut already_stopped = Vec::new();
123+
let mut skipped_self = false;
106124

107125
for s in sessions {
126+
// Skip the calling session to prevent self-destruction
127+
if let Some(ref self_br) = self_branch
128+
&& s.branch.as_ref() == self_br.as_str()
129+
{
130+
skipped_self = true;
131+
continue;
132+
}
108133
match s.status {
109134
SessionStatus::Active => active.push(s),
110135
SessionStatus::Stopped => already_stopped.push(s),
111136
_ => {}
112137
}
113138
}
114139

140+
if skipped_self && let Some(ref self_br) = self_branch {
141+
info!(
142+
event = "cli.stop_all_self_skipped",
143+
branch = self_br.as_str()
144+
);
145+
eprintln!(
146+
"{} Skipping self ({}) — use `kild stop {}` explicitly.",
147+
color::warning("Note:"),
148+
color::ice(self_br),
149+
self_br,
150+
);
151+
}
152+
115153
if active.is_empty() && already_stopped.is_empty() {
116154
println!("No running kilds to stop.");
117155
info!(event = "cli.stop_all_completed", stopped = 0, failed = 0);

0 commit comments

Comments
 (0)