use std::io::Read;
use anyhow::Result;
use serde::Deserialize;
use crate::ext::{PermissionGate, ProvenanceSink};
use crate::{config, ext, ipc, paths, record, span};
#[derive(Debug, Deserialize)]
struct HookInput {
#[serde(default)]
tool_name: String,
#[serde(default)]
tool_input: serde_json::Value,
#[serde(default)]
tool_response: serde_json::Value,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
transcript_path: Option<String>,
}
pub fn run() -> Result<()> {
if std::env::var("GALDR_HOOK_FAIL").is_ok() {
panic!("forced failure for the sensor robustness test");
}
let active = match record::read_active() {
Some(active) => active,
None => return Ok(()),
};
const MAX_HOOK_BYTES: u64 = 16 * 1024 * 1024;
let mut buf = String::new();
std::io::stdin()
.take(MAX_HOOK_BYTES)
.read_to_string(&mut buf)?;
if buf.trim().is_empty() {
return Ok(());
}
let input: HookInput = serde_json::from_str(&buf)?;
if is_galdr_control_command(&input.tool_name, &input.tool_input) {
return Ok(());
}
let decision = capture_decision(&active, input.session_id.as_deref(), input.cwd.as_deref());
if matches!(decision, Capture::Skip) {
return Ok(());
}
let span_path = paths::span_file(&active.rec_id)?;
let mut event = span::Event {
ts: record::now_rfc3339(),
seq: span::count_events(&span_path),
tool_name: input.tool_name,
tool_input: input.tool_input,
tool_response: input.tool_response,
cwd: input.cwd,
session_id: input.session_id,
};
let capture = config::Config::load_capture();
if denied_by_capture_policy(&event, &capture) {
return Ok(());
}
if capture.strip_screenshots {
strip_screenshots(&mut event.tool_input);
strip_screenshots(&mut event.tool_response);
}
apply_response_cap(&mut event, capture.max_response_chars);
let gate = ext::NoopExt;
if !gate.allow(&event) {
return Ok(());
}
span::append_event(&span_path, &event)?;
ipc::notify_best_effort(&ipc::Request::EventAppended {
rec_id: active.rec_id.clone(),
event: event.clone(),
});
ext::NoopExt.record(&event);
let new_binding = match decision {
Capture::RecordAndBind(session_id) => Some(session_id),
_ => None,
};
let new_transcript = input
.transcript_path
.filter(|_| active.transcript_path.is_none());
if new_binding.is_some() || new_transcript.is_some() {
let updated = record::ActiveRec {
bound_session: new_binding.or_else(|| active.bound_session.clone()),
transcript_path: new_transcript.or_else(|| active.transcript_path.clone()),
..active
};
let _ = record::write_active(&updated);
}
Ok(())
}
enum Capture {
Skip,
Record,
RecordAndBind(String),
}
fn capture_decision(
active: &record::ActiveRec,
session_id: Option<&str>,
cwd: Option<&str>,
) -> Capture {
match active.bound_session.as_deref() {
Some(bound) => match session_id {
Some(sid) if sid == bound => Capture::Record,
Some(_) => Capture::Skip,
None => Capture::Record,
},
None => match session_id {
None => Capture::Record,
Some(sid) => {
let cwd_ok = match (active.origin_cwd.as_deref(), cwd) {
(None, _) | (Some(_), None) => true,
(Some(origin), Some(cwd)) => path_within(cwd, origin),
};
if cwd_ok {
Capture::RecordAndBind(sid.to_string())
} else {
Capture::Skip
}
}
},
}
}
fn path_within(path: &str, base: &str) -> bool {
let base = base.trim_end_matches('/');
path == base || path.starts_with(&format!("{base}/"))
}
fn is_galdr_control_command(tool_name: &str, tool_input: &serde_json::Value) -> bool {
if tool_name != "Bash" {
return false;
}
let Some(command) = tool_input.get("command").and_then(|v| v.as_str()) else {
return false;
};
let segments: Vec<&str> = command
.split([';', '\n', '|', '&'])
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
!segments.is_empty()
&& segments
.iter()
.all(|seg| is_cd_segment(seg) || is_galdr_rec_segment(seg))
}
fn is_cd_segment(seg: &str) -> bool {
seg == "cd" || seg.starts_with("cd ")
}
fn is_galdr_rec_segment(seg: &str) -> bool {
let mut tokens = seg.split_whitespace();
let Some(prog) = tokens.next() else {
return false;
};
let is_galdr = prog == "galdr" || prog.ends_with("/galdr");
is_galdr && matches!(tokens.next(), Some("rec") | Some("hook"))
}
fn denied_by_capture_policy(event: &span::Event, capture: &config::CaptureConfig) -> bool {
if capture
.deny_tools
.iter()
.any(|tool| tool == &event.tool_name)
{
return true;
}
if let Some(cwd) = &event.cwd
&& capture
.deny_cwd_prefixes
.iter()
.any(|prefix| cwd.starts_with(prefix))
{
return true;
}
false
}
fn strip_screenshots(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
let image_ctx = is_image_context(map);
for (key, v) in map.iter_mut() {
let strip = match v.as_str() {
Some(s) if is_data_uri_image(s) => true,
Some(s) if (image_ctx || is_image_key(key)) && is_base64ish(s) => true,
_ => false,
};
if strip {
let bytes = v.as_str().map(str::len).unwrap_or(0);
*v = serde_json::json!(format!("[galdr stripped screenshot: {bytes} bytes]"));
} else {
strip_screenshots(v);
}
}
}
serde_json::Value::Array(items) => {
for item in items.iter_mut() {
strip_screenshots(item);
}
}
_ => {}
}
}
fn is_image_context(map: &serde_json::Map<String, serde_json::Value>) -> bool {
let s = |k: &str| map.get(k).and_then(|v| v.as_str());
s("type") == Some("image")
|| ["media_type", "mimeType", "mime_type"]
.iter()
.any(|k| s(k).is_some_and(|m| m.starts_with("image/")))
}
fn is_image_key(key: &str) -> bool {
matches!(
key,
"image" | "image_url" | "imageUrl" | "screenshot" | "img"
)
}
fn is_data_uri_image(s: &str) -> bool {
s.starts_with("data:image/")
}
fn is_base64ish(s: &str) -> bool {
if s.len() < 64 {
return false;
}
let ok = s
.bytes()
.filter(|b| {
b.is_ascii_alphanumeric()
|| matches!(b, b'+' | b'/' | b'=' | b'-' | b'_' | b'\n' | b'\r')
})
.count();
ok as f64 / s.len() as f64 > 0.95
}
fn apply_response_cap(event: &mut span::Event, max_chars: Option<usize>) {
let Some(max_chars) = max_chars else {
return;
};
let raw = event.tool_response.to_string();
if raw.chars().count() <= max_chars {
return;
}
let preview: String = raw.chars().take(max_chars).collect();
event.tool_response = serde_json::json!({
"galdr_truncated": true,
"original_chars": raw.chars().count(),
"preview": preview,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::record::ActiveRec;
#[test]
fn strips_a_base64_screenshot_but_keeps_the_action() {
let big = "iVBORw0KGgoAAAANSUhEUg".repeat(100); let mut response = serde_json::json!({
"type": "image",
"source": { "type": "base64", "media_type": "image/png", "data": big }
});
let mut input = serde_json::json!({ "action": "screenshot" });
strip_screenshots(&mut response);
strip_screenshots(&mut input);
let data = response["source"]["data"].as_str().unwrap();
assert!(data.contains("stripped screenshot"), "{data}");
assert!(!data.contains("iVBORw0KGgo"));
assert_eq!(input["action"], "screenshot");
}
#[test]
fn strip_leaves_short_data_and_prose_alone() {
let mut v = serde_json::json!({
"data": "ok",
"command": "git status",
"note": "a normal sentence with spaces, not base64"
});
let before = v.clone();
strip_screenshots(&mut v);
assert_eq!(v, before);
}
#[test]
fn strip_only_acts_with_image_context_not_generic_base64() {
let blob = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5".repeat(50);
let mut generic = serde_json::json!({ "stdout": blob });
let before = generic.clone();
strip_screenshots(&mut generic);
assert_eq!(generic, before, "generic base64 must not be stripped");
let mut uri = serde_json::json!({ "url": format!("data:image/png;base64,{blob}") });
strip_screenshots(&mut uri);
assert!(uri["url"].as_str().unwrap().contains("stripped screenshot"));
let mut keyed = serde_json::json!({ "image": blob.replace('+', "-") });
strip_screenshots(&mut keyed);
assert!(
keyed["image"]
.as_str()
.unwrap()
.contains("stripped screenshot")
);
}
fn active(origin: Option<&str>, bound: Option<&str>) -> ActiveRec {
ActiveRec {
rec_id: "01X".into(),
name: "t".into(),
started_at: "ts".into(),
transcript_path: None,
origin_cwd: origin.map(String::from),
bound_session: bound.map(String::from),
}
}
#[test]
fn binds_to_the_first_session_under_origin() {
let a = active(Some("/proj/galdr"), None);
match capture_decision(&a, Some("sessA"), Some("/proj/galdr/sub")) {
Capture::RecordAndBind(s) => assert_eq!(s, "sessA"),
_ => panic!("should bind to sessA"),
}
}
#[test]
fn foreign_session_in_another_dir_is_skipped_before_binding() {
let a = active(Some("/proj/galdr"), None);
assert!(matches!(
capture_decision(&a, Some("sessB"), Some("/proj/eldr")),
Capture::Skip
));
}
#[test]
fn once_bound_only_that_session_records() {
let a = active(Some("/proj/galdr"), Some("sessA"));
assert!(matches!(
capture_decision(&a, Some("sessA"), Some("/anywhere")),
Capture::Record
));
assert!(matches!(
capture_decision(&a, Some("sessB"), Some("/proj/galdr")),
Capture::Skip
));
}
#[test]
fn sessionless_events_always_record() {
let unbound = active(Some("/proj/galdr"), None);
assert!(matches!(
capture_decision(&unbound, None, Some("/tmp")),
Capture::Record
));
let bound = active(Some("/proj/galdr"), Some("sessA"));
assert!(matches!(
capture_decision(&bound, None, None),
Capture::Record
));
}
#[test]
fn no_origin_binds_to_any_first_session() {
let a = active(None, None);
assert!(matches!(
capture_decision(&a, Some("sessA"), Some("/anywhere")),
Capture::RecordAndBind(_)
));
}
#[test]
fn path_within_respects_component_boundaries() {
assert!(path_within("/a/b", "/a/b"));
assert!(path_within("/a/b/c", "/a/b"));
assert!(!path_within("/a/bc", "/a/b"));
assert!(!path_within("/x", "/a/b"));
}
#[test]
fn galdr_control_commands_are_not_recorded() {
let ctl =
|cmd: &str| is_galdr_control_command("Bash", &serde_json::json!({ "command": cmd }));
assert!(ctl("galdr rec start my-task"));
assert!(ctl("galdr rec stop"));
assert!(ctl("galdr rec status"));
assert!(ctl("cd /repo && galdr rec start x"));
assert!(ctl("/Users/me/.cargo/bin/galdr rec stop"));
assert!(ctl("galdr rec start x >/dev/null"));
assert!(!ctl("galdr distill 01ABC"));
assert!(!ctl("cargo test"));
assert!(!ctl("git commit -m 'galdr rec start'"));
assert!(!ctl("galdr rec start x && cargo build"));
assert!(!ctl(
"galdr rec start x\nVER=$(grep version Cargo.toml)\necho $VER"
));
assert!(!is_galdr_control_command(
"Read",
&serde_json::json!({ "command": "galdr rec start x" })
));
}
}