trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
//! The OS side of the supervisor-twin identity (#8878, ruling D1).
//!
//! Why: [`crate::core::twin_identity::resolve`] decides from facts; this
//! module reads them from the machine — the user config, the process table
//! and the arming record `tm launch --twin` writes — and writes that record.
//! What: [`OsProbe`] implements [`TwinProbe`] against `~/.trusty-mpm/` and the
//! live process table; [`resolve_hook`] is the hook's entry point;
//! [`arm_claude`] records one launched `claude` as armed. [`RecordStore`] and
//! [`nearest_claude_ancestor`] also bind the Architect to the `claude` tm
//! launched for it (`core::architect_launch`). Every read error is returned
//! as an error, which the resolver turns into "not twin".
//! Test: `twin_identity_tests.rs` (`an_arming_record_round_trips`,
//! `a_writable_by_others_arming_record_is_refused`,
//! `the_nearest_claude_ancestor_is_found_with_its_start_time`,
//! `the_walk_stops_at_the_nearest_claude`,
//! `a_session_nested_under_the_twin_is_not_twin`,
//! `a_reaped_process_has_no_claude_ancestor_it_is_an_error`,
//! `a_malformed_user_config_is_an_error_not_a_default`) and
//! `is_claude_of_a_dead_pid_is_an_error` here.

use std::ffi::OsString;
use std::io::Write as _;
use std::path::{Path, PathBuf};

use serde_json::Value;

use crate::core::config::MpmConfig;
use crate::core::session_profile::{PROJECT_DIR_ENV, SESSION_PROFILE_ENV};
use crate::core::twin_identity::{
    ArmingRecord, ClaudeProcess, HookContext, TwinProbe, TwinStatus, resolve,
};

/// Directory, under the `~/.trusty-mpm` root, holding one record per armed
/// `claude`, named `<pid>.json`.
pub const ARMED_DIR: &str = "twin/armed";

/// Ancestor hops walked before the search gives up: the hook's parent only.
///
/// Why (#8878 review): Claude Code spawns a hook as `spawn(command, [],
/// {shell})`, i.e. `sh -c "<command>"`, and the shell execs a simple command in
/// place; the `tm hook` processes observed live had their `claude` as the
/// direct parent. Any hop past the parent can cross a session boundary — a
/// nested session started from the twin's Bash tool, including one whose
/// process is not named `claude` (npm installs run as `node`), sits between
/// its hook and the twin. A compound hook command (a surviving `sh`) or
/// `CLAUDE_CODE_SHELL_PREFIX` puts the `claude` further up and fails closed.
/// #8938: the hook keeps this limit; only `tm fleet status` walks further
/// ([`STATUS_MAX_ANCESTOR_HOPS`]).
pub const MAX_ANCESTOR_HOPS: usize = 1;

/// Ancestor hops `tm fleet status`'s `this_session` check walks (#8938).
///
/// Why: the CLI runs from the Bash tool, `claude → zsh → tm`, so its `claude`
/// is two hops up, not one; three leaves room for one more shell. The check
/// is informational and never grants anything, so the wider walk opens no
/// write the hook's one-hop walk refuses.
// #8531: `tm repair delegation` runs from the same Bash tool, so the repair
// owner check walks this far too. It grants only when the `claude` it stops
// at IS the owning session's process, never on the walk alone.
pub const STATUS_MAX_ANCESTOR_HOPS: usize = 3;

/// The machine-backed [`TwinProbe`].
#[derive(Debug, Clone)]
pub struct OsProbe {
    /// The `~/.trusty-mpm` root; `None` when the home directory is unknown.
    pub root: Option<PathBuf>,
    /// The process whose `claude` ancestor is sought (the hook itself).
    pub start_pid: u32,
}

impl OsProbe {
    /// The probe for this process and the operator's `~/.trusty-mpm`.
    pub fn current() -> Self {
        Self {
            root: dirs::home_dir().map(|home| home.join(".trusty-mpm")),
            start_pid: std::process::id(),
        }
    }

    fn root(&self) -> Result<&Path, String> {
        self.root
            .as_deref()
            .ok_or_else(|| "the home directory is unknown".to_string())
    }
}

impl TwinProbe for OsProbe {
    fn user_config(&self) -> Result<MpmConfig, String> {
        load_user_config_strict(self.root()?)
    }

    fn nearest_claude(&self) -> Result<Option<ClaudeProcess>, String> {
        nearest_claude_ancestor(self.start_pid)
    }

    fn arming_record(&self, pid: u32) -> Result<Option<ArmingRecord>, String> {
        read_record(self.root()?, pid)
    }
}

