use std::path::{Path, PathBuf};
use crate::commands::hook_stdin::read_stdin_payload_or_deny;
use crate::commands::misc::{DISABLE_HOOKS_ENV, SUB_AGENT_ENV};
use crate::commands::pm_guard_bash::{
CommitVerdict, DeleteTarget, DispatchIdentity, SHELL_EDIT_REASON, WorktreeRemoveVerdict,
deny_linked_worktree_head_move, deny_main_checkout_head_move, docs_commit_deny_reason,
evaluate_bash_command, evaluate_destructive_delete_command,
evaluate_main_checkout_commit_command, evaluate_main_checkout_destructive_command,
evaluate_main_checkout_head_switch, evaluate_read_only_dispatch_command,
evaluate_secret_file_copy_command, evaluate_worktree_add, evaluate_worktree_remove_command,
extract_shell_edit_target, print_deny_then_audit, removal_recheck_deny, unclassifiable_reason,
};
use crate::commands::pm_guard_budget::{self, BudgetDecision, DEFAULT_FILE_CHANGE_BUDGET};
use crate::commands::pm_guard_build_lease;
use crate::commands::pm_guard_cost;
use crate::commands::pm_guard_deny_by_default::{self, PERSONA_DENY_REASON};
use crate::commands::pm_guard_content;
use crate::commands::pm_guard_deny_log::DenyContext;
pub(crate) use crate::commands::pm_guard_deny_log::audit_denied_tool;
use crate::commands::pm_guard_dispatch;
use crate::commands::pm_guard_enter_worktree;
use crate::commands::{pm_guard_fanout, pm_guard_profile, pm_guard_resume_worktree};
use crate::commands::pm_guard_architect_envfile::gate_secret_file_read;
use crate::commands::pm_guard_floor::{self, ArchitectGate};
pub(crate) use crate::commands::pm_guard_response::{
build_pm_guard_deny_response, build_pretooluse_context_response,
};
use crate::commands::pm_guard_routing::{GENERIC_ENGINEER_HINT, delegation_hint_for_path};
use crate::commands::pm_guard_trust_anchor;
use crate::commands::pm_guard_worktree_grant;
use crate::commands::pm_guard_write_boundary;
use trusty_mpm::core::agent_cost::{self, AgentCostConfig, BudgetStatus};
use trusty_mpm::core::config::MpmConfig;
pub(crate) const PM_UNRESTRICTED_ENV: &str = "TRUSTY_MPM_PM_UNRESTRICTED";
pub(crate) const EDIT_TOOLS: &[&str] = &["Edit", "Write", "MultiEdit", "NotebookEdit"];
const SOURCE_EDIT_REASON: &str = "PM must not implement source code directly (prohibition P1). \
Delegate the change to rust-engineer via the Task/Agent tool. Single-file writes to \
non-source files (docs, config, and `.trusty-mpm/` orchestration state) are permitted.";
const SOURCE_CODE_EXTENSIONS: &[&str] = &[
"rs", "py", "pyi", "pyx", "ipynb", "ts", "tsx", "js", "jsx", "mjs", "cjs", "go", "java", "kt",
"kts", "rb", "php", "c", "h", "cc", "cpp", "cxx", "hpp", "hh", "cs", "swift", "scala", "sc",
"ex", "exs", "erl", "dart", "sql", "sh", "bash", "zsh", "fish", "lua", "pl", "pm", "r", "mm",
"vue", "svelte", "proto", "hs", "clj", "cljs", "cljc", "jl", "fs", "fsx", "groovy", "vb",
"asm", "nim", "zig", "ml", "mli",
];
pub(crate) async fn pm_guard(url: &str, started: std::time::Instant) -> anyhow::Result<()> {
let payload = read_stdin_payload_or_deny(url).await;
let hook_cwd = payload
.get("cwd")
.and_then(|v| v.as_str())
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_default();
let bypassed = std::env::var_os(DISABLE_HOOKS_ENV).is_some() || pm_unrestricted();
let architect = ArchitectGate::ambient(&payload);
if pm_guard_floor::deny_floors(url, &payload, &hook_cwd, &architect, bypassed).await {
return Ok(());
}
if bypassed {
return Ok(());
}
let tool_name = crate::commands::hook_stdin::guarded_tool_name_or_deny(url, &payload).await;
let tool_input = payload.get("tool_input");
let session_id = payload
.get("session_id")
.and_then(|v| v.as_str())
.unwrap_or_default();
let caller_is_subagent = pm_guard_fanout::caller_is_subagent(&payload);
let refused = DenyContext::from_payload(url, &payload);
if tool_name == "Bash" {
let command = tool_input
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
.unwrap_or_default();
if let Some(reason) = unclassifiable_reason(command) {
audit_denied_tool(&refused, "unclassifiable-command", &reason).await; println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
if let Some(reason) = evaluate_read_only_dispatch_command(command, &payload) {
audit_denied_tool(&refused, "read-only-dispatch", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
if let Some(reason) = evaluate_worktree_add(command, &hook_cwd) {
audit_denied_tool(&refused, "worktree-add", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
if let Some(class) = evaluate_destructive_delete_command(command, &hook_cwd)
&& !(class == DeleteTarget::Worktree && architect.is_architect())
{
audit_denied_tool(&refused, "destructive-delete", class.reason()).await;
println!("{}", build_pm_guard_deny_response(class.reason()));
return Ok(());
}
if let Some(reason) = evaluate_secret_file_copy_command(command, &hook_cwd) {
audit_denied_tool(&refused, "secret-file-copy", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
if let Some(reason) = evaluate_main_checkout_destructive_command(command, &hook_cwd)
&& !architect.is_architect()
{
audit_denied_tool(&refused, "main-checkout-destructive", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
if let Some(reason) =
evaluate_main_checkout_head_switch(command, &hook_cwd, caller_is_subagent)
{
audit_denied_tool(&refused, "head-switch", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
match evaluate_main_checkout_commit_command(command, &hook_cwd)
.filter(|_| !architect.is_architect())
{
Some(CommitVerdict::Deny(reason)) => {
audit_denied_tool(&refused, "main-checkout-commit", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
Some(CommitVerdict::DocsOnly { root, dirs }) => {
let keys: Vec<&Path> = dirs.iter().map(PathBuf::as_path).collect();
let live = pm_guard_dispatch::live_shared_tree_writers_in(
url, session_id, &keys, &payload,
)
.await;
if !live.is_empty() {
let reason = docs_commit_deny_reason(&root, &live);
audit_denied_tool(&refused, "docs-commit", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
}
None => {}
}
let head_move = (command, hook_cwd.as_path());
if deny_main_checkout_head_move(url, session_id, &payload, head_move, &architect).await {
return Ok(());
}
let linked = (command, hook_cwd.as_path(), caller_is_subagent);
if deny_linked_worktree_head_move(url, session_id, &payload, linked, &architect).await {
return Ok(());
}
}
if let Some(reason) =
gate_secret_file_read(&refused, tool_name, tool_input, &hook_cwd, &architect).await
{
audit_denied_tool(&refused, "secret-file-read", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
if let Some(reason) =
pm_guard_write_boundary::evaluate_main_checkout_write(tool_name, tool_input, &hook_cwd)
&& !architect.is_architect()
{
audit_denied_tool(&refused, "main-checkout-write", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
if pm_guard_trust_anchor::deny_trust_anchor_write(url, &payload).await {
return Ok(());
}
if let Some(reason) = pm_guard_fanout::evaluate_subagent_fanout(tool_name, caller_is_subagent) {
audit_denied_tool(&refused, "subagent-fanout", reason).await;
println!("{}", build_pm_guard_deny_response(reason));
return Ok(());
}
if let Some(reason) = pm_guard_enter_worktree::evaluate_enter_worktree(
tool_name,
tool_input,
caller_is_subagent,
&hook_cwd,
) {
audit_denied_tool(&refused, "enter-worktree", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
if tool_name == "Bash" {
let command = tool_input
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
.unwrap_or_default();
match evaluate_worktree_remove_command(
command,
caller_is_subagent,
DispatchIdentity::from_payload(&payload),
&hook_cwd,
) {
WorktreeRemoveVerdict::Allow => {}
WorktreeRemoveVerdict::Deny(reason) => {
audit_denied_tool(&refused, "worktree-remove", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
WorktreeRemoveVerdict::ReCheck { target } => {
let deny = removal_recheck_deny(url, session_id, &target, &payload, started);
if let Some(reason) = deny.await {
print_deny_then_audit(&refused, "worktree-remove-recheck", &reason, started)
.await;
return Ok(());
}
}
}
}
match pm_guard_resume_worktree::evaluate_resume_worktree(
tool_name,
&payload,
caller_is_subagent,
) {
pm_guard_resume_worktree::ResumeVerdict::Allow => {}
pm_guard_resume_worktree::ResumeVerdict::Unchecked { agent, why } => eprintln!(
"tm pm-guard: could not check whether agent `{agent}` still has its worktree \
({why}); allowing this SendMessage (#8004)"
),
pm_guard_resume_worktree::ResumeVerdict::Deny(reason) => {
audit_denied_tool(&refused, "resume-worktree", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
}
let roster = match pm_guard_content::dispatch_roster(caller_is_subagent, tool_name) {
Ok(roster) => roster,
Err(reason) => {
audit_denied_tool(&refused, "dispatch-content", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
};
if let Some(roster) = roster.as_ref()
&& let Some(grant) = pm_guard_worktree_grant::evaluate_worktree_grant(
roster, tool_name, tool_input, &hook_cwd,
)
{
match grant {
pm_guard_worktree_grant::WorktreeGrant::Rewrite(updated_input) => {
match pm_guard_dispatch::evaluate_granted_worktree(
url,
session_id,
&hook_cwd,
&payload,
tool_input,
&updated_input,
)
.await
{
Some(reason) => {
audit_denied_tool(&refused, "worktree-grant-dispatch", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
}
None => println!(
"{}",
pm_guard_worktree_grant::build_worktree_grant_response(&updated_input)
),
}
}
pm_guard_worktree_grant::WorktreeGrant::Deny(reason) => {
audit_denied_tool(&refused, "worktree-grant", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
}
pm_guard_worktree_grant::WorktreeGrant::InPlace(updated_input) => {
match pm_guard_dispatch::evaluate(
roster, url, &payload, tool_name, tool_input, session_id,
)
.await
{
Some(reason) => {
audit_denied_tool(&refused, "dispatch", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
}
None => println!(
"{}",
pm_guard_worktree_grant::build_worktree_grant_response(&updated_input)
),
}
}
}
return Ok(());
}
if let Some(roster) = roster.as_ref()
&& let Some(reason) =
pm_guard_dispatch::evaluate(roster, url, &payload, tool_name, tool_input, session_id)
.await
{
audit_denied_tool(&refused, "dispatch", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
let mut cost_notice: Option<String> = None;
if caller_is_subagent {
let cost_config = MpmConfig::load_default().agent_cost;
let cost = pm_guard_cost::evaluate_agent_cost(&payload, &cost_config).await;
pm_guard_cost::warn_if_transcript_refused(session_id, &cost);
let (status, tokens) = (cost.status, cost.tokens);
match status {
BudgetStatus::Exceeded
if !pm_guard_cost::is_persistence_escape(tool_name, tool_input) =>
{
let reason = agent_cost::stop_reason(tokens, cost_config.max_tokens);
audit_denied_tool(&refused, "agent-cost", &reason).await;
println!("{}", build_pm_guard_deny_response(&reason));
return Ok(());
}
BudgetStatus::Warning => {
cost_notice = Some(agent_cost::warn_reason(tokens, &cost_config));
audit_agent_cost_warning(url, session_id, tool_name, tokens, &cost_config).await;
}
BudgetStatus::Exceeded | BudgetStatus::Ok => {}
}
}
let lease_rewrite = match pm_guard_build_lease::evaluate(tool_name, tool_input, &hook_cwd) {
pm_guard_build_lease::LeaseVerdict::Deny(reason) => {
println!(
"{}",
pm_guard_build_lease::deny_response(&refused, &reason).await
);
return Ok(());
}
pm_guard_build_lease::LeaseVerdict::Rewrite(response) => Some(response),
pm_guard_build_lease::LeaseVerdict::None => None,
};
if std::env::var_os(SUB_AGENT_ENV).is_some() {
pm_guard_build_lease::emit_allow(&payload, cost_notice, lease_rewrite);
return Ok(());
}
if payload_is_subagent_dispatch(&payload) {
if pm_guard_deny_by_default::deny_by_default_enabled()
&& evaluate_tool(tool_name, tool_input).is_some()
{
let status = pm_guard_deny_by_default::persona_status(url).await;
if status.should_deny() {
audit_denied_tool(&refused, "persona-deny-by-default", PERSONA_DENY_REASON).await;
println!("{}", build_pm_guard_deny_response(PERSONA_DENY_REASON));
return Ok(());
}
}
tracing::debug!(
tool_name,
"pm_guard: allow — native sub-agent dispatch (agent_id present in PreToolUse payload)"
);
pm_guard_build_lease::emit_allow(&payload, cost_notice, lease_rewrite);
return Ok(());
}
let Some(reason) = pm_guard_profile::verdict(tool_name, tool_input) else {
if let Some(rewrite) = lease_rewrite {
println!("{rewrite}");
}
return Ok(());
};
if reason == SOURCE_EDIT_REASON || reason == SHELL_EDIT_REASON {
let target_path = if EDIT_TOOLS.contains(&tool_name) {
edit_tool_target_path(tool_input).map(str::to_owned)
} else {
let command = tool_input
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
.unwrap_or_default();
extract_shell_edit_target(command)
};
let hint = target_path
.as_deref()
.map(delegation_hint_for_path)
.unwrap_or(GENERIC_ENGINEER_HINT);
return match pm_guard_budget::record_file_change(session_id, DEFAULT_FILE_CHANGE_BUDGET) {
BudgetDecision::Allowed { .. } => {
Ok(())
}
BudgetDecision::Exhausted { budget } => {
let exhausted_reason = format!(
"PM file-change budget {budget}/{budget} used this turn (prohibitions P1/P5). \
Delegate further changes to {hint} via the Task/Agent tool."
);
audit_denied_tool(&refused, "file-change-budget", &exhausted_reason).await;
println!("{}", build_pm_guard_deny_response(&exhausted_reason));
Ok(())
}
};
}
audit_denied_tool(&refused, "pm-tool-policy", reason).await;
println!("{}", build_pm_guard_deny_response(reason));
Ok(())
}
fn pm_unrestricted() -> bool {
std::env::var(PM_UNRESTRICTED_ENV)
.map(|v| v == "1")
.unwrap_or(false)
}
pub(crate) fn payload_is_subagent_dispatch(payload: &serde_json::Value) -> bool {
payload
.get("agent_id")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty())
}
pub(crate) fn evaluate_tool(
tool_name: &str,
tool_input: Option<&serde_json::Value>,
) -> Option<&'static str> {
if EDIT_TOOLS.contains(&tool_name) {
return evaluate_edit_tool(tool_input);
}
if tool_name == "Bash" {
let command = tool_input
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
.unwrap_or_default();
return evaluate_bash_command(command);
}
None
}
fn evaluate_edit_tool(tool_input: Option<&serde_json::Value>) -> Option<&'static str> {
let path = edit_tool_target_path(tool_input)?;
if is_pm_orchestration_path(path) {
return None;
}
if is_source_code_path(path) {
return Some(SOURCE_EDIT_REASON);
}
None
}
pub(crate) fn edit_tool_target_path(tool_input: Option<&serde_json::Value>) -> Option<&str> {
let input = tool_input?;
input
.get("file_path")
.or_else(|| input.get("notebook_path"))
.and_then(|v| v.as_str())
}
fn is_pm_orchestration_path(path: &str) -> bool {
use std::path::{Component, Path};
let p = Path::new(path);
if matches!(p.file_name().and_then(|s| s.to_str()), Some("TASK.md")) {
return true;
}
p.components().any(|c| {
matches!(c, Component::Normal(os)
if matches!(os.to_str(), Some(".trusty-mpm" | ".trusty-tools")))
})
}
pub(crate) fn is_source_code_path(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.is_some_and(|e| SOURCE_CODE_EXTENSIONS.contains(&e.as_str()))
}
pub(crate) fn emit_cost_notice(payload: &serde_json::Value, notice: Option<String>) {
let Some(text) = notice else {
return;
};
if pm_guard_cost::claim_warn_notice(payload) {
println!("{}", build_pretooluse_context_response(&text));
}
}
async fn audit_agent_cost_warning(
url: &str,
session_id: &str,
tool_name: &str,
tokens: u64,
config: &AgentCostConfig,
) {
let cwd = std::env::current_dir()
.ok()
.and_then(|p| p.to_str().map(str::to_owned))
.unwrap_or_default();
let reason = agent_cost::warn_reason(tokens, config);
let body = serde_json::json!({
"session_id": session_id,
"event": "PreToolUse",
"payload": {
"cwd": cwd,
"tool": tool_name,
"pm_guard_decision": "warn",
"agent_cost_tokens": tokens,
"agent_cost_max": config.max_tokens,
"pm_guard_reason": reason,
}
});
let Ok(client) = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_millis(500))
.timeout(std::time::Duration::from_secs(2))
.build()
else {
return;
};
let _ = client.post(format!("{url}/hooks")).json(&body).send().await;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::pm_guard_bash::SHELL_EDIT_REASON;
#[test]
fn evaluate_tool_denies_source_code_edits() {
for tool in ["Edit", "Write", "MultiEdit"] {
assert_eq!(
evaluate_tool(
tool,
Some(&serde_json::json!({"file_path": "crates/foo/src/lib.rs"}))
),
Some(SOURCE_EDIT_REASON),
"expected {tool} on a .rs file to be denied"
);
}
assert_eq!(
evaluate_tool(
"NotebookEdit",
Some(&serde_json::json!({"notebook_path": "/x/nb.ipynb"}))
),
Some(SOURCE_EDIT_REASON)
);
}
#[test]
fn evaluate_tool_allows_pm_orchestration_state() {
for path in [
".trusty-mpm/sessions/session-20260714-064556.md",
"TASK.md",
"/Users/x/.trusty-tools/trusty-mpm/claude-config/projects/p/memory/MEMORY.md",
] {
assert_eq!(
evaluate_tool("Write", Some(&serde_json::json!({"file_path": path}))),
None,
"expected PM-owned state {path} to be allowed"
);
}
}
#[test]
fn evaluate_tool_allows_non_source_single_file_writes() {
for path in [
"notes.md",
"config.json",
"data.csv",
"settings.toml",
"README",
"out.txt",
] {
assert_eq!(
evaluate_tool("Write", Some(&serde_json::json!({"file_path": path}))),
None,
"expected non-source single-file write {path} to be allowed"
);
}
}
#[test]
fn evaluate_edit_tool_fails_open_without_path() {
assert_eq!(
evaluate_tool("Write", Some(&serde_json::json!({"content": "x"}))),
None
);
assert_eq!(evaluate_tool("Edit", None), None);
}
#[test]
fn is_pm_orchestration_path_matches_owned_state() {
for p in [
".trusty-mpm/sessions/session-20260714-064556.md",
"/abs/path/.trusty-mpm/config.json",
"/Users/x/.trusty-tools/trusty-mpm/x/memory/MEMORY.md",
"TASK.md",
"sub/dir/TASK.md",
] {
assert!(is_pm_orchestration_path(p), "{p} should be PM-owned");
}
for p in [
"crates/trusty-mpm/src/lib.rs",
"src/main.rs",
"notes.md",
"MEMORY.md",
"/Users/x/some/project/MEMORY.md",
"docs/MEMORY.md",
] {
assert!(!is_pm_orchestration_path(p), "{p} should NOT be PM-owned");
}
}
#[test]
fn is_source_code_path_classifies_by_extension() {
for p in [
"a.rs",
"b.py",
"c.ts",
"d.tsx",
"e.go",
"nb.ipynb",
"x/y/z.cpp",
] {
assert!(is_source_code_path(p), "{p} should be source");
}
for p in [
"notes.md",
"Cargo.toml",
"data.json",
"TASK.md",
"README",
"log.txt",
".trusty-mpm/s.md",
] {
assert!(!is_source_code_path(p), "{p} should NOT be source");
}
}
#[test]
fn evaluate_tool_allows_read_and_task() {
for tool in [
"Read",
"Grep",
"Glob",
"Task",
"TodoWrite",
"WebFetch",
"mcp__foo__bar",
] {
assert_eq!(
evaluate_tool(tool, Some(&serde_json::json!({"x": 1}))),
None,
"expected {tool} to be allowed"
);
}
}
#[test]
fn payload_is_subagent_dispatch_true_when_agent_id_present() {
assert!(payload_is_subagent_dispatch(&serde_json::json!({
"agent_id": "agent-abc123",
"agent_type": "rust-engineer",
"tool_name": "Edit"
})));
}
#[test]
fn payload_is_subagent_dispatch_false_when_absent_or_empty() {
assert!(!payload_is_subagent_dispatch(&serde_json::json!({
"tool_name": "Edit"
})));
assert!(!payload_is_subagent_dispatch(&serde_json::json!({
"agent_id": "",
"tool_name": "Edit"
})));
assert!(!payload_is_subagent_dispatch(&serde_json::json!({
"agent_type": "rust-engineer",
"tool_name": "Edit"
})));
}
#[test]
fn evaluate_tool_delegates_bash() {
assert_eq!(
evaluate_tool(
"Bash",
Some(&serde_json::json!({"command": "sed -i s/a/b/ f"}))
),
Some(SHELL_EDIT_REASON)
);
assert_eq!(
evaluate_tool("Bash", Some(&serde_json::json!({"command": "git status"}))),
None
);
assert_eq!(evaluate_tool("Bash", Some(&serde_json::json!({}))), None);
assert_eq!(evaluate_tool("Bash", None), None);
}
}