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
//! The session instruction profile: a delegating PM, or a fleet supervisor (#8453).
//!
//! Why: a supervisor session ran on the full PM instructions and the
//! `trusty-mpm` output style, then contradicted them with CLAUDE.md override
//! blocks and the all-or-nothing `TRUSTY_MPM_PM_UNRESTRICTED=1`. An output
//! style cannot be overridden from CLAUDE.md, so its "work only through
//! delegation" floor reached the supervisor anyway. The owner ruling
//! (2026-09-23) is a separate supervisor profile with its own instructions,
//! output style, guard behaviour and model.
//!
//! What: a session is a supervisor only when the operator allow-listed the
//! project in the USER-level `~/.trusty-mpm/config.toml` (`[supervisor]
//! projects`) AND the project's committed `.trusty-mpm.toml` says
//! `profile = "supervisor"` ([`resolve`]). A project can write its own
//! `.trusty-mpm.toml`, so the file alone never exempts it (#3981). A launch
//! resolves the profile once and stamps it into the spawned `claude`'s
//! environment as [`SESSION_PROFILE_ENV`] ([`launch_env`]); `tm hook
//! --pm-guard` lifts the PM delegation rules only when the stamp AND both
//! conditions still say supervisor ([`hook_profile`]).
//!
//! FAIL-CLOSED: anything that prevents a positive "supervisor" answer — an
//! absent file, an unreadable or malformed file, an absent key, an unknown
//! value, a project missing from the allowlist, a missing or different stamp —
//! resolves to [`SessionProfile::Pm`], the full PM profile with every PM
//! guard. The supervisor profile is never partial: its prompt is compiled in
//! and a non-empty composition is asserted by test.
//!
//! Test: `session_profile_tests.rs`.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use crate::core::config::MpmConfig;
use crate::core::project_config::ProjectLevelConfig;
/// `profile` value selecting the delegating PM (the default).
pub const PM_PROFILE_ID: &str = "pm";
/// `profile` value selecting the fleet supervisor.
pub const SUPERVISOR_PROFILE_ID: &str = "supervisor";
/// Output-style id a supervisor session runs on.
pub const SUPERVISOR_OUTPUT_STYLE_ID: &str = "trusty-mpm-supervisor";
/// The launch stamp: the profile a session was launched as (#8453).
///
/// Why: the guard used to re-read `.trusty-mpm.toml` on every tool call, and
/// a session can write that file, so a PM could promote itself mid-session.
/// What: set on the spawned `claude` by `tm launch` / `tm connect`, the
/// daemon's managed launch and the in-place relaunch; Claude Code passes it on
/// to its hooks. Value: a profile id. Every launch path first clears the
/// inherited value (`claude_env_scrub::INHERITED_TM_STAMPS`, #8453), so a path
/// that does not stamp leaves the child unstamped, which the guard reads as PM.
pub const SESSION_PROFILE_ENV: &str = "TRUSTY_MPM_SESSION_PROFILE";
/// The launch directory Claude Code exports to every hook.
pub const PROJECT_DIR_ENV: &str = "CLAUDE_PROJECT_DIR";
/// Why a project that asks for the supervisor profile did not get it.
pub const NOT_ALLOW_LISTED: &str = "project requests the supervisor profile but is not \
allow-listed in ~/.trusty-mpm/config.toml (`[supervisor] projects`); running the PM profile";
/// Model a supervisor session launches on: the Opus tier alias.
///
/// Why: owner ruling (2026-09-23) — the supervisor runs on the latest Opus,
/// never a pinned id. Claude Code resolves the bare alias to its current Opus.
/// What: `"opus"`, passed to `--model` and written to the project settings
/// verbatim. It never goes through `MpmConfig::expand_model_alias`, which maps
/// `opus` to a pinned id.
/// Test: `the_supervisor_model_is_the_opus_tier_alias`.
pub const SUPERVISOR_MODEL: &str = "opus";
/// Which instruction profile a session runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SessionProfile {
/// The delegating PM: PM instructions, PM output style, PM guards.
#[default]
Pm,
/// The fleet supervisor: supervisor instructions and style, direct action.
Supervisor,
}
impl SessionProfile {
/// Whether this is the supervisor profile.
pub fn is_supervisor(self) -> bool {
self == SessionProfile::Supervisor
}
/// The profile id, as written in `.trusty-mpm.toml` and the launch stamp.
pub fn id(self) -> &'static str {
match self {
SessionProfile::Pm => PM_PROFILE_ID,
SessionProfile::Supervisor => SUPERVISOR_PROFILE_ID,
}
}
}
/// `[supervisor]` in the user-level `~/.trusty-mpm/config.toml` (#8453).
///
/// Why: the operator's grant, kept outside every project's write boundary so
/// a project cannot exempt itself from the PM rules (#3981 won't-do ruling).
/// What: `projects` — absolute project paths allowed to run the supervisor
/// profile, matched after canonicalization. A relative entry matches nothing.
/// Test: `an_allowlist_entry_for_another_path_does_not_match`.
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SupervisorConfig {
/// Absolute paths of the projects allowed to run the supervisor profile.
pub projects: Vec<PathBuf>,
/// `[supervisor.twin]` — projects granted supervisor-twin mode.
// #8878: nested here so the grant needs this allowlist too (ruling D1).
pub twin: crate::core::twin_identity::TwinGrant,
}
/// The supervisor profile's sections, in composition order.
///
/// Why: one markdown file per kept item of #8453, so a review diff names the
/// item it changes; the order here is the order delivered.
/// What: `(name, content path)`; `name` matches the file stem under
/// `content/instructions/supervisor/`, and the path is relative to the content
/// bundle's `instructions/` (#9012: runtime content, no longer compiled in).
/// Test: `golden_supervisor_prompt`, `every_kept_item_reaches_the_supervisor_prompt`.
pub const SUPERVISOR_SECTIONS: [(&str, &str); 8] = [
("identity", "supervisor/identity.md"),
("hard-limits", "supervisor/hard-limits.md"),
("relay-protocol", "supervisor/relay-protocol.md"),
("evidence-labels", "supervisor/evidence-labels.md"),
("decisions", "supervisor/decisions.md"),
("monitoring", "supervisor/monitoring.md"),
("tool-priority", "supervisor/tool-priority.md"),
("prose-style", "supervisor/prose-style.md"),
];
/// The composed supervisor instructions.
///
/// Why: the text a supervisor session receives in place of the PM prompt.
/// What: each [`SUPERVISOR_SECTIONS`] body, read from `content`, folded on its
/// own (authoring comments removed, as the PM composer does), trimmed, and
/// joined with a blank line. Pure and deterministic for one content source.
/// Test: `golden_supervisor_prompt`, `the_supervisor_prompt_is_never_empty`.
pub fn supervisor_prompt(content: &crate::core::framework_content::FrameworkContent) -> String {
SUPERVISOR_SECTIONS
.iter()
.map(|(_, path)| crate::core::instruction_fold::fold_block(content.required(path).trim()))
.filter(|body| !body.is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
/// The profile a parsed project config asks for.
///
/// Why: separates the decision from the file read so the fail-closed rule is
/// testable without a filesystem.
/// What: `Some(cfg)` with `profile = "supervisor"` (case and surrounding
/// whitespace ignored) → [`SessionProfile::Supervisor`]. Everything else →
/// [`SessionProfile::Pm`]; an unknown value is logged at `warn`.
/// Test: `an_unknown_profile_value_falls_back_to_the_pm_profile`.
pub fn profile_from_config(config: Option<&ProjectLevelConfig>) -> SessionProfile {
let Some(raw) = config.and_then(|c| c.profile.as_deref()) else {
return SessionProfile::Pm;
};
let value = raw.trim().to_ascii_lowercase();
match value.as_str() {
SUPERVISOR_PROFILE_ID => SessionProfile::Supervisor,
PM_PROFILE_ID | "" => SessionProfile::Pm,
_ => {
tracing::warn!(
profile = raw,
"unknown `.trusty-mpm.toml` profile; using the `{PM_PROFILE_ID}` profile \
(valid: `{PM_PROFILE_ID}`, `{SUPERVISOR_PROFILE_ID}`)"
);
SessionProfile::Pm
}
}
}
/// The profile `project_dir`'s committed `.trusty-mpm.toml` asks for.
///
/// Why: condition (b) of [`resolve`], on its own, for the doctor and the
/// style warning.
/// What: [`crate::core::project_config::load_or_report`] (absent → silent,
/// unreadable or malformed → logged at `error`), then [`profile_from_config`].
/// Test: `a_malformed_config_falls_back_to_the_pm_profile`.
pub fn requested(project_dir: &Path) -> SessionProfile {
profile_from_config(crate::core::project_config::load_or_report(project_dir).as_ref())
}
/// Whether the operator allow-listed `project_dir` for the supervisor profile.
///
/// Why: condition (a) of [`resolve`]. Canonical paths, so a symlink, a
/// trailing slash or `/var` versus `/private/var` cannot decide the answer.
/// What: `true` when some absolute `allowed.projects` entry canonicalizes to
/// the canonical `project_dir`. A path that cannot be canonicalized matches
/// nothing.
/// Test: `an_allowlist_entry_for_another_path_does_not_match`,
/// `a_symlinked_allowlist_entry_is_canonicalized`.
pub fn is_allow_listed(project_dir: &Path, allowed: &SupervisorConfig) -> bool {
path_is_listed(project_dir, &allowed.projects)
}
/// Whether some absolute `entries` path canonicalizes to canonical `project_dir`.
///
/// Why: the #8453 allowlist and the #8878 `[supervisor.twin]` grant match
/// paths by one rule.
/// What: a relative entry, or a path that cannot be canonicalized, matches
/// nothing.
/// Test: `a_symlinked_allowlist_entry_is_canonicalized`,
/// `a_supervisor_without_the_twin_grant_is_not_twin`.
pub fn path_is_listed(project_dir: &Path, entries: &[PathBuf]) -> bool {
let Ok(project) = std::fs::canonicalize(project_dir) else {
return false;
};
entries
.iter()
.filter(|entry| entry.is_absolute())
.filter_map(|entry| std::fs::canonicalize(entry).ok())
.any(|entry| entry == project)
}
/// The refusal reason when `project_dir` asks for the supervisor profile and
/// does not get it; `None` otherwise.
///
/// Why: `tm doctor` names the reason; the launch logs it.
/// What: [`NOT_ALLOW_LISTED`] when (b) holds and (a) does not.
/// Test: `the_doctor_reason_names_the_missing_allowlist_entry`.
pub fn refusal(project_dir: &Path, config: &MpmConfig) -> Option<&'static str> {
(requested(project_dir).is_supervisor() && !is_allow_listed(project_dir, &config.supervisor))
.then_some(NOT_ALLOW_LISTED)
}
/// The profile a session launched in `project_dir` runs.
///
/// Why: the one launch-time selector, so a project cannot promote itself.
/// What: [`SessionProfile::Supervisor`] only when `project_dir` is
/// allow-listed in `config` (the user-level config) AND [`requested`] says
/// supervisor. Otherwise [`SessionProfile::Pm`]; a refused request is logged
/// at `warn` with [`NOT_ALLOW_LISTED`].
/// Test: `a_supervisor_config_selects_the_supervisor_profile`,
/// `a_project_only_switch_stays_pm`.
pub fn resolve(project_dir: &Path, config: &MpmConfig) -> SessionProfile {
if !requested(project_dir).is_supervisor() {
return SessionProfile::Pm;
}
if is_allow_listed(project_dir, &config.supervisor) {
return SessionProfile::Supervisor;
}
tracing::warn!(project = %project_dir.display(), "{NOT_ALLOW_LISTED}");
SessionProfile::Pm
}
/// [`resolve`] against the operator's `~/.trusty-mpm/config.toml`.
///
/// Why: the prompt composer and the style report have no config in hand.
/// What: [`MpmConfig::load_default`], then [`resolve`].
/// Test: `a_project_only_switch_stays_pm`.
pub fn resolve_ambient(project_dir: &Path) -> SessionProfile {
resolve(project_dir, &MpmConfig::load_default())
}
/// The launch stamp for `profile`: `(SESSION_PROFILE_ENV, id)`.
///
/// Why: the PM stamp is written too, so an inherited supervisor stamp can
/// never reach a session launched as a PM.
/// Test: `the_launch_stamp_names_the_profile`.
pub fn launch_env(profile: SessionProfile) -> (String, String) {
(SESSION_PROFILE_ENV.to_owned(), profile.id().to_owned())
}
/// The model a session of `profile` launches on.
///
/// Why: `tm launch` passes `--model`, which outranks the project settings, so
/// it must name the supervisor's model too.
/// What: [`SUPERVISOR_MODEL`] for a supervisor; otherwise the PM model from
/// [`crate::core::model_inject::resolve_pm_model`], unchanged.
/// Test: `the_supervisor_model_is_the_opus_tier_alias`.
pub fn launch_model(profile: SessionProfile, config: &MpmConfig) -> String {
match profile {
SessionProfile::Supervisor => SUPERVISOR_MODEL.to_string(),
SessionProfile::Pm => crate::core::model_inject::resolve_pm_model(config, None),
}
}
/// The directory a `PreToolUse` hook call's session was launched in.
///
/// Why: the hook's `cwd` follows the session's `cd`, so a supervisor that
/// changes into a watched project would be judged by that project's file.
/// Claude Code exports `CLAUDE_PROJECT_DIR` — the launch directory — to every
/// hook.
/// What: `project_dir_env` when set and non-empty; otherwise `None`, which
/// the caller treats as the PM profile. The payload `cwd` is never used.
/// Test: `the_hook_reads_only_the_launch_directory`.
pub fn hook_project_dir(project_dir_env: Option<OsString>) -> Option<PathBuf> {
project_dir_env
.filter(|dir| !dir.is_empty())
.map(PathBuf::from)
}
/// The profile `tm hook --pm-guard` applies to a tool call.
///
/// Why: a stamp alone could come from an older file state, and the files
/// alone can be edited by the session; only all three together are the
/// operator's current grant.
/// What: [`SessionProfile::Supervisor`] only when `stamp` is exactly
/// [`SUPERVISOR_PROFILE_ID`], `project_dir_env` names a directory, and
/// [`resolve`] over `config()` says supervisor. `config` is called only after
/// the stamp matches, so a PM session reads no file.
/// Test: `the_hook_needs_the_stamp_the_allowlist_and_the_file`.
pub fn hook_profile(
stamp: Option<OsString>,
project_dir_env: Option<OsString>,
config: impl FnOnce() -> MpmConfig,
) -> SessionProfile {
if stamp.as_deref() != Some(std::ffi::OsStr::new(SUPERVISOR_PROFILE_ID)) {
return SessionProfile::Pm;
}
let Some(project_dir) = hook_project_dir(project_dir_env) else {
return SessionProfile::Pm;
};
resolve(&project_dir, &config())
}
#[cfg(test)]
#[path = "session_profile_tests.rs"]
mod tests;