Skip to content
3 changes: 3 additions & 0 deletions crates/pi-shell/changelog.d/6085-exit70.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- On macOS, the shell runtime no longer exits with code 70 when spawning fast-exiting child processes or entitled/setuid children (like `/bin/ps`, `/usr/bin/top`, `sudo`) that cannot be queried for unique identity. When a child is confirmed absent or terminated before observation, the process identity is recorded like on Linux. Only genuine integrity failures (ledger write failures, HMAC errors) trigger exit 70 on a live child (#6085).
8 changes: 7 additions & 1 deletion crates/pi-shell/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -604,17 +604,23 @@ mod platform {
}

pub fn unique_id(&self) -> Option<u64> {
self.live_bsdinfo()?;
let mut info = std::mem::MaybeUninit::<ProcUniqueIdentifierInfo>::zeroed();
let size = i32::try_from(std::mem::size_of::<ProcUniqueIdentifierInfo>()).ok()?;
// SAFETY: `info` is a writable buffer of the exact flavor-17 structure
// size and libproc initializes it completely on a full-size result.
let read = unsafe { proc_pidinfo(self.pid, 17, 0, info.as_mut_ptr().cast(), size) };
if read != size {
// Entitled/setuid children may deny this optional identity query.
// Callers must corroborate process death separately.
return None;
}
// SAFETY: the full structure size was reported initialized above.
let unique_id = unsafe { info.assume_init() }.unique_id;
// Re-check liveness after reading to protect against pid reuse between the read
// and this verification: if the start time has changed, the pid was
// recycled. For entitled children that deny bsdinfo access (EPERM),
// returning None is appropriate since the caller must corroborate liveness
// separately (e.g., via incarnation records with empty unique_id).
self.live_bsdinfo()?;
Some(unique_id)
}
Expand Down
143 changes: 125 additions & 18 deletions crates/pi-shell/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,51 @@ struct OwnershipLedger {
token: String,
}

/// Optional Darwin evidence may be unavailable, but an incarnation must never
/// be invented. A changed incarnation means the original child has exited.
#[cfg(any(target_os = "macos", test))]
fn observed_spawn_incarnation(
incarnation: Option<String>,
observation: process::ProcessObservation,
) -> Result<Option<String>, ()> {
match observation {
process::ProcessObservation::Absent => Ok(None),
process::ProcessObservation::Present { incarnation: observed } => {
if incarnation
.as_ref()
.is_some_and(|pinned| pinned != &observed)
{
Ok(None)
} else {
Ok(Some(observed))
}
},
process::ProcessObservation::Unknown { .. } => incarnation.map(Some).ok_or(()),
}
}

impl ExternalProcessObserver for CommandProcessObserver {
fn spawned(&self, pid: i32, process_group_id: Option<i32>) {
if let Some(upstream) = &self.upstream {
upstream.spawned(pid, process_group_id);
}
if let Some(ledger) = &self.ownership_ledger {
let process = process::Process::from_pid(pid);
let incarnation = process.as_ref().map(process::Process::incarnation);
let darwin_unique_id = process
.as_ref()
.and_then(process::Process::darwin_unique_id)
.map(|value| value.to_string());
#[cfg(target_os = "macos")]
let Some(process) = process else {
std::process::exit(70);
let incarnation = if darwin_unique_id.is_none() {
match observed_spawn_incarnation(incarnation, process::Process::observe(pid)) {
Ok(incarnation) => incarnation,
Err(()) => std::process::exit(70),
}
} else {
incarnation
};
#[cfg(not(target_os = "macos"))]
let Some(process) = process else {
let Some(incarnation) = incarnation else {
self
.targets
.lock()
Expand All @@ -94,16 +126,11 @@ impl ExternalProcessObserver for CommandProcessObserver {
}
return;
};
let incarnation = process.incarnation();
let darwin_unique_id = process.darwin_unique_id().map(|value| value.to_string());
#[cfg(target_os = "macos")]
if darwin_unique_id.is_none() {
process.kill_tree(Some(process::KILL_SIGNAL));
std::process::exit(70);
}
let payload = format!("{pid}:{incarnation}:{}", darwin_unique_id.as_deref().unwrap_or(""));
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(ledger.token.as_bytes()) else {
process.kill_tree(Some(process::KILL_SIGNAL));
if let Some(process) = &process {
process.kill_tree(Some(process::KILL_SIGNAL));
}
std::process::exit(70);
};
mac.update(payload.as_bytes());
Expand All @@ -127,14 +154,17 @@ impl ExternalProcessObserver for CommandProcessObserver {
.is_ok()
});
if !published {
process.kill_tree(Some(process::KILL_SIGNAL));
if let Some(process) = &process {
process.kill_tree(Some(process::KILL_SIGNAL));
}
std::process::exit(70);
}
self
.targets
.lock()
.expect("process target lock poisoned")
.add_process(process);
let mut targets = self.targets.lock().expect("process target lock poisoned");
if let Some(process) = process {
targets.add_process(process);
} else {
targets.add_pid(pid);
}
} else {
self
.targets
Expand Down Expand Up @@ -2248,6 +2278,83 @@ fn quote_arg(arg: &str) -> String {
mod tests {
use super::*;

#[test]
fn spawn_identity_decisions() {
use process::ProcessObservation::{Absent, Present, Unknown};
let pinned = || Some("darwin:123:456".to_owned());
let present = || Present { incarnation: "darwin:123:456".to_owned() };
let unknown = || Unknown { reason_code: "identity_unavailable".to_owned() };
assert_eq!(observed_spawn_incarnation(None, Absent), Ok(None));
assert_eq!(observed_spawn_incarnation(pinned(), Absent), Ok(None));
assert_eq!(observed_spawn_incarnation(None, present()), Ok(pinned()));
assert_eq!(observed_spawn_incarnation(pinned(), present()), Ok(pinned()));
assert_eq!(observed_spawn_incarnation(pinned(), unknown()), Ok(pinned()));
assert_eq!(observed_spawn_incarnation(None, unknown()), Err(()));
assert_eq!(
observed_spawn_incarnation(pinned(), Present { incarnation: "darwin:789:0".to_owned() }),
Ok(None),
);
}

#[cfg(target_os = "macos")]
#[test]
fn spawned_reaped_child_does_not_require_ledger_identity() {
let path = std::env::temp_dir().join(format!("pi-shell-reaped-{}.jsonl", std::process::id()));
let file = fs::File::create(&path).expect("create ledger");
let observer = CommandProcessObserver {
process_group_id: Arc::new(AtomicI32::new(0)),
targets: Arc::new(StdMutex::new(process::TerminationTargets::new())),
ownership_ledger: Some(OwnershipLedger {
file: Arc::new(StdMutex::new(file)),
token: "test-token".to_owned(),
}),
upstream: None,
};
let mut child = std::process::Command::new("/usr/bin/true")
.spawn()
.expect("spawn child");
let pid = i32::try_from(child.id()).expect("pid fits");
child.wait().expect("reap child");
observer.spawned(pid, Some(pid));
assert_eq!(observer.process_group_id.load(Ordering::SeqCst), pid);
assert_eq!(fs::read_to_string(&path).expect("read ledger"), "");
fs::remove_file(path).expect("remove ledger");
}

#[cfg(unix)]
#[test]
fn spawn_ledger_write_failure_exits_70() {
const CHILD_FLAG: &str = "PI_SHELL_TEST_LEDGER_FAILURE";
if std::env::var_os(CHILD_FLAG).is_some() {
// A read-only descriptor deterministically rejects publication on
// every Unix platform, without depending on /dev/full availability.
let file = fs::File::open("/dev/null").expect("open read-only ledger");
let observer = CommandProcessObserver {
process_group_id: Arc::new(AtomicI32::new(0)),
targets: Arc::new(StdMutex::new(process::TerminationTargets::new())),
ownership_ledger: Some(OwnershipLedger {
file: Arc::new(StdMutex::new(file)),
token: "test-token".to_owned(),
}),
upstream: None,
};
let mut child = std::process::Command::new("/bin/sleep")
.arg("30")
.spawn()
.expect("spawn identifiable child");
observer.spawned(i32::try_from(child.id()).expect("pid fits"), None);
child.kill().expect("clean up unexpected survivor");
child.wait().expect("reap unexpected survivor");
panic!("ledger failure must exit before returning");
}
let status = std::process::Command::new(std::env::current_exe().expect("test binary"))
.args(["--exact", "shell::tests::spawn_ledger_write_failure_exits_70", "--nocapture"])
.env(CHILD_FLAG, "1")
.status()
.expect("run isolated observer");
assert_eq!(status.code(), Some(70));
}

#[cfg(unix)]
static PROCESS_TEST_LOCK: TokioMutex<()> = TokioMutex::const_new(());
#[cfg(unix)]
Expand Down
73 changes: 73 additions & 0 deletions crates/pi-shell/tests/darwin_spawn_ledger.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//! Regression coverage for fast-exiting and entitled children with ownership
//! enabled.
#![cfg(target_os = "macos")]

use std::fs;

use hmac::{Hmac, Mac};
use pi_shell::{
cancel::CancelToken,
shell::{Shell, ShellOptions, ShellRunOptions},
};
use sha2::Sha256;

#[tokio::test]
async fn fast_and_entitled_children_preserve_runtime_and_signed_ledger() {
let path = std::env::temp_dir().join(format!("pi-shell-spawn-{}.jsonl", std::process::id()));
let token = "spawn-regression-token";
let shell = Shell::new(Some(ShellOptions {
ownership_ledger_path: Some(path.to_string_lossy().into_owned()),
ownership_ledger_token: Some(token.to_owned()),
..ShellOptions::default()
}));
for _ in 0..200 {
let result = shell
.run(
ShellRunOptions {
command: "/usr/bin/true".to_owned(),
timeout_ms: Some(10_000),
..ShellRunOptions::default()
},
None,
CancelToken::default(),
)
.await
.expect("run fast child");
assert_eq!(result.exit_code, Some(0));
assert!(!result.timed_out);
}
let result = shell
.run(
ShellRunOptions {
command: "/usr/bin/top -l 1".to_owned(),
timeout_ms: Some(30_000),
..ShellRunOptions::default()
},
None,
CancelToken::default(),
)
.await
.expect("run entitled child");
assert_eq!(result.exit_code, Some(0));
assert!(!result.timed_out);
let ledger = fs::read_to_string(&path).expect("read ledger");
assert!(!ledger.is_empty(), "live children must publish identity evidence");
for line in ledger.lines() {
let record: serde_json::Value = serde_json::from_str(line).expect("ledger JSON");
let incarnation = record["incarnation"].as_str().expect("incarnation");
assert!(incarnation.starts_with("darwin:"));
let unique_id = record["darwinUniqueId"].as_str().unwrap_or("");
let payload = format!("{}:{incarnation}:{unique_id}", record["pid"]);
let mut mac = Hmac::<Sha256>::new_from_slice(token.as_bytes()).expect("HMAC key");
mac.update(payload.as_bytes());
let expected: String = mac
.finalize()
.into_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
assert_eq!(record["signature"].as_str(), Some(expected.as_str()));
}
drop(shell);
fs::remove_file(path).expect("remove ledger");
}
Loading