blob: 3e258fc1f77af82cd19570bd9e7721c02935c1a0 [file]
//! Hooks the launcher installs in the harness. Two leave handoffs the harness did not
//! write: when context is about to be compacted and when the session ends, a note made
//! from the transcript's tail lands in an outbox in the private home, and the launcher
//! files the notes as handoffs when the run ends. Files, not a client, because the only
//! thing those need to reach is a directory. The third fires before a tool runs: a read
//! of the projection, the memory as files, becomes a `get` on the memory server, so the
//! filter, the label and the funnel apply to a file read as to a tool call; the server
//! is reached over the relay, the one route out of the sandbox.
use serde::Deserialize;
use serde_json::Value;
use std::path::{Path, PathBuf};
type Fallible<T> = Result<T, Box<dyn std::error::Error>>;
/// Where notes wait in the private home.
pub const OUTBOX: &str = ".memory-outbox";
/// What Claude Code passes on standard input. Other harnesses' hooks map onto this.
#[derive(Debug, Deserialize)]
pub struct HookInput {
/// Which event. Antigravity CLI names it in camelCase, as it does every key.
#[serde(alias = "hookEventName", alias = "event")]
pub hook_event_name: String,
/// The harness's own session id; Antigravity's conversation id.
#[serde(default, alias = "conversationId")]
pub session_id: String,
/// The transcript, a JSON Lines file.
#[serde(default, alias = "transcriptPath")]
pub transcript_path: Option<PathBuf>,
/// PreCompact: manual or auto.
#[serde(default)]
pub trigger: Option<String>,
/// SessionEnd: why.
#[serde(default)]
pub reason: Option<String>,
/// PreToolUse: the tool about to run.
#[serde(default)]
pub tool_name: Option<String>,
/// PreToolUse: its input, in the tool's own shape.
#[serde(default)]
pub tool_input: Value,
/// PreToolUse as Antigravity CLI sends it: the tool's name and arguments together.
#[serde(default, alias = "toolCall")]
pub tool_call: Option<Value>,
}
impl HookInput {
/// Read one event. Grok Build writes its camelCase keys and the snake_case
/// aliases side by side, with Claude's PascalCase event name under the snake key
/// only; a key present in both spellings is read from the snake one, since a
/// field and its alias together are a duplicate to the deserializer.
///
/// # Errors
///
/// When the text is not a hook event.
pub fn parse(text: &str) -> Result<HookInput, serde_json::Error> {
let mut v: Value = serde_json::from_str(text)?;
if let Some(map) = v.as_object_mut() {
for (camel, snake) in [
("hookEventName", "hook_event_name"),
("sessionId", "session_id"),
("conversationId", "session_id"),
("transcriptPath", "transcript_path"),
("toolName", "tool_name"),
("toolInput", "tool_input"),
] {
if map.contains_key(snake) {
map.remove(camel);
}
}
}
serde_json::from_value(v)
}
/// The tool about to run and its input, from whichever shape the harness sent.
fn tool(&self) -> (String, Value) {
if let Some(call) = &self.tool_call {
let name = call
.get("name")
.or_else(|| call.get("toolName"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let input = call
.get("args")
.or_else(|| call.get("arguments"))
.or_else(|| call.get("input"))
.cloned()
.unwrap_or(Value::Null);
return (name, input);
}
// Copilot may hand the arguments as a JSON string; the object inside is the input
let input = match &self.tool_input {
Value::String(text) => serde_json::from_str(text).unwrap_or(Value::Null),
other => other.clone(),
};
(self.tool_name.clone().unwrap_or_default(), input)
}
}
/// What the hook tells the harness about a tool call. Nothing said is allowed; a denial
/// carries its reason, which the harness shows the model.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Decision {
/// Not the hook's business, or a read the memory served.
Allow,
/// Refused, and why.
Deny(String),
}
impl Decision {
/// The hook's standard output in the harness's shape. Claude Code, Codex and Qwen
/// Code read a `hookSpecificOutput` and take silence as allowed; Gemini CLI and
/// OpenCode's plugin read a top-level `decision` and take silence as allowed;
/// Copilot CLI reads a top-level `permissionDecision` and takes silence as its
/// default; Antigravity CLI and Grok Build want a `decision` either way, since an
/// empty answer is refused or logged as a broken hook.
#[must_use]
pub fn output(&self, harness: &str) -> Option<String> {
if harness == "copilot" {
return match self {
Decision::Allow => None,
Decision::Deny(reason) => Some(
serde_json::json!({
"permissionDecision": "deny",
"permissionDecisionReason": reason,
})
.to_string(),
),
};
}
if matches!(harness, "antigravity" | "gemini" | "grok" | "opencode") {
return match self {
Decision::Allow if matches!(harness, "gemini" | "opencode") => None,
Decision::Allow => Some(serde_json::json!({ "decision": "allow" }).to_string()),
Decision::Deny(reason) => {
Some(serde_json::json!({ "decision": "deny", "reason": reason }).to_string())
}
};
}
match self {
Decision::Allow => None,
Decision::Deny(reason) => Some(
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
})
.to_string(),
),
}
}
}
/// The name of the event before a tool runs: `PreToolUse` in Claude Code, Codex, Qwen
/// Code, Antigravity CLI, Copilot CLI and OpenCode's plugin, `BeforeTool` in Gemini CLI.
#[must_use]
pub fn pre_tool_event(name: &str) -> bool {
matches!(name, "PreToolUse" | "BeforeTool")
}
/// Whether this harness's session-end event fires at the end of every turn rather than
/// once a session, agy's `Stop` and OpenCode's `session.idle`: its note is one a
/// session, named by the session and rewritten, so the outbox holds the last.
#[must_use]
pub fn per_session_note(harness: &str) -> bool {
matches!(harness, "antigravity" | "opencode")
}
/// The path a tool call is about, for the tools that touch files: `file_path` for a
/// read or an edit, `notebook_path` for a notebook, `path` for a search over a tree,
/// and for a search without one, its pattern when the pattern is a path.
fn path_of(input: &Value) -> Option<PathBuf> {
// Claude Code's names, Gemini CLI's and Qwen Code's (`absolute_path` is what
// `read_file` took before `file_path`), Grok Build's `target_file` and
// `target_directory`, then Antigravity CLI's, which are PascalCase
[
"file_path",
"filePath",
"absolute_path",
"notebook_path",
"target_file",
"path",
"dir_path",
"target_directory",
"pattern",
"AbsolutePath",
"SearchPath",
"DirectoryPath",
"SearchDirectory",
"Pattern",
]
.iter()
.find_map(|k| {
input
.get(*k)
.and_then(Value::as_str)
.filter(|p| !p.is_empty())
})
.map(|p| normalise(Path::new(p)))
}
/// A path with `.` and `..` folded away lexically, so `/memory/../memory/x` is
/// `/memory/x` and `/memory/..` is `/`; a path through a process's root in `/proc`,
/// which is the same tree under another name, is read from the root.
fn normalise(path: &Path) -> PathBuf {
use std::path::Component;
let text = path.to_string_lossy();
let via_proc = text
.strip_prefix("/proc/")
.and_then(|rest| rest.split_once("/root"))
.filter(|(pid, _)| {
*pid == "self" || *pid == "thread-self" || pid.bytes().all(|b| b.is_ascii_digit())
})
.map(|(_, rest)| format!("/{}", rest.trim_start_matches('/')));
let path = via_proc.as_deref().map_or(path, Path::new);
let mut out = PathBuf::new();
for c in path.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
/// A tool that reads one file, under the names the harnesses give it.
fn reads_one(tool: &str) -> bool {
matches!(
tool,
"Read" | "read" | "read_file" | "view" | "view_file" | "NotebookRead" | "read_resource"
)
}
/// A tool that lists files by glob.
fn globs(tool: &str) -> bool {
matches!(tool, "Glob" | "glob" | "find_by_name")
}
/// Whether a glob pattern, or a plain path, can reach into `projection`: it climbs with
/// a `..` component, or its literal prefix, up to the first wildcard, is under the
/// projection or names it or an ancestor of it. Paths are compared by component, so a
/// sibling whose name merely starts with the projection's is not the projection; a
/// prefix cut by a wildcard is compared as text, since `/mem*` does match `/memory`.
fn glob_reaches(pattern: &str, projection: &Path) -> bool {
use std::path::Component;
if Path::new(pattern)
.components()
.any(|c| c == Component::ParentDir)
{
return true;
}
let wild = pattern.find(['*', '?', '[']);
let literal = &pattern[..wild.unwrap_or(pattern.len())];
if !literal.starts_with('/') {
return false;
}
let under = Path::new(literal).starts_with(projection);
let above = match wild {
Some(_) => projection.to_string_lossy().starts_with(literal),
None => projection.starts_with(Path::new(literal)),
};
under || above
}
/// A tool that searches a tree, by path or by glob.
fn searches(tool: &str) -> bool {
matches!(
tool,
"Grep"
| "Glob"
| "grep"
| "glob"
| "search_files"
| "search_file_content"
| "list_directory"
| "grep_search"
| "grep_files"
| "find_by_name"
| "list_dir"
)
}
/// Whether a read of many files at once, Gemini CLI's and Qwen Code's
/// `read_many_files`, reaches into `projection`: any of its paths or globs is under
/// the projection, holds it, or climbs. Such a read is a search over bodies, whatever
/// else it lists.
fn reads_many_into(tool: &str, input: &Value, projection: &Path) -> bool {
tool == "read_many_files"
&& ["paths", "include"].iter().any(|k| {
input.get(*k).and_then(Value::as_array).is_some_and(|list| {
list.iter()
.filter_map(Value::as_str)
.any(|p| glob_reaches(p, projection))
})
})
}
/// Why a tool is refused over the projection as a whole.
fn whole_of(projection: &Path) -> String {
format!(
"{} is the memory's projection: its bodies are read one file at a time, each \
read a `get` the memory serves and counts. Use the memory's `search` tool, or \
open one entry file.",
projection.display()
)
}
/// Decide a tool call against the projection at `projection`. A read of an entry file
/// is served through `get`, which returns the server's refusal in words when it has
/// one; a read of the summaries page is free, as a list is; any other name under the
/// projection is refused, whatever put it there; a search over the projection, or over
/// a directory that holds it, is refused, since a `grep -r` over bodies is a
/// full-corpus read the memory would not have served; so is a write, which the mount
/// refuses anyway. Anything outside the projection is not the hook's.
pub fn decide<F>(tool: &str, input: &Value, projection: &Path, session: &str, get: F) -> Decision
where
F: FnOnce(&str) -> Result<(), String>,
{
let projection = normalise(projection);
let Some(path) = path_of(input) else {
if reads_many_into(tool, input, &projection) {
return Decision::Deny(whole_of(&projection));
}
// a tool the hook watches whose input it cannot read is refused, not waved
// through for want of a path it could not see
if !input.is_object() && (reads_one(tool) || searches(tool) || globs(tool)) {
return Decision::Deny(format!(
"the memory's hook could not read {tool}'s input as an object, so the call \
is refused; a smaller call, or the memory's `search` and `get` tools, will do"
));
}
return Decision::Allow;
};
// a search over an absolute ancestor of the projection covers it; a relative path
// is the working tree's, which does not hold the projection. The paths are the
// box's, rooted at `/` whatever the host: on Windows `/` has a root but is not
// absolute, having no drive
let covers = searches(tool) && path.has_root() && projection.starts_with(&path);
// a glob reaches wherever its pattern says, whatever its path says; a grep's
// pattern is a regex over text, not a place
let by_pattern = globs(tool)
&& input
.get("pattern")
.or_else(|| input.get("Pattern"))
.and_then(Value::as_str)
.is_some_and(|pat| glob_reaches(pat, &projection));
if !path.starts_with(&projection) && !covers && !by_pattern {
return Decision::Allow;
}
let whole = whole_of(&projection);
if !reads_one(tool) {
return Decision::Deny(whole);
}
if path
.file_name()
.is_some_and(|n| n == dm_server::projection::SUMMARIES)
{
return Decision::Allow;
}
// this session's recall page, `recall/<session>/<stamp>.md` directly under the
// projection: what it holds was served to this session when the recall ran;
// another session's pages are not this one's to read
let recall_page = !session.is_empty()
&& path
.parent()
.and_then(Path::file_name)
.is_some_and(|s| s == session)
&& path
.parent()
.and_then(Path::parent)
.is_some_and(|d| d == projection.join(dm_server::projection::RECALLS))
&& path.extension().is_some_and(|e| e == "md");
if recall_page {
return Decision::Allow;
}
match dm_server::projection::id_of(&path) {
Some(id) => match get(id) {
Ok(()) => Decision::Allow,
Err(why) => Decision::Deny(format!(
"the memory refused {id}: {why}. Its summary is in the README beside it; \
the memory's `search` tool finds what you may read."
)),
},
None => Decision::Deny(whole),
}
}
/// The answer to input the hook could not read. Grok Build truncates a payload over its
/// size cap to one JSON string and lets a tool through when the hook says nothing, so
/// there the answer is a refusal, which it honours on any event that takes one; the
/// other harnesses take silence as nothing decided, which refuses nothing but also
/// lets no truncated call through, since they do not truncate.
#[must_use]
pub fn unreadable(harness: &str) -> Option<String> {
(harness == "grok").then(|| {
serde_json::json!({
"decision": "deny",
"reason": "the memory's hook could not read this tool call, so it is refused; \
a smaller call, or the memory's `search` and `get` tools, will do",
})
.to_string()
})
}
/// The transcript to read for a hook event: the file the harness named, unless it is
/// Grok Build's `updates.jsonl`, the ACP log, with the chat history beside it, which is
/// the shape the tail reader knows.
#[must_use]
pub fn transcript_file(path: &Path) -> PathBuf {
if path.file_name().is_some_and(|n| n == "updates.jsonl") {
let history = path.with_file_name("chat_history.jsonl");
if history.is_file() {
return history;
}
}
path.to_path_buf()
}
/// How long the hook waits for the memory server before it refuses: inside the hook's
/// own timeout, so a stalled relay is a refusal and not a read let through.
const WAIT: std::time::Duration = std::time::Duration::from_secs(10);
/// The PreToolUse hook against `projection`, reaching the memory server at `url` and
/// waiting `wait` for it. No projection, nothing to decide. A projection with no
/// server, a server that does not answer in time, or one that refuses are all
/// refusals: the hook fails closed. A `live` projection is a mount the server serves,
/// where the open itself is the get: the hook keeps its refusals of searches and
/// writes and makes no get of its own, so a read is counted once.
async fn pre_tool_with(
input: &HookInput,
projection: Option<&Path>,
session: &str,
url: Option<&str>,
wait: std::time::Duration,
live: bool,
) -> Decision {
let Some(projection) = projection else {
return Decision::Allow;
};
let (tool, tool_input) = input.tool();
// the id to fetch is decided first, then fetched: the decision is pure, the fetch is not
let mut wanted: Option<String> = None;
let d = decide(&tool, &tool_input, projection, session, |id| {
wanted = Some(id.to_string());
Ok(())
});
let Some(id) = wanted else {
return d;
};
if live {
return d;
}
let refused = |why: String| decide(&tool, &tool_input, projection, session, |_| Err(why));
let Some(url) = url else {
return refused(
"the memory server's address is not set, so nothing here can be read".into(),
);
};
let call = crate::mcp_http::call_tool(url, "get", serde_json::json!({ "id": id }));
match tokio::time::timeout(wait, call).await {
Ok(Ok(_)) => Decision::Allow,
Ok(Err(why)) => refused(why),
Err(_) => refused(format!("the memory server did not answer within {wait:?}")),
}
}
/// The PreToolUse hook as the sandbox runs it: the projection and the server's address
/// come from the environment the launcher set. With `MEMORY_SUBAGENTS=board`, a call
/// that would spawn or orchestrate subagents is filed on the task board instead.
async fn pre_tool(input: &HookInput) -> Decision {
let projection = std::env::var_os("MEMORY_PROJECTION").map(PathBuf::from);
let live = std::env::var_os("MEMORY_PROJECTION_LIVE").is_some();
let session = std::env::var("MEMORY_SESSION").unwrap_or_default();
let url = std::env::var("MEMORY_MCP_URL").ok();
if std::env::var("MEMORY_SUBAGENTS").is_ok_and(|v| v == "board") {
let (tool, tool_input) = input.tool();
if crate::board::delegates(&tool) {
return delegate(&tool, &tool_input, &session, url.as_deref(), WAIT).await;
}
}
pre_tool_with(
input,
projection.as_deref(),
&session,
url.as_deref(),
WAIT,
live,
)
.await
}
/// File what a subagent call asks for as tasks, and refuse the call with their ids.
/// A call with nothing readable in it is let through: there is nothing to file. A
/// server that cannot be reached is a refusal that says so, since letting the harness
/// orchestrate after all would be the one thing the mode exists to prevent.
async fn delegate(
tool: &str,
tool_input: &Value,
session: &str,
url: Option<&str>,
wait: std::time::Duration,
) -> Decision {
let units = crate::board::units_of(tool, tool_input);
if units.is_empty() {
// a spawn the hook cannot read, Codex's encrypted message say, is refused all
// the same: the harness files it itself, in the clear, through the server
let parent = std::env::var("MEMORY_TASK").unwrap_or_default();
let how = if parent.is_empty() {
"call the memory server's `create_task` tool with the subagent's prompt as the body"
.to_string()
} else {
format!("call the memory server's `fan_out` tool with parent `{parent}` and one item per subagent, title and prompt in the clear; the board runs them and resumes `{parent}` with their handoffs")
};
return Decision::Deny(format!(
"Subagents are not run from here, and this call's message cannot be read by the board. Instead, {how}. Then finish what you can without their answers and leave a handoff."
));
}
let Some(url) = url else {
return Decision::Deny(
"subagents go to the task board here, and the memory server's address is not set, so nothing can be filed; do this part yourself".into(),
);
};
// under a task, the spawn is a fan-out of that task: children and a continuation
if let Ok(parent) = std::env::var("MEMORY_TASK") {
if !parent.is_empty() {
return fan_out(url, &parent, tool, &units, wait).await;
}
}
let mut ids = Vec::new();
for u in units {
let body = format!(
"{}\n\n---\nFiled from session {session}: the harness asked for a subagent (`{tool}`) and the board runs it instead.",
u.body
);
let call = crate::mcp_http::call_tool(
url,
"create_task",
serde_json::json!({ "title": u.title, "body": body }),
);
match tokio::time::timeout(wait, call).await {
Ok(Ok(v)) => {
// the server answers with JSON in a text block
let text = v
.pointer("/content/0/text")
.and_then(Value::as_str)
.unwrap_or("{}");
let answer: Value = serde_json::from_str(text).unwrap_or(Value::Null);
let id = answer
.pointer("/task/id")
.and_then(Value::as_str)
.unwrap_or("?")
.to_string();
ids.push(id);
}
Ok(Err(why)) => {
return Decision::Deny(format!(
"subagents go to the task board here, and filing failed: {why}; do this part yourself"
))
}
Err(_) => {
return Decision::Deny(format!(
"subagents go to the task board here, and the memory server did not answer within {wait:?}; do this part yourself"
))
}
}
}
Decision::Deny(crate::board::refusal(&ids))
}
/// File the units as the subagent tasks of `parent`, with a continuation that resumes
/// it when they are done, and refuse the call with what was filed.
async fn fan_out(
url: &str,
parent: &str,
tool: &str,
units: &[crate::board::Unit],
wait: std::time::Duration,
) -> Decision {
let items: Vec<Value> = units
.iter()
.map(|u| serde_json::json!({ "title": u.title, "body": u.body }))
.collect();
let call = crate::mcp_http::call_tool(
url,
"fan_out",
serde_json::json!({ "parent": parent, "items": items }),
);
let answer = match tokio::time::timeout(wait, call).await {
Ok(Ok(v)) => {
let text = v
.pointer("/content/0/text")
.and_then(Value::as_str)
.unwrap_or("{}");
serde_json::from_str::<Value>(text).unwrap_or(Value::Null)
}
Ok(Err(why)) => {
return Decision::Deny(format!(
"subagents go to the task board here, and filing failed: {why}; do this part yourself"
))
}
Err(_) => {
return Decision::Deny(format!(
"subagents go to the task board here, and the memory server did not answer within {wait:?}; do this part yourself"
))
}
};
let ids: Vec<String> = answer["children"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|c| c["id"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
// what an earlier run of this chain already asked for: done, with a handoff to
// read, or still open on the board
let mut earlier = Vec::new();
for e in answer["existing"].as_array().into_iter().flatten() {
let id = e["id"].as_str().unwrap_or("?");
match e["handoff"].as_str() {
Some(h) => earlier.push(format!(
"`{id}` was already done; its handoff is `{h}`, read it with the memory server's `get`"
)),
None => earlier.push(format!(
"`{id}` is already on the board ({}); do not ask again",
e["status"].as_str().unwrap_or("open")
)),
}
}
let mut text = String::new();
if !ids.is_empty() {
let continuation = answer["continuation"]["id"].as_str().unwrap_or("?");
text.push_str(&format!(
"{} The board will resume `{parent}` as `{continuation}` once they are done, with their handoffs in its prompt (`{tool}` filed them). ",
crate::board::refusal(&ids)
));
}
if !earlier.is_empty() {
text.push_str("This was asked for before in this chain: ");
text.push_str(&earlier.join("; "));
text.push_str(". ");
}
// held back and not named: the chain holds them in work this session may not read
if let Some(n) = answer["withheld"].as_u64().filter(|n| *n > 0) {
text.push_str(&format!(
"The board withheld {n}: asked for before in this chain, in work this session may not read; do not ask again. "
));
}
text.push_str("Finish what you can from what is already known, then leave a handoff.");
Decision::Deny(text)
}
/// Why a note was left; part of its filename so the launcher can tell them apart.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Event {
/// Context is about to be compacted; what is in it is about to be lost.
PreCompact,
/// The session is ending.
SessionEnd,
}
impl Event {
fn from_name(name: &str) -> Option<Event> {
match name {
// Claude Code and Codex say PreCompact; Gemini CLI says PreCompress
"PreCompact" | "PreCompress" => Some(Event::PreCompact),
// Antigravity CLI says Stop
"SessionEnd" | "Stop" => Some(Event::SessionEnd),
_ => None,
}
}
/// The filename suffix.
#[must_use]
pub fn suffix(self) -> &'static str {
match self {
Event::PreCompact => "precompact",
Event::SessionEnd => "sessionend",
}
}
}
/// One text turn of a transcript.
struct Turn {
role: String,
text: String,
}
fn text_of(content: &Value) -> String {
match content {
Value::String(s) => s.clone(),
Value::Array(blocks) => blocks
.iter()
.filter_map(|b| match b.get("type").and_then(Value::as_str) {
// Gemini's thought parts are text marked as thought, labelled in place
Some("text" | "input_text" | "output_text") | None => {
let text = b.get("text").and_then(Value::as_str)?;
if b.get("thought").and_then(Value::as_bool) == Some(true) {
Some(format!("{REASONING} {}", capped(text)))
} else {
Some(text.to_string())
}
}
Some("tool_use") => b
.get("name")
.and_then(Value::as_str)
.map(|n| format!("[used tool {n}]")),
Some("tool_result") => Some("[tool result]".to_string()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
/// The turns one transcript line holds, in whichever harness's shape it is.
///
/// Claude Code: `{"type":"user"|"assistant","message":{"content":…}}`, one turn a line.
/// Codex: `{"type":"response_item","payload":{"type":"message","role":…,"content":[{"text":…}]}}`.
/// Gemini CLI: a patch log, `{"$set":{"messages":[{"type":"user"|"gemini","content":[{"text":…}]}]}}`.
/// Antigravity CLI: `{"type":"USER_INPUT"|"PLANNER_RESPONSE","content":"…"}`, one a line.
/// Muse Code: the JSONL its headless run prints, records with a `payload_type`; the
/// prompt is `turn.input.user` and the answer is `run.terminal.completed`.
/// pi: `{"type":"message","message":{"role":…,"content":[{"type":"text","text":…}]}}`.
/// A Muse Code record's turn, when it is one: the prompt or the final text.
fn muse_turn(v: &Value) -> Option<(&'static str, String)> {
let payload = v.get("payload")?;
match v.get("payload_type").and_then(Value::as_str)? {
"turn.input.user" => Some(("user", payload.get("prompt")?.as_str()?.to_string())),
"run.terminal.completed" => Some(("assistant", payload.get("text")?.as_str()?.to_string())),
_ => None,
}
}
/// A Codex rollout item's turn: a user or assistant message, or a tool call named in
/// the shape Claude Code's turns name theirs, so the record holds what was done as well
/// as what was said.
fn codex_turn(p: &Value) -> Option<Turn> {
let kept = |role: &str, text: String| {
let text = text.trim().to_string();
(!text.is_empty() && text != "[tool result]").then(|| Turn {
role: role.to_string(),
text,
})
};
match p.get("type").and_then(Value::as_str)? {
"message" => {
let who = p.get("role").and_then(Value::as_str)?;
if who != "user" && who != "assistant" {
return None;
}
kept(who, text_of(p.get("content").unwrap_or(&Value::Null)))
}
"function_call" => {
let name = p.get("name").and_then(Value::as_str).unwrap_or("tool");
let full = match p.get("namespace").and_then(Value::as_str) {
Some(ns) => format!("{ns}__{name}"),
None => name.to_string(),
};
kept("assistant", format!("[used tool {full}]"))
}
_ => None,
}
}
fn turns_in(v: &Value) -> Vec<Turn> {
let keep = |role: &str, text: String| {
let text = text.trim().to_string();
(!text.is_empty() && text != "[tool result]").then(|| Turn {
role: role.to_string(),
text,
})
};
if v.get("payload_type").is_some() {
return muse_turn(v)
.and_then(|(who, text)| keep(who, text))
.into_iter()
.collect();
}
if let Some(role) = v.get("type").and_then(Value::as_str) {
let planner = match role {
"USER_INPUT" => Some("user"),
"PLANNER_RESPONSE" => Some("assistant"),
_ => None,
};
if let Some(who) = planner {
let text = v.get("content").map(text_of).unwrap_or_default();
// agy wraps what the user typed in a request tag
let text = text
.replace("<USER_REQUEST>", "")
.replace("</USER_REQUEST>", "");
return keep(who, text).into_iter().collect();
}
// pi wraps a message, with the role inside it
if role == "message" {
if let Some(m) = v.get("message") {
let who = m.get("role").and_then(Value::as_str).unwrap_or_default();
if who == "user" || who == "assistant" {
return keep(who, text_of(m.get("content").unwrap_or(&Value::Null)))
.into_iter()
.collect();
}
}
return Vec::new();
}
// Claude Code's message has content; Qwen Code's is the Gemini API's, with
// parts; Grok Build's item carries its content at the top
if role == "user" || role == "assistant" {
let content = match v.get("message") {
Some(m) => m.get("content").or_else(|| m.get("parts")),
None => v.get("content"),
};
if let Some(c) = content {
return keep(role, text_of(c)).into_iter().collect();
}
}
if role == "response_item" {
return v.get("payload").and_then(codex_turn).into_iter().collect();
}
}
let mut out = Vec::new();
if let Some(messages) = v
.pointer("/$set/messages")
.or_else(|| v.get("messages"))
.and_then(Value::as_array)
{
for m in messages {
let who = match m.get("type").and_then(Value::as_str) {
Some("user") => "user",
Some("gemini" | "model" | "assistant") => "assistant",
_ => continue,
};
out.extend(keep(who, text_of(m.get("content").unwrap_or(&Value::Null))));
}
}
out
}
/// How reasoning the provider gave is introduced in a turn.
const REASONING: &str = "[reasoning]";
/// What a turn says where the provider kept the reasoning back: Claude's thinking with
/// only a signature, redacted thinking, Codex's encrypted reasoning. A decision with
/// this before it was reasoned about; the record says so instead of showing nothing.
pub const REASONING_WITHHELD: &str = "[reasoning withheld by the provider]";
/// The most of one reasoning block a turn keeps, in characters.
const REASONING_CAP: usize = 4000;
fn capped(text: &str) -> String {
let text = text.trim();
if text.chars().count() <= REASONING_CAP {
return text.to_string();
}
format!("{}…", text.chars().take(REASONING_CAP).collect::<String>())
}
/// The reasoning one block or item carries: its text where the provider gave it, a
/// summary as one, and the withheld marker where there is only a signature.
fn reasoning_text(text: Option<&str>, summarised: bool) -> String {
match text.map(str::trim).filter(|t| !t.is_empty()) {
Some(t) if summarised => format!("{REASONING} (summarised) {}", capped(t)),
Some(t) => format!("{REASONING} {}", capped(t)),
None => REASONING_WITHHELD.to_string(),
}
}
fn joined(parts: Option<&Value>, kind: &str) -> Option<String> {
let text = parts?
.as_array()?
.iter()
.filter(|p| p.get("type").and_then(Value::as_str) == Some(kind))
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n");
(!text.trim().is_empty()).then_some(text)
}
/// The reasoning one transcript line carries, in Claude Code's or Codex's shape. Kept
/// apart from the turns, so a line that is only reasoning adds no turn and a citation's
/// numbering is the same with it as without it.
fn reasoning_in(v: &Value) -> Vec<String> {
match v.get("type").and_then(Value::as_str) {
Some("assistant") => v
.pointer("/message/content")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|b| match b.get("type").and_then(Value::as_str) {
Some("thinking") => Some(reasoning_text(
b.get("thinking").and_then(Value::as_str),
false,
)),
Some("redacted_thinking") => Some(REASONING_WITHHELD.to_string()),
_ => None,
})
.collect(),
Some("response_item") => {
let Some(p) = v
.get("payload")
.filter(|p| p.get("type").and_then(Value::as_str) == Some("reasoning"))
else {
return Vec::new();
};
let raw = joined(p.get("content"), "reasoning_text");
let summary = joined(p.get("summary"), "summary_text");
vec![match (raw, summary) {
(Some(r), _) => reasoning_text(Some(&r), false),
(None, Some(s)) => reasoning_text(Some(&s), true),
(None, None) => REASONING_WITHHELD.to_string(),
}]
}
_ => Vec::new(),
}
}
/// Every user and assistant turn of a transcript, as `(role, text)`, in order: what the
/// record keeps of it, numbered from one, and what a citation's turn number counts.
///
/// Reasoning is folded into the assistant turn it came before, so the record keeps why
/// as well as what, and where the provider withheld it the turn says that.
#[must_use]
pub fn transcript_turns(jsonl: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
let mut pending: Vec<String> = Vec::new();
for v in jsonl
.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
{
pending.extend(reasoning_in(&v));
pending.dedup();
for t in turns_in(&v) {
let text = if t.role == "assistant" && !pending.is_empty() {
let why = std::mem::take(&mut pending).join("\n");
format!("{why}\n{}", t.text)
} else {
t.text
};
out.push((t.role, text));
}
}
if let Some(last) = out.last_mut().filter(|l| l.0 == "assistant") {
if !pending.is_empty() {
last.1 = format!("{}\n{}", last.1, pending.join("\n"));
}
}
out
}
/// The last `turns` user and assistant turns of a transcript, as Markdown, capped at
/// `max_chars`. Tool calls are named, their results elided. Claude Code, Codex and Gemini
/// CLI shapes are all read.
#[cfg(test)]
#[must_use]
pub fn transcript_tail(jsonl: &str, turns: usize, max_chars: usize) -> String {
tail_turns(jsonl, turns, max_chars, false).0
}
/// The tail as [`transcript_tail`] makes it, and the numbers, from one, of the turns any
/// of whose text is in it: the turns a note or a mined entry was drawn from, which it
/// cites. With `numbered`, each turn's heading carries its number.
#[must_use]
pub fn tail_turns(
jsonl: &str,
turns: usize,
max_chars: usize,
numbered: bool,
) -> (String, Vec<usize>) {
let all = transcript_turns(jsonl);
let first = all.len().saturating_sub(turns);
let mut out = String::new();
// where each turn ends in `out`, in characters
let mut ends = Vec::new();
for (i, (role, text)) in all.iter().enumerate().skip(first) {
if numbered {
out.push_str(&format!("**{role}** (turn {})\n\n{text}\n\n", i + 1));
} else {
out.push_str(&format!("**{role}**\n\n{text}\n\n"));
}
ends.push((i + 1, out.chars().count()));
}
let out = out.trim_end().to_string();
let total = out.chars().count();
let skip = total.saturating_sub(max_chars);
let drawn = ends
.into_iter()
.filter(|(_, end)| *end > skip)
.map(|(n, _)| n)
.collect();
if skip == 0 {
return (out, drawn);
}
(
format!("…{}", out.chars().skip(skip).collect::<String>()),
drawn,
)
}
/// The line a note carries naming the turns it was drawn from, for the launcher to cite
/// and take out before filing.
const TURNS_MARK: &str = "<!-- memory:turns ";
/// A note's body with the turns it was drawn from named at its end.
#[must_use]
pub fn with_turns(body: &str, turns: &[usize]) -> String {
if turns.is_empty() {
return body.to_string();
}
format!(
"{}\n\n{TURNS_MARK}{} -->\n",
body.trim_end(),
dm_server::transcripts::ranges(turns)
)
}
/// The turns a note names, and its body without the line that names them.
fn take_turns(body: &str) -> (String, Vec<usize>) {
let mut turns = Vec::new();
let kept: Vec<&str> = body
.lines()
.filter(|l| {
let Some(spec) = l
.strip_prefix(TURNS_MARK)
.and_then(|r| r.strip_suffix(" -->"))
else {
return true;
};
if let Some((_, t)) = dm_server::transcripts::cited(&format!("transcript:x#{spec}")) {
turns.extend(t);
}
false
})
.collect();
(format!("{}\n", kept.join("\n").trim_end()), turns)
}
/// Title and body of the note for an event.
#[must_use]
pub fn note(input: &HookInput, event: Event, tail: &str) -> (String, String) {
let when = match event {
Event::PreCompact => format!(
"before compaction ({})",
input.trigger.as_deref().unwrap_or("auto")
),
Event::SessionEnd => format!(
"at session end ({})",
input.reason.as_deref().unwrap_or("other")
),
};
let title = format!("Session {} {when}", short(&input.session_id));
let transcript = input
.transcript_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(none)".into());
let body =
format!(
"# {title}\n\nLeft by the launcher's hook, not by the agent: the last exchanges of the \
transcript, {when}. Treat as raw. The harness left the transcript at `{transcript}` in \
the session's private home; the launcher keeps it in the record as a source, and this \
note cites the turns below by number.\n\n## Last exchanges\n\n{}\n",
if tail.is_empty() { "(nothing yet)" } else { tail }
);
(title, body)
}
fn short(id: &str) -> &str {
id.get(..8).unwrap_or(id)
}
/// The note's file name in the outbox. A harness whose end fires once a session keeps
/// every note, named by the time; one whose end fires every turn, agy's and OpenCode's,
/// has one note a session, named by the whole session id, since OpenCode's ids share
/// their first characters for minutes at a time, with anything that is not a file name
/// character made one.
#[must_use]
pub fn note_name(harness: &str, session_id: &str, event: Event, stamp: i128) -> String {
if per_session_note(harness) && event == Event::SessionEnd {
let prefix = if harness == "antigravity" {
"agy"
} else {
harness
};
let id: String = session_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
return format!("{prefix}-{id}-{}.md", event.suffix());
}
format!("{stamp}-{}.md", event.suffix())
}
/// Run as the hook: read the event from standard input; before a tool, decide and say
/// so on standard output; otherwise leave a note in the outbox. Exit 0 whatever
/// happens, so a hook never blocks the harness.
///
/// # Errors
///
/// When the input is not a hook event or the outbox cannot be written.
pub fn run(outbox: &Path, harness: &str) -> Fallible<()> {
let mut text = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)?;
if std::env::var_os("MEMORY_HOOK_DEBUG").is_some() {
// what the harness really sends, for an adapter written from its docs
std::fs::create_dir_all(outbox).ok();
if let Ok(mut f) = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(outbox.join("hook-debug.log"))
{
use std::io::Write;
let _ = writeln!(f, "{harness} {}", text.trim());
}
}
let input = match HookInput::parse(&text) {
Ok(input) => input,
Err(e) => {
if let Some(out) = unreadable(harness) {
println!("{out}");
}
return Err(e.into());
}
};
if pre_tool_event(&input.hook_event_name) {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
if let Some(out) = rt.block_on(pre_tool(&input)).output(harness) {
println!("{out}");
}
return Ok(());
}
let Some(event) = Event::from_name(&input.hook_event_name) else {
if harness == "antigravity" {
println!("{{}}");
}
return Ok(());
};
let transcript = input
.transcript_path
.as_deref()
.and_then(|p| std::fs::read_to_string(transcript_file(p)).ok())
.unwrap_or_default();
let (tail, turns) = tail_turns(&transcript, 10, 6000, false);
let (_, body) = note(&input, event, &tail);
let body = with_turns(&body, &turns);
std::fs::create_dir_all(outbox)?;
let stamp = time::OffsetDateTime::now_utc().unix_timestamp_nanos();
let name = note_name(harness, &input.session_id, event, stamp);
let tmp = outbox.join(format!("{name}.tmp"));
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, outbox.join(name))?;
// agy wants a JSON object from every hook; the others take silence
if harness == "antigravity" {
println!("{{}}");
}
Ok(())
}
/// A note waiting in the outbox.
#[derive(Debug)]
pub struct Note {
/// Its file, to remove once filed.
pub path: PathBuf,
/// Why it was left.
pub event: Event,
/// The first heading.
pub title: String,
/// The whole Markdown.
pub body: String,
/// The turns of the transcript it was drawn from, numbered from one.
pub turns: Vec<usize>,
}
/// The notes in an outbox, oldest first.
///
/// # Errors
///
/// When the directory cannot be read.
pub fn notes(outbox: &Path) -> Fallible<Vec<Note>> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(outbox) else {
return Ok(out);
};
for entry in rd {
let path = entry?.path();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if !name.ends_with(".md") {
continue;
}
let event = if name.ends_with("-precompact.md") {
Event::PreCompact
} else if name.ends_with("-sessionend.md") {
Event::SessionEnd
} else {
continue;
};
let (body, turns) = take_turns(&std::fs::read_to_string(&path)?);
let title = body
.lines()
.find_map(|l| l.strip_prefix("# "))
.unwrap_or("Session note")
.to_string();
out.push(Note {
path,
event,
title,
body,
turns,
});
}
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
const SID: &str = "sess-abc12345";
const TRANSCRIPT: &str = r#"{"type":"user","message":{"role":"user","content":"Load T0 and summarise it."}}
{"type":"cost-state","x":1}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Loading T0."},{"type":"tool_use","name":"mcp__memory__t0","input":{}}]}}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"...big..."}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"T0 says: two handoffs, one decision."}]}}
not json
"#;
#[test]
fn reasoning_is_kept_with_the_turn_it_led_to_and_withheld_reasoning_is_said() {
let claude = [
r#"{"type":"user","message":{"role":"user","content":"Which entry?"}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"abc"}]}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"The handoff."}]}}"#,
]
.join("\n");
let turns = transcript_turns(&claude);
assert_eq!(turns.len(), 2, "a line of reasoning alone adds no turn");
assert_eq!(turns[1].1, format!("{REASONING_WITHHELD}\nThe handoff."));
let codex = [
r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Go."}]}}"#,
r#"{"type":"response_item","payload":{"type":"reasoning","summary":[],"content":[{"type":"reasoning_text","text":"T0 names the import; search for it."}]}}"#,
r#"{"type":"response_item","payload":{"type":"function_call","name":"search","namespace":"mcp__memory","arguments":"{}"}}"#,
r#"{"type":"response_item","payload":{"type":"reasoning","summary":[{"type":"summary_text","text":"Found it."}],"encrypted_content":"x"}}"#,
]
.join("\n");
let turns = transcript_turns(&codex);
assert_eq!(turns.len(), 2);
assert_eq!(
turns[1].1,
"[reasoning] T0 names the import; search for it.\n[used tool mcp__memory__search]\n[reasoning] (summarised) Found it."
);
let gemini = r#"{"$set":{"messages":[{"type":"gemini","content":[{"text":"weigh both","thought":true},{"text":"The first."}]}]}}"#;
assert_eq!(
transcript_turns(gemini)[0].1,
"[reasoning] weigh both\nThe first."
);
}
#[test]
fn the_tail_keeps_text_and_names_tools_but_drops_results() {
let t = transcript_tail(TRANSCRIPT, 10, 10_000);
assert!(t.starts_with("**user**\n\nLoad T0"), "{t}");
assert!(t.contains("[used tool mcp__memory__t0]"), "{t}");
let codex = r#"{"type":"response_item","payload":{"type":"function_call","name":"get","namespace":"mcp__memory","arguments":"{}"}}"#;
let turns = transcript_turns(codex);
assert_eq!(
turns,
vec![(
"assistant".to_string(),
"[used tool mcp__memory__get]".to_string()
)],
"Codex's tool calls are turns too"
);
assert!(!t.contains("big"), "tool results are elided: {t}");
assert!(t.ends_with("one decision."), "{t}");
let last = transcript_tail(TRANSCRIPT, 1, 10_000);
assert!(last.starts_with("**assistant**\n\nT0 says"), "{last}");
let capped = transcript_tail(TRANSCRIPT, 10, 20);
assert!(
capped.starts_with('…') && capped.chars().count() <= 21,
"{capped}"
);
assert_eq!(transcript_tail("", 10, 100), "");
}
#[test]
fn pi_s_session_file_is_read() {
let t = concat!(
r#"{"type":"session","version":3,"id":"s1"}"#,
"\n",
r#"{"type":"model_change","provider":"llmao","modelId":"qwen3.8-27b"}"#,
"\n",
r#"{"type":"message","message":{"role":"system","content":"","sections":{}}}"#,
"\n",
r#"{"type":"message","message":{"role":"user","content":[{"type":"text","text":"what is T0?"}]}}"#,
"\n",
r#"{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"The first page."}],"usage":{"input":120,"output":4}}}"#,
"\n",
);
let tail = transcript_tail(t, 10, 10_000);
assert!(
tail.contains("what is T0?") && tail.contains("The first page."),
"{tail}"
);
assert!(!tail.contains("system"), "{tail}");
assert!(reads_one("read"), "pi's read tool");
}
#[test]
fn muse_s_headless_stream_is_read() {
// Muse Code's headless stream: records typed by payload, the prompt in
// turn.input.user and the answer in run.terminal.completed
let muse = concat!(
r#"{"payload_type":"turn.input.user","payload":{"prompt":"what is the plan?"}}"#,
"\n",
r#"{"payload_type":"run.output.delta","payload":{"text":"The"}}"#,
"\n",
r#"{"payload_type":"run.terminal.completed","payload":{"terminal":"completed","text":"The plan is M7."}}"#,
"\n",
);
let t = transcript_tail(muse, 10, 10_000);
assert!(
t.contains("what is the plan?") && t.contains("The plan is M7."),
"{t}"
);
assert!(!t.contains("\"The\""), "deltas are not turns: {t}");
}
#[test]
fn codex_and_gemini_transcripts_are_read_too() {
let codex = concat!(
r#"{"type":"session_meta","payload":{"id":"s"}}"#,
"\n",
r#"{"type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"system stuff"}]}}"#,
"\n",
r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Finish the import."}]}}"#,
"\n",
r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Step 3 done."}]}}"#,
"\n",
);
let t = transcript_tail(codex, 10, 10_000);
assert!(
!t.contains("system stuff"),
"developer messages are not turns: {t}"
);
assert!(
t.contains("**user**\n\nFinish the import.") && t.ends_with("Step 3 done."),
"{t}"
);
let gemini = concat!(
r#"{"sessionId":"g","kind":"main"}"#,
"\n",
r#"{"$set":{"messages":[{"type":"user","content":[{"text":"Hello there."}]},{"type":"gemini","content":[{"text":"General Kenobi."}]}]}}"#,
"\n",
);
let t = transcript_tail(gemini, 10, 10_000);
assert!(
t.starts_with("**user**\n\nHello there.") && t.ends_with("General Kenobi."),
"{t}"
);
// Qwen Code: one record a line, the message in the Gemini API's shape, parts
// rather than content
let qwen = concat!(
r#"{"type":"user","message":{"role":"user","parts":[{"text":"Hello there."}]},"timestamp":"t"}"#,
"\n",
r#"{"type":"tool_result","message":{"role":"user","parts":[{"functionResponse":{"name":"read_file"}}]}}"#,
"\n",
r#"{"type":"assistant","message":{"role":"model","parts":[{"text":"General Kenobi."}]}}"#,
"\n",
);
let t = transcript_tail(qwen, 10, 10_000);
assert!(
t.starts_with("**user**\n\nHello there.") && t.ends_with("General Kenobi."),
"{t}"
);
// Grok Build's chat history: typed items with the content at the top, parts
// for the user and a string for the assistant
let grok = concat!(
r#"{"type":"system","content":"be brief"}"#,
"\n",
r#"{"type":"user","content":[{"type":"text","text":"Hello there."}]}"#,
"\n",
r#"{"type":"assistant","content":"General Kenobi.","tool_calls":[]}"#,
"\n",
);
let t = transcript_tail(grok, 10, 10_000);
assert!(
t.starts_with("**user**\n\nHello there.") && t.ends_with("General Kenobi."),
"{t}"
);
assert!(!t.contains("be brief"), "a system item is not a turn: {t}");
assert_eq!(Event::from_name("PreCompress"), Some(Event::PreCompact));
assert_eq!(Event::from_name("Nope"), None);
let agy = concat!(
r#"{"type":"USER_INPUT","source":"USER_EXPLICIT","content":"<USER_REQUEST>Load T0.</USER_REQUEST>"}"#,
"\n",
r#"{"type":"PLANNER_RESPONSE","source":"MODEL","content":"T0 says: one handoff."}"#,
"\n",
r#"{"type":"TOOL_CALL","content":"ignored"}"#,
);
let t = transcript_tail(agy, 10, 10_000);
assert_eq!(
t,
"**user**\n\nLoad T0.\n\n**assistant**\n\nT0 says: one handoff."
);
}
#[test]
fn a_note_says_why_and_where() {
let input = HookInput {
hook_event_name: "PreCompact".into(),
session_id: "abcdef12-3456".into(),
transcript_path: Some("/home/user/.claude/projects/p/s.jsonl".into()),
trigger: Some("auto".into()),
reason: None,
tool_name: None,
tool_input: Value::Null,
tool_call: None,
};
let (title, body) = note(&input, Event::PreCompact, "**user**\n\nhi");
assert_eq!(title, "Session abcdef12 before compaction (auto)");
assert!(body.starts_with(&format!("# {title}\n")));
assert!(body.contains("/home/user/.claude/projects/p/s.jsonl"));
assert!(body.contains("## Last exchanges\n\n**user**\n\nhi"));
let (title, body) = note(
&HookInput {
hook_event_name: "SessionEnd".into(),
session_id: "s".into(),
transcript_path: None,
trigger: None,
reason: Some("other".into()),
tool_name: None,
tool_input: Value::Null,
tool_call: None,
},
Event::SessionEnd,
"",
);
assert_eq!(title, "Session s at session end (other)");
assert!(body.contains("(nothing yet)"));
}
#[test]
fn notes_are_read_oldest_first_and_typed_by_name() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("2-sessionend.md"), "# End\n\nbye\n").unwrap();
std::fs::write(dir.path().join("1-precompact.md"), "# Compact\n\nfull\n").unwrap();
std::fs::write(dir.path().join("3-other.md"), "# Not ours\n").unwrap();
std::fs::write(dir.path().join("x.tmp"), "").unwrap();
let n = notes(dir.path()).unwrap();
assert_eq!(n.len(), 2);
assert_eq!(n[0].event, Event::PreCompact);
assert_eq!(n[0].title, "Compact");
assert_eq!(n[1].event, Event::SessionEnd);
assert!(notes(&dir.path().join("missing")).unwrap().is_empty());
assert!(n[0].turns.is_empty());
}
#[test]
fn a_note_names_the_turns_it_was_drawn_from_and_the_launcher_takes_the_line_out() {
// five text turns; the tool result is not one
let (tail, turns) = tail_turns(TRANSCRIPT, 10, 10_000, false);
assert_eq!(tail, transcript_tail(TRANSCRIPT, 10, 10_000));
assert_eq!(turns, vec![1, 2, 3]);
assert_eq!(transcript_turns(TRANSCRIPT).len(), 3);
assert_eq!(tail_turns(TRANSCRIPT, 2, 10_000, false).1, vec![2, 3]);
// a cap that reaches only into the last turn draws from the last turn only
assert_eq!(tail_turns(TRANSCRIPT, 10, 20, false).1, vec![3]);
let (numbered, _) = tail_turns(TRANSCRIPT, 10, 10_000, true);
assert!(numbered.contains("**assistant** (turn 3)"), "{numbered}");
let dir = tempfile::tempdir().unwrap();
let body = with_turns("# Session s at session end (other)\n\nbody\n", &[2, 3]);
assert!(body.contains("<!-- memory:turns 2-3 -->"), "{body}");
std::fs::write(dir.path().join("1-sessionend.md"), &body).unwrap();
let n = notes(dir.path()).unwrap();
assert_eq!(n[0].turns, vec![2, 3]);
assert!(!n[0].body.contains("memory:turns"), "{}", n[0].body);
assert!(n[0].body.ends_with("body\n"));
assert_eq!(with_turns("x\n", &[]), "x\n");
}
fn read(p: &str) -> Value {
serde_json::json!({ "file_path": p })
}
#[test]
fn a_read_of_an_entry_file_is_a_get_and_the_summaries_page_is_free() {
let view = Path::new("/memory");
let mut asked = Vec::new();
let d = decide(
"Read",
&read("/memory/dom-me/mem-me-abc1.md"),
view,
SID,
|id| {
asked.push(id.to_string());
Ok(())
},
);
assert_eq!(
(d, asked),
(Decision::Allow, vec!["mem-me-abc1".to_string()])
);
let d = decide(
"Read",
&read("/memory/dom-me/mem-me-abc1.md"),
view,
SID,
|_| Err("not found: mem-me-abc1".into()),
);
match d {
Decision::Deny(why) => assert!(why.contains("not found") && why.contains("search")),
Decision::Allow => panic!("a refusal is a denial"),
}
assert_eq!(
decide(
"Read",
&read("/memory/dom-me/README.md"),
view,
SID,
|_| panic!("free")
),
Decision::Allow
);
// a path folded through `..` is the entry's, and is fetched
let mut folded = Vec::new();
let d = decide(
"Read",
&read("/memory/../memory/dom-me/mem-me-abc1.md"),
view,
SID,
|id| {
folded.push(id.to_string());
Ok(())
},
);
assert_eq!(
(d, folded),
(Decision::Allow, vec!["mem-me-abc1".to_string()])
);
assert!(matches!(
decide(
"NotebookRead",
&serde_json::json!({ "notebook_path": "/memory/dom-me/mem-me-abc1.md" }),
view,
SID,
|_| Err("not found".into())
),
Decision::Deny(_)
));
let out = Decision::Deny("why".into()).output("claude").unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert_eq!(Decision::Allow.output("claude"), None);
let input: HookInput = serde_json::from_str(
r#"{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/memory/a/mem-a-1.md"}}"#,
)
.unwrap();
assert_eq!(input.tool_name.as_deref(), Some("Read"));
}
#[test]
fn gemini_s_before_tool_is_a_pre_tool_event_answered_with_a_decision() {
// Gemini CLI names the event BeforeTool and reads a top-level decision
assert!(pre_tool_event("BeforeTool") && pre_tool_event("PreToolUse"));
assert!(!pre_tool_event("AfterTool"));
let v: Value =
serde_json::from_str(&Decision::Deny("why".into()).output("gemini").unwrap()).unwrap();
assert_eq!(
(v["decision"].as_str(), v["reason"].as_str()),
(Some("deny"), Some("why"))
);
assert_eq!(Decision::Allow.output("gemini"), None, "silence is allowed");
// Codex and Qwen Code read Claude Code's shape
for h in ["codex", "qwen"] {
let out = Decision::Deny("why".into()).output(h).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny", "{h}");
}
}
// the hook runs inside the Linux sandbox; these are unix paths
#[cfg(unix)]
#[test]
fn the_file_tools_of_gemini_qwen_and_codex_are_known_by_their_own_argument_names() {
let view = Path::new("/memory");
// Gemini's read_file took absolute_path before file_path; both are the file
let mut asked = Vec::new();
let d = decide(
"read_file",
&serde_json::json!({ "absolute_path": "/memory/dom-me/mem-me-abc1.md" }),
view,
SID,
|id| {
asked.push(id.to_string());
Ok(())
},
);
assert_eq!(
(d, asked),
(Decision::Allow, vec!["mem-me-abc1".to_string()])
);
// list_directory names its directory dir_path
assert!(matches!(
decide(
"list_directory",
&serde_json::json!({ "dir_path": "/memory/dom-me" }),
view,
SID,
|_| panic!("a list is not a get")
),
Decision::Deny(_)
));
// Codex's grep_files and Gemini's older search_file_content over a directory
// that holds the projection are searches over bodies
for tool in ["grep_files", "search_file_content"] {
assert!(
matches!(
decide(
tool,
&serde_json::json!({ "pattern": "x", "path": "/" }),
view,
SID,
|_| panic!("a search is not a get")
),
Decision::Deny(_)
),
"{tool}"
);
}
}
#[test]
fn a_read_of_many_files_is_a_search_when_any_of_them_reaches_the_projection() {
let view = Path::new("/memory");
// read_many_files is a read of many files: refused when any is the projection's
// or holds it, free when none is
let many = |paths: &[&str]| {
decide(
"read_many_files",
&serde_json::json!({ "paths": paths }),
view,
SID,
|_| panic!("many is not one"),
)
};
assert!(matches!(
many(&["/work/a.md", "/memory/dom-me/mem-me-abc1.md"]),
Decision::Deny(_)
));
assert!(matches!(many(&["/"]), Decision::Deny(_)));
assert_eq!(many(&["/work/a.md", "/work/b.md"]), Decision::Allow);
assert_eq!(many(&[]), Decision::Allow);
// a sibling whose name merely starts with the projection's is not the projection,
// and two dots inside a name are not a climb
assert_eq!(
many(&["/memory-notes/*.md", "/work/a..b.md"]),
Decision::Allow
);
// `include` is the spelling Gemini CLI's read_many_files sends today
assert!(matches!(
decide(
"read_many_files",
&serde_json::json!({ "include": ["/memory/**/*.md"] }),
view,
SID,
|_| panic!("many is not one")
),
Decision::Deny(_)
));
}
// the hook runs inside the Linux sandbox; these are unix paths
#[cfg(unix)]
#[test]
fn grok_s_hook_sends_both_spellings_at_once_and_wants_a_decision() {
// Grok Build writes its camelCase keys and the snake_case aliases side by side,
// with Claude's PascalCase event name under the snake key only
let text = r#"{"hookEventName":"pre_tool_use","hook_event_name":"PreToolUse","sessionId":"s1","session_id":"s1","cwd":"/work","workspaceRoot":"/work","timestamp":"t","toolName":"read_file","tool_name":"read_file","toolUseId":"u1","toolInput":{"target_file":"/memory/dom-me/mem-me-abc1.md"},"tool_input":{"target_file":"/memory/dom-me/mem-me-abc1.md"},"toolInputTruncated":false}"#;
let input = HookInput::parse(text).unwrap();
assert_eq!(input.hook_event_name, "PreToolUse");
assert_eq!(input.session_id, "s1");
let (tool, args) = input.tool();
assert_eq!(tool, "read_file");
let mut asked = Vec::new();
let d = decide("read_file", &args, Path::new("/memory"), SID, |id| {
asked.push(id.to_string());
Ok(())
});
assert_eq!(
(d, asked),
(Decision::Allow, vec!["mem-me-abc1".to_string()])
);
// its own argument names for a list and a search
assert!(matches!(
decide(
"list_dir",
&serde_json::json!({ "target_directory": "/memory/dom-me" }),
Path::new("/memory"),
SID,
|_| panic!("a list is not a get")
),
Decision::Deny(_)
));
assert!(matches!(
decide(
"grep",
&serde_json::json!({ "pattern": "x", "path": "/", "glob": "*.md" }),
Path::new("/memory"),
SID,
|_| panic!("a search is not a get")
),
Decision::Deny(_)
));
// a decision either way, as agy wants
let v: Value = serde_json::from_str(&Decision::Allow.output("grok").unwrap()).unwrap();
assert_eq!(v["decision"], "allow");
let v: Value =
serde_json::from_str(&Decision::Deny("why".into()).output("grok").unwrap()).unwrap();
assert_eq!(
(v["decision"].as_str(), v["reason"].as_str()),
(Some("deny"), Some("why"))
);
// the other shapes still parse through the same door
assert_eq!(
HookInput::parse(r#"{"hook_event_name":"SessionEnd","session_id":"a"}"#)
.unwrap()
.hook_event_name,
"SessionEnd"
);
assert_eq!(
HookInput::parse(r#"{"hookEventName":"Stop","conversationId":"c"}"#)
.unwrap()
.session_id,
"c"
);
}
#[test]
fn grok_s_unreadable_input_is_refused_and_its_history_is_read_beside_the_log() {
// Grok truncates a payload over its size cap to one JSON string and lets a
// tool through when the hook says nothing; the answer to input that cannot be
// read is a refusal there, and silence where silence already refuses
let v: Value = serde_json::from_str(&unreadable("grok").unwrap()).unwrap();
assert_eq!(v["decision"], "deny");
assert!(v["reason"].as_str().is_some_and(|r| !r.is_empty()));
assert_eq!(unreadable("claude"), None);
assert_eq!(unreadable("gemini"), None);
// the hook is handed updates.jsonl, the ACP log; the chat history beside it is
// the transcript the tail reader knows
let dir = tempfile::tempdir().unwrap();
let updates = dir.path().join("updates.jsonl");
std::fs::write(&updates, "{}").unwrap();
assert_eq!(transcript_file(&updates), updates, "nothing beside it yet");
let history = dir.path().join("chat_history.jsonl");
std::fs::write(&history, "{}").unwrap();
assert_eq!(transcript_file(&updates), history);
let other = dir.path().join("t.jsonl");
std::fs::write(&other, "{}").unwrap();
assert_eq!(
transcript_file(&other),
other,
"only the ACP log has a twin"
);
}
// the hook runs inside the Linux sandbox; these are unix paths
#[cfg(unix)]
#[test]
fn copilot_and_opencode_are_answered_in_their_own_shapes() {
// Copilot CLI, configured with the PascalCase events, sends Claude's payload and
// reads a top-level permission decision; silence is the default behaviour
let v: Value =
serde_json::from_str(&Decision::Deny("why".into()).output("copilot").unwrap()).unwrap();
assert_eq!(
(
v["permissionDecision"].as_str(),
v["permissionDecisionReason"].as_str()
),
(Some("deny"), Some("why"))
);
assert_eq!(Decision::Allow.output("copilot"), None);
// OpenCode's plugin reads a decision and throws the reason to refuse the call
let v: Value =
serde_json::from_str(&Decision::Deny("why".into()).output("opencode").unwrap())
.unwrap();
assert_eq!(
(v["decision"].as_str(), v["reason"].as_str()),
(Some("deny"), Some("why"))
);
assert_eq!(Decision::Allow.output("opencode"), None);
// Copilot may hand the arguments as a JSON string; the hook reads it as the object
let input = HookInput::parse(
r#"{"hook_event_name":"PreToolUse","session_id":"s","tool_name":"Read","tool_input":"{\"path\":\"/memory/dom-me/mem-me-abc1.md\"}"}"#,
)
.unwrap();
let (_, args) = input.tool();
assert_eq!(args["path"], "/memory/dom-me/mem-me-abc1.md");
}
#[test]
fn opencode_s_tools_are_known_and_an_unreadable_input_is_refused() {
// OpenCode's read takes filePath; a directory read is its listing
let view = Path::new("/memory");
let mut asked = Vec::new();
let d = decide(
"read",
&serde_json::json!({ "filePath": "/memory/dom-me/mem-me-abc1.md" }),
view,
SID,
|id| {
asked.push(id.to_string());
Ok(())
},
);
assert_eq!(
(d, asked),
(Decision::Allow, vec!["mem-me-abc1".to_string()])
);
assert!(matches!(
decide(
"read",
&serde_json::json!({ "filePath": "/memory/dom-me" }),
view,
SID,
|_| panic!("a directory is not a get")
),
Decision::Deny(_)
));
assert!(matches!(
decide(
"glob",
&serde_json::json!({ "pattern": "/memory/**/*.md" }),
view,
SID,
|_| panic!("a glob is not a get")
),
Decision::Deny(_)
));
// a file tool whose input the hook cannot read as an object is refused, not
// waved through for want of a path
assert!(matches!(
decide(
"Read",
&serde_json::json!("not an object"),
view,
SID,
|_| panic!()
),
Decision::Deny(_)
));
assert_eq!(
decide("Bash", &serde_json::json!("ls"), view, SID, |_| panic!()),
Decision::Allow,
"a tool the hook does not watch is not its business"
);
}
#[test]
fn a_note_is_one_a_session_where_the_end_fires_every_turn() {
// OpenCode's idle event fires at the end of every turn, as agy's Stop does
assert!(per_session_note("opencode") && per_session_note("antigravity"));
assert!(!per_session_note("claude") && !per_session_note("copilot"));
// one note a session means one per session id, not per its first characters:
// OpenCode's ids share their first eight for minutes at a time
let a = note_name(
"opencode",
"ses_7f3a1b2c3d4e5fABCDEFGHIJKL",
Event::SessionEnd,
1,
);
let b = note_name(
"opencode",
"ses_7f3a1b2c3d4e5fMNOPQRSTUVWX",
Event::SessionEnd,
2,
);
assert_ne!(a, b);
assert!(
a.ends_with("-sessionend.md") && a.starts_with("opencode-"),
"{a}"
);
assert_eq!(
note_name("opencode", "ses_x", Event::SessionEnd, 3),
note_name("opencode", "ses_x", Event::SessionEnd, 4),
"the same session rewrites its note"
);
assert_ne!(
note_name("claude", "s", Event::SessionEnd, 3),
note_name("claude", "s", Event::SessionEnd, 4),
"a harness whose end fires once keeps every note"
);
assert!(!note_name("opencode", "../x/y", Event::SessionEnd, 1).contains('/'));
}
#[test]
fn antigravity_s_hook_shape_is_read_and_answered() {
// agy wants a decision either way: an empty answer is refused as invalid
let v: Value =
serde_json::from_str(&Decision::Allow.output("antigravity").unwrap()).unwrap();
assert_eq!(v["decision"], "allow");
let v: Value =
serde_json::from_str(&Decision::Deny("why".into()).output("antigravity").unwrap())
.unwrap();
assert_eq!(
(v["decision"].as_str(), v["reason"].as_str()),
(Some("deny"), Some("why"))
);
// agy's shape of the same event
let agy: HookInput = serde_json::from_str(
r#"{"hookEventName":"PreToolUse","conversationId":"c1","workspacePaths":["/work"],"transcriptPath":"/home/user/t.jsonl","toolCall":{"name":"view_file","args":{"AbsolutePath":"/memory/dom-me/mem-me-abc1.md","StartLine":1}},"stepIdx":3}"#,
)
.unwrap();
assert_eq!(agy.hook_event_name, "PreToolUse");
assert_eq!(agy.session_id, "c1");
let (tool, input) = agy.tool();
assert_eq!(tool, "view_file");
assert!(reads_one(&tool));
// agy's own argument names, PascalCase: a read is a get, a search is refused
let view = Path::new("/memory");
let mut fetched = Vec::new();
let d = decide(&tool, &input, view, SID, |id| {
fetched.push(id.to_string());
Ok(())
});
assert_eq!(
(d, fetched),
(Decision::Allow, vec!["mem-me-abc1".to_string()])
);
for (tool, input) in [
(
"grep_search",
serde_json::json!({ "SearchPath": "/memory", "Query": "key" }),
),
(
"list_dir",
serde_json::json!({ "DirectoryPath": "/memory/dom-me" }),
),
(
"find_by_name",
serde_json::json!({ "SearchDirectory": "/work", "Pattern": "/memory/**" }),
),
(
"find_by_name",
serde_json::json!({ "SearchDirectory": "/", "Pattern": "*.md" }),
),
] {
assert!(
matches!(
decide(tool, &input, view, SID, |_| Ok(())),
Decision::Deny(_)
),
"{tool} {input}"
);
}
assert_eq!(
decide(
"grep_search",
&serde_json::json!({ "SearchPath": "/work", "Query": "x" }),
view,
SID,
|_| Ok(())
),
Decision::Allow
);
assert_eq!(Event::from_name("Stop"), Some(Event::SessionEnd));
}
#[test]
fn a_recall_page_is_free_by_place_not_by_name() {
let view = Path::new("/memory");
assert_eq!(
decide(
"Read",
&read("/memory/recall/sess-abc12345/20261004T101010-000000001.md"),
view,
SID,
|_| panic!("free: served when the recall ran")
),
Decision::Allow
);
// by place, not by name: a file beside the pages, a `recall` directory inside a
// domain, and an entry file in a domain named `recall` are not pages
for p in [
"/memory/recall/sess-abc12345/notes.txt",
"/memory/recall/20261004T101010-000000001.md",
"/memory/recall/sess-other999/20261004T101010-000000001.md",
"/memory/dom-me/recall/sess-abc12345/20261004T101010-000000001.md",
] {
assert!(
matches!(
decide("Read", &read(p), view, SID, |_| Ok(())),
Decision::Deny(_)
),
"{p}"
);
}
let mut fetched = Vec::new();
decide(
"Read",
&read("/memory/recall/mem-recall-abc1.md"),
view,
SID,
|id| {
fetched.push(id.to_string());
Ok(())
},
);
assert_eq!(
fetched,
vec!["mem-recall-abc1".to_string()],
"an entry file under a domain called recall is still a get"
);
}
#[test]
fn under_the_projection_only_an_entry_file_opens_and_outside_it_nothing_is_decided() {
let view = Path::new("/memory");
// a stray name, a leftover sibling, a path folded out and back in to a stray name
for p in [
"/memory/dom-me/planted.md",
"/memory/dom-me/mem-me-abc1.md.tmp",
"/memory/../etc/passwd/../../memory/dom-me/x",
] {
assert!(
matches!(
decide("Read", &read(p), view, SID, |_| Ok(())),
Decision::Deny(_)
),
"{p}"
);
}
for p in [
"/work/src/main.rs",
"/memory-else/x.md",
"/home/user/.memory-view/x.md",
] {
assert_eq!(
decide("Read", &read(p), view, SID, |_| panic!("not ours")),
Decision::Allow,
"{p}"
);
}
for tool in ["Edit", "Write", "MultiEdit"] {
assert!(matches!(
decide(
tool,
&read("/memory/dom-me/mem-me-abc1.md"),
view,
SID,
|_| Ok(())
),
Decision::Deny(_)
));
}
assert_eq!(
decide(
"Bash",
&serde_json::json!({ "command": "cat /memory/x" }),
view,
SID,
|_| Ok(())
),
Decision::Allow,
"a shell is not observed here; the plan says so"
);
}
// the hook runs inside the Linux sandbox; these are unix paths
#[cfg(unix)]
#[test]
fn a_search_over_the_projection_or_over_what_holds_it_is_refused() {
let view = Path::new("/memory");
for input in [
serde_json::json!({ "pattern": "key", "path": "/memory/dom-me" }),
serde_json::json!({ "pattern": "key", "path": "/" }),
serde_json::json!({ "pattern": "key", "path": "/memory/.." }),
serde_json::json!({ "pattern": "/memory/**/*.md" }),
] {
for tool in ["Grep", "Glob"] {
assert!(
matches!(
decide(tool, &input, view, SID, |_| Ok(())),
Decision::Deny(_)
),
"{tool} {input}"
);
}
}
}
// the hook runs inside the Linux sandbox; these are unix paths
#[cfg(unix)]
#[test]
fn a_glob_s_pattern_is_a_place_and_a_grep_s_is_text() {
let view = Path::new("/memory");
// what the second pass got through: a pattern reaching the projection beside a
// path that does not
for input in [
serde_json::json!({ "pattern": "/memory/**/*.md", "path": "/work" }),
serde_json::json!({ "pattern": "../memory/**/*.md", "path": "/work" }),
serde_json::json!({ "pattern": "/mem*/**/*.md", "path": "/work" }),
serde_json::json!({ "pattern": "/memory/**/*.md", "path": "" }),
] {
assert!(
matches!(
decide("Glob", &input, view, SID, |_| Ok(())),
Decision::Deny(_)
),
"{input}"
);
}
// a grep's pattern is a regex over text: the projection's name in it is a word
for input in [
serde_json::json!({ "pattern": "/memory", "path": "/work" }),
serde_json::json!({ "pattern": "INSIDE_PROJECTION = \"/memory\"", "path": "/work" }),
] {
assert_eq!(
decide("Grep", &input, view, SID, |_| Ok(())),
Decision::Allow,
"{input}"
);
}
assert_eq!(
decide(
"Glob",
&serde_json::json!({ "pattern": "**/memory/**", "path": "/work" }),
view,
SID,
|_| Ok(())
),
Decision::Allow,
"a relative glob in the working tree"
);
assert_eq!(
decide(
"Glob",
&serde_json::json!({ "pattern": "/memory-notes/*.md", "path": "/work" }),
view,
SID,
|_| Ok(())
),
Decision::Allow,
"a sibling whose name starts with the projection's"
);
// the same tree under another name
let mut via_proc = Vec::new();
let d = decide(
"Read",
&read("/proc/self/root/memory/dom-me/mem-me-abc1.md"),
view,
SID,
|id| {
via_proc.push(id.to_string());
Ok(())
},
);
assert_eq!(
(d, via_proc),
(Decision::Allow, vec!["mem-me-abc1".to_string()])
);
assert!(matches!(
decide(
"Read",
&read("/proc/1/root/memory/dom-me/stray.md"),
view,
SID,
|_| Ok(())
),
Decision::Deny(_)
));
for input in [
serde_json::json!({ "pattern": "key", "path": "/work" }),
serde_json::json!({ "pattern": "key" }),
serde_json::json!({ "pattern": "**/*.md", "path": "/memor" }),
// Claude Code's own spelling of the working tree, and its parent
serde_json::json!({ "pattern": "key", "path": "." }),
serde_json::json!({ "pattern": "key", "path": ".." }),
serde_json::json!({ "pattern": "key", "path": "./src" }),
] {
assert_eq!(
decide("Grep", &input, view, SID, |_| Ok(())),
Decision::Allow,
"beside the projection, or in the working tree: {input}"
);
}
}
fn read_input(p: &str) -> HookInput {
serde_json::from_str(&format!(
r#"{{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{{"file_path":"{p}"}}}}"#
))
.unwrap()
}
#[tokio::test]
async fn the_hook_fails_closed_without_a_server_or_without_an_answer() {
let view = Path::new("/memory");
let entry = read_input("/memory/dom-me/mem-me-abc1.md");
let wait = std::time::Duration::from_millis(200);
assert_eq!(
pre_tool_with(&entry, None, SID, None, wait, false).await,
Decision::Allow,
"no projection, nothing to decide"
);
assert!(
matches!(
pre_tool_with(&entry, Some(view), SID, None, wait, false).await,
Decision::Deny(_)
),
"a projection with no server address is refused, not let through"
);
assert_eq!(
pre_tool_with(
&read_input("/work/src/main.rs"),
Some(view),
SID,
None,
wait,
false
)
.await,
Decision::Allow,
"outside the projection nothing is decided, server or not"
);
// a server that accepts and never answers
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/mcp", listener.local_addr().unwrap());
let hold = tokio::spawn(async move {
let mut kept = Vec::new();
loop {
if let Ok((s, _)) = listener.accept().await {
kept.push(s);
}
}
});
let started = std::time::Instant::now();
let d = pre_tool_with(&entry, Some(view), SID, Some(&url), wait, false).await;
hold.abort();
assert!(
matches!(&d, Decision::Deny(why) if why.contains("did not answer")),
"{d:?}"
);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
}
#[tokio::test]
async fn over_a_live_mount_the_hook_makes_no_get_and_keeps_its_refusals() {
let view = Path::new("/memory");
let wait = std::time::Duration::from_millis(200);
// no server address: over a render that is a refusal, over a mount the open is
// the get and the read goes through
let entry = read_input("/memory/dom-me/mem-me-abc1.md");
assert_eq!(
pre_tool_with(&entry, Some(view), SID, None, wait, true).await,
Decision::Allow
);
let grep: HookInput = serde_json::from_str(
r#"{"hook_event_name":"PreToolUse","tool_name":"Grep","tool_input":{"pattern":"key","path":"/memory"}}"#,
)
.unwrap();
assert!(
matches!(
pre_tool_with(&grep, Some(view), SID, None, wait, true).await,
Decision::Deny(_)
),
"a search over the whole projection is refused, mount or render"
);
let stray = read_input("/memory/dom-me/notes.txt");
assert!(
matches!(
pre_tool_with(&stray, Some(view), SID, None, wait, true).await,
Decision::Deny(_)
),
"a name that is not an entry's is refused, mount or render"
);
}
}