mind-cli 0.45.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
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
//! Hardening of the catalog scan against source-controlled tree shapes that
//! are not items: a symlinked entry in a convention kind directory, an
//! `--add-root` skills container, or an `[[items]]` declared path (DSC-108), a
//! declared path escaping the clone through a symlinked parent (DSC-73), and a
//! declared `workflow` entry whose path is a directory (DSC-109).
//!
//! Each test drives the real `mind` binary against a hermetic fixture: a local
//! git repo melded by filesystem path, with `MIND_HOME`/`CLAUDE_HOME` pointed
//! at temp dirs. No network.

use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU32, Ordering};

static COUNTER: AtomicU32 = AtomicU32::new(0);

struct Sandbox {
    base: PathBuf,
    source: PathBuf,
    mind_home: PathBuf,
    claude_home: PathBuf,
}

struct Run {
    stdout: String,
    stderr: String,
    success: bool,
}

impl Sandbox {
    /// A source repo carrying one ordinary workflow, committed. Tests add the
    /// shape under test on top.
    fn new() -> Sandbox {
        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
        let base =
            std::env::temp_dir().join(format!("mind-scan-hardening-{}-{n}", std::process::id()));
        let _ = std::fs::remove_dir_all(&base);
        let source = base.join("agents");
        let sb = Sandbox {
            base: base.clone(),
            source: source.clone(),
            mind_home: base.join("mind"),
            claude_home: base.join("claude"),
        };
        write(
            &source.join("workflows/ship.js"),
            "export const meta = { name: 'ship', description: 'Ship it' };\n",
        );
        git(&source, &["-c", "init.defaultBranch=main", "init", "-q"]);
        git(&source, &["config", "user.email", "t@t"]);
        git(&source, &["config", "user.name", "t"]);
        git(&source, &["add", "-A"]);
        git(&source, &["commit", "-qm", "initial"]);
        std::fs::create_dir_all(&sb.mind_home).unwrap();
        sb
    }

    fn mind(&self, args: &[&str]) -> Run {
        let out = Command::new(env!("CARGO_BIN_EXE_mind"))
            .args(args)
            .env("MIND_HOME", &self.mind_home)
            .env("CLAUDE_HOME", &self.claude_home)
            .env_remove("MIND_AGENT_HOMES")
            .env_remove("MIND_MAX_METADATA_SIZE")
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .stdin(Stdio::null())
            .output()
            .expect("run mind");
        Run {
            stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
            success: out.status.success(),
        }
    }

    fn source_spec(&self) -> String {
        self.source.to_string_lossy().into_owned()
    }

    fn commit(&self) {
        git(&self.source, &["add", "-A"]);
        git(&self.source, &["commit", "-qm", "fixture"]);
    }
}

impl Drop for Sandbox {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.base);
    }
}

fn write(path: &Path, contents: &str) {
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    std::fs::write(path, contents).unwrap();
}

fn git(repo: &Path, args: &[&str]) {
    std::fs::create_dir_all(repo).unwrap();
    let status = Command::new("git")
        .args(args)
        .current_dir(repo)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .expect("run git");
    assert!(status.success(), "git {args:?} failed");
}

