mind-cli 0.36.0

A manager for agent tooling (skills, agents, rules, tools) that melds arbitrary git repos and links items into your agent directories.
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
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
//! ANSI-escape sanitization shared between the CLI and TUI layers.
//!
//! The CLI applies `strip_ansi` at every catalog-string display site
//! (commands.rs, MKT-9 / DSC-69). This module exposes the same logic as
//! `pub(crate)` so the TUI data layer can sanitize at its model boundary
//! (TUI-60) without depending on commands.rs.

/// An item's identity key (`kind:name`), where `name` is attacker-controlled:
/// a directory name in a melded repo, or a `[[items]] name` in its
/// `mind.toml` (DSC-95). This wraps the raw string so the two readings of a
/// key -- identity (map/set lookups, comparisons, the manifest key, a path
/// component) and display (a human/`--json` print site) -- are distinct
/// types, not two methods on `String` that happen to look alike at a call
/// site.
///
/// The actual property this type provides: `Display` is withheld, so the
/// DEFAULT formatting path (`println!("{}", key)`, `format!("{key}")`,
/// `{}` inside a `--json` struct via `#[derive(Serialize)]` on a `String`
/// field) cannot leak a raw key -- those are compile errors instead of a
/// call-site discipline a 19th print site could quietly skip. `.as_str()`
/// (identity) and `impl From<ItemKey> for String` (identity, e.g. to key a
/// `BTreeMap<String, _>`) remain as explicit, EXPLICITLY NAMED escape
/// hatches: each call site that reaches for one is stating, in its own
/// text, "I want the raw reading" -- the type does not stop a caller from
/// asking for the raw string, it only stops that string from reaching a
/// terminal by accident via the default path. Discipline is still required
/// at each `as_str()`/`.into()` call site; this type narrows where that
/// discipline is needed to those explicit sites instead of every print site
/// in the codebase.
///
/// `Debug` is still derived and is safe to use in a `{:?}` (e.g. a panic
/// message or an `expect`): `Debug for str` escapes control characters (an
/// ESC becomes `\u{1b}`), so it cannot forge a terminal escape sequence the
/// way `Display` could.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct ItemKey(String);

impl ItemKey {
    /// Build a key from its raw `kind:name` (or similar) string. Callers are
    /// the three `key()` constructors (`InstalledItem`, `CatalogItem`,
    /// `UnmanagedItem`); construct via those rather than directly where a
    /// `CatalogItem`/`InstalledItem`/`UnmanagedItem` is available.
    pub(crate) fn new(raw: impl Into<String>) -> Self {
        ItemKey(raw.into())
    }

    /// The raw key, for identity use only: map/set lookups, equality
    /// comparisons, a manifest key, a path component. An explicit,
    /// discipline-guarded escape hatch (see the type doc above) -- it does
    /// not sanitize, so a caller reaching for it is asking for the raw
    /// reading and must not hand the result to a `println!`/`format!`/
    /// `eprintln!` or a `--json` field; use [`Self::display`] there instead.
    pub(crate) fn as_str(&self) -> &str {
        &self.0
    }

    /// The key sanitized for a terminal (DSC-95): the only string this type
    /// produces that may reach a human/`--json` print site.
    pub(crate) fn display(&self) -> String {
        strip_ansi(&self.0)
    }
}

impl std::borrow::Borrow<str> for ItemKey {
    fn borrow(&self) -> &str {
        &self.0
    }
}

impl PartialEq<str> for ItemKey {
    fn eq(&self, other: &str) -> bool {
        self.0 == other
    }
}

impl PartialEq<&str> for ItemKey {
    fn eq(&self, other: &&str) -> bool {
        self.0 == *other
    }
}

impl PartialEq<ItemKey> for str {
    fn eq(&self, other: &ItemKey) -> bool {
        self == other.0
    }
}

impl PartialEq<ItemKey> for &str {
    fn eq(&self, other: &ItemKey) -> bool {
        *self == other.0
    }
}

