use crate::common::Drip;
use serde_json::{json, Value};
use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
fn long_python_module() -> String {
let mut s = String::from("import os\nimport sys\n\n");
for n in 0..5 {
s.push_str(&format!("def fn_{n}(a, b, c):\n"));
for i in 0..12 {
s.push_str(&format!(" step_{i:02} = a + b + {i}\n"));
}
s.push_str(" return step_11\n\n");
}
s
}
fn run_pre_edit(drip: &Drip, payload: Value, extra_env: &[(&str, &str)]) -> Value {
let mut cmd = Command::new(&drip.bin);
cmd.args(["hook", "claude-pre-edit"])
.env("DRIP_DATA_DIR", drip.data_dir.path())
.env("DRIP_SESSION_ID", &drip.session_id)
.env_remove("DRIP_DISABLE")
.env_remove("DRIP_PRE_EDIT_WARN")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in extra_env {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("spawn pre-edit hook");
child
.stdin
.as_mut()
.unwrap()
.write_all(payload.to_string().as_bytes())
.unwrap();
let o = child.wait_with_output().unwrap();
assert!(
o.status.success(),
"pre-edit hook errored: stderr={}",
String::from_utf8_lossy(&o.stderr)
);
serde_json::from_slice(&o.stdout).unwrap_or_else(|_| {
panic!(
"pre-edit hook didn't emit JSON: stdout={}",
String::from_utf8_lossy(&o.stdout)
)
})
}
fn permission(resp: &Value) -> &str {
resp.get("hookSpecificOutput")
.and_then(|h| h.get("permissionDecision"))
.and_then(|d| d.as_str())
.expect("response must include permissionDecision")
}
fn reason(resp: &Value) -> Option<&str> {
resp.get("hookSpecificOutput")
.and_then(|h| h.get("permissionDecisionReason"))
.and_then(|d| d.as_str())
}
fn read_with_compression(drip: &Drip, file: &Path) -> String {
drip.read_stdout(file)
}
#[test]
fn edit_inside_elided_body_is_denied_with_symbol_and_range() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("module.py");
fs::write(&f, long_python_module()).unwrap();
let out = read_with_compression(&drip, &f);
assert!(
out.contains("(semantic-compressed)"),
"precondition: read must trigger compression: {out}"
);
let payload = json!({
"session_id": drip.session_id,
"tool_name": "Edit",
"tool_input": {
"file_path": f.to_string_lossy(),
"old_string": " step_05 = a + b + 5\n step_06 = a + b + 6\n",
"new_string": " step_05 = 'patched'\n step_06 = 'patched'\n",
}
});
let resp = run_pre_edit(&drip, payload, &[]);
assert_eq!(permission(&resp), "deny");
let r = reason(&resp).expect("deny must carry a reason");
assert!(
r.contains("STOP"),
"reason must lead with STOP for visibility: {r}"
);
assert!(
r.contains("`fn_") || r.contains("fn_"),
"reason must name an elided fn: {r}"
);
assert!(
r.contains('L') && r.contains('-'),
"reason must cite the original line range: {r}"
);
assert!(
r.contains("drip refresh"),
"reason must point at the recovery command: {r}"
);
}
#[test]
fn edit_outside_elided_region_passes_through() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("module.py");
fs::write(&f, long_python_module()).unwrap();
let out = read_with_compression(&drip, &f);
assert!(out.contains("(semantic-compressed)"));
let payload = json!({
"session_id": drip.session_id,
"tool_name": "Edit",
"tool_input": {
"file_path": f.to_string_lossy(),
"old_string": "import os\nimport sys\n",
"new_string": "import os\nimport sys\nimport json\n",
}
});
let resp = run_pre_edit(&drip, payload, &[]);
assert_eq!(permission(&resp), "allow", "imports are visible: {resp}");
}
#[test]
fn edit_on_signature_line_passes_through() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("module.py");
fs::write(&f, long_python_module()).unwrap();
read_with_compression(&drip, &f);
let payload = json!({
"session_id": drip.session_id,
"tool_name": "Edit",
"tool_input": {
"file_path": f.to_string_lossy(),
"old_string": "def fn_3(a, b, c):",
"new_string": "def fn_3(a, b, c, *, debug=False):",
}
});
let resp = run_pre_edit(&drip, payload, &[]);
assert_eq!(
permission(&resp),
"allow",
"signature edits must pass: {resp}"
);
}
#[test]
fn write_tool_warns_on_any_compressed_file() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("module.py");
fs::write(&f, long_python_module()).unwrap();
read_with_compression(&drip, &f);
let payload = json!({
"session_id": drip.session_id,
"tool_name": "Write",
"tool_input": {
"file_path": f.to_string_lossy(),
"content": "def fn_0():\n return 1\n",
}
});
let resp = run_pre_edit(&drip, payload, &[]);
assert_eq!(
permission(&resp),
"deny",
"Write replaces unseen bytes — must block: {resp}"
);
let r = reason(&resp).unwrap();
assert!(r.contains("Write"), "reason should name the tool: {r}");
}
#[test]
fn multiedit_with_one_elided_target_is_denied_for_that_target() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("module.py");
fs::write(&f, long_python_module()).unwrap();
read_with_compression(&drip, &f);
let payload = json!({
"session_id": drip.session_id,
"tool_name": "MultiEdit",
"tool_input": {
"file_path": f.to_string_lossy(),
"edits": [
{
"old_string": "import os\n",
"new_string": "import os # patched\n",
},
{
"old_string": " step_09 = a + b + 9\n step_10 = a + b + 10\n",
"new_string": " step_09 = 0\n step_10 = 0\n",
},
]
}
});
let resp = run_pre_edit(&drip, payload, &[]);
assert_eq!(
permission(&resp),
"deny",
"any elided target in MultiEdit must trigger deny: {resp}"
);
}
#[test]
fn no_source_map_means_no_block() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("tiny.py");
fs::write(&f, "def hi():\n return 1\n").unwrap();
drip.read_stdout(&f);
let payload = json!({
"session_id": drip.session_id,
"tool_name": "Edit",
"tool_input": {
"file_path": f.to_string_lossy(),
"old_string": " return 1\n",
"new_string": " return 2\n",
}
});
let resp = run_pre_edit(&drip, payload, &[]);
assert_eq!(permission(&resp), "allow");
}
#[test]
fn untracked_file_means_no_block() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("never_read.py");
fs::write(&f, "x = 1\n").unwrap();
let payload = json!({
"session_id": drip.session_id,
"tool_name": "Edit",
"tool_input": {
"file_path": f.to_string_lossy(),
"old_string": "x = 1\n",
"new_string": "x = 2\n",
}
});
let resp = run_pre_edit(&drip, payload, &[]);
assert_eq!(permission(&resp), "allow");
}
#[test]
fn drip_pre_edit_warn_zero_bypasses_the_guard() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("module.py");
fs::write(&f, long_python_module()).unwrap();
read_with_compression(&drip, &f);
let payload = json!({
"session_id": drip.session_id,
"tool_name": "Edit",
"tool_input": {
"file_path": f.to_string_lossy(),
"old_string": " step_05 = a + b + 5\n step_06 = a + b + 6\n",
"new_string": " step_05 = 'x'\n step_06 = 'x'\n",
}
});
let resp = run_pre_edit(&drip, payload, &[("DRIP_PRE_EDIT_WARN", "0")]);
assert_eq!(
permission(&resp),
"allow",
"DRIP_PRE_EDIT_WARN=0 must bypass the guard: {resp}"
);
}
#[test]
fn drip_disable_bypasses_the_guard() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("module.py");
fs::write(&f, long_python_module()).unwrap();
read_with_compression(&drip, &f);
let payload = json!({
"session_id": drip.session_id,
"tool_name": "Edit",
"tool_input": {
"file_path": f.to_string_lossy(),
"old_string": " step_05 = a + b + 5\n step_06 = a + b + 6\n",
"new_string": " step_05 = 'x'\n step_06 = 'x'\n",
}
});
let resp = run_pre_edit(&drip, payload, &[("DRIP_DISABLE", "1")]);
assert_eq!(permission(&resp), "allow");
}
#[test]
fn unknown_tool_passes_through() {
let drip = Drip::new();
let payload = json!({
"session_id": drip.session_id,
"tool_name": "Bash",
"tool_input": { "command": "ls" }
});
let resp = run_pre_edit(&drip, payload, &[]);
assert_eq!(permission(&resp), "allow");
}
#[test]
fn very_short_old_string_does_not_trigger_spurious_block() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("module.py");
fs::write(&f, long_python_module()).unwrap();
read_with_compression(&drip, &f);
let payload = json!({
"session_id": drip.session_id,
"tool_name": "Edit",
"tool_input": {
"file_path": f.to_string_lossy(),
"old_string": "a",
"new_string": "A",
}
});
let resp = run_pre_edit(&drip, payload, &[]);
assert_eq!(
permission(&resp),
"allow",
"1-char old_string must not trigger blocks: {resp}"
);
}