use std::io::{self, Write};
use std::path::Path;
use tirith_core::rules::cloaking;
pub fn save(url: &str, save_path: &str, sha256: Option<String>, json: bool) -> i32 {
let dest = Path::new(save_path);
let binding =
match tirith_core::runner::remote_fetch_save_boundary_binding(url, dest, sha256.as_deref())
{
Ok(binding) => binding,
Err(error) => return report_save_error(&error, json),
};
let operation = binding.operation();
let cwd = std::env::current_dir()
.ok()
.map(|path| path.to_string_lossy().into_owned());
let policy = tirith_core::policy::Policy::discover(cwd.as_deref());
let pending = tirith_core::task_boundary::prepare_locally_derived_boundary_authorization::<
tirith_core::task_boundary::RemoteScriptRunBoundary,
>(
&operation,
&policy.task_gate,
&tirith_core::task_analysis::TaskAnalysisContext::default(),
);
let assessment = match &pending {
Ok(pending) => Some(pending.assessment()),
Err(error) => error.assessment(),
};
if let Some(assessment) = assessment {
if let Err(error) = tirith_core::audit::log_task_boundary_assessment(assessment) {
tirith_core::audit::audit_diagnostic(format!(
"fetch-save task-boundary audit append failed: {error}"
));
}
}
let pending = match pending {
Ok(pending) => pending,
Err(error) => return report_save_error(&error.to_string(), json),
};
let (result, entry) = match tirith_core::runner::download_to_path_and_mark_tainted_authorized(
url,
dest,
sha256.as_deref(),
pending,
&operation,
) {
Ok(result) => result,
Err(error) => return report_save_error(&error, json),
};
if json {
#[derive(serde::Serialize)]
struct SaveOut<'a> {
saved_path: String,
sha256: &'a str,
final_url: &'a str,
size: u64,
interpreter: &'a str,
tainted: bool,
taint_entry: &'a tirith_core::taint::TaintEntry,
}
let out = SaveOut {
saved_path: entry.path.clone(),
sha256: &result.sha256,
final_url: &result.final_url,
size: result.size,
interpreter: &result.interpreter,
tainted: true,
taint_entry: &entry,
};
if !super::write_json_stdout(&out, "tirith fetch --save: failed to write JSON output") {
return 2;
}
} else {
println!(
"Saved {} bytes to {} (SHA256: {})",
result.size,
save_path,
tirith_core::receipt::short_hash(&result.sha256)
);
println!(
"Marked TAINTED (origin: fetch --save, source: {}).",
result.final_url
);
println!();
println!("This file was NOT executed. A later `bash {save_path}` or `source {save_path}`");
println!("will be flagged by tirith. Review it first; once you trust it:");
println!(" tirith taint clear {save_path}");
}
0
}
fn report_save_error(error: &str, json: bool) -> i32 {
if json {
let err = serde_json::json!({ "error": error });
if !super::write_json_stdout(&err, "tirith fetch --save: failed to write JSON output") {
return 2;
}
} else {
eprintln!("tirith fetch --save: {error}");
}
1
}
pub fn run(url: &str, json: bool) -> i32 {
let cwd = std::env::current_dir()
.ok()
.map(|path| path.to_string_lossy().into_owned());
let policy = tirith_core::policy::Policy::discover(cwd.as_deref());
run_with_policy(url, json, &policy)
}
fn run_with_policy(url: &str, json: bool, policy: &tirith_core::policy::Policy) -> i32 {
let compiled =
tirith_core::redact::CompiledCustomPatterns::new_silent(&policy.dlp_custom_patterns);
let mut task_boundary = None;
let checked = cloaking::check_with_audit(url, &policy.task_gate, |assessment| {
let projection = redacted_task_boundary_projection(assessment, &compiled);
let detail = serde_json::to_string(&projection).ok();
tirith_core::audit::log_hook_event(
"cli",
"fetch_cloaking",
"task_boundary",
None,
detail.as_deref(),
);
task_boundary = Some(projection);
});
match checked {
Ok(authorized) => {
let result = authorized.result;
if json {
print_json(&result, task_boundary.as_ref());
} else if !print_human(&result) {
return 2;
}
if result.cloaking_detected {
1
} else {
0
}
}
Err(error) => {
if task_boundary.is_none() {
task_boundary = error
.assessment()
.filter(|assessment| assessment.is_recordable())
.map(|assessment| redacted_task_boundary_projection(assessment, &compiled));
}
let safe_error = tirith_core::redact::redact_sanitize_redact_with_compiled(
&error.to_string(),
&compiled,
);
if json {
let body = cloaking_error_json(&safe_error, task_boundary.as_ref());
if !super::write_json_stdout(
&body,
"tirith fetch: failed to write JSON error output",
) {
return 2;
}
} else {
eprintln!("tirith fetch: {safe_error}");
}
2
}
}
}
fn redacted_task_boundary_projection(
assessment: &tirith_core::task_boundary::BoundaryAssessment,
compiled: &tirith_core::redact::CompiledCustomPatterns,
) -> serde_json::Value {
let mut projection = assessment.projection();
tirith_core::redact::redact_json_strings(&mut projection, compiled);
tirith_core::verdict::bound_json_value_for_output(projection)
}
fn cloaking_error_json(
error: &str,
task_boundary: Option<&serde_json::Value>,
) -> serde_json::Value {
let mut body = serde_json::json!({"error": error});
if let Some(task_boundary) = task_boundary {
body.as_object_mut()
.unwrap()
.insert("task_boundary".to_string(), task_boundary.clone());
}
body
}
fn print_json(result: &cloaking::CloakingResult, task_boundary: Option<&serde_json::Value>) {
let mut json = result.to_json(true);
if let Some(task_boundary) = task_boundary {
json.as_object_mut()
.unwrap()
.insert("task_boundary".to_string(), task_boundary.clone());
}
println!(
"{}",
serde_json::to_string_pretty(&json).unwrap_or_else(|e| {
eprintln!("tirith: fetch: JSON serialization failed: {e}");
"{}".to_string()
})
);
}
fn print_human(result: &cloaking::CloakingResult) -> bool {
let mut stdout = io::stdout().lock();
if print_human_to(&mut stdout, result).is_err() {
eprintln!("tirith fetch: failed to write human output");
return false;
}
true
}
fn print_human_to<W: Write>(out: &mut W, result: &cloaking::CloakingResult) -> io::Result<()> {
let url = super::sanitize_for_human_output(&result.url, false);
writeln!(out, "Cloaking check: {url}")?;
writeln!(out)?;
for agent in &result.agent_responses {
let status = if agent.status_code == 0 {
"FAILED".to_string()
} else {
agent.status_code.to_string()
};
let agent_name = super::sanitize_for_human_output(&agent.agent_name, false);
writeln!(
out,
" {:<14} status={:<6} length={}",
agent_name, status, agent.content_length
)?;
}
writeln!(out)?;
if result.cloaking_detected {
writeln!(
out,
"{}",
tirith_core::style::bold_red("Cloaking detected!", tirith_core::style::Stream::Stdout)
)?;
for diff in &result.diff_pairs {
let agent_a = super::sanitize_for_human_output(&diff.agent_a, false);
let agent_b = super::sanitize_for_human_output(&diff.agent_b, false);
writeln!(
out,
" {} vs {}: {} chars different",
agent_a, agent_b, diff.diff_chars
)?;
if let Some(ref text) = diff.diff_text {
let text = super::sanitize_for_human_output(text, true).replace('\n', "\n ");
writeln!(out, " {text}")?;
}
}
} else {
writeln!(
out,
"{}",
tirith_core::style::green("No cloaking detected.", tirith_core::style::Stream::Stdout)
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn cloaking_result(url: &str, agent: &str, diff_text: &str) -> cloaking::CloakingResult {
cloaking::CloakingResult {
url: url.to_string(),
cloaking_detected: true,
findings: Vec::new(),
agent_responses: vec![cloaking::AgentResponse {
agent_name: agent.to_string(),
status_code: 200,
content_length: 42,
}],
diff_pairs: vec![cloaking::DiffPair {
agent_a: "chrome".to_string(),
agent_b: agent.to_string(),
diff_chars: 42,
diff_text: Some(diff_text.to_string()),
}],
}
}
#[test]
fn human_renderer_neutralizes_untrusted_terminal_controls() {
let result = cloaking_result(
"https://safe.example/before\x1b]52;c;aGVsbG8=\x07after\u{202e}",
"bot\x1b[2J\u{009b}\nFORGED",
"remote\x1b[31mred\x1b[0m\nFORGED DETAIL",
);
let mut out = Vec::new();
print_human_to(&mut out, &result).expect("render human output");
let rendered = String::from_utf8(out).expect("renderer emits UTF-8");
assert!(
!rendered.contains('\x1b'),
"ESC must not reach the terminal"
);
assert!(
!rendered.contains('\u{202e}'),
"bidi controls must not reach the terminal"
);
assert!(
!rendered.contains('\u{009b}'),
"C1 CSI must not reach the terminal"
);
assert!(
!rendered.contains("\nFORGED"),
"untrusted fields must not forge a top-level line: {rendered:?}"
);
assert!(rendered.contains("https://safe.example/beforeafter"));
assert!(rendered.contains("bot"));
assert!(rendered.contains("remotered\n FORGED DETAIL"));
}
#[test]
fn human_renderer_preserves_legitimate_unicode() {
let result = cloaking_result(
"https://例え.テスト/路径",
"検査-bot",
"café résumé 東京 🚀",
);
let mut out = Vec::new();
print_human_to(&mut out, &result).expect("render human output");
let rendered = String::from_utf8(out).expect("renderer emits UTF-8");
assert!(rendered.contains("https://例え.テスト/路径"));
assert!(rendered.contains("検査-bot"));
assert!(rendered.contains("café résumé 東京 🚀"));
}
#[test]
fn json_projection_preserves_raw_structured_values() {
let raw_url = "https://safe.example/\x1b[2J路径";
let raw_diff = "before\x1b]52;c;aGVsbG8=\x07after";
let result = cloaking_result(raw_url, "検査-bot", raw_diff);
let json = result.to_json(true);
assert_eq!(json["url"], raw_url);
assert_eq!(json["agents"][0]["agent"], "検査-bot");
assert_eq!(json["diffs"][0]["diff_text"], raw_diff);
}
#[test]
fn enforcing_operator_policy_refuses_before_fetch() {
let policy = tirith_core::policy::Policy {
task_gate: tirith_core::web3_policy::TaskGatePolicy {
mode: tirith_core::web3_policy::TaskGateMode::Enforce,
effects_denied_for_untrusted_sources: [
tirith_core::effects::CommandEffectKind::NetworkEgress,
]
.into_iter()
.collect(),
..Default::default()
},
..Default::default()
};
assert!(cloaking::check("https://example.com", &policy.task_gate).is_err());
}
#[test]
fn json_error_retains_the_structured_task_boundary_projection() {
let projection = serde_json::json!({
"boundary": "fetch_cloaking",
"mode": "enforce",
"outcome": "deny"
});
let body = cloaking_error_json("authorization refused", Some(&projection));
assert_eq!(body["error"], "authorization refused");
assert_eq!(body["task_boundary"], projection);
}
}