// spec: DSC-108
#[cfg(unix)]
#[test]
fn a_symlinked_skill_anchor_is_not_a_discovery_oracle() {
    // The flat kinds are not the only classification the scan makes. A skill is
    // `skills/<name>/` plus a `SKILL.md` anchor, and following the ANCHOR link
    // is strictly worse than following a workflow's: the anchor is the file
    // whose frontmatter becomes the item's description, so a `SKILL.md` aimed
    // at an arbitrary `.md` on the consumer's machine both answers "does this
    // path exist" (the skill appears or does not) and republishes that file's
    // `description:` into the catalog.
    let sb = Sandbox::new();
    let outside = sb.base.join("outside/private.md");
    write(
        &outside,
        "---\ndescription: SECRETDESCRIPTION from outside the source\n---\n",
    );
    std::fs::create_dir_all(sb.source.join("skills/leak")).unwrap();
    std::os::unix::fs::symlink(&outside, sb.source.join("skills/leak/SKILL.md")).unwrap();
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    assert!(
        meld.success,
        "a source carrying a symlinked anchor must still meld: {} {}",
        meld.stdout, meld.stderr
    );
    let probe = sb.mind(&["probe", "--no-tui"]);
    let listing = format!("{}{}", probe.stdout, probe.stderr);
    assert!(
        !listing.contains("leak"),
        "a skill whose SKILL.md is a symlink must not be discovered: {listing}"
    );
    assert!(
        !listing.contains("SECRETDESCRIPTION"),
        "a symlinked anchor must not republish a linked-to file's description: {listing}"
    );
    // It is also not installable, so discovery was the only surface that could
    // have leaked: the copy walk refuses a symlink in an item tree (LIFE-42).
    let learn = sb.mind(&["learn", "skill:leak"]);
    assert!(
        !learn.success,
        "a symlink-anchored skill must not be installable: {} {}",
        learn.stdout, learn.stderr
    );
}

// spec: DSC-108
#[cfg(unix)]
#[test]
fn a_symlinked_skill_directory_is_not_an_item() {
    // The other half of the skill shape: the DIRECTORY entry itself is a link,
    // pointing at a real skill tree outside the repo. Following it would offer
    // an item whose content is not the source's, and which cannot install
    // (LIFE-42 refuses a symlinked item root), so the offer could never be
    // honored.
    let sb = Sandbox::new();
    write(
        &sb.base.join("outside/realskill/SKILL.md"),
        "---\ndescription: SECRETDESCRIPTION from outside the source\n---\n",
    );
    std::fs::create_dir_all(sb.source.join("skills")).unwrap();
    std::os::unix::fs::symlink(
        sb.base.join("outside/realskill"),
        sb.source.join("skills/leak"),
    )
    .unwrap();
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    assert!(
        meld.success,
        "a source carrying a symlinked skill dir must still meld: {} {}",
        meld.stdout, meld.stderr
    );
    let probe = sb.mind(&["probe", "--no-tui"]);
    let listing = format!("{}{}", probe.stdout, probe.stderr);
    assert!(
        !listing.contains("leak") && !listing.contains("SECRETDESCRIPTION"),
        "a symlinked skill directory must not be discovered as an item: {listing}"
    );
}

// spec: DSC-108
#[cfg(unix)]
#[test]
fn a_symlinked_kind_container_is_not_listed() {
    // The per-entry no-follow check runs only after the container is listed, so
    // a `workflows/` that is itself a link would walk a directory outside the
    // source and offer its files as items.
    let sb = Sandbox::new();
    write(
        &sb.base.join("outside/wf/leak.js"),
        "export const meta = { name: 'leak', description: 'SECRETDESCRIPTION' };\n",
    );
    std::fs::remove_dir_all(sb.source.join("workflows")).unwrap();
    std::os::unix::fs::symlink(sb.base.join("outside/wf"), sb.source.join("workflows")).unwrap();
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    assert!(
        meld.success,
        "a source with a symlinked container must still meld: {} {}",
        meld.stdout, meld.stderr
    );
    let probe = sb.mind(&["probe", "--no-tui"]);
    let listing = format!("{}{}", probe.stdout, probe.stderr);
    assert!(
        !listing.contains("leak") && !listing.contains("SECRETDESCRIPTION"),
        "a symlinked workflows/ container must not be listed: {listing}"
    );
}

