diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/phone-opus/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/phone-opus/src/mcp/service.rs | 175 | ||||
| -rw-r--r-- | crates/phone-opus/tests/mcp_hardening.rs | 40 |
3 files changed, 207 insertions, 9 deletions
diff --git a/crates/phone-opus/Cargo.toml b/crates/phone-opus/Cargo.toml index c7626c6..14ad43e 100644 --- a/crates/phone-opus/Cargo.toml +++ b/crates/phone-opus/Cargo.toml @@ -16,6 +16,7 @@ dirs.workspace = true libmcp.workspace = true serde.workspace = true serde_json.workspace = true +time.workspace = true thiserror.workspace = true users.workspace = true uuid.workspace = true diff --git a/crates/phone-opus/src/mcp/service.rs b/crates/phone-opus/src/mcp/service.rs index 9fc1134..2baa744 100644 --- a/crates/phone-opus/src/mcp/service.rs +++ b/crates/phone-opus/src/mcp/service.rs @@ -10,6 +10,7 @@ use libmcp::{Generation, SurfaceKind}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use thiserror::Error; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use users::get_current_uid; use uuid::Uuid; @@ -351,6 +352,7 @@ struct BackgroundConsultFailure { struct BackgroundConsultResponseRecord { cwd: String, response: String, + persisted_output_path: String, duration_ms: u64, duration_api_ms: Option<u64>, num_turns: u64, @@ -369,6 +371,7 @@ impl BackgroundConsultResponseRecord { Self { cwd: response.cwd.display(), response: response.result, + persisted_output_path: response.persisted_output_path.display(), duration_ms: response.duration_ms, duration_api_ms: response.duration_api_ms, num_turns: response.num_turns, @@ -384,10 +387,7 @@ impl BackgroundConsultResponseRecord { } fn model_name(&self) -> Option<String> { - let Value::Object(models) = self.model_usage.as_ref()? else { - return None; - }; - models.keys().next().cloned() + model_name(self.model_usage.as_ref()) } } @@ -495,6 +495,7 @@ struct ClaudeJsonEnvelope { struct ConsultResponse { cwd: WorkingDirectory, result: String, + persisted_output_path: PersistedConsultPath, duration_ms: u64, duration_api_ms: Option<u64>, num_turns: u64, @@ -510,10 +511,7 @@ struct ConsultResponse { impl ConsultResponse { fn model_name(&self) -> Option<String> { - let Value::Object(models) = self.model_usage.as_ref()? else { - return None; - }; - models.keys().next().cloned() + model_name(self.model_usage.as_ref()) } } @@ -525,6 +523,11 @@ const XDG_CONFIG_DIR_NAME: &str = "xdg-config"; const XDG_CACHE_DIR_NAME: &str = "xdg-cache"; const XDG_STATE_DIR_NAME: &str = "xdg-state"; const SHARED_TMP_ROOTS: [&str; 2] = ["/tmp", "/var/tmp"]; +const CONSULT_OUTPUT_ROOT: &str = "/tmp/phone_opus-consults"; +const CONSULT_OUTPUT_KEEP_COUNT: usize = 256; +const CONSULT_OUTPUT_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); +const CONSULT_TIMESTAMP_FORMAT: &[time::format_description::FormatItem<'static>] = + time::macros::format_description!("[year][month][day]T[hour][minute][second]Z"); const CLAUDE_SEED_FILES: [&str; 5] = [ ".credentials.json", "settings.json", @@ -684,6 +687,31 @@ impl ClaudeSandbox { } } +#[derive(Debug, Clone)] +struct PersistedConsultPath(PathBuf); + +impl PersistedConsultPath { + fn new(request: &ConsultRequest) -> io::Result<Self> { + fs::create_dir_all(CONSULT_OUTPUT_ROOT)?; + let timestamp = OffsetDateTime::now_utc() + .format(CONSULT_TIMESTAMP_FORMAT) + .map_err(|error| io::Error::other(error.to_string()))?; + let slug = consult_slug(request.prompt.as_str()); + Ok(Self(Path::new(CONSULT_OUTPUT_ROOT).join(format!( + "{timestamp}-{slug}-{}.json", + Uuid::new_v4() + )))) + } + + fn as_path(&self) -> &Path { + self.0.as_path() + } + + fn display(&self) -> String { + self.0.display().to_string() + } +} + fn deserialize<T: for<'de> Deserialize<'de>>( value: Value, operation: &str, @@ -925,6 +953,7 @@ fn background_job_tool_output( "prompt_prefix_injected": record.prompt_prefix_injected, "result": record.result.as_ref().map(|result| json!({ "response": result.response, + "persisted_output_path": result.persisted_output_path, "duration_ms": result.duration_ms, "num_turns": result.num_turns, "session_id": result.session_id, @@ -947,6 +976,7 @@ fn background_job_tool_output( result.num_turns, render_duration_ms(result.duration_ms) )); + lines.push(format!("saved={}", result.persisted_output_path)); lines.push("response:".to_owned()); lines.push(result.response.clone()); } @@ -1130,9 +1160,13 @@ fn invoke_claude(request: &ConsultRequest) -> Result<ConsultResponse, ConsultInv .unwrap_or_else(|| downstream_message(output.status.code(), &stdout, &stderr)), )); } + let result = envelope.result.clone().unwrap_or_default(); + let persisted_output_path = persist_consult_output(request, &result, &envelope) + .map_err(ConsultInvocationError::Spawn)?; Ok(ConsultResponse { cwd: request.cwd.clone(), - result: envelope.result.unwrap_or_default(), + result, + persisted_output_path, duration_ms: envelope.duration_ms.unwrap_or(0), duration_api_ms: envelope.duration_api_ms, num_turns: envelope.num_turns.unwrap_or(0), @@ -1231,6 +1265,112 @@ fn write_bytes_file(path: &Path, bytes: &[u8]) -> io::Result<()> { Ok(()) } +fn persist_consult_output( + request: &ConsultRequest, + result: &str, + envelope: &ClaudeJsonEnvelope, +) -> io::Result<PersistedConsultPath> { + let path = PersistedConsultPath::new(request)?; + let saved_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|error| io::Error::other(error.to_string()))?; + let artifact = json!({ + "kind": "phone_opus_consult", + "saved_at": saved_at, + "saved_unix_ms": unix_ms_now(), + "cwd": request.cwd.display(), + "prompt": request.prompt.as_str(), + "prompt_prefix": CLAUDE_CONSULT_PREFIX, + "effective_prompt": request.prompt.rendered(), + "session_mode": request.session_mode(), + "requested_session_id": request.requested_session_id(), + "response": result, + "model": model_name(envelope.model_usage.as_ref()), + "duration_ms": envelope.duration_ms.unwrap_or(0), + "duration_api_ms": envelope.duration_api_ms, + "num_turns": envelope.num_turns.unwrap_or(0), + "stop_reason": envelope.stop_reason, + "session_id": envelope.session_id, + "total_cost_usd": envelope.total_cost_usd, + "usage": envelope.usage, + "model_usage": envelope.model_usage, + "permission_denials": envelope.permission_denials, + "fast_mode_state": envelope.fast_mode_state, + "uuid": envelope.uuid, + }); + write_json_file(path.as_path(), &artifact)?; + let _ = prune_persisted_consult_outputs(); + Ok(path) +} + +fn prune_persisted_consult_outputs() -> io::Result<()> { + let root = Path::new(CONSULT_OUTPUT_ROOT); + if !root.exists() { + return Ok(()); + } + let now = SystemTime::now(); + let mut entries = fs::read_dir(root)? + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file())) + .filter_map(|entry| { + let path = entry.path(); + (path.extension().and_then(|ext| ext.to_str()) == Some("json")).then_some(path) + }) + .collect::<Vec<_>>(); + entries.sort_unstable_by(|left, right| right.file_name().cmp(&left.file_name())); + for stale in entries.iter().skip(CONSULT_OUTPUT_KEEP_COUNT) { + let _ = fs::remove_file(stale); + } + for path in entries.iter().take(CONSULT_OUTPUT_KEEP_COUNT) { + let age = path + .metadata() + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| now.duration_since(modified).ok()); + if age.is_some_and(|age| age > CONSULT_OUTPUT_MAX_AGE) { + let _ = fs::remove_file(path); + } + } + Ok(()) +} + +fn consult_slug(prompt: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + for ch in prompt.chars() { + let next = if ch.is_ascii_alphanumeric() { + Some(ch.to_ascii_lowercase()) + } else if ch.is_ascii_whitespace() || "-_./:".contains(ch) { + Some('-') + } else { + None + }; + let Some(next) = next else { + continue; + }; + if next == '-' { + if slug.is_empty() || last_was_dash { + continue; + } + last_was_dash = true; + } else { + last_was_dash = false; + } + slug.push(next); + if slug.len() >= 48 { + break; + } + } + while slug.ends_with('-') { + let _ = slug.pop(); + } + if slug.is_empty() { + "consult".to_owned() + } else { + slug + } +} + fn consult_output( request: &ConsultRequest, response: &ConsultResponse, @@ -1241,6 +1381,7 @@ fn consult_output( "mode": request.mode().as_str(), "response": response.result, "cwd": response.cwd.display(), + "persisted_output_path": response.persisted_output_path.display(), "session_mode": request.session_mode(), "requested_session_id": request.requested_session_id(), "prompt_prefix_injected": true, @@ -1256,6 +1397,7 @@ fn consult_output( "mode": request.mode().as_str(), "response": response.result, "cwd": response.cwd.display(), + "persisted_output_path": response.persisted_output_path.display(), "prompt": request.prompt.as_str(), "prompt_prefix": CLAUDE_CONSULT_PREFIX, "effective_prompt": request.prompt.rendered(), @@ -1310,6 +1452,10 @@ fn concise_text(request: &ConsultRequest, response: &ConsultResponse) -> String if let Some(session_id) = response.session_id.as_deref() { lines.push(format!("session: {session_id}")); } + lines.push(format!( + "saved: {}", + response.persisted_output_path.display() + )); if !response.permission_denials.is_empty() { lines.push(format!( "permission_denials: {}", @@ -1349,6 +1495,10 @@ fn full_text(request: &ConsultRequest, response: &ConsultResponse) -> String { if let Some(session_id) = response.session_id.as_deref() { lines.push(format!("session: {session_id}")); } + lines.push(format!( + "saved: {}", + response.persisted_output_path.display() + )); if let Some(cost) = response.total_cost_usd { lines.push(format!("cost_usd: {cost:.6}")); } @@ -1391,6 +1541,13 @@ fn usage_summary(usage: Option<&Value>) -> Option<String> { }) } +fn model_name(model_usage: Option<&Value>) -> Option<String> { + let Value::Object(models) = model_usage? else { + return None; + }; + models.keys().next().cloned() +} + fn render_duration_ms(duration_ms: u64) -> String { if duration_ms < 1_000 { return format!("{duration_ms}ms"); diff --git a/crates/phone-opus/tests/mcp_hardening.rs b/crates/phone-opus/tests/mcp_hardening.rs index 23a1325..f7ce125 100644 --- a/crates/phone-opus/tests/mcp_hardening.rs +++ b/crates/phone-opus/tests/mcp_hardening.rs @@ -12,6 +12,7 @@ use libmcp_testkit::read_json_lines; use serde as _; use serde_json::{Value, json}; use thiserror as _; +use time as _; use users as _; use uuid as _; @@ -409,6 +410,26 @@ fn consult_can_resume_a_prior_session_with_read_only_toolset_and_requested_worki tool_content(&consult)["session_id"].as_str(), Some(resumed_session) ); + let persisted_output_path = must_some( + tool_content(&consult)["persisted_output_path"] + .as_str() + .map(str::to_owned), + "persisted output path", + )?; + assert!(persisted_output_path.starts_with("/tmp/phone_opus-consults/")); + let persisted_output = must( + fs::read_to_string(&persisted_output_path), + "read persisted consult output", + )?; + let persisted_output: Value = must( + serde_json::from_str(&persisted_output), + "parse persisted consult output", + )?; + assert_eq!(persisted_output["response"].as_str(), Some("oracle")); + assert_eq!( + persisted_output["requested_session_id"].as_str(), + Some(resumed_session) + ); let pwd = must(fs::read_to_string(&pwd_file), "read fake pwd file")?; assert_eq!(pwd.trim(), sandbox.display().to_string()); @@ -616,6 +637,25 @@ fn consult_can_run_in_background_and_be_polled() -> TestResult { tool_content(&job)["result"]["response"].as_str(), Some("background oracle") ); + let persisted_output_path = must_some( + tool_content(&job)["result"]["persisted_output_path"] + .as_str() + .map(str::to_owned), + "background persisted output path", + )?; + assert!(persisted_output_path.starts_with("/tmp/phone_opus-consults/")); + let persisted_output = must( + fs::read_to_string(&persisted_output_path), + "read background persisted consult output", + )?; + let persisted_output: Value = must( + serde_json::from_str(&persisted_output), + "parse background persisted consult output", + )?; + assert_eq!( + persisted_output["response"].as_str(), + Some("background oracle") + ); let jobs = harness.call_tool(5, "consult_jobs", json!({ "render": "json" }))?; assert_tool_ok(&jobs); |