/// Escape hatch for a call site that genuinely needs to own the raw string
/// as a plain `String` (e.g. to insert as a `BTreeMap<String, _>` key so an
/// on-disk format stays unchanged). Still the raw, unsanitized value --
/// identity, not display; see the type doc above for why this and
/// [`ItemKey::as_str`] are the two named, explicit ways to reach it.
///
/// No `Serialize`/`Deserialize` impl exists for `ItemKey` itself, and
/// deliberately so: nothing in this codebase serializes an `ItemKey`
/// directly today (every persisted/JSON site goes through this `Into<String>`
/// or `.as_str()` first), and adding a transparent `Serialize` impl would
/// reopen exactly the leak this type exists to close -- a future
/// `#[derive(Serialize)]` struct with a raw `ItemKey` field would silently
/// emit the unsanitized string into a `--json` envelope with no compile
/// error, the same class of miss (`ItemHooksJson`-style) this refactor was
/// written to catch.
impl From<ItemKey> for String {
    fn from(k: ItemKey) -> String {
        k.0
    }
}

/// Whether `c` is a C0, DEL, or C1 control character (the "collapse to a
/// space" bucket [`strip_ansi`] uses for a run of these -- see there).
fn is_control(c: char) -> bool {
    c < '\x20' || ('\x7f'..='\u{009f}').contains(&c)
}

/// Whether `c` is a security-blocked Unicode code point: a bidi-override,
/// directional-mark, or zero-width/invisible character (spoofing vectors), a
/// line/paragraph separator, or a member of the Unicode tag block or the
/// variation-selector block. Silently dropped by [`strip_ansi`], with no space
/// substituted -- unlike a control character, none of these represents a word
/// boundary, so inserting a space where one of them sat would add whitespace
/// that was never there.
///
/// This is the Unicode format category (Cf) -- code points that affect layout
/// or rendering but carry no visible glyph of their own -- plus two blocks that
/// are not Cf but are equally invisible: the tag block (U+E0000-U+E007F,
/// nominally deprecated but the standard "invisible ASCII smuggling" vector: a
/// payload hidden in tag characters renders as nothing to a human at a
/// terminal, yet is plain text to a parser or an AI agent reading the same
/// string) and the variation selectors (U+FE00-U+FE0F, U+E0100-U+E01EF: they
/// modify the glyph of the character before them and are themselves invisible,
/// so they can be smuggled between two characters to defeat a substring
/// comparison the same way a zero-width character can). Deliberately NOT
/// blocked: combining marks (U+0300 range) and printable non-ASCII generally --
/// both are visible, so stripping them would corrupt legitimate non-English
/// text (see [`strip_ansi`]'s doc comment) without closing an invisibility
/// vector.
///
/// M5 (NS-73) broadened this from the original bidi/zero-width/separator set;
/// every code point blocked before that change is still blocked (a superset),
/// so DSC-96-era callers that only exercise the older set keep working.
fn is_blocked_unicode(c: char) -> bool {
    matches!(
        c,
        // Bidi-override code points: phishing/spoofing vectors.
        '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
        // Line separator and paragraph separator.
        | '\u{2028}' | '\u{2029}'
        // Directional marks (LRM, RLM, Arabic Letter Mark): weaker spoofing
        // vectors than the overrides above, but still non-printing marks that
        // can misrepresent direction-sensitive text; stripped as
        // defense-in-depth (the overrides are the primary threat and are
        // already blocked above).
        | '\u{200E}' | '\u{200F}' | '\u{061C}'
        // Zero-width characters: invisible, so a hostile source can use them
        // to defeat a visual/substring comparison of a sanitized string.
        | '\u{200B}' | '\u{2060}' | '\u{FEFF}'
        // Soft hyphen: renders as nothing unless a line break falls there.
        | '\u{00AD}'
        // Mongolian vowel separator: a zero-width format character.
        | '\u{180E}'
        // Invisible mathematical operators (function application, invisible
        // times/separator/plus): U+2060 (word joiner) is listed above; these
        // fill out the rest of the same Cf run.
        | '\u{2061}'..='\u{2064}'
        // Deprecated Cf format characters (inhibit/activate symmetric
        // swapping, national digit shapes, nominal digit shapes): invisible
        // layout-affecting code points, same rationale as the bidi marks.
        | '\u{206A}'..='\u{206F}'
        // Variation selectors: modify the glyph of the preceding character
        // and are themselves invisible, so one can be smuggled between two
        // characters to defeat a substring comparison.
        | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}'
        // Interlinear annotation characters: invisible markup, never meant to
        // reach a plain-text display.
        | '\u{FFF9}'..='\u{FFFB}'
        // Hangul fillers: assigned code points that render as blank.
        | '\u{115F}' | '\u{3164}'
        // The Unicode tag block: nominally deprecated, but the standard
        // "invisible ASCII smuggling" vector -- a payload hidden here renders
        // as nothing to a human at a terminal, yet is plain text to a parser
        // or an AI agent reading the same string. The most important addition
        // in this broadening (M5): this text is read by both.
        | '\u{E0000}'..='\u{E007F}'
    )
}