// spec: DSC-108
#[cfg(unix)]
#[test]
fn a_symlinked_tool_directory_is_not_an_item() {
    // A tool needs no anchor file at all: the directory IS the item, so the
    // directory classification is the whole test of whether it exists. Same
    // no-follow rule, same reason.
    let sb = Sandbox::new();
    write(
        &sb.base.join("outside/realtool/TOOL.md"),
        "---\ndescription: SECRETDESCRIPTION from outside the source\n---\n",
    );
    std::fs::create_dir_all(sb.source.join("tools")).unwrap();
    std::os::unix::fs::symlink(
        sb.base.join("outside/realtool"),
        sb.source.join("tools/leak"),
    )
    .unwrap();
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    assert!(
        meld.success,
        "a source carrying a symlinked tool dir must still meld: {} {}",
        meld.stdout, meld.stderr
    );
    let probe = sb.mind(&["probe", "--no-tui"]);
    let listing = format!("{}{}", probe.stdout, probe.stderr);
    assert!(
        !listing.contains("leak") && !listing.contains("SECRETDESCRIPTION"),
        "a symlinked tool directory must not be discovered as an item: {listing}"
    );
}

// spec: DSC-108
#[cfg(unix)]
#[test]
fn a_discover_glob_does_not_reach_a_symlinked_entry_either() {
    // `[discover]` globs never pass through the flat scan, so the no-follow
    // rule has to hold in the glob expansion too or the authoritative layer is
    // a second route to the same oracle: `workflows/*.js` matches a symlink by
    // NAME, and the match is what the item is built from.
    //
    // A link whose target resolves OUTSIDE the repo is already a hard DSC-81
    // refusal (covered separately below); the two uncovered cases are the ones
    // that stay lexically inside, where nothing but the classification decides:
    // a link to a real file in the repo, and a dangling link (whose target
    // cannot be canonicalized, so DSC-81's check has nothing to reject).
    let sb = Sandbox::new();
    write(
        &sb.source.join("src/real-impl.js"),
        "export const meta = { name: 'leak', description: 'Inside the repo' };\n",
    );
    std::os::unix::fs::symlink(
        sb.source.join("src/real-impl.js"),
        sb.source.join("workflows/leak.js"),
    )
    .unwrap();
    std::os::unix::fs::symlink(
        sb.base.join("nowhere/absent.js"),
        sb.source.join("workflows/dangling.js"),
    )
    .unwrap();
    write(
        &sb.source.join("mind.toml"),
        "[discover]\nworkflows = { include = [\"workflows/*.js\"] }\n",
    );
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    let probe = sb.mind(&["probe", "--no-tui"]);
    let listing = format!(
        "{}{}{}{}",
        meld.stdout, meld.stderr, probe.stdout, probe.stderr
    );
    assert!(
        probe.success,
        "the glob scan must not fail on a symlinked match: {listing}"
    );
    assert!(
        listing.contains("ship"),
        "the real globbed workflow must still be discovered: {listing}"
    );
    assert!(
        !listing.contains("leak") && !listing.contains("dangling"),
        "a globbed symlink must not be discovered as an item: {listing}"
    );
}

// spec: DSC-81
#[cfg(unix)]
#[test]
fn a_discover_glob_matching_an_escaping_symlink_is_still_a_hard_refusal() {
    // The DSC-108 filter must not soften DSC-81: a glob match whose link
    // resolves outside the repo root is the author's own pattern reaching out of
    // the clone, and that stays a named error rather than becoming a silent
    // skip. Pinned because the two rules meet in one loop and the order between
    // them is what decides.
    let sb = Sandbox::new();
    write(
        &sb.base.join("outside/private.js"),
        "export const meta = { name: 'leak', description: 'Outside' };\n",
    );
    std::os::unix::fs::symlink(
        sb.base.join("outside/private.js"),
        sb.source.join("workflows/leak.js"),
    )
    .unwrap();
    write(
        &sb.source.join("mind.toml"),
        "[discover]\nworkflows = { include = [\"workflows/*.js\"] }\n",
    );
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    let combined = format!("{}{}", meld.stdout, meld.stderr);
    assert!(
        !meld.success,
        "a glob match resolving outside the repo root must be refused: {combined}"
    );
    assert!(
        combined.contains("outside the repo root"),
        "the refusal must say the match escaped the root: {combined}"
    );
}

