use std::path::Path;
use serde_json::Value;
use trusty_mpm::core::agent::is_subagent_dispatch_tool;
use trusty_mpm::core::content_source::AgentRoster;
use trusty_mpm::core::delegation_authority::deployed_agent_dirs;
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;
use crate::commands::pm_guard_dispatch_type::{DeployedTiers, undetermined_type_refusal};
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(
roster: &AgentRoster,
tool_name: &str,
tool_input: Option<&Value>,
cwd: &Path,
) -> Option<WorktreeGrant> {
evaluate_worktree_grant_with(roster, tool_name, tool_input, cwd, &deployed_agent_dirs)
}
pub(crate) fn evaluate_worktree_grant_with(
roster: &AgentRoster,
tool_name: &str,
tool_input: Option<&Value>,
cwd: &Path,
deployed: DeployedTiers<'_>,
) -> 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(Some(roster), 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);
}
let accepts_isolation = tool_name == ISOLATION_AWARE_DISPATCH_TOOL;
if let Some(reason) =
undetermined_type_refusal(roster, tool_input, cwd, accepts_isolation, deployed)
{
return Some(WorktreeGrant::Deny(reason));
}
if !accepts_isolation {
return Some(WorktreeGrant::Deny(TASK_DENY_REASON.to_string()));
}
Some(WorktreeGrant::Rewrite(with_granted_isolation(tool_input)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WorktreeGrant {
Rewrite(Value),
Deny(String),
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()
}
pub(crate) fn with_additional_context(response: &str, context: Option<&str>) -> String {
let Some(context) = context else {
return response.to_string();
};
let Ok(mut parsed) = serde_json::from_str::<Value>(response) else {
return response.to_string();
};
let Some(output) = parsed
.get_mut("hookSpecificOutput")
.and_then(Value::as_object_mut)
else {
return response.to_string();
};
output.insert(
"additionalContext".to_string(),
Value::String(context.to_string()),
);
parsed.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn r() -> &'static trusty_mpm::core::content_source::AgentRoster {
crate::commands::install::test_roster_ref()
}
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(
r(),
"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");
}
fn granted_isolation(agent: &str, dir: &Path) -> Value {
match evaluate_worktree_grant(r(), "Agent", Some(&input(agent, None)), dir) {
Some(WorktreeGrant::Rewrite(updated)) => updated["isolation"].clone(),
other => panic!("{agent} must be granted a worktree, got {other:?}"),
}
}
#[test]
fn grants_a_worktree_to_a_deployed_agent_tm_does_not_bundle() {
let dir = main_checkout();
let agents = dir.path().join(".claude/agents");
std::fs::create_dir_all(&agents).expect("mkdir agents");
std::fs::write(
agents.join("fixture-deployed-ops.md"),
"---\nname: fixture-deployed-ops\nrole: ops\n---\n\n# Ops\n",
)
.expect("write agent");
assert_eq!(
granted_isolation("fixture-deployed-ops", dir.path()),
"worktree"
);
let reason = refusal(Some(&input("fixture-defined-nowhere", None)), dir.path());
assert!(reason.contains("`fixture-defined-nowhere`"), "{reason}");
}
#[test]
fn grants_a_worktree_to_a_harness_builtin_writer() {
let dir = main_checkout();
for agent in ["general-purpose", "claude", "statusline-setup"] {
assert_eq!(granted_isolation(agent, dir.path()), "worktree", "{agent}");
}
}
fn refusal(tool_input: Option<&Value>, dir: &Path) -> String {
match evaluate_worktree_grant(r(), "Agent", tool_input, dir) {
Some(WorktreeGrant::Deny(reason)) => reason,
other => panic!("{tool_input:?} must be refused, got {other:?}"),
}
}
#[test]
fn refuses_an_untyped_dispatch_instead_of_isolating_it() {
let dir = main_checkout();
for sent in [Some(serde_json::json!({"prompt": "go"})), None] {
let reason = refusal(sent.as_ref(), dir.path());
assert!(reason.contains("no `subagent_type`"), "{reason}");
assert!(reason.contains("Set `subagent_type`"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
}
}
#[test]
fn refuses_an_unparsable_agent_type() {
let dir = main_checkout();
for raw in [
serde_json::json!(42),
serde_json::json!(null),
serde_json::json!(""),
serde_json::json!(["rust-engineer"]),
] {
let sent = serde_json::json!({"subagent_type": raw, "prompt": "go"});
let reason = refusal(Some(&sent), dir.path());
assert!(reason.contains("not an agent name"), "{raw}: {reason}");
}
}
#[test]
fn refuses_an_unknown_agent_name() {
let dir = main_checkout();
for agent in ["some-project-custom-agent", "Rust-Engineer"] {
let reason = refusal(Some(&input(agent, None)), dir.path());
assert!(reason.contains(&format!("`{agent}`")), "{reason}");
assert!(reason.contains("not a bundled agent"), "{reason}");
}
let task = evaluate_worktree_grant(
r(),
"Task",
Some(&input("some-project-custom-agent", None)),
dir.path(),
);
let Some(WorktreeGrant::Deny(reason)) = task else {
panic!("an unknown Task dispatch must be refused, got {task:?}");
};
assert!(reason.contains("`some-project-custom-agent`"), "{reason}");
assert!(reason.contains("through the `Agent` tool"), "{reason}");
assert_eq!(
evaluate_worktree_grant(
r(),
"Agent",
Some(&input("some-project-custom-agent", Some("worktree"))),
dir.path()
),
None
);
}
#[test]
fn does_not_grant_a_read_only_agent() {
let dir = main_checkout();
for agent in [
"research",
"code-critic",
"code-analyzer",
"ticketing",
"Explore",
"Plan",
"claude-code-guide",
] {
assert_eq!(
evaluate_worktree_grant(r(), "Agent", Some(&input(agent, None)), dir.path()),
None,
"{agent} only reads and must not be moved"
);
}
}
#[test]
fn does_not_grant_a_worktree_to_version_control() {
let dir = main_checkout();
assert_eq!(
evaluate_worktree_grant(
r(),
"Agent",
Some(&input("version-control", None)),
dir.path()
),
None,
"version-control merges into main and must not be moved out of it"
);
}
#[test]
fn does_not_grant_an_already_isolated_dispatch() {
let dir = main_checkout();
for mode in ["worktree", "remote"] {
assert_eq!(
evaluate_worktree_grant(
r(),
"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(
r(),
"Agent",
Some(&input("rust-engineer", None)),
worktree.path()
),
None
);
let plain = tempfile::tempdir().expect("tempdir");
assert_eq!(
evaluate_worktree_grant(
r(),
"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(r(), 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(r(), "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.to_string()));
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(r(), "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(r(), "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(r(), "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()
);
}
#[test]
fn an_agent_cost_notice_is_merged_into_the_one_hook_output_object() {
let grant = build_worktree_grant_response(&serde_json::json!({"isolation": "worktree"}));
let merged = with_additional_context(&grant, Some("this build costs an extra 3 min"));
let parsed: Value = serde_json::from_str(&merged).expect("valid JSON");
assert_eq!(
parsed["hookSpecificOutput"]["additionalContext"],
"this build costs an extra 3 min"
);
assert_eq!(
parsed["hookSpecificOutput"]["updatedInput"]["isolation"], "worktree",
"the merge must not drop the rewrite the grant already carried"
);
assert_eq!(
parsed.as_object().map(serde_json::Map::len),
Some(1),
"exactly one top-level key: one object reaches stdout, never two"
);
}
#[test]
fn an_absent_notice_leaves_the_response_byte_identical() {
let grant = build_worktree_grant_response(&serde_json::json!({"isolation": "worktree"}));
assert_eq!(with_additional_context(&grant, None), grant);
}
}