use super::{
records::{
HookContextInjectionRecord, HookContextInjectionRecordInput, HookContextInjectionStatus,
HookPhase,
},
runtime::{HOOK_MAX_STDIN_BYTES, HookPathPolicies, HookRuntime},
};
use crate::{
config::{HookDefinition, HookFailurePolicy, HookPayloadMode},
hex::lower_hex,
output::{ToolDispatchContext, is_credential_like_key, redact_sensitive_text},
providers::{ChatMessage, ProviderConversationItem, ToolCall},
tools::ToolResult,
};
use serde::Serialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
fs,
path::{Component, Path, PathBuf},
sync::Arc,
};
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
pub(super) const HOOK_SCHEMA: &str = "magi-code.tool_hook";
pub(super) const HOOK_SCHEMA_VERSION: u8 = 1;
const HOOK_PAYLOAD_REF_HARD_MAX_BYTES: usize = 16 * 1024 * 1024;
pub(super) const MAX_AFFECTED_PATHS: usize = 16;
const MAX_AFFECTED_PATH_CHARS: usize = 1024;
pub(super) fn hook_failed_context_injection_record(
phase: HookPhase,
call: &ToolCall,
hook: &HookDefinition,
hook_index: usize,
max_bytes: usize,
) -> HookContextInjectionRecord {
HookContextInjectionRecord::new(HookContextInjectionRecordInput {
phase,
call,
hook,
hook_index,
status: HookContextInjectionStatus::HookFailed,
item_count: 0,
byte_count: 0,
max_bytes,
})
}
pub(super) struct ProviderContextParseResult {
pub(super) status: HookContextInjectionStatus,
pub(super) items: Vec<ProviderConversationItem>,
pub(super) byte_count: usize,
}
pub(super) fn parse_provider_context_stdout(
stdout: &str,
max_bytes: usize,
) -> ProviderContextParseResult {
let parsed = match serde_json::from_str::<Value>(stdout) {
Ok(parsed) => parsed,
Err(_) => {
return ProviderContextParseResult {
status: HookContextInjectionStatus::InvalidJson,
items: Vec::new(),
byte_count: 0,
};
}
};
let Some(context_items) = parsed.get("context_items").and_then(Value::as_array) else {
return ProviderContextParseResult {
status: HookContextInjectionStatus::InvalidShape,
items: Vec::new(),
byte_count: 0,
};
};
if context_items.is_empty() {
return ProviderContextParseResult {
status: HookContextInjectionStatus::InvalidShape,
items: Vec::new(),
byte_count: 0,
};
}
let mut byte_count = 0usize;
let mut items = Vec::with_capacity(context_items.len());
for item in context_items {
let Some(object) = item.as_object() else {
return ProviderContextParseResult {
status: HookContextInjectionStatus::InvalidShape,
items: Vec::new(),
byte_count: 0,
};
};
let Some(role) = object.get("role").and_then(Value::as_str) else {
return ProviderContextParseResult {
status: HookContextInjectionStatus::InvalidShape,
items: Vec::new(),
byte_count: 0,
};
};
if role != "user" {
return ProviderContextParseResult {
status: HookContextInjectionStatus::UnsupportedRole,
items: Vec::new(),
byte_count: 0,
};
}
let Some(content) = object.get("content").and_then(Value::as_str) else {
return ProviderContextParseResult {
status: HookContextInjectionStatus::InvalidShape,
items: Vec::new(),
byte_count: 0,
};
};
if content.trim().is_empty() {
return ProviderContextParseResult {
status: HookContextInjectionStatus::EmptyContent,
items: Vec::new(),
byte_count: 0,
};
}
byte_count = byte_count.saturating_add(content.len());
if byte_count > max_bytes {
return ProviderContextParseResult {
status: HookContextInjectionStatus::OverLimit,
items: Vec::new(),
byte_count,
};
}
items.push(ProviderConversationItem::Message(ChatMessage::user(
content,
)));
}
ProviderContextParseResult {
status: HookContextInjectionStatus::Success,
items,
byte_count,
}
}
pub(super) struct HookPayloadArtifacts {
pub(super) stdin_json: String,
cleanup_paths: Vec<PathBuf>,
pub(super) cleanup_warnings: Arc<std::sync::Mutex<Vec<String>>>,
}
impl Drop for HookPayloadArtifacts {
fn drop(&mut self) {
for path in &self.cleanup_paths {
if let Some(warning) = cleanup_payload_ref_path(path) {
self.cleanup_warnings.lock().unwrap().push(warning);
}
}
}
}
pub(super) fn cleanup_payload_ref_path(path: &Path) -> Option<String> {
let mut warnings = Vec::new();
if let Err(error) = fs::remove_file(path) {
warnings.push(format!("remove payload file: {error}"));
}
if let Some(parent) = path.parent()
&& let Err(error) = fs::remove_dir(parent)
{
warnings.push(format!("remove payload directory: {error}"));
}
(!warnings.is_empty()).then(|| {
redact_sensitive_text(&format!(
"hook payload ref cleanup incomplete: {}",
warnings.join("; ")
))
})
}
pub(super) fn drain_payload_cleanup_warnings(
warnings: &Arc<std::sync::Mutex<Vec<String>>>,
) -> Vec<String> {
std::mem::take(&mut *warnings.lock().unwrap())
}
#[derive(Serialize)]
struct HookContextEnvelope {
cwd: String,
session_id: Option<String>,
session_path: Option<String>,
provider_id: Option<String>,
model_id: Option<String>,
agent_id: Option<String>,
invocation_mode: String,
turn_id: Option<String>,
message_id: Option<String>,
subagent: bool,
timestamp: String,
}
#[derive(Serialize)]
pub(super) struct AffectedPath {
path: String,
kind: &'static str,
source: &'static str,
}
#[derive(Serialize)]
struct PayloadRef {
path: String,
bytes: usize,
payload_mode: &'static str,
media_type: &'static str,
digest_sha256: String,
}
#[derive(Serialize)]
struct PayloadBodyRef<'a> {
request: &'a Value,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<&'a Value>,
}
#[derive(Serialize)]
struct InlinePayload<'a> {
inline: bool,
status: &'static str,
data: PayloadBodyRef<'a>,
}
struct InlineEnvelopeJsonInput<'a> {
phase: HookPhase,
hook: &'a HookDefinition,
hook_index: usize,
call: &'a ToolCall,
context: &'a HookContextEnvelope,
affected_paths: &'a [AffectedPath],
payload_mode: HookPayloadMode,
failure_policy: HookFailurePolicy,
request: &'a Value,
result: Option<&'a Value>,
}
#[derive(Serialize)]
struct InlineEnvelopeRef<'a> {
schema: &'static str,
schema_version: u8,
phase: &'static str,
hook: InlineEnvelopeHook,
tool: InlineEnvelopeTool<'a>,
context: &'a HookContextEnvelope,
affected_paths: &'a [AffectedPath],
payload: InlinePayload<'a>,
payload_ref: Value,
cwd: &'a str,
payload_mode: &'static str,
request: &'a Value,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<&'a Value>,
}
#[derive(Serialize)]
struct InlineEnvelopeHook {
label: String,
index: usize,
failure_policy: &'static str,
payload_mode: &'static str,
}
#[derive(Serialize)]
struct InlineEnvelopeTool<'a> {
name: &'a str,
call_id: &'a str,
}
fn inline_envelope_json(input: InlineEnvelopeJsonInput<'_>) -> serde_json::Result<String> {
serde_json::to_string(&InlineEnvelopeRef {
schema: HOOK_SCHEMA,
schema_version: HOOK_SCHEMA_VERSION,
phase: input.phase.as_str(),
hook: InlineEnvelopeHook {
label: input.hook.effective_label(),
index: input.hook_index,
failure_policy: input.failure_policy.as_str(),
payload_mode: input.payload_mode.as_str(),
},
tool: InlineEnvelopeTool {
name: &input.call.name,
call_id: &input.call.id,
},
context: input.context,
affected_paths: input.affected_paths,
payload: InlinePayload {
inline: true,
status: "inline",
data: PayloadBodyRef {
request: input.request,
result: input.result,
},
},
payload_ref: Value::Null,
cwd: &input.context.cwd,
payload_mode: input.payload_mode.as_str(),
request: input.request,
result: input.result,
})
}
fn inline_body_guarantees_overflow(body_text: Option<&str>) -> bool {
body_text.is_some_and(|text| text.len() >= HOOK_MAX_STDIN_BYTES)
}
impl HookRuntime {
pub(super) fn build_payload(
&self,
phase: HookPhase,
hook: &HookDefinition,
hook_index: usize,
call: &ToolCall,
result: Option<&ToolResult>,
context: Option<&ToolDispatchContext>,
) -> anyhow::Result<HookPayloadArtifacts> {
let payload_mode = hook.payload.unwrap_or(self.settings.payload);
let cleanup_warnings = Arc::new(std::sync::Mutex::new(Vec::new()));
let request = request_payload(payload_mode, call);
let result_payload = result.map(|result| result_payload(payload_mode, result));
let mut body_text = (payload_mode == HookPayloadMode::Full)
.then(|| {
serde_json::to_string(&PayloadBodyRef {
request: &request,
result: result_payload.as_ref(),
})
})
.transpose()?;
let affected_paths = affected_paths_for_call(&self.cwd, call, self.path_policies);
let context_envelope = context_envelope(&self.cwd, context);
if !inline_body_guarantees_overflow(body_text.as_deref()) {
let inline = inline_envelope_json(InlineEnvelopeJsonInput {
phase,
hook,
hook_index,
call,
context: &context_envelope,
affected_paths: &affected_paths,
payload_mode,
failure_policy: hook.failure_policy.unwrap_or(self.settings.failure_policy),
request: &request,
result: result_payload.as_ref(),
})?;
if inline.len() <= HOOK_MAX_STDIN_BYTES {
return Ok(HookPayloadArtifacts {
stdin_json: inline,
cleanup_paths: Vec::new(),
cleanup_warnings,
});
}
}
let body_text = match body_text.take() {
Some(body_text) => body_text,
None => serde_json::to_string(&payload_body(&request, result_payload.as_ref()))?,
};
let body_bytes = body_text.len();
if payload_mode == HookPayloadMode::Full
&& body_bytes <= HOOK_PAYLOAD_REF_HARD_MAX_BYTES
&& let Ok(path) = self.write_payload_ref(&body_text, context)
{
let digest = Sha256::digest(body_text.as_bytes());
let payload_ref = json!(PayloadRef {
path: path.display().to_string(),
bytes: body_bytes,
payload_mode: payload_mode.as_str(),
media_type: "application/json",
digest_sha256: format!("sha256:{}", lower_hex(digest)),
});
let summary = overflow_summary("moved_to_payload_ref", body_bytes, None);
let ref_envelope = envelope_value(EnvelopeValueInput {
phase,
hook,
hook_index,
call,
context: &context_envelope,
affected_paths: &affected_paths,
payload_mode,
failure_policy: hook.failure_policy.unwrap_or(self.settings.failure_policy),
payload: json!({"inline": false, "status": "referenced"}),
payload_ref,
request: summary.clone(),
result: result_payload.as_ref().map(|_| summary.clone()),
});
let stdin_json = serde_json::to_string(&ref_envelope)?;
if stdin_json.len() <= HOOK_MAX_STDIN_BYTES {
return Ok(HookPayloadArtifacts {
stdin_json,
cleanup_paths: vec![path],
cleanup_warnings,
});
}
if let Some(warning) = cleanup_payload_ref_path(&path) {
cleanup_warnings.lock().unwrap().push(warning);
}
}
let summary = overflow_summary(
"omitted_too_large",
body_bytes,
Some("payload exceeded hook stdin cap and could not be referenced"),
);
let omitted = envelope_value(EnvelopeValueInput {
phase,
hook,
hook_index,
call,
context: &context_envelope,
affected_paths: &affected_paths,
payload_mode,
failure_policy: hook.failure_policy.unwrap_or(self.settings.failure_policy),
payload: json!({
"inline": false,
"status": "omitted_too_large",
"original_bytes": body_bytes,
"reason": "payload exceeded hook stdin cap and could not be referenced"
}),
payload_ref: Value::Null,
request: summary.clone(),
result: result_payload.as_ref().map(|_| summary),
});
Ok(HookPayloadArtifacts {
stdin_json: serde_json::to_string(&omitted)?,
cleanup_paths: Vec::new(),
cleanup_warnings,
})
}
fn write_payload_ref(
&self,
body_text: &str,
context: Option<&ToolDispatchContext>,
) -> anyhow::Result<PathBuf> {
let root = payload_ref_root(context)
.unwrap_or_else(|| std::env::temp_dir().join("magi-code-hook-payloads"));
self.write_payload_ref_in_root(body_text, root)
}
fn write_payload_ref_in_root(&self, body_text: &str, root: PathBuf) -> anyhow::Result<PathBuf> {
fs::create_dir_all(&root)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&root, fs::Permissions::from_mode(0o700))?;
}
let canonical_root = hardened_payload_ref_dir(&root, "root")?;
let dir = root.join(uuid::Uuid::new_v4().to_string());
fs::create_dir(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
}
let canonical_dir = hardened_payload_ref_dir(&dir, "payload directory")?;
if !canonical_dir.starts_with(&canonical_root) {
anyhow::bail!("hook payload ref directory escaped runtime root");
}
let path = canonical_dir.join("payload.json");
crate::persistence::atomic_write_with_permissions(
&path,
body_text.as_bytes(),
Some(0o600),
)?;
Ok(path.canonicalize()?)
}
}
#[cfg(test)]
impl HookRuntime {
pub(super) fn write_payload_ref_for_test(
&self,
body_text: &str,
root: PathBuf,
) -> anyhow::Result<PathBuf> {
self.write_payload_ref_in_root(body_text, root)
}
}
fn hardened_payload_ref_dir(path: &Path, label: &str) -> anyhow::Result<PathBuf> {
if fs::symlink_metadata(path)?.file_type().is_symlink() {
anyhow::bail!("hook payload ref {label} is a symlink");
}
let canonical = path.canonicalize()?;
#[cfg(unix)]
{
if fs::symlink_metadata(&canonical)?.file_type().is_symlink() {
anyhow::bail!("hook payload ref {label} is a symlink");
}
let owner = fs::metadata(&canonical)?.uid();
let current = unsafe { libc::getuid() };
if owner != current {
anyhow::bail!("hook payload ref {label} owner mismatch");
}
}
Ok(canonical)
}
#[cfg(test)]
pub(super) fn build_payload(
cwd: &Path,
phase: HookPhase,
hook: &HookDefinition,
call: &ToolCall,
result: Option<&ToolResult>,
settings: &crate::config::HookSettings,
) -> Value {
let payload_mode = hook.payload.unwrap_or(settings.payload);
let request = request_payload(payload_mode, call);
let result_payload = result.map(|result| result_payload(payload_mode, result));
envelope_value(EnvelopeValueInput {
phase,
hook,
hook_index: 0,
call,
context: &context_envelope(cwd, None),
affected_paths: &affected_paths_for_call(cwd, call, HookPathPolicies::default()),
payload_mode,
failure_policy: hook.failure_policy.unwrap_or(settings.failure_policy),
payload: json!({"inline": true, "status": "inline", "data": payload_body(&request, result_payload.as_ref())}),
payload_ref: Value::Null,
request,
result: result_payload,
})
}
fn request_payload(payload_mode: HookPayloadMode, call: &ToolCall) -> Value {
match payload_mode {
HookPayloadMode::Full => call.arguments.clone(),
HookPayloadMode::Redacted => redact_json(&call.arguments, true),
}
}
fn result_payload(payload_mode: HookPayloadMode, result: &ToolResult) -> Value {
match payload_mode {
HookPayloadMode::Full => {
serde_json::to_value(result).unwrap_or_else(|_| json!({"error":"serialization failed"}))
}
HookPayloadMode::Redacted => json!({
"tool_name": result.tool_name,
"success": result.success,
"content": "<redacted:tool-output>",
"metadata": redact_json(&result.metadata, true),
}),
}
}
fn payload_body(request: &Value, result: Option<&Value>) -> Value {
let mut body = json!({"request": request});
if let Some(result) = result {
body["result"] = result.clone();
}
body
}
struct EnvelopeValueInput<'a> {
phase: HookPhase,
hook: &'a HookDefinition,
hook_index: usize,
call: &'a ToolCall,
context: &'a HookContextEnvelope,
affected_paths: &'a [AffectedPath],
payload_mode: HookPayloadMode,
failure_policy: HookFailurePolicy,
payload: Value,
payload_ref: Value,
request: Value,
result: Option<Value>,
}
fn envelope_value(input: EnvelopeValueInput<'_>) -> Value {
let mut envelope = json!({
"schema": HOOK_SCHEMA,
"schema_version": HOOK_SCHEMA_VERSION,
"phase": input.phase.as_str(),
"hook": {
"label": input.hook.effective_label(),
"index": input.hook_index,
"failure_policy": input.failure_policy.as_str(),
"payload_mode": input.payload_mode.as_str()
},
"tool": {"name": input.call.name, "call_id": input.call.id},
"context": input.context,
"affected_paths": input.affected_paths,
"payload": input.payload,
"payload_ref": input.payload_ref,
"cwd": input.context.cwd,
"payload_mode": input.payload_mode.as_str(),
"request": input.request,
});
if let Some(result) = input.result {
envelope["result"] = result;
}
envelope
}
fn context_envelope(cwd: &Path, context: Option<&ToolDispatchContext>) -> HookContextEnvelope {
let metadata = context.map(|context| &context.hook_context);
HookContextEnvelope {
cwd: cwd.display().to_string(),
session_id: metadata.and_then(|metadata| metadata.session_id.clone()),
session_path: metadata
.and_then(|metadata| metadata.session_path.as_ref())
.map(|path| path.display().to_string()),
provider_id: metadata.and_then(|metadata| metadata.provider_id.clone()),
model_id: metadata.and_then(|metadata| metadata.model_id.clone()),
agent_id: metadata.and_then(|metadata| metadata.agent_id.clone()),
invocation_mode: metadata
.map(|metadata| metadata.invocation_mode.as_str().to_string())
.unwrap_or_else(|| "print".to_string()),
turn_id: metadata.and_then(|metadata| metadata.turn_id.clone()),
message_id: metadata.and_then(|metadata| metadata.message_id.clone()),
subagent: metadata.is_some_and(|metadata| metadata.subagent),
timestamp: chrono::Utc::now().to_rfc3339(),
}
}
pub(super) fn payload_ref_root(context: Option<&ToolDispatchContext>) -> Option<PathBuf> {
let metadata = &context?.hook_context;
let session_path = metadata.session_path.as_ref()?;
let session_dir = session_path.parent()?;
let session_id = metadata
.session_id
.as_deref()
.filter(|session_id| valid_payload_ref_session_id(session_id))
.unwrap_or("no-session");
Some(session_dir.join("hook-payloads").join(session_id))
}
fn valid_payload_ref_session_id(session_id: &str) -> bool {
if session_id.is_empty()
|| session_id == "."
|| session_id == ".."
|| session_id.contains(['/', '\\', '.'])
|| session_id
.chars()
.any(|ch| ch.is_whitespace() || ch.is_control())
{
return false;
}
uuid::Uuid::parse_str(session_id).is_ok()
|| session_id
.chars()
.all(|ch| ch.is_ascii_hexdigit() || ch == '-')
}
fn overflow_summary(status: &str, original_bytes: usize, reason: Option<&str>) -> Value {
let mut summary = json!({
"status": status,
"original_bytes": original_bytes,
"omitted_fields": ["request", "result"]
});
if let Some(reason) = reason {
summary["reason"] = json!(reason);
}
summary
}
pub(super) fn affected_paths_for_call(
cwd: &Path,
call: &ToolCall,
policies: HookPathPolicies,
) -> Vec<AffectedPath> {
let Some(source) = affected_path_source(call.name.as_str(), policies) else {
return Vec::new();
};
let raw_paths = affected_path_raw_values(call);
let Ok(canonical_cwd) = cwd.canonicalize() else {
return Vec::new();
};
raw_paths
.into_iter()
.filter_map(|raw_path| {
normalize_affected_path(&canonical_cwd, raw_path, source.resolution).map(|path| {
AffectedPath {
path,
kind: source.kind,
source: source.source,
}
})
})
.take(MAX_AFFECTED_PATHS)
.collect()
}
fn affected_path_raw_values(call: &ToolCall) -> Vec<&str> {
if call.name == "read"
&& let Some(paths) = call.arguments.get("paths").and_then(Value::as_array)
{
return paths.iter().filter_map(Value::as_str).collect();
}
call.arguments
.get("path")
.and_then(Value::as_str)
.into_iter()
.collect()
}
#[derive(Debug, Clone, Copy)]
struct AffectedPathSource {
kind: &'static str,
source: &'static str,
resolution: AffectedPathResolution,
}
#[derive(Debug, Clone, Copy)]
enum AffectedPathResolution {
Existing { allow_absolute_paths: bool },
Write { allow_absolute_paths: bool },
}
fn affected_path_source(tool_name: &str, policies: HookPathPolicies) -> Option<AffectedPathSource> {
match tool_name {
"read" => Some(AffectedPathSource {
kind: "read_target",
source: "request.paths",
resolution: AffectedPathResolution::Existing {
allow_absolute_paths: policies.read_absolute_paths,
},
}),
"grep" | "ffgrep" => Some(AffectedPathSource {
kind: "read_target",
source: "request.path",
resolution: AffectedPathResolution::Existing {
allow_absolute_paths: policies.grep_absolute_paths,
},
}),
"find" | "fffind" => Some(AffectedPathSource {
kind: "read_target",
source: "request.path",
resolution: AffectedPathResolution::Existing {
allow_absolute_paths: policies.find_absolute_paths,
},
}),
"list_files" => Some(AffectedPathSource {
kind: "read_target",
source: "request.path",
resolution: AffectedPathResolution::Existing {
allow_absolute_paths: policies.list_files_absolute_paths,
},
}),
"write" => Some(AffectedPathSource {
kind: "write_target",
source: "request.path",
resolution: AffectedPathResolution::Write {
allow_absolute_paths: policies.write_absolute_paths,
},
}),
_ => None,
}
}
fn normalize_affected_path(
cwd: &Path,
raw_path: &str,
resolution: AffectedPathResolution,
) -> Option<String> {
if raw_path.trim().is_empty() || raw_path.chars().count() > MAX_AFFECTED_PATH_CHARS {
return None;
}
let user_path = Path::new(raw_path);
let is_absolute = user_path.is_absolute();
let candidate = if is_absolute {
user_path.to_path_buf()
} else {
cwd.join(user_path)
};
match resolution {
AffectedPathResolution::Existing {
allow_absolute_paths,
} => normalize_existing_affected_path(cwd, &candidate, is_absolute, allow_absolute_paths),
AffectedPathResolution::Write {
allow_absolute_paths,
} => normalize_write_affected_path(cwd, &candidate, is_absolute, allow_absolute_paths),
}
}
fn normalize_existing_affected_path(
cwd: &Path,
candidate: &Path,
is_absolute: bool,
allow_absolute_paths: bool,
) -> Option<String> {
let canonical = candidate.canonicalize().ok()?;
if (!is_absolute || !allow_absolute_paths) && !canonical.starts_with(cwd) {
return None;
}
Some(display_affected_path(cwd, &canonical, is_absolute))
}
fn normalize_write_affected_path(
cwd: &Path,
candidate: &Path,
is_absolute: bool,
allow_absolute_paths: bool,
) -> Option<String> {
let allow_absolute = is_absolute && allow_absolute_paths;
if let Ok(canonical) = candidate.canonicalize() {
if !allow_absolute && !canonical.starts_with(cwd) {
return None;
}
return Some(display_affected_path(cwd, &canonical, is_absolute));
}
let normalized = lexical_normalize(candidate);
if !allow_absolute && !normalized.starts_with(cwd) {
return None;
}
let parent = normalized.parent()?;
let existing_parent = nearest_existing_ancestor(parent).ok()?;
let parent_canonical = existing_parent.canonicalize().ok()?;
if !allow_absolute && !parent_canonical.starts_with(cwd) {
return None;
}
Some(display_affected_path(cwd, &normalized, is_absolute))
}
fn display_affected_path(cwd: &Path, path: &Path, was_absolute: bool) -> String {
if was_absolute {
return path.display().to_string();
}
path.strip_prefix(cwd)
.ok()
.map(|relative| {
let text = relative.display().to_string();
if text.is_empty() {
".".to_string()
} else {
text
}
})
.unwrap_or_else(|| path.display().to_string())
}
fn nearest_existing_ancestor(path: &Path) -> anyhow::Result<PathBuf> {
let mut cursor = path;
loop {
if cursor.exists() {
return Ok(cursor.to_path_buf());
}
cursor = cursor
.parent()
.ok_or_else(|| anyhow::anyhow!("no existing ancestor for '{}'", path.display()))?;
}
}
fn lexical_normalize(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
fn redact_json(value: &Value, redact_large_or_sensitive_content: bool) -> Value {
match value {
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, value)| {
let redacted = if is_credential_like_key(key) {
json!("<redacted>")
} else if redact_large_or_sensitive_content && is_sensitive_payload_key(key) {
summarize_value(key, value)
} else {
redact_json(value, redact_large_or_sensitive_content)
};
(key.clone(), redacted)
})
.collect(),
),
Value::Array(values) => Value::Array(
values
.iter()
.map(|value| redact_json(value, redact_large_or_sensitive_content))
.collect(),
),
Value::String(text) => Value::String(redact_sensitive_text(text)),
other => other.clone(),
}
}
fn is_sensitive_payload_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
matches!(
key.as_str(),
"command"
| "cmd"
| "content"
| "oldtext"
| "old_text"
| "newtext"
| "new_text"
| "stdout"
| "stderr"
| "output"
)
}
fn summarize_value(key: &str, value: &Value) -> Value {
let kind = if matches!(
key.to_ascii_lowercase().as_str(),
"stdout" | "stderr" | "output"
) {
"tool-output"
} else if matches!(key.to_ascii_lowercase().as_str(), "command" | "cmd") {
"command"
} else {
"content"
};
let bytes = match value {
Value::String(text) => text.len(),
other => other.to_string().len(),
};
json!({"redacted": true, "kind": kind, "bytes": bytes})
}