// spec: DSC-108
#[cfg(unix)]
#[test]
fn a_symlinked_workflow_pointing_outside_the_source_is_not_an_item() {
    // The scan runs over a tree the source controls entirely. Classifying an
    // entry by following its link would answer questions about the CONSUMER's
    // filesystem: whether a path exists (the item appears or does not) and how
    // big the file there is (`review`'s workflow size report). Both answers
    // have to come from the repo's own entries, so a symlink is not an item
    // whatever it points at.
    let sb = Sandbox::new();

    // A real, readable `.js` outside the source repo: the scan must not reach
    // it even though following the link plainly would.
    let outside = sb.base.join("outside/private.js");
    write(
        &outside,
        "export const meta = { name: 'private', description: 'Not the source\\'s file' };\n",
    );
    std::os::unix::fs::symlink(&outside, sb.source.join("workflows/leak.js")).unwrap();
    // The same shape one kind over, to prove the rule is the flat scan's and
    // not something specific to the workflow kind.
    let outside_md = sb.base.join("outside/private.md");
    write(
        &outside_md,
        "---\ndescription: Not the source's file\n---\n",
    );
    std::fs::create_dir_all(sb.source.join("agents")).unwrap();
    std::os::unix::fs::symlink(&outside_md, sb.source.join("agents/leak.md")).unwrap();
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    assert!(
        meld.success,
        "a source carrying a symlinked entry must still meld: {} {}",
        meld.stdout, meld.stderr
    );

    let probe = sb.mind(&["probe", "--no-tui"]);
    assert!(probe.success, "probe must succeed: {}", probe.stderr);
    let listing = format!("{}{}", probe.stdout, probe.stderr);
    assert!(
        listing.contains("ship"),
        "the real workflow must still be discovered: {listing}"
    );
    assert!(
        !listing.contains("leak"),
        "a symlinked entry must not be discovered as an item: {listing}"
    );
    assert!(
        !listing.contains("private"),
        "nothing from outside the source root may reach the catalog: {listing}"
    );

    // And the name is not installable either: it is not in the catalog at all.
    let learn = sb.mind(&["learn", "workflow:leak"]);
    assert!(
        !learn.success,
        "a symlinked entry must not be installable: {} {}",
        learn.stdout, learn.stderr
    );
}

// spec: DSC-108
#[cfg(unix)]
#[test]
fn a_symlinked_workflow_with_a_dangling_target_is_not_an_item_either() {
    // The existence oracle is the point: whether the link resolves must not
    // change the item listing, or the listing answers "does this path exist on
    // your machine" one entry at a time.
    let sb = Sandbox::new();
    std::os::unix::fs::symlink(
        sb.base.join("nowhere/absent.js"),
        sb.source.join("workflows/probe-existence.js"),
    )
    .unwrap();
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    assert!(
        meld.success,
        "a dangling symlink must not fail the meld: {} {}",
        meld.stdout, meld.stderr
    );
    let probe = sb.mind(&["probe", "--no-tui"]);
    let listing = format!("{}{}", probe.stdout, probe.stderr);
    assert!(
        !listing.contains("probe-existence"),
        "a dangling symlink must not be discovered as an item: {listing}"
    );
}

// spec: DSC-109
#[test]
fn a_declared_workflow_whose_path_is_a_directory_is_refused() {
    // A workflow IS one `.js` file, and that file is also the metadata the
    // scan reads. A directory has nothing to read, would install a tree at a
    // link target the harness tries to load as a script, and would report a
    // directory's size in `review`. The author wrote the path, so it is a hard
    // refusal rather than a skipped item.
    let sb = Sandbox::new();
    write(
        &sb.source.join("bundle/index.js"),
        "export const meta = { name: 'bundle', description: 'A bundle' };\n",
    );
    write(
        &sb.source.join("mind.toml"),
        "[[items]]\nkind = \"workflow\"\nname = \"bundle\"\npath = \"bundle\"\n",
    );
    sb.commit();

    // The refusal lands wherever the source is first scanned: at the meld
    // itself, or at the first scanning verb after a register-only meld. Either
    // is the scan-time refusal DSC-109 describes; what must not happen is the
    // entry being catalogued.
    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    let probe = sb.mind(&["probe", "--no-tui"]);
    assert!(
        !meld.success || !probe.success,
        "a directory-shaped workflow entry must be refused at scan time: meld={} {} probe={} {}",
        meld.stdout,
        meld.stderr,
        probe.stdout,
        probe.stderr
    );
    let combined = format!(
        "{}{}{}{}",
        meld.stdout, meld.stderr, probe.stdout, probe.stderr
    );
    assert!(
        combined.contains("bundle"),
        "the refusal must name the offending item: {combined}"
    );
    assert!(
        combined.contains("directory"),
        "the refusal must say the path is a directory: {combined}"
    );
    assert!(
        combined.contains(".js") || combined.contains("single"),
        "the refusal must say what a workflow path has to name: {combined}"
    );
}