/// The twin identity of the running `tm hook --pm-guard` call (#8878).
///
/// Why: the hook's single entry point; later PRs gate relaxations on it.
/// What: [`resolve`] over `payload`, the #8453 launch stamp,
/// `CLAUDE_PROJECT_DIR`, `CLAUDE_MPM_SUB_AGENT` (read through `env`) and
/// [`OsProbe::current`]. `env` is injected so a test never mutates the process
/// environment; production passes `std::env::var_os`.
/// Test: `unrestricted_and_disable_hooks_do_not_imply_twin_mode`.
pub fn resolve_hook(
    payload: &Value,
    env: impl Fn(&str) -> Option<OsString>,
    probe: &impl TwinProbe,
) -> TwinStatus {
    let stamp = env(SESSION_PROFILE_ENV);
    let project_dir = env(PROJECT_DIR_ENV);
    let sub_agent = env(trusty_common::claude_config::CLAUDE_MPM_SUB_AGENT_ENV_VAR);
    let ctx = HookContext {
        payload,
        profile_stamp: stamp.as_deref(),
        project_dir: project_dir.as_deref(),
        sub_agent_env: sub_agent.is_some(),
    };
    resolve(&ctx, probe)
}

/// `<root>/config.toml`, parsed strictly (#8878 condition a).
///
/// Why: [`MpmConfig::load`] turns an unreadable or malformed file into the
/// defaults, which is right for most settings. The twin grant needs the error
/// itself, so a damaged file is reported as a refusal, not read as "no grant".
/// What: an absent file → `Ok(MpmConfig::default())` (no grant, no
/// allowlist); any other read error or a TOML parse error → `Err`.
/// Test: `a_malformed_user_config_is_an_error_not_a_default`.
pub fn load_user_config_strict(root: &Path) -> Result<MpmConfig, String> {
    let path = root.join("config.toml");
    match std::fs::read_to_string(&path) {
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(MpmConfig::default()),
        Err(e) => Err(format!("{}: {e}", path.display())),
        Ok(raw) => toml::from_str(&raw).map_err(|e| format!("{}: {e}", path.display())),
    }
}

/// Where one kind of launch record lives under the `~/.trusty-mpm` root.
///
/// Why: the twin arming (#8878 D1) and the Architect's launch (#8878, ruling
/// A) bind a `claude` the same way — PID and start time, written by the tm
/// code that started it — and differ only in where the record sits.
/// What: records are `<root>/<dir>/<pid>.<ext>`, one [`ArmingRecord`] each.
/// Test: `an_arming_record_round_trips`,
/// `a_writable_by_others_arming_record_is_refused`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RecordStore {
    /// Directory under the root.
    pub dir: &'static str,
    /// File extension, without the dot.
    pub ext: &'static str,
}

/// The twin arming records: `twin/armed/<pid>.json`.
pub const TWIN_RECORDS: RecordStore = RecordStore {
    dir: ARMED_DIR,
    ext: "json",
};

impl RecordStore {
    /// Path of the record for `pid` under `root`.
    pub fn path(&self, root: &Path, pid: u32) -> PathBuf {
        root.join(self.dir).join(format!("{pid}.{}", self.ext))
    }

    /// Read the record for `pid` (#8878 condition b).
    ///
    /// What: absent → `Ok(None)`. `Err` for any other read error, a file
    /// other users can write (unix), or content that does not parse as an
    /// [`ArmingRecord`].
    /// Test: `an_arming_record_round_trips`,
    /// `a_writable_by_others_arming_record_is_refused`.
    pub fn read(&self, root: &Path, pid: u32) -> Result<Option<ArmingRecord>, String> {
        let path = self.path(root, pid);
        let raw = match std::fs::read(&path) {
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(e) => return Err(format!("{}: {e}", path.display())),
            Ok(raw) => raw,
        };
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            let mode = std::fs::metadata(&path)
                .map_err(|e| format!("{}: {e}", path.display()))?
                .permissions()
                .mode();
            if mode & 0o022 != 0 {
                return Err(format!(
                    "{} is writable by other users (mode {mode:o})",
                    path.display()
                ));
            }
        }
        serde_json::from_slice(&raw)
            .map(Some)
            .map_err(|e| format!("{}: {e}", path.display()))
    }

    /// Write `record` atomically under `root`, mode 0600.
    ///
    /// Test: `an_arming_record_round_trips`.
    pub fn write(&self, root: &Path, record: &ArmingRecord) -> Result<PathBuf, String> {
        let path = self.path(root, record.pid);
        let dir = path
            .parent()
            .ok_or_else(|| format!("{} has no parent", path.display()))?;
        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
        let body = serde_json::to_vec_pretty(record).map_err(|e| e.to_string())?;
        // NamedTempFile is created 0600 on unix, and `persist` renames it in place.
        let mut staged =
            tempfile::NamedTempFile::new_in(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
        staged
            .write_all(&body)
            .map_err(|e| format!("{}: {e}", path.display()))?;
        staged
            .persist(&path)
            .map_err(|e| format!("{}: {e}", path.display()))?;
        Ok(path)
    }

    /// Record the `claude` at `pid`, launched in `project_dir`.
    ///
    /// Why: binding by PID and start time means a later process that reuses
    /// the PID is not the recorded one.
    /// What: reads `pid`'s start time from the process table, then
    /// [`Self::write`]s it with the canonical `project_dir`.
    /// Test: `the_nearest_claude_ancestor_is_found_with_its_start_time` (the
    /// start-time read), `an_arming_record_round_trips` (the write).
    pub fn record_claude(
        &self,
        root: &Path,
        pid: u32,
        project_dir: &Path,
    ) -> Result<ArmingRecord, String> {
        let start_time = process_facts(pid)?.start_time;
        let project_dir = std::fs::canonicalize(project_dir)
            .map_err(|e| format!("{}: {e}", project_dir.display()))?;
        let record = ArmingRecord {
            pid,
            start_time,
            project_dir,
            armed_at: chrono::Utc::now().to_rfc3339(),
        };
        self.write(root, &record)?;
        Ok(record)
    }
}

