use std::path::Path;
use serde_json::Value;
use trusty_mpm::core::agent::is_subagent_dispatch_tool;
use trusty_mpm::core::dispatch_isolation::{
dispatch_agent, dispatch_isolation, requires_own_worktree_in_main_checkout,
};
use trusty_mpm::core::project_aliases::is_main_checkout;
use trusty_mpm::project::dispatched_agent_worktree_enabled;
const GRANTED_ISOLATION: &str = "worktree";
const ISOLATION_AWARE_DISPATCH_TOOL: &str = "Agent";
pub(crate) const TASK_DENY_REASON: &str = "Unisolated dispatch denied in a main checkout (ADR-0044): this session is standing in a \
project's main checkout, which is read-only apart from documents and configuration, and \
this dispatch would put an agent that may write files into it. Another session may be \
standing in the same directory — the reported failure is a commit landing on a branch \
belonging to a different workstream, with no error at any step. Dispatch through the \
`Agent` tool with `isolation: \"worktree\"` instead, which gets the agent a tree of its \
own; `Task` carries no isolation parameter, so it cannot be given one here. A read-only \
agent (research, review, analysis) is never blocked by this rule.";
const IN_PLACE_WORKFLOW_NOTE: &str = "\n\n---\nWorktree isolation is OFF for this project: it sets `agent_worktree = false` in \
`.trusty-mpm.toml` (trusty-tools#5814). No worktree has been created for you and none will \
be reclaimed, so work IN PLACE in the current working directory — edit the files where they \
are, commit on the branch the checkout already has, and push. Do not create a worktree, a \
branch, or a pull request unless this project's own instructions ask for one. The \
main-checkout write boundary is unchanged: a source-file edit here is still denied.";
pub(crate) fn evaluate_worktree_grant(
tool_name: &str,
tool_input: Option<&Value>,
cwd: &Path,
) -> Option<WorktreeGrant> {
if !is_subagent_dispatch_tool(tool_name) {
return None;
}
let agent = dispatch_agent(tool_input).unwrap_or_default();
if !requires_own_worktree_in_main_checkout(agent, dispatch_isolation(tool_input)) {
return None;
}
if !is_main_checkout(cwd) {
return None;
}
if !dispatched_agent_worktree_enabled(cwd) {
return with_in_place_notice(tool_input).map(WorktreeGrant::InPlace);
}
if tool_name != ISOLATION_AWARE_DISPATCH_TOOL {
return Some(WorktreeGrant::Deny(TASK_DENY_REASON));
}
Some(WorktreeGrant::Rewrite(with_granted_isolation(tool_input)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WorktreeGrant {
Rewrite(Value),
Deny(&'static str),
InPlace(Value),
}
fn with_granted_isolation(tool_input: Option<&Value>) -> Value {
let mut input = match tool_input {
Some(Value::Object(map)) => Value::Object(map.clone()),
_ => Value::Object(serde_json::Map::new()),
};
input["isolation"] = Value::String(GRANTED_ISOLATION.to_string());
input
}
fn with_in_place_notice(tool_input: Option<&Value>) -> Option<Value> {
let Some(Value::Object(map)) = tool_input else {
return None;
};
let prompt = map.get("prompt").and_then(Value::as_str)?;
if prompt.contains(IN_PLACE_WORKFLOW_NOTE) {
return None;
}
let mut input = Value::Object(map.clone());
input["prompt"] = Value::String(format!("{prompt}{IN_PLACE_WORKFLOW_NOTE}"));
Some(input)
}
pub(crate) fn build_worktree_grant_response(updated_input: &Value) -> String {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": updated_input,
}
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use trusty_mpm::core::dispatch_isolation::ISOLATING_DISPATCH_MODES;
fn main_checkout() -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join(".git")).expect("mkdir .git");
dir
}
fn input(agent: &str, isolation: Option<&str>) -> Value {
match isolation {
Some(i) => serde_json::json!({"subagent_type": agent, "isolation": i}),
None => serde_json::json!({"subagent_type": agent}),
}
}
#[test]
fn granted_mode_is_recognised_as_isolating() {
assert_eq!(ISOLATING_DISPATCH_MODES[0], GRANTED_ISOLATION);
assert!(
trusty_mpm::core::dispatch_isolation::isolation_separates_working_tree(Some(
GRANTED_ISOLATION
))
);
}
#[test]
fn grants_a_worktree_to_an_unisolated_writer() {
let dir = main_checkout();
let grant =
evaluate_worktree_grant("Agent", Some(&input("rust-engineer", None)), dir.path())
.expect("a writer in a main checkout must be granted a worktree");
let Some(WorktreeGrant::Rewrite(updated)) = Some(grant) else {
panic!("expected a rewrite");
};
assert_eq!(updated["isolation"], "worktree");
assert_eq!(updated["subagent_type"], "rust-engineer");
}
#[test]
fn grants_a_worktree_to_an_unknown_agent() {
let dir = main_checkout();
for agent in ["some-project-custom-agent", "Rust-Engineer"] {
assert!(
matches!(
evaluate_worktree_grant("Agent", Some(&input(agent, None)), dir.path()),
Some(WorktreeGrant::Rewrite(_))
),
"{agent} is unknown and must be isolated rather than trusted"
);
}
assert!(matches!(
evaluate_worktree_grant("Agent", Some(&serde_json::json!({})), dir.path()),
Some(WorktreeGrant::Rewrite(_))
));
assert!(matches!(
evaluate_worktree_grant("Agent", None, dir.path()),
Some(WorktreeGrant::Rewrite(_))
));
}
#[test]
fn does_not_grant_a_read_only_agent() {
let dir = main_checkout();
for agent in ["research", "code-critic", "code-analyzer", "ticketing"] {
assert_eq!(
evaluate_worktree_grant("Agent", Some(&input(agent, None)), dir.path()),
None,
"{agent} only reads and must not be moved"
);
}
}
#[test]
fn does_not_grant_an_already_isolated_dispatch() {
let dir = main_checkout();
for mode in ["worktree", "remote"] {
assert_eq!(
evaluate_worktree_grant(
"Agent",
Some(&input("rust-engineer", Some(mode))),
dir.path()
),
None,
"isolation={mode} is already a tree of its own"
);
}
}
#[test]
fn does_not_grant_outside_a_main_checkout() {
let worktree = tempfile::tempdir().expect("tempdir");
std::fs::write(worktree.path().join(".git"), "gitdir: /elsewhere").expect("write .git");
assert_eq!(
evaluate_worktree_grant(
"Agent",
Some(&input("rust-engineer", None)),
worktree.path()
),
None
);
let plain = tempfile::tempdir().expect("tempdir");
assert_eq!(
evaluate_worktree_grant("Agent", Some(&input("rust-engineer", None)), plain.path()),
None
);
}
#[test]
fn does_not_touch_a_non_dispatch_tool() {
let dir = main_checkout();
for tool in ["Read", "Edit", "Write", "Bash", "SendMessage"] {
assert_eq!(
evaluate_worktree_grant(tool, Some(&input("rust-engineer", None)), dir.path()),
None,
"{tool} is not a dispatch"
);
}
}
#[test]
fn task_dispatch_is_denied_rather_than_rewritten() {
let dir = main_checkout();
let grant =
evaluate_worktree_grant("Task", Some(&input("rust-engineer", None)), dir.path())
.expect("a Task writer in a main checkout must be handled");
assert_eq!(grant, WorktreeGrant::Deny(TASK_DENY_REASON));
assert!(TASK_DENY_REASON.contains("Agent"));
assert!(TASK_DENY_REASON.contains(r#"isolation: "worktree""#));
}
#[test]
fn rewrite_preserves_every_field_the_caller_sent() {
let original = serde_json::json!({
"subagent_type": "rust-engineer",
"prompt": "do the thing",
"description": "short label",
"model": "sonnet",
});
let updated = with_granted_isolation(Some(&original));
assert_eq!(updated["prompt"], "do the thing");
assert_eq!(updated["description"], "short label");
assert_eq!(updated["model"], "sonnet");
assert_eq!(updated["subagent_type"], "rust-engineer");
assert_eq!(updated["isolation"], "worktree");
}
fn main_checkout_with_config(body: &str) -> TempDir {
let dir = main_checkout();
std::fs::write(
dir.path()
.join(trusty_mpm::core::project_config::PROJECT_CONFIG_FILE),
body,
)
.expect("write project config");
dir
}
#[test]
fn grants_nothing_when_the_project_opts_out() {
let dir = main_checkout_with_config("agent_worktree = false\n");
let sent = serde_json::json!({"subagent_type": "rust-engineer", "prompt": "do the thing"});
let grant = evaluate_worktree_grant("Agent", Some(&sent), dir.path())
.expect("an opted-out project must still annotate the dispatch");
let WorktreeGrant::InPlace(updated) = grant else {
panic!("an opted-out project must not be granted a worktree");
};
assert!(
updated.get("isolation").is_none(),
"the opt-out must not add isolation: {updated}"
);
assert_eq!(updated["subagent_type"], "rust-engineer");
}
#[test]
fn opt_out_covers_task_which_would_otherwise_be_denied() {
let dir = main_checkout_with_config("agent_worktree = false\n");
let sent = serde_json::json!({"subagent_type": "rust-engineer", "prompt": "go"});
assert!(
matches!(
evaluate_worktree_grant("Task", Some(&sent), dir.path()),
Some(WorktreeGrant::InPlace(_))
),
"the opt-out must reach Task before the deny"
);
}
#[test]
fn grants_a_worktree_when_the_project_says_nothing_or_says_yes() {
for body in [
"default_model = \"opus\"\n",
"agent_worktree = true\n",
"worktree = false\n",
"agent_worktre = false\n",
"agent_worktree = \"false\"\n",
"agent_worktree = false\nnot toml at all(",
] {
let dir = main_checkout_with_config(body);
let sent = serde_json::json!({"subagent_type": "rust-engineer", "prompt": "go"});
let grant = evaluate_worktree_grant("Agent", Some(&sent), dir.path())
.expect("a writer in a main checkout is still granted a worktree");
let WorktreeGrant::Rewrite(updated) = grant else {
panic!("expected the ordinary grant for config: {body}");
};
assert_eq!(updated["isolation"], "worktree", "config was: {body}");
}
}
#[test]
fn in_place_notice_is_appended_to_the_prompt() {
let sent =
serde_json::json!({"subagent_type": "documentation", "prompt": "write the memo"});
let updated = with_in_place_notice(Some(&sent)).expect("a prompted dispatch is annotated");
let prompt = updated["prompt"].as_str().expect("prompt stays a string");
assert!(prompt.starts_with("write the memo"));
assert!(prompt.ends_with(IN_PLACE_WORKFLOW_NOTE));
assert!(prompt.contains("agent_worktree = false"));
assert_eq!(updated["subagent_type"], "documentation");
}
#[test]
fn in_place_notice_is_not_appended_twice() {
let sent = serde_json::json!({"prompt": format!("brief{IN_PLACE_WORKFLOW_NOTE}")});
assert_eq!(with_in_place_notice(Some(&sent)), None);
}
#[test]
fn in_place_leaves_a_promptless_dispatch_alone() {
assert_eq!(with_in_place_notice(None), None);
assert_eq!(
with_in_place_notice(Some(&serde_json::json!({"subagent_type": "documentation"}))),
None
);
assert_eq!(
with_in_place_notice(Some(&serde_json::json!({"prompt": 7}))),
None
);
}
#[test]
fn grant_response_has_the_documented_shape() {
let body = build_worktree_grant_response(&serde_json::json!({"isolation": "worktree"}));
let parsed: Value = serde_json::from_str(&body).expect("valid JSON");
assert_eq!(parsed["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert_eq!(
parsed["hookSpecificOutput"]["updatedInput"]["isolation"],
"worktree"
);
assert!(
parsed["hookSpecificOutput"]
.get("permissionDecision")
.is_none()
);
}
}