use std::collections::BTreeMap;
use std::panic::{self, AssertUnwindSafe};
use serde::Serialize;
use serde_json::Value;
use crate::scrub::ScrubResult;
pub(crate) const WITHHELD: &str = "[redacted] tool output withheld: the scrubber errored, so raw output was suppressed to avoid leaking secrets.";
#[derive(Serialize)]
struct HookOutput<'a> {
decision: &'a str,
reason: &'a str,
#[serde(rename = "hookSpecificOutput")]
hook_specific_output: HookSpecificOutput<'a>,
}
#[derive(Serialize)]
struct HookSpecificOutput<'a> {
#[serde(rename = "hookEventName")]
hook_event_name: &'a str,
#[serde(rename = "updatedToolOutput", skip_serializing_if = "Value::is_null")]
updated_tool_output: Value,
}
type HookResult = (Vec<u8>, BTreeMap<String, usize>);
fn scrub_payload(data: &[u8], scrub: &dyn Fn(&str) -> ScrubResult) -> Result<HookResult, String> {
let payload: Value =
serde_json::from_slice(data).map_err(|e| format!("parse hook payload: {e}"))?;
let tool_name = string_field_or_empty(&payload, "tool_name")?;
let response = payload.get("tool_response").unwrap_or(&Value::Null);
if tool_name == "Bash" {
scrub_bash_response(response, scrub)
} else {
scrub_tool_response(response, &tool_name, scrub)
}
}
pub fn scrub_payload_or_withhold(data: &[u8], scrub: &dyn Fn(&str) -> ScrubResult) -> HookResult {
match panic::catch_unwind(AssertUnwindSafe(|| scrub_payload(data, scrub))) {
Ok(Ok(hook_result)) => hook_result,
_ => (withheld_output(data), BTreeMap::new()),
}
}
pub fn withheld_output(data: &[u8]) -> Vec<u8> {
let payload: Value = serde_json::from_slice(data).unwrap_or(Value::Null);
let updated = match payload.get("tool_response") {
None | Some(Value::Null) => Value::String(WITHHELD.into()),
Some(response) => withhold_strings(response),
};
encode(&HookOutput {
decision: "block",
reason: WITHHELD,
hook_specific_output: HookSpecificOutput {
hook_event_name: "PostToolUse",
updated_tool_output: updated,
},
})
.expect("a parsed Value always serializes")
}
pub(crate) fn withhold_strings(value: &Value) -> Value {
match value {
Value::String(_) => Value::String(WITHHELD.into()),
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, child)| (key.clone(), withhold_strings(child)))
.collect(),
),
Value::Array(items) => Value::Array(items.iter().map(withhold_strings).collect()),
other => other.clone(),
}
}
pub fn prepend_reason(out: &[u8], line: &str) -> Vec<u8> {
let Ok(mut envelope) = serde_json::from_slice::<Value>(out) else {
return out.to_vec();
};
let Some(reason) = envelope["reason"].as_str() else {
return out.to_vec();
};
envelope["reason"] = Value::String(format!("{line}\n{reason}"));
encode(&envelope).unwrap_or_else(|_| out.to_vec())
}
fn string_field_or_empty(value: &Value, key: &str) -> Result<String, String> {
match value.get(key) {
None | Some(Value::Null) => Ok(String::new()),
Some(Value::String(s)) => Ok(s.clone()),
Some(_) => Err(format!("parse hook payload: {key} is not a string")),
}
}
fn scrub_bash_response(
response: &Value,
scrub: &dyn Fn(&str) -> ScrubResult,
) -> Result<HookResult, String> {
if !(response.is_object() || response.is_null()) {
return Err("parse hook payload: tool_response is not an object".into());
}
let stdout_result = scrub(&string_field_or_empty(response, "stdout")?);
let stderr_result = scrub(&string_field_or_empty(response, "stderr")?);
if !stdout_result.has_redactions() && !stderr_result.has_redactions() {
return Ok((Vec::new(), BTreeMap::new()));
}
let mut updated = response.clone();
for (key, scrubbed) in [
("stdout", &stdout_result.text),
("stderr", &stderr_result.text),
] {
if let Some(slot @ Value::String(_)) = updated.get_mut(key) {
*slot = Value::String(scrubbed.clone());
}
}
let mut counts = stdout_result.counts_by_pattern;
for (pattern, count) in stderr_result.counts_by_pattern {
*counts.entry(pattern).or_default() += count;
}
let scrubbed = format!("{}\n{}", stdout_result.text, stderr_result.text);
let block = block_output("command", &counts, &scrubbed, updated)?;
Ok((block, counts))
}
fn scrub_tool_response(
response: &Value,
tool_name: &str,
scrub: &dyn Fn(&str) -> ScrubResult,
) -> Result<HookResult, String> {
let text = text_to_scrub(response)?;
let result = scrub(&text);
if !result.has_redactions() {
return Ok((Vec::new(), BTreeMap::new()));
}
let updated = match response {
Value::String(_) => Value::String(result.text.clone()),
other => scrub_json(other, scrub),
};
let block = block_output(tool_name, &result.counts_by_pattern, &result.text, updated)?;
Ok((block, result.counts_by_pattern))
}
pub(crate) fn text_to_scrub(response: &Value) -> Result<String, String> {
if let Value::String(s) = response {
return Ok(s.clone());
}
let mut parts = Vec::new();
collect_strings(response, &mut parts);
if !parts.is_empty() {
return Ok(parts.join("\n"));
}
match response {
Value::Null => Ok(String::new()),
other => to_go_json(other),
}
}
fn collect_strings(value: &Value, strings: &mut Vec<String>) {
match value {
Value::String(s) if !s.is_empty() => strings.push(s.clone()),
Value::Object(map) => {
for (key, child) in map {
if !key.is_empty() {
strings.push(key.clone());
}
collect_strings(child, strings);
}
}
Value::Array(items) => {
for item in items {
collect_strings(item, strings);
}
}
_ => {}
}
}
pub(crate) fn scrub_json(value: &Value, scrub: &dyn Fn(&str) -> ScrubResult) -> Value {
match value {
Value::String(s) => Value::String(scrub(s).text),
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, child)| (scrub(key).text, scrub_json(child, scrub)))
.collect(),
),
Value::Array(items) => {
Value::Array(items.iter().map(|item| scrub_json(item, scrub)).collect())
}
other => other.clone(),
}
}
fn marker_lines(scrubbed: &str) -> String {
let lines: Vec<String> = scrubbed
.split('\n')
.filter(|line| line.contains("[REDACTED"))
.map(|line| format!("- {line}"))
.collect();
if lines.is_empty() {
return "Secret(s) removed from tool response.".to_string();
}
lines.join("\n")
}
fn block_output(
source: &str,
counts_by_pattern: &BTreeMap<String, usize>,
scrubbed: &str,
updated: Value,
) -> Result<Vec<u8>, String> {
let count: usize = counts_by_pattern.values().sum();
let mut hits: Vec<_> = counts_by_pattern.iter().collect();
hits.sort_by(|left, right| right.1.cmp(left.1).then(left.0.cmp(right.0)));
let summary: Vec<String> = hits
.iter()
.map(|(name, hit_count)| match hit_count {
1 => name.to_string(),
_ => format!("{name} ×{hit_count}"),
})
.collect();
let summary = summary.join(", ");
encode(&HookOutput {
decision: "block",
reason: &format!(
"[redacted] {count} secret(s) scrubbed from {source} output: {summary}\n\n{}",
marker_lines(scrubbed)
),
hook_specific_output: HookSpecificOutput {
hook_event_name: "PostToolUse",
updated_tool_output: updated,
},
})
}
pub fn encode<T: Serialize>(value: &T) -> Result<Vec<u8>, String> {
let mut out = to_go_json(value)?.into_bytes();
out.push(b'\n');
Ok(out)
}
fn to_go_json<T: Serialize>(value: &T) -> Result<String, String> {
let json = serde_json::to_string(value).map_err(|e| format!("encode response: {e}"))?;
Ok(json
.replace('\u{2028}', "\\u2028")
.replace('\u{2029}', "\\u2029"))
}
#[cfg(test)]
mod tests;