// spec: DSC-109
#[test]
fn a_declared_workflow_whose_path_is_a_file_still_works() {
    // Control: the check is about the shape of the path, not about declaring a
    // workflow in `[[items]]` at all.
    let sb = Sandbox::new();
    write(
        &sb.source.join("bundle/deploy.js"),
        "export const meta = { name: 'deploy', description: 'Deploy it' };\n",
    );
    write(
        &sb.source.join("mind.toml"),
        "[[items]]\nkind = \"workflow\"\nname = \"deploy\"\npath = \"bundle/deploy.js\"\n",
    );
    sb.commit();

    let r = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    assert!(
        r.success,
        "a file-shaped workflow entry must meld: {} {}",
        r.stdout, r.stderr
    );
    let probe = sb.mind(&["probe", "--no-tui"]);
    assert!(
        probe.stdout.contains("deploy"),
        "the declared workflow must be catalogued: {}",
        probe.stdout
    );
}

// spec: DSC-108 DSC-109
#[cfg(unix)]
#[test]
fn a_declared_workflow_symlinked_to_a_directory_is_dropped_at_scan() {
    // DSC-109 classifies no-follow, so a symlink is NOT a directory to it. The
    // DSC-108 guard on declared paths then drops the symlink at scan with a
    // warning: it is never catalogued, so there is nothing to install.
    let sb = Sandbox::new();
    write(
        &sb.source.join("real/index.js"),
        "export const meta = { name: 'bundle', description: 'A bundle' };\n",
    );
    std::os::unix::fs::symlink(sb.source.join("real"), sb.source.join("bundle")).unwrap();
    write(
        &sb.source.join("mind.toml"),
        "[[items]]\nkind = \"workflow\"\nname = \"bundle\"\npath = \"bundle\"\n",
    );
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    assert!(
        meld.success,
        "a symlinked declared path is dropped, not a failed meld: {} {}",
        meld.stdout, meld.stderr
    );
    assert!(
        meld.stderr.contains("symlink"),
        "the drop must be reported, naming the symlink: {}",
        meld.stderr
    );
    let learn = sb.mind(&["learn", "workflow:bundle", "--yes"]);
    assert!(
        !learn.success,
        "a dropped entry is not in the catalog, so it cannot install: {} {}",
        learn.stdout, learn.stderr
    );
    // Nothing linked into the lobe, not even a dangling link.
    assert!(
        std::fs::symlink_metadata(sb.claude_home.join("workflows/bundle.js")).is_err(),
        "a dropped entry must leave no link behind"
    );
}

