Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/harness/agy/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ impl Decoder for AgyProgress {
report: Report {
warnings: Vec::new(),
summary,
..Report::default()
},
ended: Ended {
session_id: self.session_id.clone(),
Expand Down
1 change: 1 addition & 0 deletions src/harness/codex/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ impl Decoder for CodexProgress {
report: Report {
warnings: Vec::new(),
summary,
..Report::default()
},
ended: Ended {
session_id: self.session_id,
Expand Down
46 changes: 43 additions & 3 deletions src/harness/interpretation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ use crate::interrupt;

mod stream;
pub(super) use stream::Stream;
mod security;
pub use security::SafeguardRefusal;
use security::Security;

/// Live interpretation has only line condensation. Consume it after the
/// process owner has stopped or waited for the child and joined its workers.
pub struct Interpretation {
cli: &'static str,
decoder: Box<dyn Decoder>,
retained: Retained,
security: Option<Security>,
}

/// Reporting survives ordinary failures; only success supplies Resume facts.
Expand All @@ -25,9 +29,12 @@ pub struct Completion {
pub outcome: Result<Ended>,
}

#[derive(Default)]
pub struct Report {
pub warnings: Vec<String>,
pub summary: Option<String>,
/// Answering Models recovered from retained records, in response order.
pub models: Vec<String>,
}

#[derive(Debug)]
Expand Down Expand Up @@ -93,6 +100,7 @@ impl Retained {
if log.message.is_some() {
facts.ended.final_message = log.message;
}
facts.report.models = log.models;
}
}
Self::OpenCode(worktree) => {
Expand All @@ -102,6 +110,7 @@ impl Retained {
// A readable export is authoritative, even without text.
facts.ended.final_message = export.message;
facts.report.summary = export.summary;
facts.report.models = export.models;
if export.failure.is_some() {
facts.outcome = TurnOutcome::Failed;
facts.diagnostic = facts.diagnostic.take().or(export.failure);
Expand All @@ -118,14 +127,19 @@ enum Failure {
Rejected {
status: ExitStatus,
diagnostic: Option<String>,
refusal: Option<SafeguardRefusal>,
},
}

impl Failure {
fn session_error(self, cli: &str) -> anyhow::Error {
match self {
Self::Execution(error) => error,
Self::Rejected { status, diagnostic } => {
Self::Rejected {
status,
diagnostic,
refusal,
} => {
let ended = if status.success() {
"'s turn failed".to_string()
} else {
Expand All @@ -136,9 +150,13 @@ impl Failure {
.map_or("by signal".to_string(), |code| code.to_string())
)
};
match diagnostic {
let error = match diagnostic {
Some(error) => anyhow!("{cli}{ended}: {error}"),
None => anyhow!("{cli}{ended}"),
};
match refusal {
Some(refusal) => error.context(refusal),
None => error,
}
}
}
Expand All @@ -151,12 +169,25 @@ impl Interpretation {
cli,
decoder,
retained,
security: None,
}
}

/// Apply refusal and Model reporting rules independently of the session's log label.
pub fn for_security(mut self, requested_model: Option<&str>) -> Self {
self.security = Some(Security::new(self.cli, requested_model));
self
}

/// Unknown or malformed lines produce no progress, never an error.
pub fn condense(&mut self, raw: &str) -> Vec<String> {
self.decoder.condense(raw)
let mut lines = self.decoder.condense(raw);
if let Some(security) = &mut self.security
&& let Some(line) = security.observe(raw)
{
lines.push(line);
}
lines
}

pub fn finish(self, execution: Result<ExitStatus>) -> Completion {
Expand Down Expand Up @@ -187,8 +218,16 @@ impl Interpretation {
if let Err(error) = interrupt::check() {
return (None, Err(Failure::Execution(error)));
}
let security_session = self.security.is_some();
let mut facts = self.decoder.complete();
let refusal = self.security.and_then(|security| security.refusal);
if refusal.is_some() {
facts.outcome = TurnOutcome::Failed;
}
let recovered = self.retained.reconcile(&mut facts);
if !security_session {
facts.report.models.clear();
}
if let Err(error) = interrupt::check() {
return (None, Err(Failure::Execution(error)));
}
Expand All @@ -200,6 +239,7 @@ impl Interpretation {
Err(Failure::Rejected {
status,
diagnostic: facts.diagnostic,
refusal,
})
} else {
Ok(facts.ended)
Expand Down
116 changes: 116 additions & 0 deletions src/harness/interpretation/security.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! Security session evidence: safeguard refusals are failures even when a
//! Harness reports a successful turn. Raw diagnostics stay in local logs.

use std::fmt;

use serde_json::Value;

use crate::harness::Harness;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SafeguardRefusal {
ClaudeCyber,
CodexCyber,
}

impl SafeguardRefusal {
pub fn description(self) -> &'static str {
match self {
Self::ClaudeCyber => "Claude Code's [cyber] safeguard refusal",
Self::CodexCyber => "Codex's cybersecurity safeguard refusal",
}
}
}

impl fmt::Display for SafeguardRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.description())
}
}

impl std::error::Error for SafeguardRefusal {}

pub(super) struct Security {
harness: Option<Harness>,
pub refusal: Option<SafeguardRefusal>,
last_model: Option<String>,
requested_model: Option<String>,
}

impl Security {
pub fn new(cli: &str, requested_model: Option<&str>) -> Self {
Self {
harness: Harness::named(cli),
refusal: None,
last_model: None,
requested_model: requested_model.map(String::from),
}
}

pub fn observe(&mut self, raw: &str) -> Option<String> {
let event = serde_json::from_str::<Value>(raw).ok()?;
let model = match self.harness {
Some(Harness::Claude) => {
let refused = match event["type"].as_str() {
Some("system") => {
event["subtype"] == "model_refusal_no_fallback"
&& event["api_refusal_category"] == "cyber"
}
Some("result") => event["result"].as_str().is_some_and(|text| {
let text = text.trim_start();
text.starts_with("[cyber]")
|| (text.starts_with("API Error:") && text.contains("[cyber]"))
}),
_ => false,
};
if refused {
self.refusal = Some(SafeguardRefusal::ClaudeCyber);
}
Self::answer_model(&event)
}
Some(Harness::Grok) => Self::answer_model(&event),
Some(Harness::Codex) => {
if event["type"] == "turn.failed"
&& event["error"]["message"]
.as_str()
.is_some_and(|text| text.to_ascii_lowercase().contains("cybersecurity"))
{
self.refusal = Some(SafeguardRefusal::CodexCyber);
}
if event["type"] == "thread.started" && self.last_model.is_none() {
let model = self.requested_model.as_deref().unwrap_or("Harness default");
let basis = if self.requested_model.is_some() {
"requested"
} else {
"no Model requested"
};
let line = format!("Model: {model} ({basis})");
self.last_model = Some(model.to_string());
return Some(line);
}
None
}
Some(Harness::Agy) if event["event"] == "init" => event["init"]["model"].as_str(),
_ => None,
}?;
if model.is_empty() || self.last_model.as_deref() == Some(model) {
return None;
}
self.last_model = Some(model.to_string());
Some(format!("Model: {model}"))
}

// Init names the requested Model; result.modelUsage includes sub-agents.
// Only main-loop assistant messages identify the Model that answered.
fn answer_model(event: &Value) -> Option<&str> {
if event["type"] != "assistant"
|| !event["parent_tool_use_id"].is_null()
|| event["is_api_error_message"] == true
{
return None;
}
event["message"]["model"]
.as_str()
.filter(|model| *model != "<synthetic>")
}
}
1 change: 1 addition & 0 deletions src/harness/interpretation/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ impl Decoder for Stream {
report: Report {
warnings: Vec::new(),
summary,
..Report::default()
},
ended,
outcome: TurnOutcome::from_failed(failed),
Expand Down
73 changes: 73 additions & 0 deletions src/harness/interpretation_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,79 @@
use super::*;
use std::os::unix::process::ExitStatusExt;

#[test]
fn agy_security_progress_names_the_resolved_model() {
let mut interpretation = stream(Harness::Agy, "").for_security(Some("gemini-3.8-flash"));
let lines = interpretation.condense(
r#"{"event":"init","conversation_id":"s1","init":{"model":"gemini-3.8-flash-high"}}"#,
);
assert!(
lines.contains(&"Model: gemini-3.8-flash-high".to_string()),
"{lines:?}"
);
}

#[test]
fn a_security_session_can_discuss_the_cyber_marker_without_being_refused() {
let mut interpretation = stream(Harness::Claude, "").for_security(None);
interpretation.condense(
r#"{"type":"result","subtype":"success","result":"The [cyber] refusal test passed.\nSecurity audit: complete"}"#,
);
let ended = finish(interpretation).outcome.unwrap();
assert_eq!(
ended.final_message.as_deref(),
Some("The [cyber] refusal test passed.\nSecurity audit: complete")
);
}

#[test]
fn a_terminal_claude_cyber_refusal_overrides_a_successful_security_result() {
let event = r#"{"type":"system","subtype":"model_refusal_no_fallback","api_refusal_category":"cyber","content":"Private safeguard explanation."}"#;
for security in [false, true] {
let mut interpretation = stream(Harness::Claude, "");
if security {
interpretation = interpretation.for_security(None);
}
interpretation.condense(event);
interpretation.condense(
r#"{"type":"result","subtype":"success","result":"Security audit: complete"}"#,
);
let completion = finish(interpretation);
if security {
let error = completion.outcome.unwrap_err();
assert_eq!(
error.downcast_ref::<interpretation::SafeguardRefusal>(),
Some(&interpretation::SafeguardRefusal::ClaudeCyber)
);
} else {
assert!(completion.outcome.is_ok());
}
}
}

#[test]
fn security_model_progress_names_only_new_main_loop_answers() {
let mut interpretation = stream(Harness::Claude, "").for_security(Some("opus"));
for ignored in [
r#"{"type":"system","subtype":"init","model":"requested-model"}"#,
r#"{"type":"assistant","parent_tool_use_id":"child","message":{"model":"child-model","content":[]}}"#,
r#"{"type":"result","subtype":"success","modelUsage":{"child-model":{"inputTokens":100}}}"#,
] {
assert!(
interpretation
.condense(ignored)
.iter()
.all(|line| !line.starts_with("Model:"))
);
}
let answer = r#"{"type":"assistant","message":{"model":"claude-opus-4-8","content":[]}}"#;
assert_eq!(
interpretation.condense(answer),
vec!["Model: claude-opus-4-8"]
);
assert!(interpretation.condense(answer).is_empty());
}

#[test]
fn claude_completion_keeps_the_last_result_and_the_last_complete_usage() {
let mut interpretation = Harness::Claude
Expand Down
6 changes: 6 additions & 0 deletions src/harness/muse/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf};
#[derive(Default)]
pub(in crate::harness) struct SessionLog {
pub(in crate::harness) message: Option<String>,
pub(in crate::harness) models: Vec<String>,
tokens: Option<Tokens>,
}

Expand Down Expand Up @@ -46,6 +47,11 @@ impl SessionLog {
self.message = event["text"].as_str().map(String::from);
}
Some("model_completed") => {
if let Some(model) = event["model"].as_str().filter(|model| !model.is_empty())
&& self.models.last().map(String::as_str) != Some(model)
{
self.models.push(model.to_string());
}
if let Some(usage) = event["usage"].as_object() {
let tokens = self.tokens.get_or_insert_default();
tokens.input += usage
Expand Down
1 change: 1 addition & 0 deletions src/harness/muse/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ impl Decoder for MuseProgress {
report: Report {
warnings: self.skill_load.warnings(),
summary: None,
..Report::default()
},
ended: Ended {
session_id: self.session_id,
Expand Down
Loading
Loading