trusty-mpm 1.7.4

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
//! The `PreToolUse` stdout envelope every `tm hook` decision is printed in.
//!
//! Why: two shapes share one JSON envelope and one protocol rule — stdout may
//! carry exactly one object — and they are printed from four modules
//! (`pm_guard`, `divert_check`, and the rules they call). Keeping both builders
//! in one place is what stops a second, subtly different envelope appearing at
//! a new print site; splitting them out of `pm_guard.rs` is also what kept that
//! file under the 500-SLOC cap when #7172 added a rule to it.
//!
//! What: [`build_pretooluse_deny_response`] is the private deny envelope. Only
//! two builders reach it, so the compiler decides who may print an unprefixed
//! deny: [`build_pm_guard_deny_response`] carries the
//! [`PM_GUARD_REFUSAL_PREFIX`] every `tm hook --pm-guard` refusal starts with
//! (#8546), and [`build_divert_deny_response`] is `divert_check`'s own,
//! unprefixed. [`build_pretooluse_context_response`] speaks to the agent and
//! decides nothing. None ever emits an explicit `allow` — see each doc for why.
//!
//! Test: the `#[cfg(test)]` suite below.

/// Build the `hookSpecificOutput.permissionDecision = "deny"` JSON body.
///
/// Why: this is the exact shape Claude Code parses on a `PreToolUse` hook's
/// `exit 0` stdout to block a tool call. Confirmed against the live Claude Code
/// hooks reference (<https://code.claude.com/docs/en/hooks>, confirmed
/// 2026-07-03): a deny is
/// `{"hookSpecificOutput":{"hookEventName":"PreToolUse",
/// "permissionDecision":"deny","permissionDecisionReason":"…"}}`, and the
/// documented ALLOW form is simply exit 0 with no JSON — which is why
/// [`crate::commands::pm_guard::pm_guard`] prints nothing on ALLOW rather than emitting a
/// `permissionDecision:"allow"` object. Mirrors
/// `hook_rewrite::build_pretooluse_rewrite_response` (same `hookSpecificOutput`
/// envelope, different decision payload).
/// What: returns the deny `serde_json::Value`; its `Display` impl is compact
/// single-line JSON, so `println!("{}", …)` satisfies the protocol's "stdout
/// must contain only the JSON object" rule.
/// Private since #8547 review: a caller outside this module must choose
/// [`build_pm_guard_deny_response`] or [`build_divert_deny_response`].
/// Test: `build_pretooluse_deny_response_has_expected_shape`.
fn build_pretooluse_deny_response(reason: &str) -> serde_json::Value {
    serde_json::json!({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": reason
        }
    })
}

/// The text every refusal `tm hook --pm-guard` prints begins with (#8546).
///
/// Why: the Claude Code harness refuses some tool calls itself — for example a
/// Bash command "too complex to verify that it stays inside the worktree" — and
/// both refusals reach the agent and the operator as bare text. The prefix is
/// how a reader tells tm's refusal from the harness's, so it is never added to
/// another layer's refusal: `divert_check` prints its own, unprefixed.
/// What: the literal `tm pm-guard: `.
/// Test: `pm_guard_deny_carries_the_prefix_exactly_once`.
pub(crate) const PM_GUARD_REFUSAL_PREFIX: &str = "tm pm-guard: ";

/// Build the deny for a `tm hook --pm-guard` refusal: `reason` behind
/// [`PM_GUARD_REFUSAL_PREFIX`].
///
/// Why (#8546): the one builder every pm-guard deny site calls, so the prefix
/// is added in one place instead of being written into ~30 reason strings, any
/// of which a new rule could forget.
/// What: the private deny envelope over `reason` with the prefix prepended; a
/// `reason` that already starts with it is not prefixed twice.
/// Test: `pm_guard_deny_carries_the_prefix_exactly_once`,
/// `pm_guard_modules_never_print_an_unprefixed_deny`.
pub(crate) fn build_pm_guard_deny_response(reason: &str) -> serde_json::Value {
    let body = reason
        .strip_prefix(PM_GUARD_REFUSAL_PREFIX)
        .unwrap_or(reason);
    build_pretooluse_deny_response(&format!("{PM_GUARD_REFUSAL_PREFIX}{body}"))
}

/// Build the deny for a `tm hook --divert-check` refusal, with no prefix.
///
/// Why (#8546 review): `divert_check` is a separate hook layer whose refusal
/// must not read as tm pm-guard's, so it needs an unprefixed deny. Naming it
/// keeps the generic builder private, and the scan in the test suite below
/// fails if a pm-guard module calls this one.
/// What: [`build_pretooluse_deny_response`] over `reason`, unchanged.
/// Test: `divert_deny_carries_no_prefix`.
pub(crate) fn build_divert_deny_response(reason: &str) -> serde_json::Value {
    build_pretooluse_deny_response(reason)
}