// spec: DSC-108
#[cfg(unix)]
#[test]
fn a_declared_workflow_symlinked_to_an_outside_file_is_dropped_unread() {
    // `[[items]]` names a path by hand, so without a no-follow check it is a
    // third route (after the convention scan and `[discover]` globs) to read
    // a file on the consumer's machine into the catalog and report its size.
    let sb = Sandbox::new();
    let outside = sb.base.join("outside/probe.js");
    // Padded to a distinctive byte count, so a size report would show it.
    let mut body =
        String::from("export const meta = { name: 'probe', description: 'SECRET-DECL' };\n");
    while body.len() < 4321 {
        body.push('/');
    }
    body.truncate(4321);
    write(&outside, &body);
    std::os::unix::fs::symlink(&outside, sb.source.join("workflows/probe.js")).unwrap();
    write(
        &sb.source.join("mind.toml"),
        "[[items]]\nkind = \"workflow\"\nname = \"ship\"\npath = \"workflows/ship.js\"\n\n\
         [[items]]\nkind = \"workflow\"\nname = \"probe\"\npath = \"workflows/probe.js\"\n",
    );
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    assert!(
        meld.success,
        "a symlinked declared path is dropped, not a failed meld: {} {}",
        meld.stdout, meld.stderr
    );
    assert!(
        meld.stderr.contains("warning") && meld.stderr.contains("symlink"),
        "the drop must be warned about: {}",
        meld.stderr
    );
    let probe = sb.mind(&["probe", "--no-tui"]);
    assert!(probe.success, "probe must succeed: {}", probe.stderr);
    assert!(
        probe.stdout.contains("workflow:ship"),
        "the real declared workflow is still offered: {}",
        probe.stdout
    );
    assert!(
        !probe.stdout.contains("workflow:probe"),
        "a symlinked declared workflow must not be offered: {}",
        probe.stdout
    );
    let review = sb.mind(&["review", &sb.source_spec()]);
    let combined = format!(
        "{}{}{}{}",
        meld.stdout, meld.stderr, review.stdout, review.stderr
    );
    assert!(
        !combined.contains("SECRET-DECL"),
        "the outside file's content must never be read: {combined}"
    );
    assert!(
        !combined.contains("4321"),
        "the outside file's size must never be reported: {combined}"
    );
}

// spec: DSC-73
#[cfg(unix)]
#[test]
fn a_declared_path_through_a_symlinked_parent_dir_is_a_hard_error() {
    // The final component is a regular file, so the no-follow check passes it;
    // only the canonical containment check sees that a symlinked PARENT carries
    // it out of the clone. The author wrote the path, so it is refused outright,
    // like an escaping `[discover]` match (DSC-81).
    let sb = Sandbox::new();
    write(
        &sb.base.join("outside/a.md"),
        "---\ndescription: SECRET-PARENT\n---\n",
    );
    std::os::unix::fs::symlink(sb.base.join("outside"), sb.source.join("linked")).unwrap();
    write(
        &sb.source.join("mind.toml"),
        "[[items]]\nkind = \"agent\"\nname = \"a\"\npath = \"linked/a.md\"\n",
    );
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    let probe = sb.mind(&["probe", "--no-tui"]);
    let combined = format!(
        "{}{}{}{}",
        meld.stdout, meld.stderr, probe.stdout, probe.stderr
    );
    assert!(
        !meld.success || !probe.success,
        "an escaping declared path must be refused at scan: {combined}"
    );
    assert!(
        combined.contains("resolves outside the repo root"),
        "the refusal must say the path escaped the root: {combined}"
    );
    assert!(
        !combined.contains("SECRET-PARENT"),
        "the outside file must not be read: {combined}"
    );
}

