1use anyhow::{anyhow, Result};
8use std::collections::HashMap;
9#[cfg(unix)]
10use std::os::unix::process::CommandExt;
11use std::path::{Path, PathBuf};
12use std::process::{Child, Command, Stdio};
13use std::sync::{Mutex, OnceLock};
14use std::time::Duration;
15
16const SECRET_MARKERS: &[&str] = &[
17 "SECRET",
18 "TOKEN",
19 "PASSWORD",
20 "API_KEY",
21 "AUTHORIZATION",
22 "PRIVATE_KEY",
23];
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CommandAdmission {
27 Allow,
28 Deny,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct AdmittedCommand {
33 pub cwd: PathBuf,
34 pub command: String,
35 pub transcript_env: Vec<(String, String)>,
36}
37
38#[derive(Debug)]
39struct Job {
40 child: Child,
41}
42
43struct Session {
44 cwd: PathBuf,
45 root: Option<PathBuf>,
47 env: HashMap<String, String>,
48 jobs: HashMap<String, Job>,
49}
50
51fn sessions() -> &'static Mutex<HashMap<String, Session>> {
52 static SESSIONS: OnceLock<Mutex<HashMap<String, Session>>> = OnceLock::new();
53 SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
54}
55
56pub fn bind_session(session_id: &str, cwd: &Path) {
57 bind_session_with_root(session_id, cwd, None);
58}
59
60pub fn bind_session_rooted(session_id: &str, cwd: &Path, root: &Path) {
63 bind_session_with_root(session_id, cwd, Some(root.to_path_buf()));
64}
65
66fn bind_session_with_root(session_id: &str, cwd: &Path, root: Option<PathBuf>) {
67 sessions().lock().expect("shell sessions").insert(
68 session_id.to_string(),
69 Session {
70 cwd: cwd.to_path_buf(),
71 root,
72 env: HashMap::new(),
73 jobs: HashMap::new(),
74 },
75 );
76}
77
78pub fn drop_session(session_id: &str) {
79 let jobs = sessions()
80 .lock()
81 .expect("shell sessions")
82 .remove(session_id)
83 .map(|mut session| session.jobs.drain().map(|(_, job)| job).collect::<Vec<_>>());
84 if let Some(jobs) = jobs {
85 for mut job in jobs {
86 let _ = kill_child(&mut job.child);
87 }
88 }
89}
90
91#[cfg(test)]
92fn job_count(session_id: &str) -> usize {
93 sessions()
94 .lock()
95 .expect("shell sessions")
96 .get(session_id)
97 .map(|session| session.jobs.len())
98 .unwrap_or(0)
99}
100
101pub fn cwd(session_id: &str) -> Option<PathBuf> {
102 sessions()
103 .lock()
104 .expect("shell sessions")
105 .get(session_id)
106 .map(|session| session.cwd.clone())
107}
108
109pub fn set_env(session_id: &str, key: &str, value: &str) -> Result<()> {
110 let mut guard = sessions().lock().expect("shell sessions");
111 let session = guard
112 .get_mut(session_id)
113 .ok_or_else(|| anyhow!("shell session {session_id} is not bound"))?;
114 session.env.insert(key.to_string(), value.to_string());
115 Ok(())
116}
117
118pub fn transcript_env(session_id: &str) -> Vec<(String, String)> {
120 sessions()
121 .lock()
122 .expect("shell sessions")
123 .get(session_id)
124 .map(|session| {
125 session
126 .env
127 .iter()
128 .filter(|(key, _)| !is_secret_key(key))
129 .map(|(key, value)| (key.clone(), value.clone()))
130 .collect()
131 })
132 .unwrap_or_default()
133}
134
135pub fn admit(
137 session_id: &str,
138 command: &str,
139 admission: CommandAdmission,
140) -> Result<AdmittedCommand> {
141 let mut guard = sessions().lock().expect("shell sessions");
142 let session = guard
143 .get_mut(session_id)
144 .ok_or_else(|| anyhow!("shell session {session_id} is not bound"))?;
145 if admission == CommandAdmission::Deny {
146 return Err(anyhow!("command denied inside shell session {session_id}"));
147 }
148 let trimmed = command.trim();
149 if let Some(path) = simple_cd(trimmed) {
150 let next = resolve_cd(&session.cwd, path);
151 if let Some(root) = session.root.as_deref() {
152 if !stays_under_root(&next, root) {
153 return Err(anyhow!(
154 "cd cannot leave the isolation worktree for shell session {session_id}"
155 ));
156 }
157 }
158 session.cwd = next;
159 }
160 Ok(AdmittedCommand {
161 cwd: session.cwd.clone(),
162 command: trimmed.to_string(),
163 transcript_env: session
164 .env
165 .iter()
166 .filter(|(key, _)| !is_secret_key(key))
167 .map(|(key, value)| (key.clone(), value.clone()))
168 .collect(),
169 })
170}
171
172pub fn detach(session_id: &str, command: &str, admission: CommandAdmission) -> Result<String> {
173 let admitted = admit(session_id, command, admission)?;
174 if admitted.command.starts_with("cd ") {
175 return Err(anyhow!("cd is not a detachable job"));
176 }
177 let child = spawn(session_id, &admitted)?;
178 let id = format!("job-{}", child.id());
179 sessions()
180 .lock()
181 .expect("shell sessions")
182 .get_mut(session_id)
183 .ok_or_else(|| anyhow!("shell session {session_id} is not bound"))?
184 .jobs
185 .insert(id.clone(), Job { child });
186 Ok(id)
187}
188
189pub fn poll(session_id: &str, job_id: &str) -> Result<String> {
190 let mut guard = sessions().lock().expect("shell sessions");
191 let job = guard
192 .get_mut(session_id)
193 .and_then(|session| session.jobs.get_mut(job_id))
194 .ok_or_else(|| anyhow!("unknown job {job_id}"))?;
195 match job.child.try_wait()? {
196 Some(status) => Ok(format!("exited {status}")),
197 None => Ok("running".to_string()),
198 }
199}
200
201pub fn kill(session_id: &str, job_id: &str) -> Result<()> {
202 let mut job = sessions()
203 .lock()
204 .expect("shell sessions")
205 .get_mut(session_id)
206 .and_then(|session| session.jobs.remove(job_id))
207 .ok_or_else(|| anyhow!("unknown job {job_id}"))?;
208 kill_child(&mut job.child)
209}
210
211pub fn kill_session(session_id: &str) {
214 let jobs = sessions()
215 .lock()
216 .expect("shell sessions")
217 .get_mut(session_id)
218 .map(|session| session.jobs.drain().map(|(_, job)| job).collect::<Vec<_>>())
219 .unwrap_or_default();
220 for mut job in jobs {
221 let _ = kill_child(&mut job.child);
222 }
223}
224
225pub fn run_bounded(session_id: &str, command: &str, timeout: Duration) -> Result<String> {
227 let admitted = admit(session_id, command, CommandAdmission::Allow)?;
228 if admitted.command.starts_with("cd ") {
229 return Ok(format!("cwd {}", admitted.cwd.display()));
230 }
231 let mut child = spawn(session_id, &admitted)?;
232 let started = std::time::Instant::now();
233 loop {
234 if child.try_wait()?.is_some() {
235 return Ok("exited".to_string());
236 }
237 if started.elapsed() >= timeout {
238 kill_child(&mut child)?;
239 return Err(anyhow!("shell timeout killed the process group"));
240 }
241 std::thread::sleep(Duration::from_millis(20));
242 }
243}
244
245fn spawn(session_id: &str, admitted: &AdmittedCommand) -> Result<Child> {
246 let overlay = overlay_env(session_id);
247 #[cfg(unix)]
248 let mut command = {
249 let mut command = Command::new("sh");
250 command.arg("-c").arg(&admitted.command);
251 command
252 };
253 #[cfg(windows)]
254 let mut command = windows_detached_command(admitted, &overlay)?;
255 command
256 .current_dir(&admitted.cwd)
257 .stdin(Stdio::null())
258 .stdout(Stdio::piped())
259 .stderr(Stdio::piped());
260 for (key, value) in &overlay {
261 command.env(key, value);
262 }
263 #[cfg(unix)]
264 unsafe {
265 command.pre_exec(|| {
266 if libc::setpgid(0, 0) != 0 {
267 return Err(std::io::Error::last_os_error());
268 }
269 Ok(())
270 });
271 }
272 #[cfg_attr(not(windows), allow(unused_mut))]
273 let mut child = command.spawn().map_err(|error| {
274 anyhow!("failed to spawn detached shell for session {session_id}: {error}")
275 })?;
276 #[cfg(windows)]
277 {
278 use std::os::windows::io::AsRawHandle;
279 if let Err(error) = crate::tools::builtin::bash::bind_windows_process_tree(
280 child.as_raw_handle(),
281 child.id(),
282 ) {
283 let _ = child.kill();
284 let _ = child.wait();
285 return Err(anyhow!(
286 "failed to bind detached shell job for session {session_id}: {error}"
287 ));
288 }
289 }
290 Ok(child)
291}
292
293#[cfg(windows)]
297fn windows_detached_command(
298 admitted: &AdmittedCommand,
299 overlay: &HashMap<String, String>,
300) -> Result<Command> {
301 use std::os::windows::process::CommandExt;
302
303 let powershell =
304 crate::tools::builtin::bash::windows_host_powershell(&admitted.cwd).map_err(|error| {
305 anyhow!(
306 "failed to resolve PowerShell 7 for {}: {error}",
307 admitted.cwd.display()
308 )
309 })?;
310 let mut source = String::new();
311 for key in overlay.keys() {
312 if is_powershell_identifier(key) {
313 source.push_str(&format!("${key} = $env:{key}\n"));
314 }
315 }
316 source.push_str(&admitted.command);
317 let wrapped = crate::tools::builtin::bash::build_powershell_command(&source);
318 let encoded = crate::tools::builtin::bash::encode_powershell_command(&wrapped);
319 let mut command = Command::new(&powershell);
320 command.args([
321 "-NoLogo",
322 "-NoProfile",
323 "-NonInteractive",
324 "-ExecutionPolicy",
325 "Bypass",
326 ]);
327 let encoded_chars = format!("{powershell:?} -EncodedCommand {encoded}")
328 .encode_utf16()
329 .count();
330 if encoded_chars <= 30_000 {
331 command.arg("-EncodedCommand").arg(encoded);
332 } else {
333 let unique = std::time::SystemTime::now()
334 .duration_since(std::time::UNIX_EPOCH)
335 .map(|elapsed| elapsed.as_nanos())
336 .unwrap_or(0);
337 let path =
338 std::env::temp_dir().join(format!("a3s-detach-{}-{unique}.ps1", std::process::id()));
339 let literal = path.to_string_lossy().replace('\'', "''");
340 let body = format!(
341 "trap {{ exit 1 }}\nRegister-EngineEvent -SourceIdentifier PowerShell.Exiting -Action {{ Remove-Item -LiteralPath '{literal}' -Force -ErrorAction SilentlyContinue }} | Out-Null\n{wrapped}\nif (-not $?) {{ exit 1 }}\n"
342 );
343 std::fs::write(&path, body.as_bytes())
344 .map_err(|error| anyhow!("failed to write detached shell script: {error}"))?;
345 command.arg("-File").arg(&path);
346 }
347 command.creation_flags(crate::tools::builtin::bash::CREATE_NO_WINDOW);
348 Ok(command)
349}
350
351#[cfg(windows)]
352fn is_powershell_identifier(key: &str) -> bool {
353 let mut chars = key.chars();
354 match chars.next() {
355 Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
356 _ => return false,
357 }
358 chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
359}
360
361fn kill_child(child: &mut Child) -> Result<()> {
362 #[cfg(unix)]
366 let pid = child.id();
367 #[cfg(unix)]
368 unsafe {
369 libc::kill(-(pid as i32), libc::SIGKILL);
370 }
371 #[cfg(not(unix))]
372 {
373 let _ = child.kill();
374 }
375 let _ = child.wait();
376 #[cfg(unix)]
377 unsafe {
378 let gone = libc::kill(pid as i32, 0) != 0;
379 if !gone {
380 return Err(anyhow!("child {pid} survived process-group kill"));
381 }
382 }
383 Ok(())
384}
385
386fn overlay_env(session_id: &str) -> HashMap<String, String> {
388 sessions()
389 .lock()
390 .expect("shell sessions")
391 .get(session_id)
392 .map(|session| session.env.clone())
393 .unwrap_or_default()
394}
395
396fn simple_cd(command: &str) -> Option<&str> {
397 let rest = command.strip_prefix("cd ")?.trim();
398 if rest.is_empty()
399 || rest.contains("&&")
400 || rest.contains(';')
401 || rest.contains('|')
402 || rest.contains('>')
403 {
404 return None;
405 }
406 Some(rest)
407}
408
409fn resolve_cd(cwd: &Path, raw: &str) -> PathBuf {
410 let path = Path::new(raw);
411 if path.is_absolute() {
412 path.to_path_buf()
413 } else {
414 cwd.join(path)
415 }
416}
417
418fn stays_under_root(next: &Path, root: &Path) -> bool {
419 let lexical = normalize_lexical(next);
420 let root = normalize_lexical(root);
421 if !lexical.starts_with(&root) {
422 return false;
423 }
424 let Ok(canonical) = std::fs::canonicalize(&lexical) else {
425 return true;
426 };
427 let root = std::fs::canonicalize(&root).unwrap_or(root);
428 canonical.starts_with(root)
429}
430
431fn normalize_lexical(path: &Path) -> PathBuf {
432 let mut normalized = PathBuf::new();
433 for component in path.components() {
434 match component {
435 std::path::Component::ParentDir => {
436 normalized.pop();
437 }
438 std::path::Component::CurDir => {}
439 other => normalized.push(other.as_os_str()),
440 }
441 }
442 normalized
443}
444
445fn is_secret_key(key: &str) -> bool {
446 let upper = key.to_ascii_uppercase();
447 SECRET_MARKERS.iter().any(|marker| upper.contains(marker))
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn cd_is_visible_in_the_same_session_and_not_in_another() {
456 let root = tempfile::tempdir().unwrap();
457 let nested = root.path().join("nested");
458 std::fs::create_dir(&nested).unwrap();
459 bind_session("a", root.path());
460 bind_session("b", root.path());
461 admit("a", "cd nested", CommandAdmission::Allow).unwrap();
462 assert_eq!(cwd("a").unwrap(), nested);
463 assert_eq!(cwd("b").unwrap(), root.path());
464 let denied = admit("a", "cd ..", CommandAdmission::Deny);
465 assert!(denied.is_err());
466 assert_eq!(cwd("a").unwrap(), nested);
467 let denied_again = admit("a", "touch leaked.txt", CommandAdmission::Deny);
468 assert!(denied_again
469 .unwrap_err()
470 .to_string()
471 .contains("command denied inside shell session"));
472 assert_eq!(cwd("a").unwrap(), nested);
473 assert!(!nested.join("leaked.txt").exists());
474 drop_session("a");
475 drop_session("b");
476 }
477
478 #[test]
479 fn detached_job_outlives_the_return_and_can_be_polled_and_killed() {
480 let root = tempfile::tempdir().unwrap();
481 bind_session("jobs", root.path());
482 let id = detach("jobs", "sleep 30", CommandAdmission::Allow).unwrap();
483 assert_eq!(poll("jobs", &id).unwrap(), "running");
484 kill("jobs", &id).unwrap();
485 assert!(poll("jobs", &id).is_err());
486 drop_session("jobs");
487 }
488
489 #[test]
490 fn command_overlay_does_not_cross_sessions_that_share_a_cwd() {
491 let root = tempfile::tempdir().unwrap();
492 let owner = "overlay-owner";
493 let other = "overlay-other";
494 bind_session(owner, root.path());
495 bind_session(other, root.path());
496 set_env(owner, "A3S_SHELL_OVERLAY", "owner-only").unwrap();
497 set_env(other, "A3S_SHELL_OVERLAY", "other-only").unwrap();
498 assert_eq!(
499 overlay_env(other)
500 .get("A3S_SHELL_OVERLAY")
501 .map(String::as_str),
502 Some("other-only")
503 );
504 run_bounded(
505 other,
506 "printf '%s' \"$A3S_SHELL_OVERLAY\" > other-overlay.txt",
507 Duration::from_secs(5),
508 )
509 .unwrap();
510 let written = std::fs::read_to_string(root.path().join("other-overlay.txt")).unwrap();
511 assert_eq!(
512 written.trim(),
513 "other-only",
514 "the writer session must record its own overlay, not the other session"
515 );
516 assert!(
517 !written.contains("owner-only"),
518 "overlay leaked across sessions that share a cwd: {written:?}"
519 );
520 drop_session(owner);
521 drop_session(other);
522 }
523
524 #[test]
525 fn cancel_kills_detached_jobs_in_the_session() {
526 let root = tempfile::tempdir().unwrap();
527 bind_session("cancel", root.path());
528 let id = detach("cancel", "sleep 30", CommandAdmission::Allow).unwrap();
529 assert_eq!(poll("cancel", &id).unwrap(), "running");
530 kill_session("cancel");
531 assert!(poll("cancel", &id).is_err());
532 drop_session("cancel");
533 }
534
535 #[test]
536 fn timeout_leaves_no_child_process() {
537 let root = tempfile::tempdir().unwrap();
538 bind_session("timeout", root.path());
539 let error = run_bounded("timeout", "sleep 30", Duration::from_millis(80)).unwrap_err();
540 assert!(error.to_string().contains("killed the process group"));
541 drop_session("timeout");
542 }
543
544 #[cfg(windows)]
545 #[test]
546 fn killing_a_detached_job_stops_its_descendant_before_a_later_write() {
547 let root = tempfile::tempdir().unwrap();
548 let child_started = root.path().join("child-started");
549 let leaked = root.path().join("leaked");
550 let child_started_literal = child_started.to_string_lossy().replace('\'', "''");
551 let leaked_literal = leaked.to_string_lossy().replace('\'', "''");
552 let powershell = crate::tools::builtin::bash::windows_host_powershell(root.path())
553 .expect("PowerShell 7");
554 let powershell_literal = powershell.to_string_lossy().replace('\'', "''");
555 let command = format!(
556 "$child = Start-Process -FilePath '{powershell_literal}' -PassThru -WindowStyle Hidden \
557 -ArgumentList '-NoLogo','-NoProfile','-NonInteractive','-Command','Set-Content -LiteralPath ''{child_started_literal}'' -Value started; Start-Sleep -Seconds 1; Set-Content -LiteralPath ''{leaked_literal}'' -Value leaked'; \
558 Wait-Process -Id $child.Id"
559 );
560 let session = format!("detach-tree-{}", std::process::id());
561 bind_session(&session, root.path());
562 let id = detach(&session, &command, CommandAdmission::Allow).unwrap();
563 let deadline = std::time::Instant::now() + Duration::from_secs(8);
564 while !child_started.exists() {
565 assert!(
566 deadline > std::time::Instant::now(),
567 "descendant did not start before the detached shell was killed"
568 );
569 std::thread::sleep(Duration::from_millis(20));
570 }
571 kill(&session, &id).unwrap();
572 std::thread::sleep(Duration::from_millis(1_200));
573 drop_session(&session);
574 assert!(
575 !leaked.exists(),
576 "killing a detached job must stop a descendant before its later write"
577 );
578 }
579
580 #[cfg(windows)]
581 #[test]
582 fn timing_out_a_shell_stops_its_descendant_before_a_later_write() {
583 let root = tempfile::tempdir().unwrap();
584 let child_started = root.path().join("child-started");
585 let leaked = root.path().join("leaked");
586 let child_started_literal = child_started.to_string_lossy().replace('\'', "''");
587 let leaked_literal = leaked.to_string_lossy().replace('\'', "''");
588 let powershell = crate::tools::builtin::bash::windows_host_powershell(root.path())
589 .expect("PowerShell 7");
590 let powershell_literal = powershell.to_string_lossy().replace('\'', "''");
591 let command = format!(
592 "$child = Start-Process -FilePath '{powershell_literal}' -PassThru -WindowStyle Hidden \
593 -ArgumentList '-NoLogo','-NoProfile','-NonInteractive','-Command','Set-Content -LiteralPath ''{child_started_literal}'' -Value started; Start-Sleep -Seconds 30; Set-Content -LiteralPath ''{leaked_literal}'' -Value leaked'; \
594 Wait-Process -Id $child.Id"
595 );
596 let session = format!("timeout-tree-{}", std::process::id());
597 bind_session(&session, root.path());
598 let session_for_run = session.clone();
599 let runner = std::thread::spawn(move || {
600 run_bounded(&session_for_run, &command, Duration::from_secs(8))
601 });
602 let deadline = std::time::Instant::now() + Duration::from_secs(6);
603 while !child_started.exists() {
604 assert!(
605 deadline > std::time::Instant::now(),
606 "descendant did not start before the shell timeout"
607 );
608 std::thread::sleep(Duration::from_millis(20));
609 }
610 let error = runner.join().expect("timeout runner").unwrap_err();
611 assert!(
612 error.to_string().contains("killed the process group"),
613 "timeout did not report the process-group kill: {error}"
614 );
615 std::thread::sleep(Duration::from_millis(1_200));
616 drop_session(&session);
617 assert!(
618 !leaked.exists(),
619 "a shell timeout must stop a descendant before its later write"
620 );
621 }
622
623 #[test]
624 fn transcript_omits_secret_env() {
625 let root = tempfile::tempdir().unwrap();
626 bind_session("env", root.path());
627 set_env("env", "PATH", "/usr/bin").unwrap();
628 set_env("env", "OPENAI_API_KEY", "sk-secret").unwrap();
629 let visible = transcript_env("env");
630 assert!(visible.iter().any(|(key, _)| key == "PATH"));
631 assert!(visible.iter().all(|(key, _)| key != "OPENAI_API_KEY"));
632 drop_session("env");
633 }
634
635 #[test]
637 #[ignore = "S-WT-01 rooted shell soak"]
638 fn soak_rooted_shell_keeps_cwd_inside_one_hundred_commands() {
639 let root = tempfile::tempdir().unwrap();
640 let nested = root.path().join("nested");
641 std::fs::create_dir(&nested).unwrap();
642 let outside =
643 std::env::temp_dir().join(format!("a3s-shell-escape-{}.txt", std::process::id()));
644 let _ = std::fs::remove_file(&outside);
645 let session = format!("soak-wt-{}", std::process::id());
646 bind_session_rooted(&session, root.path(), root.path());
647 let parent = root.path().parent().expect("parent");
648 let absolute_escape = format!("cd {}", parent.display());
649
650 for index in 0..100 {
651 let before = cwd(&session).expect("cwd");
652 let command = match index % 4 {
653 0 => "cd nested",
654 1 => "cd ..",
655 2 => absolute_escape.as_str(),
656 _ => "cd ..",
657 };
658 let result = admit(&session, command, CommandAdmission::Allow);
659 let after = cwd(&session).expect("cwd");
660 assert!(
661 stays_under_root(&after, root.path()),
662 "cycle {index} cwd left the root: {after:?}"
663 );
664 if result.is_err() {
665 assert_eq!(before, after, "denied cd moved cwd on cycle {index}");
666 }
667 assert_eq!(
668 job_count(&session),
669 0,
670 "cycle {index} leaked a detached job"
671 );
672 assert!(
673 !outside.exists(),
674 "cycle {index} created a file outside the root"
675 );
676 }
677
678 drop_session(&session);
679 let _ = std::fs::remove_file(&outside);
680 }
681}