/// Build a `hookSpecificOutput.additionalContext` body — a message TO the agent
/// that changes no decision.
///
/// Why (#4837 review, HIGH): the agent-cost warning is addressed to the agent
/// ("wrap up and report back"), but the only channel it had was an audit POST
/// to the daemon, so no agent ever read it — the graduated warn-then-stop
/// response did not exist from the agent's side. `additionalContext` is the
/// channel that fixes it: Claude Code injects the string next to the tool
/// result, and the object carries **no** `permissionDecision`, so the call
/// still goes through the normal permission flow. Per the hooks reference
/// (<https://code.claude.com/docs/en/hooks>, confirmed 2026-08-04): "Exit code
/// 0 with no output means the hook has no decision to report, so the tool call
/// continues through the normal permission flow… staying silent doesn't approve
/// it", and `additionalContext` is listed for `PreToolUse` as appearing "next
/// to the tool result". That is precisely the property the author needed and
/// could not find: context reaches the agent, and an explicit `allow` — which
/// WOULD bypass the permission flow — is never emitted.
/// What: returns the `serde_json::Value` whose compact `Display` is the single
/// stdout object; mirrors [`build_pretooluse_deny_response`]'s envelope.
/// Test: `build_pretooluse_context_response_carries_no_decision`.
pub(crate) fn build_pretooluse_context_response(context: &str) -> serde_json::Value {
    serde_json::json!({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "additionalContext": context
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_pretooluse_deny_response_has_expected_shape() {
        let v = build_pretooluse_deny_response("nope");
        assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
        assert_eq!(v["hookSpecificOutput"]["permissionDecisionReason"], "nope");
        // Display is compact single-line JSON (protocol: stdout is only the object).
        assert!(!v.to_string().contains('\n'));
    }

    #[test]
    fn pm_guard_deny_carries_the_prefix_exactly_once() {
        for reason in ["nope", "tm pm-guard: nope"] {
            let v = build_pm_guard_deny_response(reason);
            assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
            assert_eq!(
                v["hookSpecificOutput"]["permissionDecisionReason"], "tm pm-guard: nope",
                "input {reason:?}"
            );
        }
    }

    #[test]
    fn divert_deny_carries_no_prefix() {
        // divert_check is its own layer; its refusal must not read as pm-guard's.
        let v = build_divert_deny_response("nope");
        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
        assert_eq!(v["hookSpecificOutput"]["permissionDecisionReason"], "nope");
    }

    /// Every production `.rs` file of the `tm` binary, skipping `tests/`
    /// directories, `tests.rs` and `*_tests.rs`.
    fn tm_binary_sources() -> Vec<std::path::PathBuf> {
        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/bin/tm");
        let mut out = Vec::new();
        let mut stack = vec![root];
        while let Some(dir) = stack.pop() {
            for entry in std::fs::read_dir(&dir).expect("read source dir") {
                let path = entry.expect("dir entry").path();
                if path.is_dir() {
                    if !path.ends_with("tests") {
                        stack.push(path);
                    }
                    continue;
                }
                let name = path
                    .file_name()
                    .map(|n| n.to_string_lossy().into_owned())
                    .unwrap_or_default();
                let is_test = name == "tests.rs" || name.ends_with("_tests.rs");
                if name.ends_with(".rs") && !is_test {
                    out.push(path);
                }
            }
        }
        out
    }

    /// The lines of `text` before its `#[cfg(test)] mod …` test module.
    ///
    /// A doc comment that merely mentions `#[cfg(test)]` does not end the
    /// production section: the marker must stand alone on an unindented line
    /// and be followed, past any other attributes, by a `mod` item.
    fn production_lines(text: &str) -> Vec<&str> {
        let lines: Vec<&str> = text.lines().collect();
        let end = (0..lines.len())
            .find(|&i| {
                lines[i] == "#[cfg(test)]"
                    && lines[i + 1..]
                        .iter()
                        .find(|l| !l.starts_with("#["))
                        .is_some_and(|l| l.starts_with("mod "))
            })
            .unwrap_or(lines.len());
        lines[..end].to_vec()
    }

    #[test]
    fn pm_guard_modules_never_print_an_unprefixed_deny() {
        // #8546: the private generic builder is enforced by the compiler. What
        // it cannot see is a hand-built deny object anywhere in the binary, or
        // a pm-guard module borrowing divert_check's unprefixed builder. This
        // scan fails on either whether or not any test exercises the new path.
        let sources = tm_binary_sources();
        assert!(sources.len() > 100, "scan found too few files: {sources:?}");
        let mut offenders = Vec::new();
        for path in sources {
            if path.ends_with("pm_guard_response.rs") {
                continue;
            }
            let divert = path.ends_with("divert_check.rs");
            let text = std::fs::read_to_string(&path).expect("read source");
            for (i, line) in production_lines(&text).iter().enumerate() {
                let code = line.trim_start();
                if code.starts_with("//") {
                    continue;
                }
                if code.contains("\"permissionDecision\"")
                    || (!divert && code.contains("build_divert_deny_response"))
                {
                    offenders.push(format!("{}:{}: {code}", path.display(), i + 1));
                }
            }
        }
        assert!(
            offenders.is_empty(),
            "pm-guard modules must deny through build_pm_guard_deny_response: {offenders:#?}"
        );
    }

    #[test]
    fn build_pretooluse_context_response_carries_no_decision() {
        // #4837 review HIGH: this is the channel that reaches the AGENT. The
        // absence of `permissionDecision` is the whole safety property — with
        // one, the object would approve the call and bypass the permission
        // flow, which is exactly what the author declined to do.
        let v = build_pretooluse_context_response("wrap up");
        assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
        assert_eq!(v["hookSpecificOutput"]["additionalContext"], "wrap up");
        assert!(
            v["hookSpecificOutput"].get("permissionDecision").is_none(),
            "an explicit decision here would bypass the permission flow"
        );
        assert!(!v.to_string().contains('\n'));
    }
}