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
//! One small per-session fact, written by the statusline and read by another
//! `tm` process (#6972 for the model, #7074 for the transcript path).
//!
//! Why: Claude Code's `statusLine` payload is the only place several session
//! facts ever reach `tm` — the authoritative model id, and the path to the
//! session's own transcript. A separate `tm` process (`tm divert`, `tm
//! commit-trailers`) needs them and shares nothing with the render but the
//! filesystem. #6972 solved that for the model; #7074 needed the identical
//! store for a second fact, and two copies of "write only when it changed,
//! atomically, never failing the render" would drift. This module is the one
//! copy; [`crate::core::session_model`] is a named facade over it.
//!
//! What: `<root>/usage/<kind>/<session_id>` holds one trimmed string.
//! [`record_session_value`] writes it, [`read_session_record`] reads it back.
//!
//! Two properties every caller depends on:
//!
//! - **The write is skipped when nothing changed.** `statusLine` fires on every
//! render cycle; the value is read and compared first, so a steady session
//! does one small read per render and no write at all.
//! - **Every failure is silent.** A missing home directory, an unwritable root,
//! a session id that is not a safe filename — each returns without writing.
//! Nothing here may cost the status bar a render.
//!
//! Both halves of a transcript record are screened, because both come from the
//! same attacker-influenceable payload: [`is_safe_session_id`] the file NAME,
//! [`contained_transcript_path`] the value (#7250).
//!
//! #7278 widened that second screen past this module's own writer. Every `tm`
//! entry point that opens a `transcript_path` taken from a hook payload — the
//! statusline recorder here, the Stop-hook parking detector, and the pm-guard
//! cost evaluator — routes through [`contained_transcript_path`] /
//! [`contained_transcript_file`], and resolves the boundary it screens against
//! through [`claude_config_dir`]. One screen and one boundary resolver, because
//! a second copy of either is what drifts.
//!
//! Test: the inline suite in `session_record_tests.rs` —
//! `a_recorded_value_reads_back`, `rejects_a_path_traversal_session_id`,
//! `an_unchanged_value_leaves_the_file_untouched`,
//! `a_blank_value_is_never_recorded`, `two_kinds_do_not_collide`,
//! `rejects_a_transcript_path_outside_the_config_dir`,
//! `rejects_a_traversing_transcript_path`,
//! `rejects_a_symlink_under_the_config_dir_aimed_outside_it`,
//! `accepts_a_transcript_path_under_the_config_dir`,
//! `the_containment_screen_never_leans_on_frameworkpaths_default`,
//! `session_record_ids_lists_written_ids`,
//! `session_record_ids_of_a_missing_kind_is_empty`.
use ;
/// Record kind holding the session's harness model id (#6972).
pub const KIND_MODEL: &str = "session-model";
/// Record kind holding the path to the session's Claude Code transcript
/// (#7074).
///
/// Why: the transcript is the only source of a session's output-token count —
/// the `statusLine` payload carries a dollar cost and a context-window size,
/// never tokens out — and `tm commit-trailers` runs in a different process from
/// the render that learns the path.
/// Test: `two_kinds_do_not_collide`.
pub const KIND_TRANSCRIPT: &str = "session-transcript";
/// Record kind holding the session's turn-1 startup-context reading (#7424).
///
/// Why: the startup figure is measured once, at the render that first sees an
/// assistant turn, and read back much later by `tm session ls` and `tm doctor`
/// in other processes — the same shape as the two kinds above, so it uses the
/// same store rather than a fourth one. It sits under `usage/` beside the
/// savings ledger the `💸` segment folds, because "what this session spent
/// before it started" and "what the harness avoided spending" are read
/// together.
/// What: the value is one JSON object — see
/// [`crate::core::startup_context::StartupContextRecord`] — rather than the
/// bare scalar the other two kinds hold, because a reading is only usable
/// alongside the project it belongs to and the time it was taken.
/// Test: `two_kinds_do_not_collide`, and the startup-context suite in
/// `startup_context_tests.rs`.
pub const KIND_STARTUP_CONTEXT: &str = "session-startup-context";
/// Is `session_id` a safe single path segment?
///
/// Why: `session_id` arrives from Claude Code's stdin JSON, so it is
/// attacker-influenceable; an unchecked value would let `../../.bashrc` name
/// the file this module writes.
/// What: non-empty, and only `[A-Za-z0-9_-]`.
/// Test: `rejects_a_path_traversal_session_id`.
/// Screen a payload's `transcript_path`, returning the file it may record.
///
/// Why (#7250): `transcript_path` reaches `tm` from Claude Code's `statusLine`
/// stdin JSON on the same footing as `session_id`, which [`is_safe_session_id`]
/// already screens — and a LATER `tm` process opens whatever this store holds.
/// Unscreened, a crafted payload aims that read at any file on the machine.
/// What: the path must be absolute, carry no `..` component, and canonicalize
/// under the canonicalized `claude_config_dir`, the directory Claude Code writes
/// transcripts beneath (`<config>/projects/<slug>/<session_id>.jsonl`).
/// Canonicalization rather than lexical resolution, on BOTH sides: the
/// transcript exists by the time a render reports its path, and resolving
/// symlinks is what stops a link planted under the config directory from
/// aiming the read outside it. A path that cannot be canonicalized is rejected,
/// which costs nothing — `statusLine` fires again seconds later and records it
/// then. The returned path is the CANONICAL one, so the reader opens the file
/// that was checked rather than re-resolving the payload's spelling.
/// Test: `rejects_a_transcript_path_outside_the_config_dir`,
/// `rejects_a_traversing_transcript_path`,
/// `rejects_a_symlink_under_the_config_dir_aimed_outside_it`,
/// `accepts_a_transcript_path_under_the_config_dir`.
/// [`contained_transcript_path`] for a candidate already held as a [`Path`].
///
/// Why (#7278): the pm-guard cost evaluator DERIVES a subagent transcript path
/// (`<dir>/<parent-stem>/subagents/agent-<id>.jsonl`) rather than reading one
/// field verbatim, so it holds a `PathBuf` and not a `&str`. Round-tripping
/// that through `to_str()` would drop a non-UTF-8 path on the floor at the
/// screen instead of at the read — a silently different answer — and a second
/// copy of the containment rule to avoid the round trip is exactly what the
/// common-entry-point rule forbids. This is the one implementation; the `&str`
/// entry point above trims and delegates here.
/// What: the checks [`contained_transcript_path`] documents — absolute, no
/// `..` component, canonicalizes under the canonicalized `claude_config_dir` —
/// returning the CANONICAL path so the caller opens the file that was checked.
/// Test: `rejects_a_transcript_path_outside_the_config_dir`,
/// `rejects_a_traversing_transcript_path`,
/// `rejects_a_symlink_under_the_config_dir_aimed_outside_it`,
/// `accepts_a_transcript_path_under_the_config_dir`,
/// `screens_a_path_valued_candidate_the_same_way`.
/// The Claude config directory a payload's `transcript_path` must sit under.
///
/// Why (#7250, widened #7278): the boundary every containment screen in this
/// module compares against. `CLAUDE_CONFIG_DIR` relocates it for every
/// daemon-managed `tm` session, so a resolver that only knew `~/.claude` would
/// refuse every legitimate managed transcript. Three call sites need this
/// answer — the statusline recorder, the Stop-hook parking detector, and the
/// pm-guard cost evaluator — and three copies of the fallback chain would
/// drift apart; this is the one copy.
///
/// Why not [`FrameworkPaths::default`](crate::core::paths::FrameworkPaths::default)
/// (#7290): with no home directory it resolves against `"."`, which
/// canonicalizes to the process's working directory, so containment would
/// silently re-scope to whatever `<cwd>/.claude` happens to be instead of
/// refusing. A boundary that moves with the caller's cwd is not a boundary.
/// [`FrameworkPaths::under`](crate::core::paths::FrameworkPaths::under) over an
/// explicit `dirs::home_dir()?` is what makes "no home" a `None` the caller
/// must handle.
/// What: `CLAUDE_CONFIG_DIR` when set and non-blank, else `~/.claude` via
/// `FrameworkPaths::under(dirs::home_dir()?).claude_home_dir()`. `None` when
/// neither resolves, and every caller then refuses the path outright — that is
/// what makes the screen fail closed.
/// Test: `the_containment_screen_never_leans_on_frameworkpaths_default` pins
/// the #7290 rule this resolver exists to keep; the `~/.claude` fallback shape
/// has its own coverage in `claude_home_dir_matches_default_home`. The env-var
/// branch is exercised end to end by `tm_hook_idle_parking`, which points
/// `CLAUDE_CONFIG_DIR` at its fixture directory rather than mutating this
/// process's environment.
/// The record's path under an explicit framework root.
///
/// Why: taking the root as an argument is what keeps the statusline writer and
/// every reader on the SAME root — each resolves `--root` / `TRUSTY_MPM_ROOT`
/// at its own call site and passes the result in, exactly as the savings ledger
/// beside it does.
/// What: `<root>/usage/<kind>/<session_id>`; `None` when the id is not a safe
/// file name.
/// Test: `a_recorded_value_reads_back`, `rejects_a_path_traversal_session_id`.
/// The directory every record of `kind` is written into.
///
/// Why (#7424): a reader that wants EVERY session's record — the doctor
/// startup-context sample — needs the directory, and rebuilding
/// `<root>/usage/<kind>` at that call site would be a second spelling of the
/// layout this module owns.
/// What: `<root>/usage/<kind>`.
/// Test: `a_recorded_value_reads_back`, `session_record_ids_lists_written_ids`.
/// Every session id that has a record of `kind`.
///
/// Why (#7424): the doctor check samples recent sessions, and there is no
/// index — the ids ARE the file names. Screening each with
/// [`is_safe_session_id`] on the way out means a stray file dropped into the
/// directory by hand cannot become a session id a later read joins onto a path.
/// What: the directory's entries, unsorted, filtered to safe ids. An absent or
/// unreadable directory yields an empty vector, never an error — every reader
/// of this store fails soft.
/// Test: `session_record_ids_lists_written_ids`,
/// `session_record_ids_of_a_missing_kind_is_empty`.
/// Read the value recorded for `session_id` under `kind`.
///
/// What: the file's trimmed contents; `None` when the id is unsafe, the file is
/// absent or unreadable, or it holds only whitespace.
/// Test: `a_recorded_value_reads_back`, `reading_an_absent_record_is_none`.
/// Remember `value` for `session_id`, writing only when it changed.
///
/// Why/What: see the module doc. No-ops on a blank value, an unsafe session id,
/// or an unchanged value; otherwise creates `<root>/usage/<kind>/` and writes
/// through a temp file renamed into place, so a reader sees either the old
/// value or the new one and never a truncated file. Every error is discarded.
/// Test: `a_recorded_value_reads_back`,
/// `an_unchanged_value_leaves_the_file_untouched`,
/// `a_blank_value_is_never_recorded`.