/// Strip ANSI escape sequences, C0/DEL/C1 control characters, and Unicode
/// bidi-override/separator/zero-width/invisible code points from `s`
/// (DSC-69, MKT-9; the invisible/format set is [`is_blocked_unicode`], NS-73).
///
/// Printable non-ASCII (U+00A0 and above, minus the blocked ranges) is
/// preserved so non-English curator messages are not corrupted.
///
/// The first pass, `strip_ansi_escapes::strip`, is itself a small terminal
/// emulator: it does not merely delete a recognized escape sequence, it
/// *executes* it against a virtual terminal state and forwards only what that
/// terminal would have printed. One consequence a caller composing a display
/// string must know about: a bare OSC introducer (`\x1b]`) with no terminator
/// is treated as "sequence still open" and consumes everything after it to the
/// end of input -- so a value ending in an unterminated OSC (e.g. an item named
/// `evil\x1b]`) silently swallows whatever a caller appends *after* it in the
/// same composed string, not just the item name itself. Sanitize each field
/// with this function BEFORE composing a line out of several fields, never
/// after: compose-then-sanitize lets one field's dangling escape eat its
/// neighbors.
///
/// A maximal run of consecutive control characters that reach this function's
/// own filter collapses to a single space rather than vanishing, so text
/// built by joining lines with one -- a multi-line hook command embedded in a
/// consent disclosure, say -- does not have two originally-separate lines
/// silently fuse into one word when the separator between them is stripped
/// (CLI-224). In practice the only control character that ever reaches this
/// filter is `\n`: the first pass, `strip_ansi_escapes::strip`, is itself a
/// small terminal emulator that forwards a C0 control byte to its output only
/// when it is `\n` (see its `Perform::execute`), so every other C0/DEL/C1
/// control is already gone, without a trace, before this function's loop
/// runs -- unaffected by the collapse behavior below, which only ever
/// operates on what actually arrives. A security-blocked Unicode code point
/// ([`is_blocked_unicode`]) is still dropped with no space substituted: none
/// of those represents a word boundary the way a control character does.
pub(crate) fn strip_ansi(s: &str) -> String {
    let bytes = strip_ansi_escapes::strip(s);
    // Input is valid UTF-8, so output is too; lossy conversion is a no-op in practice.
    let text = String::from_utf8_lossy(&bytes);
    let mut out = String::with_capacity(text.len());
    let mut in_control_run = false;
    for c in text.chars() {
        if is_blocked_unicode(c) {
            continue;
        }
        if is_control(c) {
            if !in_control_run {
                out.push(' ');
                in_control_run = true;
            }
            continue;
        }
        out.push(c);
        in_control_run = false;
    }
    out
}