/// Path of the twin arming record for `pid` under `root`.
pub fn record_path(root: &Path, pid: u32) -> PathBuf {
    TWIN_RECORDS.path(root, pid)
}

/// Read the twin arming record for `pid`; see [`RecordStore::read`].
pub fn read_record(root: &Path, pid: u32) -> Result<Option<ArmingRecord>, String> {
    TWIN_RECORDS.read(root, pid)
}

/// Write a twin arming record; see [`RecordStore::write`].
pub fn write_record(root: &Path, record: &ArmingRecord) -> Result<PathBuf, String> {
    TWIN_RECORDS.write(root, record)
}

/// Arm the `claude` at `pid`, launched in `project_dir`, for twin mode.
///
/// Why: `tm launch --twin` binds the arming to the process it started.
/// What: [`RecordStore::record_claude`] into [`TWIN_RECORDS`].
pub fn arm_claude(root: &Path, pid: u32, project_dir: &Path) -> Result<ArmingRecord, String> {
    TWIN_RECORDS.record_claude(root, pid, project_dir)
}

/// Parent and start time of one process-table entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProcessFacts {
    /// Parent PID; `None` when the table records no parent.
    pub parent: Option<u32>,
    /// Start time, Unix seconds.
    pub start_time: u64,
}

/// Read `pid`'s parent and start time; `Err` when the table has no entry.
// #8902: public so the Architect pane guard can check a launch record is live.
// #8878 R1: `architect_session` reads the start time it records.
pub fn process_facts(pid: u32) -> Result<ProcessFacts, String> {
    use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
    let spid = Pid::from_u32(pid);
    let mut sys = System::new();
    sys.refresh_processes_specifics(
        ProcessesToUpdate::Some(&[spid]),
        true,
        ProcessRefreshKind::nothing(),
    );
    let proc_ = sys
        .process(spid)
        .ok_or_else(|| format!("the process table holds no entry for pid {pid}"))?;
    Ok(ProcessFacts {
        parent: proc_.parent().map(Pid::as_u32),
        start_time: proc_.start_time(),
    })
}

/// Whether `pid`'s command name contains `claude`, any case.
///
/// Why: the launch side finds the `claude` it arms by this same rule
/// (`core::process::process_name_is_claude`), so the hook must match the same
/// processes. A broader rule is also the fail-closed one: every extra match can
/// only stop the walk nearer the hook, at a process with no arming record.
/// What: `/proc/<pid>/comm` on Linux, else `ps -o comm=`. Unlike the launch
/// side, a failed read is `Err`, never `false`, so the walk cannot skip a
/// process it could not identify.
/// Test: `is_claude_of_a_dead_pid_is_an_error`.
// #8531: the daemon's repair-owner walk uses the same rule.
pub(crate) fn is_claude(pid: u32) -> Result<bool, String> {
    #[cfg(target_os = "linux")]
    if let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) {
        return Ok(comm.to_ascii_lowercase().contains("claude"));
    }
    let out = std::process::Command::new("ps")
        .args(["-p", &pid.to_string(), "-o", "comm="])
        .output()
        .map_err(|e| format!("ps: {e}"))?;
    if !out.status.success() {
        return Err(format!("ps found no process {pid}"));
    }
    Ok(String::from_utf8_lossy(&out.stdout)
        .to_ascii_lowercase()
        .contains("claude"))
}

