From 3927f64b4607a03c3996f776c080c2e8693e0717 Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Mon, 20 Jul 2026 17:32:56 +0100 Subject: [PATCH 1/3] fix(jobs): trust Pi project resources --- src/agent.rs | 2 +- src/pi.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 7982ad6..bc86040 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -139,7 +139,7 @@ impl Runner { match self { Runner::Claude(r) => r.run_unattended(req, timeout).await, Runner::Codex(r) => r.run_unattended(req, timeout).await, - Runner::Pi(r) => r.run(req, timeout).await, + Runner::Pi(r) => r.run_unattended(req, timeout).await, #[cfg(test)] Runner::Fake(r) => r.run(req, timeout).await, } diff --git a/src/pi.rs b/src/pi.rs index acee131..bed4c27 100644 --- a/src/pi.rs +++ b/src/pi.rs @@ -14,10 +14,25 @@ pub struct Runner { pub bin: String, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum RunMode { + Configured, + Unattended, + Evaluator, +} + impl Runner { /// Executes one turn and returns Pi's final reply plus its stable session id. pub async fn run(&self, req: Request<'_>, timeout: Duration) -> Result { - self.run_with_mode(req, timeout, false).await + self.run_with_mode(req, timeout, RunMode::Configured).await + } + + pub async fn run_unattended( + &self, + req: Request<'_>, + timeout: Duration, + ) -> Result { + self.run_with_mode(req, timeout, RunMode::Unattended).await } pub async fn run_evaluator( @@ -25,17 +40,17 @@ impl Runner { req: Request<'_>, timeout: Duration, ) -> Result { - self.run_with_mode(req, timeout, true).await + self.run_with_mode(req, timeout, RunMode::Evaluator).await } async fn run_with_mode( &self, req: Request<'_>, timeout: Duration, - evaluator: bool, + mode: RunMode, ) -> Result { let attempt = crate::agent::output_with_retry(|| { - let mut cmd = self.command(&req, evaluator); + let mut cmd = self.command(&req, mode); let prompt = req.prompt.as_bytes().to_vec(); async move { let mut child = cmd.spawn()?; @@ -92,16 +107,19 @@ impl Runner { }) } - fn command(&self, req: &Request<'_>, evaluator: bool) -> Command { + fn command(&self, req: &Request<'_>, mode: RunMode) -> Command { let mut cmd = Command::new(&self.bin); cmd.arg("--print").arg("--mode").arg("json"); - if evaluator { - cmd.arg("--no-tools") + if mode == RunMode::Evaluator { + cmd.arg("--no-approve") + .arg("--no-tools") .arg("--no-extensions") .arg("--no-skills") .arg("--no-prompt-templates") .arg("--no-context-files") .arg("--no-session"); + } else if mode == RunMode::Unattended { + cmd.arg("--approve"); } if !req.instructions.trim().is_empty() { cmd.arg("--append-system-prompt") @@ -252,12 +270,34 @@ mod tests { let args = read_args(&args_path); assert_arg_pair(&args, "--mode", "json"); assert_arg_pair(&args, "--append-system-prompt", "SOUL instructions"); + assert!(!args.contains(&"--approve".to_string())); + assert!(!args.contains(&"--no-approve".to_string())); assert!(!args.contains(&"--tools".to_string())); assert!(!args.contains(&"--session".to_string())); assert_eq!(read_prompt(&args_path), "user message"); assert!(!args.contains(&"user message".to_string())); } + #[tokio::test] + async fn unattended_run_trusts_project_local_resources() { + let args_path = temp_path("pi-unattended-args"); + let work_dir = temp_dir("pi-unattended-work"); + let cli = FakeCli::new("pi", &success_script(&args_path, "pi-session", "done")); + let runner = Runner { bin: cli.bin() }; + + runner + .run_unattended( + request(work_dir.to_str().unwrap(), true), + Duration::from_secs(5), + ) + .await + .unwrap(); + + let args = read_args(&args_path); + assert!(args.iter().any(|arg| arg == "--approve")); + assert!(!args.iter().any(|arg| arg == "--no-approve")); + } + #[tokio::test] async fn sends_option_and_file_like_prompts_verbatim_over_stdin() { for prompt in [ @@ -455,6 +495,8 @@ printf '%s\n' '{"type":"message_end","message":{"role":"assistant","content":[{" assert!(args.iter().any(|arg| arg == "--no-extensions")); assert!(args.iter().any(|arg| arg == "--no-skills")); assert!(args.iter().any(|arg| arg == "--no-context-files")); + assert!(args.iter().any(|arg| arg == "--no-approve")); + assert!(!args.iter().any(|arg| arg == "--approve")); } fn success_script(args_path: &std::path::Path, session: &str, reply: &str) -> String { From ca918b06babc38708844bbd86add30ffb8fb78d8 Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Sat, 25 Jul 2026 21:28:03 +0100 Subject: [PATCH 2/3] fix(jobs): restrict Pi project trust --- src/agent.rs | 6 +++++- src/jobs.rs | 37 ++++++++++++++++++++++++++++++++++++- src/pi.rs | 45 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index bc86040..b587778 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -135,11 +135,15 @@ impl Runner { &self, req: Request<'_>, timeout: Duration, + trust_project_resources: bool, ) -> Result { match self { Runner::Claude(r) => r.run_unattended(req, timeout).await, Runner::Codex(r) => r.run_unattended(req, timeout).await, - Runner::Pi(r) => r.run_unattended(req, timeout).await, + Runner::Pi(r) => { + r.run_unattended(req, timeout, trust_project_resources) + .await + } #[cfg(test)] Runner::Fake(r) => r.run(req, timeout).await, } diff --git a/src/jobs.rs b/src/jobs.rs index e6fd3b3..61408c2 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -1941,6 +1941,8 @@ async fn execute(cfg: &Config, job: &Job) -> std::result::Result std::result::Result Ok(output.reply), Err(RunError::Timeout) => Err(ExecutionError::Timeout), Err(RunError::Failed(error) | RunError::SessionMissing(error)) => { @@ -4009,10 +4014,40 @@ printf '%s\n' ok > {} let args = std::fs::read_to_string(&args_path).unwrap(); assert!(args.lines().any(|line| line == "--mode")); assert!(args.lines().any(|line| line == "json")); + assert!(args.lines().any(|line| line == "--no-approve")); + assert!(!args.lines().any(|line| line == "--approve")); assert!(!args.lines().any(|line| line == "--session")); assert_eq!( std::fs::read_to_string(format!("{}.stdin", args_path.to_string_lossy())).unwrap(), "\nInspect this directory.\n" ); } + + #[tokio::test] + async fn pi_job_trusts_resources_only_at_the_assistant_root() { + let jobs_dir = temp_dir("jobs-pi-assistant-root"); + let database = temp_path("jobs-pi-assistant-root-db"); + let run_dir = temp_dir("jobs-pi-assistant-root-run"); + let args_path = temp_path("jobs-pi-assistant-root-args"); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\ncat > {}.stdin\nprintf '%s\\n' '{{\"type\":\"session\",\"id\":\"pi-job-session\"}}'\nprintf '%s\\n' '{{\"type\":\"message_end\",\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"pi result\"}}],\"stopReason\":\"stop\"}}}}'\n", + sh_arg(&args_path), + sh_arg(&args_path) + ); + let cli = FakeCli::new("pi", &script); + let mut cfg = cfg(&jobs_dir, &database, &run_dir); + cfg.agent_commands.pi = cli.bin(); + let runbook = + "+++\nversion = 1\ntimeout = \"5s\"\nbackend = \"pi\"\n+++\n\nInspect this directory.\n"; + write_job(&jobs_dir, "pi-job", runbook); + + let output = run_manual(&cfg, Catalog::load_named(&cfg, "pi-job").unwrap()) + .await + .unwrap(); + + assert_eq!(output.1, "pi result"); + let args = std::fs::read_to_string(args_path).unwrap(); + assert!(args.lines().any(|line| line == "--approve")); + assert!(!args.lines().any(|line| line == "--no-approve")); + } } diff --git a/src/pi.rs b/src/pi.rs index bed4c27..6248e43 100644 --- a/src/pi.rs +++ b/src/pi.rs @@ -17,7 +17,7 @@ pub struct Runner { #[derive(Clone, Copy, PartialEq, Eq)] enum RunMode { Configured, - Unattended, + Unattended { trust_project_resources: bool }, Evaluator, } @@ -31,8 +31,16 @@ impl Runner { &self, req: Request<'_>, timeout: Duration, + trust_project_resources: bool, ) -> Result { - self.run_with_mode(req, timeout, RunMode::Unattended).await + self.run_with_mode( + req, + timeout, + RunMode::Unattended { + trust_project_resources, + }, + ) + .await } pub async fn run_evaluator( @@ -118,8 +126,15 @@ impl Runner { .arg("--no-prompt-templates") .arg("--no-context-files") .arg("--no-session"); - } else if mode == RunMode::Unattended { - cmd.arg("--approve"); + } else if let RunMode::Unattended { + trust_project_resources, + } = mode + { + cmd.arg(if trust_project_resources { + "--approve" + } else { + "--no-approve" + }); } if !req.instructions.trim().is_empty() { cmd.arg("--append-system-prompt") @@ -289,6 +304,7 @@ mod tests { .run_unattended( request(work_dir.to_str().unwrap(), true), Duration::from_secs(5), + true, ) .await .unwrap(); @@ -298,6 +314,27 @@ mod tests { assert!(!args.iter().any(|arg| arg == "--no-approve")); } + #[tokio::test] + async fn unattended_run_does_not_trust_external_project_resources() { + let args_path = temp_path("pi-unattended-untrusted-args"); + let work_dir = temp_dir("pi-unattended-untrusted-work"); + let cli = FakeCli::new("pi", &success_script(&args_path, "pi-session", "done")); + let runner = Runner { bin: cli.bin() }; + + runner + .run_unattended( + request(work_dir.to_str().unwrap(), true), + Duration::from_secs(5), + false, + ) + .await + .unwrap(); + + let args = read_args(&args_path); + assert!(args.iter().any(|arg| arg == "--no-approve")); + assert!(!args.iter().any(|arg| arg == "--approve")); + } + #[tokio::test] async fn sends_option_and_file_like_prompts_verbatim_over_stdin() { for prompt in [ From f5057ee4f10371f1034fa84af36618c660a753fc Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Sat, 25 Jul 2026 21:33:30 +0100 Subject: [PATCH 3/3] fix(examples): make inbox job portable --- examples/assistant/jobs/daily-inbox-triage.md | 5 ++--- src/jobs.rs | 13 +++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/assistant/jobs/daily-inbox-triage.md b/examples/assistant/jobs/daily-inbox-triage.md index 9381e73..5bfec38 100644 --- a/examples/assistant/jobs/daily-inbox-triage.md +++ b/examples/assistant/jobs/daily-inbox-triage.md @@ -1,15 +1,14 @@ +++ version = 1 timeout = "10m" -workdir = "/Users/owainlewis/.cos/work" -backend = "codex" [[triggers]] id = "daily-evening" kind = "cron" schedule = "30 17 * * *" timezone = "Europe/London" -enabled = true +# Enable only after configuring Gmail tools and a primary delivery route. +enabled = false +++ Triage Owain's Gmail inbox from the last 48 hours and apply the existing diff --git a/src/jobs.rs b/src/jobs.rs index 61408c2..098a540 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -2280,18 +2280,19 @@ mod tests { #[test] fn daily_inbox_example_is_a_valid_scheduled_job() { let jobs_dir = temp_dir("inbox-example-jobs"); - let workdir = temp_dir("inbox-example-work"); let database = temp_path("inbox-example-db"); let run_dir = temp_dir("inbox-example-run"); - let contents = include_str!("../examples/assistant/jobs/daily-inbox-triage.md").replace( - "~/.push/workspaces/daily-inbox-triage", - &workdir.to_string_lossy(), - ); - write_job(&jobs_dir, "daily-inbox-triage", &contents); + let contents = include_str!("../examples/assistant/jobs/daily-inbox-triage.md"); + write_job(&jobs_dir, "daily-inbox-triage", contents); let cfg = cfg(&jobs_dir, &database, &run_dir); let job = Catalog::load_named(&cfg, "daily-inbox-triage").unwrap(); + assert!(!contents.contains("/Users/")); + assert_eq!( + job.workdir, + std::fs::canonicalize(&cfg.assistant_root).unwrap() + ); assert_eq!(job.triggers.len(), 1); assert!(!job.triggers[0].enabled); }