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
//! The Architect's env-file exemption from the #7266 secret-file rule (#8939).
//!
//! Why: owner ruling item 44 — the Architect must manage a project's dotenv
//! files, and every caller is denied any command naming one. The Architect
//! rulings on the #8939 design narrow the grant to two verbs that print no
//! value: `tm env set` (the value arrives on stdin or from the login Keychain,
//! never in argv) and `tm env keys` (names only).
//! What: [`evaluate_secret_file_read_gated`] is the one secret-read decision
//! both deny sites call — `pm_guard` on the guarded path and `pm_guard_floor`
//! under a bypass — so the two cannot drift. Its order: the value-printing
//! rules (a printed credential, the pod/launchd/pm2 env dumps, a launchd
//! plist, and since #8879 a run script whose body reads a credential) deny
//! unconditionally; then the #7266 file rule; then a file-rule
//! deny is lifted only when [`envfile_shape`] matches AND the call is the
//! process-bound Architect's main thread. A shape match with a failed
//! identity denies with the #8878 PR-I identity suffix. [`gate_secret_file_read`]
//! and [`audit_envfile_allow`] write the `architect-envfile` audit line and
//! mint the one-shot grant `tm env` requires (`env_file_grant`).
//! [`envfile_policy`] is the path policy the guard and `tm env` share.
//! Tools (`Read`, `Write`, `Edit`, `Grep`, …) are never exempt.
//! Scope (Architect ruling Q2 addendum): `CLAUDE_PROJECT_DIR` plus the
//! `[supervisor] projects` list of the user config `~/.trusty-mpm/config.toml`,
//! read by the same reader as the #8878 identity check. It never comes from
//! `~/.trusty-mpm/project-paths.json`, which any PM launch writes.
//! FAIL-CLOSED: a path that does not resolve, lies outside that scope, or is
//! not a regular file (a missing file too, for `set`) keeps the deny; so does
//! any identity failure. An unreadable or malformed config lists no root, so
//! only `CLAUDE_PROJECT_DIR` counts; with that unset too, nothing is in scope.
//! Residual 1 (accepted, Architect ruling Q7): the main-thread/subagent split
//! relies on the harness putting `agent_id` in every subagent payload; a
//! subagent shares the Architect's `claude` PID. This applies equally to
//! every #8878 exemption.
//! Test: `pm_guard_architect_envfile_tests.rs`; end to end in
//! `tests/tm_hook_pm_guard_architect_envfile_8939.rs`.
use std::path::{Path, PathBuf};
use serde_json::Value;
use trusty_mpm::core::config::MpmConfig;
use crate::commands::env_file::is_key_name;
use crate::commands::env_file_grant;
use crate::commands::pm_guard_architect_reason::with_identity;
use crate::commands::pm_guard_bash::evaluate_credential_print_command;
use crate::commands::pm_guard_deny_log::{AUDIT_POST_TIMEOUT, DenyContext};
use crate::commands::pm_guard_floor::ArchitectGate;
use crate::commands::pm_guard_secret_env_files::evaluate_env_plist_read;
use crate::commands::pm_guard_secret_nested::evaluate_nested_secret_rules;
use crate::commands::pm_guard_secret_read::evaluate_secret_file_read_in;
use crate::commands::pm_guard_secret_script::evaluate_script_body_secret_read;
use crate::commands::pm_guard_trust_anchor::HookEnv;
use crate::commands::pm_guard_trust_anchor_paths::{Resolved, resolve};
/// The rule name on the audit line an exempted call writes.
pub(crate) const ENVFILE_RULE: &str = "architect-envfile";
/// Bytes a shape-matching command may contain: no quote, expansion, glob,
/// redirect, pipe, separator, `=` (so no `VAR=` prefix and no `KEY=value`),
/// tab or newline.
fn is_shape_byte(c: char) -> bool {
c.is_ascii_alphanumeric() || " _./-@:+,".contains(c)
}
/// One exempted `tm env` call: what the audit line and the grant record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EnvfileCall {
/// `set` or `keys`.
pub(crate) verb: &'static str,
/// The resolved env file.
pub(crate) path: PathBuf,
/// The key `set` writes; empty for `keys`. Never a value.
pub(crate) keys: Vec<String>,
/// The Keychain `(service, account)` `set` reads, by name; never a value.
pub(crate) keychain: Option<(String, String)>,
}
/// A dotenv file inside the project scope, as [`envfile_policy`] resolved it.
///
/// Why: #8939 fix round — the verb must touch only the path the policy
/// checked, never its argv path. The field is private, so only the policy
/// makes one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ScopedEnvfile(PathBuf);
impl ScopedEnvfile {
/// The canonical path the policy checked.
pub(crate) fn path(&self) -> &Path {
&self.0
}
}
/// The one env-file policy the guard and `tm env` share (#8939 fix round).
///
/// Why: the guard and the verb each checked a different half; the verb
/// checked neither the dotenv name nor the scope.
/// What: [`place_envfile`] (dotenv name, regular file or absent when
/// `may_be_absent`, not a symlink, resolved) then [`in_scope`]. `None` means
/// refuse.
/// Test: `a_config_listed_root_is_in_scope_and_another_path_is_not`,
/// `an_architect_bound_set_on_a_non_env_file_never_reads_the_keychain`,
/// `an_out_of_scope_env_file_is_refused_by_the_verb`.
pub(crate) fn envfile_policy(
word: &str,
cwd: &Path,
may_be_absent: bool,
env: &HookEnv,
config: impl FnOnce() -> MpmConfig,
) -> Option<ScopedEnvfile> {
let path = place_envfile(word, cwd, may_be_absent)?;
in_scope(&path, env, config).then_some(ScopedEnvfile(path))
}
/// The secret-read decision for one tool call: `Some(reason)` denies.
///
/// Why: #8939 — one function for both deny sites, so the guarded and the
/// bypassed path cannot drift.
/// What: see the module doc. On an exemption the call is recorded on `gate`
/// for [`audit_envfile_allow`].
/// Test: `the_architect_main_thread_may_list_env_key_names`,
/// `the_architect_main_thread_may_set_an_env_key`,
/// `the_value_rules_run_before_the_envfile_exemption`,
/// `every_identity_failure_denies_the_envfile_exemption`.
pub(crate) fn evaluate_secret_file_read_gated(
tool_name: &str,
tool_input: Option<&Value>,
hook_cwd: &Path,
gate: &ArchitectGate<'_>,
) -> Option<String> {
let command = (tool_name == "Bash").then(|| {
tool_input
.and_then(|v| v.get("command"))
.and_then(Value::as_str)
.unwrap_or_default()
});
// 1. The value-printing rules, unconditionally: the Architect included.
if let Some(reason) = command
.and_then(|c| {
evaluate_credential_print_command(c).or_else(|| evaluate_nested_secret_rules(c))
})
.or_else(|| evaluate_env_plist_read(tool_name, tool_input, hook_cwd))
// #8879: a script the command runs is judged by its body.
.or_else(|| command.and_then(|c| evaluate_script_body_secret_read(c, hook_cwd)))
{
return Some(reason);
}
// 2. The #7266 file rule. Its entry point re-runs the Bash value rules,
// which step 1 has already cleared, so only the file rule can answer.
// #8093: the hook cwd lets the copy grant see its destination.
let reason = evaluate_secret_file_read_in(tool_name, tool_input, Some(hook_cwd))?;
// 3. The exemption: the shape first, then the identity.
let shape = |c| envfile_shape(c, hook_cwd, gate.env(), || gate.config());
let Some(call) = command.and_then(shape) else {
return Some(reason);
};
match gate.identity() {
Ok(()) => {
gate.record_envfile(call);
None
}
Err(why) => Some(with_identity(reason, why)),
}
}
/// [`evaluate_secret_file_read_gated`] for `pm_guard`, auditing an exemption.
pub(crate) async fn gate_secret_file_read(
ctx: &DenyContext<'_>,
tool_name: &str,
tool_input: Option<&Value>,
hook_cwd: &Path,
gate: &ArchitectGate<'_>,
) -> Option<String> {
let deny = evaluate_secret_file_read_gated(tool_name, tool_input, hook_cwd, gate);
if deny.is_none() {
audit_envfile_allow(ctx, gate).await;
}
deny
}
/// Mint the `tm env` grant, then POST the `architect-envfile` allow line,
/// when `gate` recorded an exemption.
///
/// Why: Architect ruling Q6 — each exempted call leaves an audit line with the
/// path and key names only. Best effort, like every guard audit POST. The
/// grant (#8939 fix round) is what lets the verb run at all; a failed mint
/// leaves the verb refusing.
/// Test: `an_exempted_call_is_audited_with_the_path_and_key_names_only`,
/// `the_main_thread_grant_lets_the_verb_run_once`.
pub(crate) async fn audit_envfile_allow(ctx: &DenyContext<'_>, gate: &ArchitectGate<'_>) {
mint_envfile_grant(gate);
let Some(body) = allow_audit_body(ctx, gate) else {
return;
};
let Ok(client) = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_millis(500))
.timeout(AUDIT_POST_TIMEOUT)
.build()
else {
return;
};
let _ = client
.post(format!("{}/hooks", ctx.url))
.json(&body)
.send()
.await;
}
/// The audit body for the exemption `gate` recorded, or `None`.
pub(crate) fn allow_audit_body(ctx: &DenyContext<'_>, gate: &ArchitectGate<'_>) -> Option<Value> {
let call = gate.envfile()?;
let path = call.path.display().to_string();
Some(serde_json::json!({
"session_id": ctx.session_id,
"event": "PreToolUse",
"payload": {
"cwd": ctx.cwd,
"tool": ctx.tool_name,
"pm_guard_decision": "allow",
"pm_guard_rule": ENVFILE_RULE,
"pm_guard_reason": format!("{ENVFILE_RULE}: `tm env {}` on {path}", call.verb),
"path": path,
"keys": call.keys,
}
}))
}
/// Write the one-shot `tm env` grant for the exemption `gate` recorded.
///
/// What: nothing when no exemption was recorded, the home is unknown, or the
/// call is a `set` with no `--from-keychain` (a grant would bind no value,
/// and `tm env` refuses that form); a write error is logged and leaves no
/// grant, so the verb refuses.
/// Test: `a_stdin_form_set_gets_no_grant_and_is_refused`.
pub(crate) fn mint_envfile_grant(gate: &ArchitectGate<'_>) {
let (Some(call), Some(home)) = (gate.envfile(), gate.env().home.as_deref()) else {
return;
};
// #8939 delta critic MEDIUM: only a Keychain-sourced `set` is granted.
if call.verb == "set" && call.keychain.is_none() {
return;
}
if let Err(e) = env_file_grant::mint(home, call) {
tracing::warn!("tm env grant not written: {e}");
}
}
/// The exempt shape of `command`, or `None`.
///
/// Why: #8939 design §2 with rulings Q1-Q4 — only `tm env keys <path>` and
/// `tm env set <path> <KEY> [--from-keychain <service> --account <account>]`,
/// as one bare-`tm` segment, on a dotenv file inside the project scope.
/// What: every byte passes [`is_shape_byte`]; the words are split on spaces
/// and must be exactly one of the two forms, with no word after `set`/`keys`
/// starting with `-` bar the two flags, each once. The path is placed against
/// `hook_cwd`, must be a regular file (or absent, for `set`) and not a
/// symlink, and must resolve, lexical and resolved names both in the dotenv
/// family, inside [`in_scope`]. `config` is the user config reader; it runs
/// only once the path is placed.
/// Test: `the_architect_main_thread_still_may_not_print_an_env_value`,
/// `a_config_listed_root_is_in_scope_and_another_path_is_not`.
pub(crate) fn envfile_shape(
command: &str,
hook_cwd: &Path,
env: &HookEnv,
config: impl FnOnce() -> MpmConfig,
) -> Option<EnvfileCall> {
if command.is_empty() || !command.chars().all(is_shape_byte) {
return None;
}
let words: Vec<&str> = command.split(' ').filter(|w| !w.is_empty()).collect();
let (verb, path, keys, keychain) = match words.as_slice() {
["tm", "env", "keys", path] => ("keys", *path, Vec::new(), None),
["tm", "env", "set", path, key, flags @ ..] => {
is_key_name(key).then_some(())?;
(
"set",
*path,
vec![(*key).to_owned()],
keychain_flags(flags)?,
)
}
_ => return None,
};
let file = envfile_policy(path, hook_cwd, verb == "set", env, config)?;
Some(EnvfileCall {
verb,
path: file.0,
keys,
keychain,
})
}
/// `Some(None)` for no flags; `Some(Some((service, account)))` for
/// `--from-keychain <service> --account <account>` in either order, with no
/// value starting with `-`; `None` for anything else.
fn keychain_flags(flags: &[&str]) -> Option<Option<(String, String)>> {
match flags {
[] => Some(None),
[a, x, b, y] if a != b && !x.starts_with('-') && !y.starts_with('-') => match (*a, *b) {
("--from-keychain", "--account") => Some(Some(((*x).to_owned(), (*y).to_owned()))),
("--account", "--from-keychain") => Some(Some(((*y).to_owned(), (*x).to_owned()))),
_ => None,
},
_ => None,
}
}
/// Whether `name` is in the dotenv family: `.env`, or `.env.<anything>`.
/// Placeholder names (`.env.example`) never reach here: the file rule allows
/// them for every caller.
pub(crate) fn is_dotenv_name(name: &str) -> bool {
name == ".env"
|| name
.strip_prefix(".env.")
.is_some_and(|rest| !rest.is_empty())
}
/// The resolved env file `word` names, or `None`.
///
/// What: `word` must not start with `-`; a relative word joins `hook_cwd`,
/// which must be absolute. The entry must be a regular file, not a symlink;
/// `may_be_absent` also accepts "not found". Both the lexical and the resolved
/// basename must be [`is_dotenv_name`].
fn place_envfile(word: &str, hook_cwd: &Path, may_be_absent: bool) -> Option<PathBuf> {
if word.starts_with('-') {
return None;
}
let placed = if Path::new(word).is_absolute() {
PathBuf::from(word)
} else if hook_cwd.is_absolute() {
hook_cwd.join(word)
} else {
return None;
};
let dotenv = |p: &Path| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(is_dotenv_name)
};
if !dotenv(&placed) {
return None;
}
match std::fs::symlink_metadata(&placed) {
Ok(meta) if meta.file_type().is_file() => {}
Err(e) if may_be_absent && e.kind() == std::io::ErrorKind::NotFound => {}
_ => return None,
}
match resolve(&placed) {
Resolved::Path(resolved) if dotenv(&resolved) => Some(resolved),
_ => None,
}
}
/// Whether the resolved `path` lies strictly inside `CLAUDE_PROJECT_DIR` or a
/// `[supervisor] projects` root (Architect ruling Q2 and its addendum).
///
/// What: the listed roots come from `config()` — the user config, never
/// `project-paths.json` (see the module doc); an unreadable or malformed
/// config loads as the default, which lists none. A relative listed entry is
/// skipped, as `session_profile::path_is_listed` skips it. Each root is
/// canonicalized; a root that does not resolve, is `/`, or is the home
/// directory or one of its ancestors is skipped.
/// Test: `a_config_listed_root_is_in_scope_and_another_path_is_not`,
/// `a_project_paths_json_entry_grants_no_scope`,
/// `an_unreadable_config_leaves_only_the_project_dir_in_scope`.
fn in_scope(path: &Path, env: &HookEnv, config: impl FnOnce() -> MpmConfig) -> bool {
let home = env
.home
.as_deref()
.and_then(|h| std::fs::canonicalize(h).ok());
let project = env.project_dir.as_ref().map(PathBuf::from);
// #8939 Q2 addendum: the trust anchor is the only source of listed roots.
let listed = config().supervisor.projects.into_iter();
let listed = listed.filter(|root| root.is_absolute());
project.into_iter().chain(listed).any(|root| {
let Ok(root) = std::fs::canonicalize(&root) else {
return false;
};
let degenerate =
root.parent().is_none() || home.as_deref().is_some_and(|h| h.starts_with(&root));
!degenerate && path != root && path.starts_with(&root)
})
}
#[cfg(test)]
#[path = "pm_guard_architect_envfile_tests.rs"]
mod tests;