/// The nearest `claude` ancestor of `start_pid`, excluding `start_pid` itself.
///
/// Why: condition (c). Claude Code runs a hook as its own child, so the
/// `claude` directly above the hook is the session the call belongs to.
/// What: [`nearest_claude_in`] over the live process table: one hop.
/// #8938: the hook stays at one hop on purpose. `tm fleet status` walks up to
/// [`STATUS_MAX_ANCESTOR_HOPS`] ([`nearest_claude_for_status`]); a hook walk
/// that deep would let a nested non-`claude` runtime under the Architect's
/// Bash tool (`claude → zsh → node → hook`) pass as the Architect.
/// Test: `the_nearest_claude_ancestor_is_found_with_its_start_time`,
/// `a_reaped_process_has_no_claude_ancestor_it_is_an_error`,
/// `the_hook_walk_stays_one_hop`.
pub fn nearest_claude_ancestor(start_pid: u32) -> Result<Option<ClaudeProcess>, String> {
    nearest_claude_in(start_pid, process_facts, is_claude)
}

/// The `claude` that `tm fleet status`'s `this_session` check binds (#8938).
///
/// Why: the CLI runs from the Bash tool, so its `claude` sits above a shell:
/// `claude → zsh → tm fleet status`. The hook's one-hop walk never reaches it
/// and reported a bound Architect as unbound from inside its own session.
/// What: [`nearest_claude_for_status_in`] over the live process table: at most
/// [`STATUS_MAX_ANCESTOR_HOPS`], stopping at the first `claude`, an
/// unidentifiable process in between an error. The result is informational;
/// the hook keeps [`nearest_claude_ancestor`]'s one hop — see its doc.
/// Test: `this_session_reaches_claude_through_the_bash_shell`,
/// `a_nested_claude_under_the_architect_is_not_this_session`,
/// `the_hook_walk_stays_one_hop`.
pub fn nearest_claude_for_status(start_pid: u32) -> Result<Option<ClaudeProcess>, String> {
    nearest_claude_for_status_in(start_pid, process_facts, is_claude)
}

/// [`nearest_claude_for_status`] over an injected process table.
pub fn nearest_claude_for_status_in(
    start_pid: u32,
    facts: impl Fn(u32) -> Result<ProcessFacts, String>,
    is_claude: impl Fn(u32) -> Result<bool, String>,
) -> Result<Option<ClaudeProcess>, String> {
    nearest_claude_within(start_pid, STATUS_MAX_ANCESTOR_HOPS, facts, is_claude)
}

/// The hook's walk ([`MAX_ANCESTOR_HOPS`]) over an injected process table.
///
/// Why: the walk's fail-closed rules are testable only over an injected table.
/// What: [`nearest_claude_within`] at [`MAX_ANCESTOR_HOPS`].
/// Test: `the_walk_stops_at_the_nearest_claude`,
/// `a_session_nested_under_the_twin_is_not_twin`,
/// `an_unidentifiable_ancestor_stops_the_walk`, `the_hook_walk_stays_one_hop`.
pub fn nearest_claude_in(
    start_pid: u32,
    facts: impl Fn(u32) -> Result<ProcessFacts, String>,
    is_claude: impl Fn(u32) -> Result<bool, String>,
) -> Result<Option<ClaudeProcess>, String> {
    nearest_claude_within(start_pid, MAX_ANCESTOR_HOPS, facts, is_claude)
}

/// The nearest ancestor of `start_pid` that `is_claude` accepts, within
/// `max_hops`.
///
/// What: walks parent links from `start_pid` for at most `max_hops` and
/// returns the FIRST accepted ancestor, with its start time — a nested
/// `claude` below an armed one is the caller, not the armed one. Reaching
/// PID 1, a process with no parent, or the hop limit → `Ok(None)`. Any
/// `facts` or `is_claude` error → `Err`: an ancestor that cannot be
/// identified is never skipped.
fn nearest_claude_within(
    start_pid: u32,
    max_hops: usize,
    facts: impl Fn(u32) -> Result<ProcessFacts, String>,
    is_claude: impl Fn(u32) -> Result<bool, String>,
) -> Result<Option<ClaudeProcess>, String> {
    let mut pid = match facts(start_pid)?.parent {
        Some(parent) => parent,
        None => return Ok(None),
    };
    for _ in 0..max_hops {
        if pid <= 1 {
            return Ok(None);
        }
        let entry = facts(pid)?;
        if is_claude(pid)? {
            return Ok(Some(ClaudeProcess {
                pid,
                start_time: entry.start_time,
            }));
        }
        match entry.parent {
            Some(parent) => pid = parent,
            None => return Ok(None),
        }
    }
    Ok(None)
}

#[cfg(test)]
mod tests {
    /// A name read that cannot find the process is an error, never "not
    /// claude", so the walk cannot step past an unidentified ancestor.
    #[cfg(unix)]
    #[test]
    fn is_claude_of_a_dead_pid_is_an_error() {
        let mut child = std::process::Command::new("true")
            .spawn()
            .expect("spawn true");
        let pid = child.id();
        child.wait().expect("reap true");
        let got = super::is_claude(pid);
        assert!(got.is_err(), "{got:?}");
    }
}