From 022223682580629fbd2880d4d39496d34d949be0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 4 Jul 2026 18:48:25 +0000 Subject: [PATCH 1/2] test(agent): cover credential-scrub patterns + policy-denied classification Parity test backfill (#4468) for behaviors #4495 shipped without unit coverage: - credentials: bare AWS (AKIA/ASIA), OpenAI (sk-), and space-separated Bearer redaction (#4453). - tool_status::classify: POLICY_DENIED_MARKER -> Denied (non-recoverable), and a TTL-expiry deny reason (contains 'timed out') -> ApprovalExpired not Timeout (#4459). Verified via CI (local cargo builds OOM on this box). Claude-Session: https://claude.ai/code/session_019j5TLsRLHsM3kqAYFyH4hR --- src/openhuman/agent/harness/credentials.rs | 49 ++++++++++++++++++++++ src/openhuman/tool_status/ops.rs | 21 ++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/openhuman/agent/harness/credentials.rs b/src/openhuman/agent/harness/credentials.rs index 847a114770..41bf4549a0 100644 --- a/src/openhuman/agent/harness/credentials.rs +++ b/src/openhuman/agent/harness/credentials.rs @@ -111,4 +111,53 @@ mod tests { let output = scrub_credentials(input); assert!(output.contains("api_key: 1234*[REDACTED]")); } + + // #4453: bare, unlabelled secrets that show up in env dumps / API responses. + + #[test] + fn scrubs_bare_aws_access_key() { + let out = scrub_credentials("config dump AKIAIOSFODNN7EXAMPLE trailing text"); + assert!( + !out.contains("AKIAIOSFODNN7EXAMPLE"), + "bare AWS access key must be redacted: {out}" + ); + assert!( + out.contains("[REDACTED]"), + "redaction marker present: {out}" + ); + } + + #[test] + fn scrubs_bare_openai_key() { + let out = scrub_credentials("response body sk-abcdefghij1234567890ABCDEFGHIJ end"); + assert!( + !out.contains("abcdefghij1234567890ABCDEFGHIJ"), + "openai secret body must be redacted: {out}" + ); + assert!( + out.contains("sk-"), + "the sk- scheme is kept for context: {out}" + ); + assert!( + out.contains("[REDACTED]"), + "redaction marker present: {out}" + ); + } + + #[test] + fn scrubs_space_separated_bearer_token() { + let out = scrub_credentials("Authorization: Bearer abcDEF1234567890ghijklmnop done"); + assert!( + !out.contains("abcDEF1234567890ghijklmnop"), + "space-separated bearer token must be redacted: {out}" + ); + assert!( + out.contains("Bearer"), + "the scheme word is kept for context: {out}" + ); + assert!( + out.contains("[REDACTED]"), + "redaction marker present: {out}" + ); + } } diff --git a/src/openhuman/tool_status/ops.rs b/src/openhuman/tool_status/ops.rs index 5f607b912f..ee81d0201d 100644 --- a/src/openhuman/tool_status/ops.rs +++ b/src/openhuman/tool_status/ops.rs @@ -388,6 +388,27 @@ mod tests { ); } + #[test] + fn policy_denied_marker_is_denied_and_not_recoverable() { + use crate::openhuman::security::POLICY_DENIED_MARKER; + let text = format!("{POLICY_DENIED_MARKER} you declined this shell action"); + assert_eq!(class_of(&text), ToolFailureClass::Denied); + // UserDeclined family — never eligible for an auto-retry (#4459). + assert!(!classify(&text, false).recoverable); + } + + #[test] + fn policy_denied_ttl_expiry_is_approval_expired_not_timeout() { + use crate::openhuman::security::POLICY_DENIED_MARKER; + // A TTL-expiry deny reason literally contains "timed out" — the policy + // marker must win over the timeout sniff so it classifies as an expired + // approval, NOT an execution Timeout that promises an auto-retry (#4459). + let text = format!("{POLICY_DENIED_MARKER} Approval for 'shell' timed out after 600s"); + assert_eq!(class_of(&text), ToolFailureClass::ApprovalExpired); + assert_ne!(class_of(&text), ToolFailureClass::Timeout); + assert!(!classify(&text, false).recoverable); + } + #[test] fn numeric_status_codes_need_word_boundaries() { // `403`/`503` embedded in a longer digit run must NOT trip the code From b0407b2ca78cd87a4c2b476d7ee3c4be07278cd9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 4 Jul 2026 18:56:36 +0000 Subject: [PATCH 2/2] test(agent): cover image-aware token estimate + subagent spawn-strip More parity backfill (#4468): - estimate_text_tokens: [IMAGE:...] markers priced at the flat IMAGE_MARKER_TOKEN_COST, not chars/4 of the base64 body (#4462). - is_subagent_spawn_or_delegate_tool: spawn/delegate/worker tools are always stripped from a child run, ordinary tools are not (#4452). Claude-Session: https://claude.ai/code/session_019j5TLsRLHsM3kqAYFyH4hR --- src/openhuman/tinyagents/middleware.rs | 40 ++++++++++++++++++++++++++ src/openhuman/tinyagents/tests.rs | 27 +++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index b85436ff05..8025efa8e0 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -3140,6 +3140,46 @@ impl Middleware<()> for ImageAwareMessageTrimMiddleware { #[cfg(test)] mod tests { use super::*; + + // #4462: image-aware token estimation. A base64 image marker must be priced + // at the flat IMAGE_MARKER_TOKEN_COST, not chars/4 of its payload — otherwise + // one image reads as millions of tokens and the trimmer evicts everything, + // including the system prompt. + + #[test] + fn estimate_text_tokens_markerless_is_chars_over_four() { + assert_eq!(estimate_text_tokens(&"a".repeat(40)), (40 + 3) / 4); + assert_eq!(estimate_text_tokens(""), 0); + } + + #[test] + fn estimate_text_tokens_prices_image_marker_flat_not_by_length() { + let huge = "x".repeat(40_000); + let text = format!("[IMAGE:{huge}]"); + let tokens = estimate_text_tokens(&text); + // chars/4 of the payload would be ~10_000; the flat price is 1_200. + assert!( + tokens >= IMAGE_MARKER_TOKEN_COST, + "at least the flat image cost: {tokens}" + ); + assert!( + tokens < 2_000, + "image priced flat, not by base64 length: {tokens}" + ); + } + + #[test] + fn estimate_text_tokens_charges_each_image_marker_once() { + let tokens = estimate_text_tokens("[IMAGE:aaaa] and [IMAGE:bbbb]"); + assert!( + tokens >= 2 * IMAGE_MARKER_TOKEN_COST, + "two images each priced: {tokens}" + ); + assert!( + tokens < 2 * IMAGE_MARKER_TOKEN_COST + 100, + "no runaway from the surrounding text: {tokens}" + ); + } use serde_json::json; use tinyagents::harness::context::{RunConfig, RunContext}; use tinyagents::harness::model::ModelRequest; diff --git a/src/openhuman/tinyagents/tests.rs b/src/openhuman/tinyagents/tests.rs index 0ce5c942fb..e4fcac2fc2 100644 --- a/src/openhuman/tinyagents/tests.rs +++ b/src/openhuman/tinyagents/tests.rs @@ -684,3 +684,30 @@ fn record_unobserved_turn_usage_gates_on_observed_tokens() { assert!(record_unobserved_turn_usage("m", 0, 3, 0, 0.0)); assert!(record_unobserved_turn_usage("m", 10, 3, 2, 0.5)); } + +#[test] +fn spawn_and_delegate_tools_are_never_registered_on_subagents() { + // #4452: a child run must never be able to register a spawn/delegate tool, + // even if the resolved allowlist somehow contains one — the registration + // site strips these unconditionally as defense-in-depth. + for name in [ + "spawn_subagent", + "spawn_worker_thread", + "use_tinyplace", + "agent_prepare_context", + "delegate_research", + "delegate_", + ] { + assert!( + is_subagent_spawn_or_delegate_tool(name), + "{name} must be treated as a spawn/delegate tool" + ); + } + // Ordinary tools (and near-miss names) must NOT be stripped. + for name in ["shell", "read_file", "web_search", "spawn", "subagent"] { + assert!( + !is_subagent_spawn_or_delegate_tool(name), + "{name} is a normal tool and must not be stripped" + ); + } +}