// spec: DSC-108 DSC-84
#[cfg(unix)]
#[test]
fn an_add_root_skills_container_classifies_symlinks_no_follow() {
    // The `--add-root` containered `skills/` pass is its own loop, separate
    // from the convention scan, so it needs the same no-follow classification:
    // a linked skill dir or a linked SKILL.md anchor would read a file outside
    // the clone into the catalog.
    let sb = Sandbox::new();
    write(
        &sb.source.join("extra/skills/real/SKILL.md"),
        "---\ndescription: the source's own skill\n---\n",
    );
    write(
        &sb.base.join("outside/linked/SKILL.md"),
        "---\ndescription: SECRET-ADDROOT\n---\n",
    );
    write(
        &sb.base.join("outside/anchor.md"),
        "---\ndescription: SECRET-ADDROOT anchor\n---\n",
    );
    std::os::unix::fs::symlink(
        sb.base.join("outside/linked"),
        sb.source.join("extra/skills/linked"),
    )
    .unwrap();
    std::fs::create_dir_all(sb.source.join("extra/skills/anchor")).unwrap();
    std::os::unix::fs::symlink(
        sb.base.join("outside/anchor.md"),
        sb.source.join("extra/skills/anchor/SKILL.md"),
    )
    .unwrap();
    sb.commit();

    let meld = sb.mind(&[
        "meld",
        &sb.source_spec(),
        "--add-root",
        "extra",
        "--register-only",
    ]);
    assert!(
        meld.success,
        "a source with symlinked add-root entries must still meld: {} {}",
        meld.stdout, meld.stderr
    );
    let probe = sb.mind(&["probe", "--no-tui"]);
    assert!(probe.success, "probe must succeed: {}", probe.stderr);
    assert!(
        probe.stdout.contains("skill:real"),
        "the add-root's own skill is offered: {}",
        probe.stdout
    );
    let combined = format!(
        "{}{}{}{}",
        meld.stdout, meld.stderr, probe.stdout, probe.stderr
    );
    assert!(
        !combined.contains("linked") && !combined.contains("skill:anchor"),
        "a symlinked skill dir or anchor must not be discovered: {combined}"
    );
    assert!(
        !combined.contains("SECRET-ADDROOT"),
        "no linked-to file's description may reach the catalog: {combined}"
    );
}

// spec: DSC-109
#[test]
fn a_declared_workflow_whose_path_does_not_exist_fails_at_install_not_at_scan() {
    // The shape check is about a path that exists and is the wrong KIND of
    // thing. A path that names nothing at all is the ordinary
    // declared-but-absent case every kind shares: it is not a scan failure (the
    // scan makes no existence claim about a declared path), and the install is
    // where it is reported, naming the path.
    let sb = Sandbox::new();
    write(
        &sb.source.join("mind.toml"),
        "[[items]]\nkind = \"workflow\"\nname = \"ghost\"\npath = \"workflows/ghost.js\"\n",
    );
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    assert!(
        meld.success,
        "an absent declared path must not fail the scan: {} {}",
        meld.stdout, meld.stderr
    );
    let learn = sb.mind(&["learn", "workflow:ghost", "--yes"]);
    assert!(
        !learn.success,
        "installing an absent declared workflow must fail: {} {}",
        learn.stdout, learn.stderr
    );
    assert!(
        format!("{}{}", learn.stdout, learn.stderr).contains("ghost.js"),
        "the failure must name the path that is missing: {} {}",
        learn.stdout,
        learn.stderr
    );
}

// spec: WF-10
#[test]
fn a_declared_workflow_may_carry_a_non_js_source_extension_and_still_links_as_js() {
    // The extension rule (WF-3) governs the CONVENTION scan's discovery, not
    // what an author may declare: a declared entry names its own file, and the
    // link is built from the kind and the effective name, so it is `.js` in the
    // lobe whatever the file is called in the repo. Pinned because the DSC-109
    // shape check sits right next to it and must not grow into an extension
    // check by accident.
    let sb = Sandbox::new();
    write(
        &sb.source.join("src/deploy.ts"),
        "export const meta = { name: 'deploy', description: 'Deploy it' };\n",
    );
    write(
        &sb.source.join("mind.toml"),
        "[[items]]\nkind = \"workflow\"\nname = \"deploy\"\npath = \"src/deploy.ts\"\n",
    );
    sb.commit();

    let meld = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
    assert!(meld.success, "must meld: {} {}", meld.stdout, meld.stderr);
    let learn = sb.mind(&["learn", "workflow:deploy", "--yes"]);
    assert!(
        learn.success,
        "a declared workflow with a non-.js source path must install: {} {}",
        learn.stdout, learn.stderr
    );
    assert!(
        sb.claude_home.join("workflows/deploy.js").exists(),
        "the lobe link must be `.js` regardless of the source file's extension"
    );
}