/// Sanitize a filesystem path for display (DSC-95): a path built from a
/// source-controlled item name (a store path, a lobe link target) can embed
/// ANSI/control/bidi bytes just as much as the name itself.
// spec: CLI-232 DSC-95
pub(crate) fn display_path(p: &std::path::Path) -> String {
    strip_ansi(&p.display().to_string())
}

/// Whether `s` contains any character [`strip_ansi`] would remove or collapse: a
/// C0/DEL/C1 control character (ESC, `\x1b`, is one, so any ANSI escape sequence
/// makes this true) or a security-blocked Unicode code point -- bidi override,
/// directional mark, zero-width/invisible format character, line/paragraph
/// separator, or a member of the Unicode tag block or the variation-selector
/// block ([`is_blocked_unicode`], broadened by NS-73).
///
/// Use this to *reject* a source-controlled value that will key a filesystem
/// path, a namespace prefix, or an item's identity (DSC-95, DSC-96, NS-72,
/// NS-73), where sanitizing in place would silently mutate identity -- e.g. two
/// item names that differ only by a tag-block code point would otherwise
/// render identically after display sanitization, making them indistinguishable
/// in `recall`/`probe` while remaining distinct on disk. To clean a value that
/// is only ever displayed, use [`strip_ansi`] instead.
pub(crate) fn has_blocked_chars(s: &str) -> bool {
    s.chars().any(|c| is_control(c) || is_blocked_unicode(c))
}

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

    // spec: TUI-60
    #[test]
    fn strip_ansi_removes_escape_sequences() {
        // An ANSI color sequence must be stripped entirely.
        assert_eq!(strip_ansi("\x1b[31mhello\x1b[0m"), "hello");
        // Nested/compound sequences are stripped too.
        assert_eq!(strip_ansi("\x1b[1;32mgreen bold\x1b[0m"), "green bold");
    }

    // spec: TUI-60
    #[test]
    fn strip_ansi_removes_bidi_overrides() {
        // Bidi-override U+202E is a phishing/spoofing vector and must be removed.
        assert_eq!(strip_ansi("pay \u{202E}oot"), "pay oot");
        // Every bidi range is stripped.
        assert_eq!(strip_ansi("\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}"), "");
        assert_eq!(strip_ansi("\u{2066}\u{2067}\u{2068}\u{2069}"), "");
    }

    // spec: TUI-60
    #[test]
    fn strip_ansi_removes_line_and_para_separators() {
        assert_eq!(strip_ansi("line\u{2028}break"), "linebreak");
        assert_eq!(strip_ansi("para\u{2029}sep"), "parasep");
    }

    // spec: TUI-60
    #[test]
    fn strip_ansi_preserves_printable_ascii() {
        let s = "hello world 123!";
        assert_eq!(strip_ansi(s), s);
    }

    // spec: TUI-60
    #[test]
    fn strip_ansi_preserves_printable_unicode() {
        // Accented and non-Latin characters are preserved.
        assert_eq!(strip_ansi("hello\u{00e9}"), "hello\u{00e9}");
        assert_eq!(strip_ansi("caf\u{00e9}"), "caf\u{00e9}");
    }

    // spec: TUI-60
    #[test]
    fn strip_ansi_removes_c0_controls() {
        // `strip_ansi_escapes::strip` (the first pass) is itself a terminal
        // emulator that only forwards a C0 control byte to the raw output when
        // it is `\n`; every other C0 control (NUL, a plain `\x01`, unit
        // separator `\x1f`, ...) is consumed there and never reaches this
        // module's own control-character filter at all, so it leaves no
        // space -- unaffected by the CLI-224 collapse-to-space fix below,
        // which only ever sees a control character that survived that first
        // pass (in practice, just `\n`).
        assert_eq!(strip_ansi("a\x00b"), "ab");
        assert_eq!(strip_ansi("a\x01b"), "ab");
        assert_eq!(strip_ansi("a\x1fb"), "ab");
        // Space (0x20) is preserved.
        assert_eq!(strip_ansi("a b"), "a b");
    }

    // spec: CLI-224
    #[test]
    fn strip_ansi_collapses_newline_runs_to_one_space_not_joining_words() {
        // The motivating case (security fix 3): a multi-line hook command
        // embedded in a disclosure message must not have its lines silently
        // fused into one word when the newline between them is stripped.
        assert_eq!(
            strip_ansi("make build\nmake install"),
            "make build make install"
        );
        // A run of several control characters (e.g. "\r\n") still collapses to
        // exactly one space, not one per character.
        assert_eq!(strip_ansi("line one\r\nline two"), "line one line two");
        assert_eq!(strip_ansi("a\n\n\nb"), "a b");
        // A leading or trailing run still produces a boundary space rather than
        // vanishing, since the rule is "a run of control bytes is one space",
        // not "unless it's at an edge".
        assert_eq!(strip_ansi("\ntail"), " tail");
        assert_eq!(strip_ansi("head\n"), "head ");
    }

    // spec: CLI-224
    #[test]
    fn strip_ansi_blocked_unicode_still_vanishes_without_a_space() {
        // A security-blocked Unicode code point (bidi override, directional
        // mark, zero-width, line/paragraph separator) is not a word boundary
        // the way a control character is, so it is still dropped outright, with
        // no space substituted -- this pins that the CLI-224 space-collapse
        // fix applies only to the control-character bucket.
        assert_eq!(strip_ansi("pay\u{202E}oot"), "payoot");
        assert_eq!(strip_ansi("wo\u{200B}rd"), "word");
        assert_eq!(strip_ansi("line\u{2028}break"), "linebreak");
    }

    // spec: CLI-224
    #[test]
    fn strip_ansi_removes_directional_marks_and_zero_width() {
        // Directional marks (LRM, RLM, ALM) and zero-width characters (ZWSP,
        // WORD JOINER, BOM) are residual spoofing vectors left after the strong
        // bidi overrides were already stripped; removed as defense-in-depth.
        assert_eq!(strip_ansi("a\u{200E}b"), "ab", "LRM");
        assert_eq!(strip_ansi("a\u{200F}b"), "ab", "RLM");
        assert_eq!(strip_ansi("a\u{061C}b"), "ab", "ALM");
        assert_eq!(strip_ansi("a\u{200B}b"), "ab", "zero-width space");
        assert_eq!(strip_ansi("a\u{2060}b"), "ab", "word joiner");
        assert_eq!(
            strip_ansi("a\u{FEFF}b"),
            "ab",
            "BOM / zero-width no-break space"
        );
    }

    // spec: NS-73
    #[test]
    fn strip_ansi_removes_m5_broadened_set() {
        // M5: the blocked set is broadened to cover the rest of the Unicode
        // format category (Cf) plus the tag block and the variation
        // selectors. Each of these was invisible before this change and is
        // now removed with no space substituted, same as the rest of
        // `is_blocked_unicode`.
        assert_eq!(strip_ansi("a\u{00AD}b"), "ab", "soft hyphen");
        assert_eq!(strip_ansi("a\u{180E}b"), "ab", "Mongolian vowel separator");
        assert_eq!(
            strip_ansi("a\u{2061}\u{2062}\u{2063}\u{2064}b"),
            "ab",
            "invisible math operators"
        );
        assert_eq!(
            strip_ansi("a\u{206A}b"),
            "ab",
            "deprecated Cf format character"
        );
        assert_eq!(strip_ansi("a\u{FE0F}b"), "ab", "variation selector");
        assert_eq!(
            strip_ansi("a\u{E0100}b"),
            "ab",
            "supplementary variation selector"
        );
        assert_eq!(strip_ansi("a\u{FFF9}b"), "ab", "interlinear annotation");
        assert_eq!(strip_ansi("a\u{115F}b"), "ab", "Hangul choseong filler");
        assert_eq!(strip_ansi("a\u{3164}b"), "ab", "Hangul filler");
        // The Unicode tag block (M5's headline addition): renders as nothing
        // in a terminal, so a name like `review\u{E0041}` (TAG LATIN SMALL
        // LETTER A appended) would otherwise sanitize identically to `review`
        // while remaining a distinct raw string -- exactly the ambiguity
        // DSC-96 relies on this module to close.
        assert_eq!(
            strip_ansi("review\u{E0041}"),
            "review",
            "Unicode tag block character"
        );
        assert_eq!(
            strip_ansi("\u{E0001}\u{E007F}"),
            "",
            "tag-block language tag and cancel tag"
        );
    }

    // spec: TUI-60
    #[test]
    fn strip_ansi_empty_string() {
        assert_eq!(strip_ansi(""), "");
    }

    // spec: DSC-95 NS-73
    #[test]
    fn has_blocked_chars_flags_exactly_what_strip_removes() {
        // Clean strings (including printable non-ASCII) are not flagged, and
        // `strip_ansi` is a no-op on them -- the two must agree in both
        // directions (M5b), not merely on membership in these sample lists.
        let ok = ["", "hello", "my-skill", "caf\u{00e9}", "a.b_c-2"];
        for s in ok {
            assert!(!has_blocked_chars(s), "{s:?} should be clean");
            assert_eq!(
                has_blocked_chars(s),
                strip_ansi(s) != s,
                "has_blocked_chars/strip_ansi disagree on {s:?}"
            );
        }
        // Control characters, ANSI escapes (ESC is a control char), and the
        // security-blocked Unicode set (the original bidi/zero-width/separator
        // set plus the NS-73 broadening: soft hyphen, Mongolian vowel
        // separator, invisible math operators, deprecated Cf format chars,
        // variation selectors, interlinear annotation, Hangul fillers, and the
        // Unicode tag block) are all flagged, and every one of them is also a
        // case where `strip_ansi` actually changes the string -- pinning that
        // the predicate flags EXACTLY what the stripper removes, not a
        // superset or subset of it (M5b).
        let bad = [
            "a\x00b",
            "a\nb",
            "a\x1b[0mb",
            "pay\u{202E}oot",
            "a\u{2066}b",
            "a\u{200B}b",
            "a\u{200E}b",
            "a\u{FEFF}b",
            "a\u{2028}b",
            "a\u{0085}b",
            // NS-73 additions.
            "a\u{00AD}b",
            "a\u{180E}b",
            "a\u{2061}b",
            "a\u{206A}b",
            "a\u{FE0F}b",
            "a\u{E0100}b",
            "a\u{FFF9}b",
            "a\u{115F}b",
            "a\u{3164}b",
            "review\u{E0041}",
        ];
        for s in bad {
            assert!(has_blocked_chars(s), "{s:?} should be flagged");
            assert_eq!(
                has_blocked_chars(s),
                strip_ansi(s) != s,
                "has_blocked_chars/strip_ansi disagree on {s:?}"
            );
        }
    }

    // spec: CLI-224
    // INDEPENDENT CERTIFICATION of the load-bearing claim in `strip_ansi`'s doc
    // comment: `strip_ansi_escapes::strip` (the first pass) forwards only `\n`
    // among C0/DEL/C1 control bytes, so the collapse-to-space behavior added by
    // CLI-224 can only ever affect a `\n` in practice. If a TAB or CR survived
    // the first pass, it would reach the collapse loop and become a space,
    // widening the shared-caller impact beyond what the doc comment documents
    // (hook.rs disclosure, selfupdate.rs, tui/data.rs). These pin that they do
    // NOT survive: a TAB or CR is consumed by the first pass and leaves nothing
    // (not even a space), exactly like NUL / `\x01` / `\x1f` above.
    #[test]
    fn strip_ansi_tab_and_cr_are_consumed_by_first_pass_leaving_no_space() {
        // TAB (0x09) is dropped outright, no space -- proving it never reaches
        // the collapse loop. If this became "a b", TAB survived the first pass
        // and the collapse behavior now affects tabs too (wider than documented).
        assert_eq!(
            strip_ansi("a\tb"),
            "ab",
            "TAB must not survive to become a space"
        );
        // A lone CR (0x0d), not part of a CRLF, is likewise consumed with no
        // space. (Within a run alongside `\n`, e.g. "\r\n", the run collapses to
        // one space; that is covered separately above.)
        assert_eq!(
            strip_ansi("a\rb"),
            "ab",
            "lone CR must not survive to become a space"
        );
        // Vertical tab and form feed, other C0 controls, are also consumed.
        assert_eq!(strip_ansi("a\x0bb"), "ab", "VT must not survive");
        assert_eq!(strip_ansi("a\x0cb"), "ab", "FF must not survive");
        // An ESC that opens a recognized sequence is consumed with the sequence,
        // never emitted as a control char the collapse loop would turn to a space.
        assert_eq!(
            strip_ansi("a\x1b[0mb"),
            "ab",
            "an ANSI reset leaves no space"
        );
        // A trailing lone ESC (nothing after it to form a sequence) is likewise
        // dropped, leaving no space.
        assert_eq!(
            strip_ansi("ab\x1b"),
            "ab",
            "a trailing bare ESC leaves no space"
        );
        // The one control that DOES survive the first pass is `\n`, and only it
        // drives the collapse-to-space -- the asymmetry the whole CLI-224
        // rationale rests on.
        assert_eq!(
            strip_ansi("a\nb"),
            "a b",
            "newline is the sole survivor and becomes a space"
        );
    }

    // spec: DSC-95
    // NOTE: `ItemKey::display` is implemented as a direct call to
    // `strip_ansi(&self.0)`, so this assertion holds by construction for
    // every input -- it is a tautology today, not independent verification
    // that the sanitization is correct (that coverage lives in
    // `strip_ansi`'s own tests above). Its value is as a change-detector: if
    // `display()` were ever reimplemented to call something other than
    // `strip_ansi` (or to skip a field), this would catch the drift.
    #[test]
    fn item_key_display_matches_strip_ansi() {
        for raw in [
            "skill:review",
            "skill:evil\x1b[31mred\x1b[0m",
            "agent:pay\u{202E}oot",
            "rule:wo\u{200B}rd",
            "tool:a\nb",
            "",
        ] {
            let key = ItemKey::new(raw);
            assert_eq!(
                key.display(),
                strip_ansi(raw),
                "ItemKey::display() must equal strip_ansi() for {raw:?}"
            );
        }
    }

    // spec: DSC-95
    // `as_str()` returns the raw, unsanitized value unchanged -- it is the
    // identity accessor, not display.
    #[test]
    fn item_key_as_str_is_raw() {
        let raw = "skill:evil\x1b[31m";
        let key = ItemKey::new(raw);
        assert_eq!(key.as_str(), raw);
    }

    // spec: DSC-95
    // `ItemKey` compares and hashes as identity: two keys built from the same
    // raw string are equal and a `Borrow<str>`/`PartialEq<str>` lookup by the
    // raw string succeeds, so map/set membership call sites keep working.
    #[test]
    fn item_key_identity_equality_and_str_borrow() {
        use std::collections::HashSet;

        let a = ItemKey::new("skill:review");
        let b = ItemKey::new("skill:review");
        assert_eq!(a, b);
        assert_eq!(a, *"skill:review");
        assert_eq!("skill:review", a);

        let mut set: HashSet<ItemKey> = HashSet::new();
        set.insert(a.clone());
        assert!(set.contains("skill:review"));
    }
}