Give the Rust CLI a real WASM capability sandbox, skills, and a shell gate

a9cbb4e8196b · AtlantisPleb · · parent c9a6c21bbd71

Give the Rust CLI a real WASM capability sandbox, skills, and a shell gate

`crates/openagents-cli` had no WebAssembly engine, so `capability` could not
be implemented and — after a round of advertising a tool that was never in
`list_tools()` — was not declared either. This brings wasmtime into the
binary and ports the host from `packages/openagents-cli/src/coder-plugins.ts`
and `coder-plugin-engine.ts`, closing the WASM half of #71 and the
`capability` half of #84.

The new `crates/openagents-cli/src/plugins.rs` owns the whole contract:
manifest validation, SHA-256 digest verification before the module is
compiled, import inspection against what the manifest declares, read-only
mounts resolved to canonical roots, and the `packet-v0` invocation itself.
Limits are enforced rather than declared — `timeout_ms` becomes a wasmtime
epoch deadline with a watchdog that fires it, and `memory_max_mib` becomes a
`StoreLimits` ceiling, which the Node host could not enforce at all. A plugin
whose limits cannot be enforced does not run.

`capability` is now declared and answered in `tools.rs`, and a plugin loaded
through it declares a further tool under its own manifest name and input
schema. Approval keeps the TypeScript host's three tiers: pure compute loads
without asking, read-only mounts need an operator, and declared network hosts
never load. `oa plugin list|search|inspect|run` is the operator surface;
`--allow-mounts` is the operator.

Skill discovery is ported from `coder-skills.ts`: `SKILL.md` under
`.agents/skills`, `~/.agents/skills`, then the shipped set, nearest name
winning, with front matter that understands `|` and `>` block scalars — the
old parser catalogued the `effect` skill as describing itself as "|". A skill
marked `auto: true` is injected into the system prompt, which is the
end-to-end path that had never been walked.

`check_shell_refusal` was three literal strings; `rm -rf /Users/…`,
`rm -fr ~/`, `mkfs`, `dd of=/dev/disk2`, and a fork bomb all passed it. It is
now the refusal table from `coder-shell.ts`, with the `rm` target pattern
widened to catch `rm -rf ~/` and `--no-preserve-root`, and the
machine-stopping words anchored to command position so
`echo 'shutdown the server' >> notes.md` still runs.

Every sandbox limit is tested by violating it: a guest that grows past its
ceiling is denied the pages, one that never returns is trapped at its
deadline, one that imports `openagents.write_file` never loads, one that
reads `../secret` or through a planted symlink is refused, and one changed
byte in an artifact fails its pin.

Refs #71, #84.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified Cargo.lock
  • modified crates/openagents-cli/Cargo.toml
  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/lib.rs
  • added crates/openagents-cli/src/plugins.rs
  • modified crates/openagents-cli/src/runtime.rs
  • modified crates/openagents-cli/src/tools.rs
  • added crates/openagents-cli/tests/plugin_host_test.rs

Diff

8 files changed, +3683 -85

Cargo.lock modified +620 -3

@@ -2,6 +2,15 @@

2 2
# It is not intended for manual editing.
3 3
version = 4
4 4
5
[[package]]
6
name = "addr2line"
7
version = "0.25.1"
8
source = "registry+https://github.com/rust-lang/crates.io-index"
9
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
10
dependencies = [
11
 "gimli",
12
]
13
5 14
[[package]]
6 15
name = "aho-corasick"
7 16
version = "1.1.4"

@@ -89,6 +98,18 @@ dependencies = [

89 98
 "windows-sys 0.61.2",
90 99
]
91 100
101
[[package]]
102
name = "anyhow"
103
version = "1.0.104"
104
source = "registry+https://github.com/rust-lang/crates.io-index"
105
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
106
107
[[package]]
108
name = "arbitrary"
109
version = "1.4.2"
110
source = "registry+https://github.com/rust-lang/crates.io-index"
111
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
112
92 113
[[package]]
93 114
name = "arrayvec"
94 115
version = "0.7.8"

@@ -168,7 +189,7 @@ dependencies = [

168 189
 "bitflags 2.11.1",
169 190
 "cexpr",
170 191
 "clang-sys",
171
 "itertools",
192
 "itertools 0.13.0",
172 193
 "proc-macro2",
173 194
 "quote",
174 195
 "regex",

@@ -254,6 +275,9 @@ name = "bumpalo"

254 275
version = "3.20.3"
255 276
source = "registry+https://github.com/rust-lang/crates.io-index"
256 277
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
278
dependencies = [
279
 "allocator-api2",
280
]
257 281
258 282
[[package]]
259 283
name = "bytes"

@@ -384,6 +408,15 @@ dependencies = [

384 408
 "cc",
385 409
]
386 410
411
[[package]]
412
name = "cobs"
413
version = "0.3.0"
414
source = "registry+https://github.com/rust-lang/crates.io-index"
415
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
416
dependencies = [
417
 "thiserror 2.0.18",
418
]
419
387 420
[[package]]
388 421
name = "coder-lite"
389 422
version = "0.1.0"

@@ -512,6 +545,153 @@ dependencies = [

512 545
 "libc",
513 546
]
514 547
548
[[package]]
549
name = "cranelift-assembler-x64"
550
version = "0.123.14"
551
source = "registry+https://github.com/rust-lang/crates.io-index"
552
checksum = "6835dba958b2ab7ab523e7e99296e0524317f60430a00cf5850562ef78ea7001"
553
dependencies = [
554
 "cranelift-assembler-x64-meta",
555
]
556
557
[[package]]
558
name = "cranelift-assembler-x64-meta"
559
version = "0.123.14"
560
source = "registry+https://github.com/rust-lang/crates.io-index"
561
checksum = "0b6e4ce8ee6d899381fbdd9e6561336c651189d46cecaeee09b29e8d80aa786e"
562
dependencies = [
563
 "cranelift-srcgen",
564
]
565
566
[[package]]
567
name = "cranelift-bforest"
568
version = "0.123.14"
569
source = "registry+https://github.com/rust-lang/crates.io-index"
570
checksum = "0cb6d37015df7ea4b60450c1229ad5f5819a1fb27434b063f8e6216dfbd0c42a"
571
dependencies = [
572
 "cranelift-entity",
573
]
574
575
[[package]]
576
name = "cranelift-bitset"
577
version = "0.123.14"
578
source = "registry+https://github.com/rust-lang/crates.io-index"
579
checksum = "986bea0b0858b55192782120032ce9c15943fa073f186f6e479653c59e62c329"
580
dependencies = [
581
 "serde",
582
 "serde_derive",
583
]
584
585
[[package]]
586
name = "cranelift-codegen"
587
version = "0.123.14"
588
source = "registry+https://github.com/rust-lang/crates.io-index"
589
checksum = "9f30aeb2de7f97d6f26b4a1642615834daad58e2e4d7c027810010a3a32f22be"
590
dependencies = [
591
 "bumpalo",
592
 "cranelift-assembler-x64",
593
 "cranelift-bforest",
594
 "cranelift-bitset",
595
 "cranelift-codegen-meta",
596
 "cranelift-codegen-shared",
597
 "cranelift-control",
598
 "cranelift-entity",
599
 "cranelift-isle",
600
 "gimli",
601
 "hashbrown 0.15.5",
602
 "log",
603
 "pulley-interpreter",
604
 "regalloc2",
605
 "rustc-hash",
606
 "serde",
607
 "smallvec",
608
 "target-lexicon",
609
 "wasmtime-internal-math",
610
]
611
612
[[package]]
613
name = "cranelift-codegen-meta"
614
version = "0.123.14"
615
source = "registry+https://github.com/rust-lang/crates.io-index"
616
checksum = "cd5dd137fcdedef33b6fd40edf1ced024460d764ceb75833e8198a843395945c"
617
dependencies = [
618
 "cranelift-assembler-x64-meta",
619
 "cranelift-codegen-shared",
620
 "cranelift-srcgen",
621
 "heck",
622
 "pulley-interpreter",
623
]
624
625
[[package]]
626
name = "cranelift-codegen-shared"
627
version = "0.123.14"
628
source = "registry+https://github.com/rust-lang/crates.io-index"
629
checksum = "ab54b260ef23a8f0f536679b9fc3b3b3e05353e8d1448f3ab83df02078e8be9b"
630
631
[[package]]
632
name = "cranelift-control"
633
version = "0.123.14"
634
source = "registry+https://github.com/rust-lang/crates.io-index"
635
checksum = "3f3e569779ad70537f34a670d444ee3d75ae583b2023913f4682814b0979f7e8"
636
dependencies = [
637
 "arbitrary",
638
]
639
640
[[package]]
641
name = "cranelift-entity"
642
version = "0.123.14"
643
source = "registry+https://github.com/rust-lang/crates.io-index"
644
checksum = "2ff53acc85f5c5f7d9315ff133a6671d329a0f04aa2d1a8a2e81d59709ccddcb"
645
dependencies = [
646
 "cranelift-bitset",
647
 "serde",
648
 "serde_derive",
649
]
650
651
[[package]]
652
name = "cranelift-frontend"
653
version = "0.123.14"
654
source = "registry+https://github.com/rust-lang/crates.io-index"
655
checksum = "ab5976c0ff5bfadf61cd8bda81fea78ee5a07018b9cd03e66c0952c56684928b"
656
dependencies = [
657
 "cranelift-codegen",
658
 "log",
659
 "smallvec",
660
 "target-lexicon",
661
]
662
663
[[package]]
664
name = "cranelift-isle"
665
version = "0.123.14"
666
source = "registry+https://github.com/rust-lang/crates.io-index"
667
checksum = "77b4f73d2288e9480fd2d1d9ab576394dce4805443d6148c6d819dbf78865ce4"
668
669
[[package]]
670
name = "cranelift-native"
671
version = "0.123.14"
672
source = "registry+https://github.com/rust-lang/crates.io-index"
673
checksum = "fe9650c2baf22fa1e2542a5bdd8152616ec2023d929c4cbb450ff677ad8d9c21"
674
dependencies = [
675
 "cranelift-codegen",
676
 "libc",
677
 "target-lexicon",
678
]
679
680
[[package]]
681
name = "cranelift-srcgen"
682
version = "0.123.14"
683
source = "registry+https://github.com/rust-lang/crates.io-index"
684
checksum = "4ad4f61ae701d73c326d3df08c366b29ad10f1ba06c245092f217b8d2306746b"
685
686
[[package]]
687
name = "crc32fast"
688
version = "1.5.1"
689
source = "registry+https://github.com/rust-lang/crates.io-index"
690
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
691
dependencies = [
692
 "cfg-if",
693
]
694
515 695
[[package]]
516 696
name = "crossbeam-channel"
517 697
version = "0.5.16"

@@ -521,6 +701,25 @@ dependencies = [

521 701
 "crossbeam-utils",
522 702
]
523 703
704
[[package]]
705
name = "crossbeam-deque"
706
version = "0.8.7"
707
source = "registry+https://github.com/rust-lang/crates.io-index"
708
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
709
dependencies = [
710
 "crossbeam-epoch",
711
 "crossbeam-utils",
712
]
713
714
[[package]]
715
name = "crossbeam-epoch"
716
version = "0.9.20"
717
source = "registry+https://github.com/rust-lang/crates.io-index"
718
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
719
dependencies = [
720
 "crossbeam-utils",
721
]
722
524 723
[[package]]
525 724
name = "crossbeam-utils"
526 725
version = "0.8.22"

@@ -697,6 +896,18 @@ dependencies = [

697 896
 "zeroize",
698 897
]
699 898
899
[[package]]
900
name = "embedded-io"
901
version = "0.4.0"
902
source = "registry+https://github.com/rust-lang/crates.io-index"
903
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
904
905
[[package]]
906
name = "embedded-io"
907
version = "0.6.1"
908
source = "registry+https://github.com/rust-lang/crates.io-index"
909
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
910
700 911
[[package]]
701 912
name = "encoding_rs"
702 913
version = "0.8.35"

@@ -733,6 +944,12 @@ dependencies = [

733 944
 "pin-project-lite",
734 945
]
735 946
947
[[package]]
948
name = "fallible-iterator"
949
version = "0.3.0"
950
source = "registry+https://github.com/rust-lang/crates.io-index"
951
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
952
736 953
[[package]]
737 954
name = "fastrand"
738 955
version = "2.5.0"

@@ -919,6 +1136,17 @@ dependencies = [

919 1136
 "r-efi 6.0.0",
920 1137
]
921 1138
1139
[[package]]
1140
name = "gimli"
1141
version = "0.32.3"
1142
source = "registry+https://github.com/rust-lang/crates.io-index"
1143
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
1144
dependencies = [
1145
 "fallible-iterator",
1146
 "indexmap",
1147
 "stable_deref_trait",
1148
]
1149
922 1150
[[package]]
923 1151
name = "glob"
924 1152
version = "0.3.3"

@@ -964,6 +1192,7 @@ dependencies = [

964 1192
 "allocator-api2",
965 1193
 "equivalent",
966 1194
 "foldhash",
1195
 "serde",
967 1196
]
968 1197
969 1198
[[package]]

@@ -1214,6 +1443,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"

1214 1443
dependencies = [
1215 1444
 "equivalent",
1216 1445
 "hashbrown 0.17.1",
1446
 "serde",
1447
 "serde_core",
1217 1448
]
1218 1449
1219 1450
[[package]]

@@ -1259,6 +1490,15 @@ dependencies = [

1259 1490
 "either",
1260 1491
]
1261 1492
1493
[[package]]
1494
name = "itertools"
1495
version = "0.14.0"
1496
source = "registry+https://github.com/rust-lang/crates.io-index"
1497
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
1498
dependencies = [
1499
 "either",
1500
]
1501
1262 1502
[[package]]
1263 1503
name = "itoa"
1264 1504
version = "1.0.18"

@@ -1379,6 +1619,12 @@ version = "1.5.0"

1379 1619
source = "registry+https://github.com/rust-lang/crates.io-index"
1380 1620
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1381 1621
1622
[[package]]
1623
name = "leb128fmt"
1624
version = "0.1.0"
1625
source = "registry+https://github.com/rust-lang/crates.io-index"
1626
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
1627
1382 1628
[[package]]
1383 1629
name = "libc"
1384 1630
version = "0.2.186"

@@ -1395,6 +1641,12 @@ dependencies = [

1395 1641
 "windows-link 0.2.1",
1396 1642
]
1397 1643
1644
[[package]]
1645
name = "libm"
1646
version = "0.2.16"
1647
source = "registry+https://github.com/rust-lang/crates.io-index"
1648
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1649
1398 1650
[[package]]
1399 1651
name = "linux-raw-sys"
1400 1652
version = "0.4.15"

@@ -1467,6 +1719,15 @@ version = "2.8.0"

1467 1719
source = "registry+https://github.com/rust-lang/crates.io-index"
1468 1720
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
1469 1721
1722
[[package]]
1723
name = "memfd"
1724
version = "0.6.5"
1725
source = "registry+https://github.com/rust-lang/crates.io-index"
1726
checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227"
1727
dependencies = [
1728
 "rustix 1.1.4",
1729
]
1730
1470 1731
[[package]]
1471 1732
name = "mime"
1472 1733
version = "0.3.17"

@@ -1667,6 +1928,18 @@ dependencies = [

1667 1928
 "objc2-core-foundation",
1668 1929
]
1669 1930
1931
[[package]]
1932
name = "object"
1933
version = "0.37.3"
1934
source = "registry+https://github.com/rust-lang/crates.io-index"
1935
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
1936
dependencies = [
1937
 "crc32fast",
1938
 "hashbrown 0.15.5",
1939
 "indexmap",
1940
 "memchr",
1941
]
1942
1670 1943
[[package]]
1671 1944
name = "oboe"
1672 1945
version = "0.6.1"

@@ -1743,6 +2016,8 @@ dependencies = [

1743 2016
 "tungstenite",
1744 2017
 "unicode-segmentation",
1745 2018
 "unicode-width 0.2.0",
2019
 "wasmtime",
2020
 "wat",
1746 2021
 "zeroize",
1747 2022
]
1748 2023

@@ -1835,6 +2110,18 @@ version = "0.3.33"

1835 2110
source = "registry+https://github.com/rust-lang/crates.io-index"
1836 2111
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
1837 2112
2113
[[package]]
2114
name = "postcard"
2115
version = "1.1.3"
2116
source = "registry+https://github.com/rust-lang/crates.io-index"
2117
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
2118
dependencies = [
2119
 "cobs",
2120
 "embedded-io 0.4.0",
2121
 "embedded-io 0.6.1",
2122
 "serde",
2123
]
2124
1838 2125
[[package]]
1839 2126
name = "potential_utf"
1840 2127
version = "0.1.5"

@@ -1871,6 +2158,29 @@ dependencies = [

1871 2158
 "unicode-ident",
1872 2159
]
1873 2160
2161
[[package]]
2162
name = "pulley-interpreter"
2163
version = "36.0.14"
2164
source = "registry+https://github.com/rust-lang/crates.io-index"
2165
checksum = "eb0a4b56042e461cc64456650182938e2d1ede98fa0c8a975027416a2809c414"
2166
dependencies = [
2167
 "cranelift-bitset",
2168
 "log",
2169
 "pulley-macros",
2170
 "wasmtime-internal-math",
2171
]
2172
2173
[[package]]
2174
name = "pulley-macros"
2175
version = "36.0.14"
2176
source = "registry+https://github.com/rust-lang/crates.io-index"
2177
checksum = "244667bea2e214273442a71f26adb12b88a41f66718fb2c6eea47c00f0dc325f"
2178
dependencies = [
2179
 "proc-macro2",
2180
 "quote",
2181
 "syn 2.0.117",
2182
]
2183
1874 2184
[[package]]
1875 2185
name = "quinn"
1876 2186
version = "0.11.9"

@@ -2019,7 +2329,7 @@ dependencies = [

2019 2329
 "crossterm",
2020 2330
 "indoc",
2021 2331
 "instability",
2022
 "itertools",
2332
 "itertools 0.13.0",
2023 2333
 "lru",
2024 2334
 "paste",
2025 2335
 "strum",

@@ -2038,6 +2348,26 @@ dependencies = [

2038 2348
 "unicode-width 0.2.0",
2039 2349
]
2040 2350
2351
[[package]]
2352
name = "rayon"
2353
version = "1.12.0"
2354
source = "registry+https://github.com/rust-lang/crates.io-index"
2355
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
2356
dependencies = [
2357
 "either",
2358
 "rayon-core",
2359
]
2360
2361
[[package]]
2362
name = "rayon-core"
2363
version = "1.13.0"
2364
source = "registry+https://github.com/rust-lang/crates.io-index"
2365
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
2366
dependencies = [
2367
 "crossbeam-deque",
2368
 "crossbeam-utils",
2369
]
2370
2041 2371
[[package]]
2042 2372
name = "redox_syscall"
2043 2373
version = "0.5.18"

@@ -2047,6 +2377,20 @@ dependencies = [

2047 2377
 "bitflags 2.11.1",
2048 2378
]
2049 2379
2380
[[package]]
2381
name = "regalloc2"
2382
version = "0.12.2"
2383
source = "registry+https://github.com/rust-lang/crates.io-index"
2384
checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734"
2385
dependencies = [
2386
 "allocator-api2",
2387
 "bumpalo",
2388
 "hashbrown 0.15.5",
2389
 "log",
2390
 "rustc-hash",
2391
 "smallvec",
2392
]
2393
2050 2394
[[package]]
2051 2395
name = "regex"
2052 2396
version = "1.13.0"

@@ -2567,6 +2911,9 @@ name = "smallvec"

2567 2911
version = "1.15.1"
2568 2912
source = "registry+https://github.com/rust-lang/crates.io-index"
2569 2913
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
2914
dependencies = [
2915
 "serde",
2916
]
2570 2917
2571 2918
[[package]]
2572 2919
name = "socket2"

@@ -2701,6 +3048,12 @@ dependencies = [

2701 3048
 "libc",
2702 3049
]
2703 3050
3051
[[package]]
3052
name = "target-lexicon"
3053
version = "0.13.5"
3054
source = "registry+https://github.com/rust-lang/crates.io-index"
3055
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
3056
2704 3057
[[package]]
2705 3058
name = "tempfile"
2706 3059
version = "3.27.0"

@@ -2714,6 +3067,15 @@ dependencies = [

2714 3067
 "windows-sys 0.61.2",
2715 3068
]
2716 3069
3070
[[package]]
3071
name = "termcolor"
3072
version = "1.4.1"
3073
source = "registry+https://github.com/rust-lang/crates.io-index"
3074
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
3075
dependencies = [
3076
 "winapi-util",
3077
]
3078
2717 3079
[[package]]
2718 3080
name = "thiserror"
2719 3081
version = "1.0.69"

@@ -3035,7 +3397,7 @@ version = "1.1.0"

3035 3397
source = "registry+https://github.com/rust-lang/crates.io-index"
3036 3398
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
3037 3399
dependencies = [
3038
 "itertools",
3400
 "itertools 0.13.0",
3039 3401
 "unicode-segmentation",
3040 3402
 "unicode-width 0.1.14",
3041 3403
]

@@ -3189,6 +3551,26 @@ dependencies = [

3189 3551
 "unicode-ident",
3190 3552
]
3191 3553
3554
[[package]]
3555
name = "wasm-encoder"
3556
version = "0.236.1"
3557
source = "registry+https://github.com/rust-lang/crates.io-index"
3558
checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7"
3559
dependencies = [
3560
 "leb128fmt",
3561
 "wasmparser 0.236.1",
3562
]
3563
3564
[[package]]
3565
name = "wasm-encoder"
3566
version = "0.258.0"
3567
source = "registry+https://github.com/rust-lang/crates.io-index"
3568
checksum = "e974fe6821a8cf64575d51ea2194e2c8f77e7b66e9afe7419ce8a97f9ee0d251"
3569
dependencies = [
3570
 "leb128fmt",
3571
 "wasmparser 0.258.0",
3572
]
3573
3192 3574
[[package]]
3193 3575
name = "wasm-streams"
3194 3576
version = "0.4.2"

@@ -3215,6 +3597,241 @@ dependencies = [

3215 3597
 "web-sys",
3216 3598
]
3217 3599
3600
[[package]]
3601
name = "wasmparser"
3602
version = "0.236.1"
3603
source = "registry+https://github.com/rust-lang/crates.io-index"
3604
checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7"
3605
dependencies = [
3606
 "bitflags 2.11.1",
3607
 "hashbrown 0.15.5",
3608
 "indexmap",
3609
 "semver",
3610
 "serde",
3611
]
3612
3613
[[package]]
3614
name = "wasmparser"
3615
version = "0.258.0"
3616
source = "registry+https://github.com/rust-lang/crates.io-index"
3617
checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313"
3618
dependencies = [
3619
 "bitflags 2.11.1",
3620
 "indexmap",
3621
 "semver",
3622
]
3623
3624
[[package]]
3625
name = "wasmprinter"
3626
version = "0.236.1"
3627
source = "registry+https://github.com/rust-lang/crates.io-index"
3628
checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1"
3629
dependencies = [
3630
 "anyhow",
3631
 "termcolor",
3632
 "wasmparser 0.236.1",
3633
]
3634
3635
[[package]]
3636
name = "wasmtime"
3637
version = "36.0.14"
3638
source = "registry+https://github.com/rust-lang/crates.io-index"
3639
checksum = "7d05c745dc0978e589ef295958f3130122afc33d96af6bad3f0f06dbe7ac43a8"
3640
dependencies = [
3641
 "addr2line",
3642
 "anyhow",
3643
 "bitflags 2.11.1",
3644
 "bumpalo",
3645
 "cc",
3646
 "cfg-if",
3647
 "hashbrown 0.15.5",
3648
 "indexmap",
3649
 "libc",
3650
 "log",
3651
 "mach2",
3652
 "memfd",
3653
 "object",
3654
 "once_cell",
3655
 "postcard",
3656
 "pulley-interpreter",
3657
 "rayon",
3658
 "rustix 1.1.4",
3659
 "serde",
3660
 "serde_derive",
3661
 "smallvec",
3662
 "target-lexicon",
3663
 "wasmparser 0.236.1",
3664
 "wasmtime-environ",
3665
 "wasmtime-internal-asm-macros",
3666
 "wasmtime-internal-cranelift",
3667
 "wasmtime-internal-fiber",
3668
 "wasmtime-internal-jit-debug",
3669
 "wasmtime-internal-jit-icache-coherence",
3670
 "wasmtime-internal-math",
3671
 "wasmtime-internal-slab",
3672
 "wasmtime-internal-unwinder",
3673
 "wasmtime-internal-versioned-export-macros",
3674
 "windows-sys 0.60.2",
3675
]
3676
3677
[[package]]
3678
name = "wasmtime-environ"
3679
version = "36.0.14"
3680
source = "registry+https://github.com/rust-lang/crates.io-index"
3681
checksum = "9fd1d43cfaa1a0859d2f4fccc15e7e571e2a88b357e81bc88ba6c501b83d925d"
3682
dependencies = [
3683
 "anyhow",
3684
 "cranelift-bitset",
3685
 "cranelift-entity",
3686
 "gimli",
3687
 "indexmap",
3688
 "log",
3689
 "object",
3690
 "postcard",
3691
 "serde",
3692
 "serde_derive",
3693
 "smallvec",
3694
 "target-lexicon",
3695
 "wasm-encoder 0.236.1",
3696
 "wasmparser 0.236.1",
3697
 "wasmprinter",
3698
]
3699
3700
[[package]]
3701
name = "wasmtime-internal-asm-macros"
3702
version = "36.0.14"
3703
source = "registry+https://github.com/rust-lang/crates.io-index"
3704
checksum = "515dd7158bf1719b41290cd2e6a2a46ec944484146816992f195af3720e49b3f"
3705
dependencies = [
3706
 "cfg-if",
3707
]
3708
3709
[[package]]
3710
name = "wasmtime-internal-cranelift"
3711
version = "36.0.14"
3712
source = "registry+https://github.com/rust-lang/crates.io-index"
3713
checksum = "5ba1736927b58e50e741e407da7c037c0250f3e213833a09c89dcd8f73ae2eac"
3714
dependencies = [
3715
 "anyhow",
3716
 "cfg-if",
3717
 "cranelift-codegen",
3718
 "cranelift-control",
3719
 "cranelift-entity",
3720
 "cranelift-frontend",
3721
 "cranelift-native",
3722
 "gimli",
3723
 "itertools 0.14.0",
3724
 "log",
3725
 "object",
3726
 "pulley-interpreter",
3727
 "smallvec",
3728
 "target-lexicon",
3729
 "thiserror 2.0.18",
3730
 "wasmparser 0.236.1",
3731
 "wasmtime-environ",
3732
 "wasmtime-internal-math",
3733
 "wasmtime-internal-versioned-export-macros",
3734
]
3735
3736
[[package]]
3737
name = "wasmtime-internal-fiber"
3738
version = "36.0.14"
3739
source = "registry+https://github.com/rust-lang/crates.io-index"
3740
checksum = "7b238e4c20bddb900ec0cb380252d63e8d0644fd94de001119574f5921e895d9"
3741
dependencies = [
3742
 "anyhow",
3743
 "cc",
3744
 "cfg-if",
3745
 "libc",
3746
 "rustix 1.1.4",
3747
 "wasmtime-internal-asm-macros",
3748
 "wasmtime-internal-versioned-export-macros",
3749
 "windows-sys 0.60.2",
3750
]
3751
3752
[[package]]
3753
name = "wasmtime-internal-jit-debug"
3754
version = "36.0.14"
3755
source = "registry+https://github.com/rust-lang/crates.io-index"
3756
checksum = "8f259b13685ad51e3dcf58cb69031279ed0d79c25bc3ccc8b50e7160ed04fbfe"
3757
dependencies = [
3758
 "cc",
3759
 "wasmtime-internal-versioned-export-macros",
3760
]
3761
3762
[[package]]
3763
name = "wasmtime-internal-jit-icache-coherence"
3764
version = "36.0.14"
3765
source = "registry+https://github.com/rust-lang/crates.io-index"
3766
checksum = "41fed85537936b16460bac352ad149052c025db50467c7bc539dd47b31439374"
3767
dependencies = [
3768
 "anyhow",
3769
 "cfg-if",
3770
 "libc",
3771
 "windows-sys 0.60.2",
3772
]
3773
3774
[[package]]
3775
name = "wasmtime-internal-math"
3776
version = "36.0.14"
3777
source = "registry+https://github.com/rust-lang/crates.io-index"
3778
checksum = "82fff10da41d0d15d90ebba70946a0aa16ed0957ae7b77e0b6d2a46e8221e555"
3779
dependencies = [
3780
 "libm",
3781
]
3782
3783
[[package]]
3784
name = "wasmtime-internal-slab"
3785
version = "36.0.14"
3786
source = "registry+https://github.com/rust-lang/crates.io-index"
3787
checksum = "e44a8c097bab08d349d57dce1ab818859fefbe261ab3632b38fe127b1b551108"
3788
3789
[[package]]
3790
name = "wasmtime-internal-unwinder"
3791
version = "36.0.14"
3792
source = "registry+https://github.com/rust-lang/crates.io-index"
3793
checksum = "7f40a57d5e7c221ce56391d7dca0a918ba17ea00185462c7facbf534d7745184"
3794
dependencies = [
3795
 "anyhow",
3796
 "cfg-if",
3797
 "cranelift-codegen",
3798
 "log",
3799
 "object",
3800
]
3801
3802
[[package]]
3803
name = "wasmtime-internal-versioned-export-macros"
3804
version = "36.0.14"
3805
source = "registry+https://github.com/rust-lang/crates.io-index"
3806
checksum = "e085bfce1cb2089dbeef6e280a5d598666923d3dcd308712fe429fe43c9d19f5"
3807
dependencies = [
3808
 "proc-macro2",
3809
 "quote",
3810
 "syn 2.0.117",
3811
]
3812
3813
[[package]]
3814
name = "wast"
3815
version = "258.0.0"
3816
source = "registry+https://github.com/rust-lang/crates.io-index"
3817
checksum = "97f7defc7ecca8b19ac7f824598eadd0c53985ee00c74060d65051e9da5b58a1"
3818
dependencies = [
3819
 "bumpalo",
3820
 "leb128fmt",
3821
 "memchr",
3822
 "unicode-width 0.2.0",
3823
 "wasm-encoder 0.258.0",
3824
]
3825
3826
[[package]]
3827
name = "wat"
3828
version = "1.258.0"
3829
source = "registry+https://github.com/rust-lang/crates.io-index"
3830
checksum = "7555c008cca87f2ac58d9f83ccda7e7b44611093ce28eb28f052e7c78024b9bf"
3831
dependencies = [
3832
 "wast",
3833
]
3834
3218 3835
[[package]]
3219 3836
name = "web-sys"
3220 3837
version = "0.3.99"
crates/openagents-cli/Cargo.toml modified +12

@@ -41,9 +41,21 @@ zeroize = "1"

41 41
# desktop audio transport already uses, so no new TLS stack enters the tree.
42 42
tungstenite = { version = "0.28", default-features = false, features = ["handshake", "rustls-tls-native-roots"] }
43 43
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
44
# The WebAssembly host behind the `capability` tool. Cranelift compiles the
45
# guest, `StoreLimits` enforces the manifest's memory ceiling, and epoch
46
# interruption enforces its timeout against a guest that never returns. The
47
# default feature set drags in the component model, GC, async fibers, and a
48
# compilation cache this host has no use for; these four are what a
49
# `packet-v0` core module needs.
50
wasmtime = { version = "36", default-features = false, features = ["cranelift", "runtime", "std", "parallel-compilation"] }
44 51
45 52
[dev-dependencies]
46 53
tempfile = "3"
54
# The sandbox tests need guests that misbehave on purpose -- one that grows
55
# past the ceiling, one that never returns, one that imports a capability its
56
# manifest never declared. None of those can be a checked-in artifact, so the
57
# tests assemble them from WAT.
58
wat = "1"
47 59
bech32 = "0.11"
48 60
# The Computer channel tests stand up a real Phoenix-shaped socket server, so
49 61
# the client's framing, policy, journal, and backoff run against a live peer.
crates/openagents-cli/src/cli.rs modified +3

@@ -78,6 +78,8 @@ pub enum Commands {

78 78
    Memory(MemoryArgs),
79 79
    /// Generic API route invocation
80 80
    Api(crate::api_passthrough::ApiArgs),
81
    /// Sandboxed WebAssembly capability plugins: catalog, digests, and runs
82
    Plugin(crate::plugins::PluginArgs),
81 83
    /// Trace inspection and session export
82 84
    Trace(TraceArgs),
83 85
    /// Replace this binary with the release the channel names

@@ -1133,6 +1135,7 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1133 1135
        }
1134 1136
        Commands::Memory(mem) => run_memory(mem.action, &api_base, token, cli.json).await,
1135 1137
        Commands::Api(api) => crate::api_passthrough::run(api, &endpoint, cli.json).await,
1138
        Commands::Plugin(plugin) => crate::plugins::run(plugin, cli.json).await,
1136 1139
        Commands::Trace(trace) => run_trace(trace.action),
1137 1140
        Commands::Update(update) => {
1138 1141
            crate::update::run(update.channel, update.version, update.check, update.force).await?;
crates/openagents-cli/src/lib.rs modified +1

@@ -25,6 +25,7 @@ pub mod forum;

25 25
pub mod identity;
26 26
pub mod interactive;
27 27
pub mod memory_client;
28
pub mod plugins;
28 29
pub mod provider;
29 30
pub mod repo;
30 31
pub mod runtime;
crates/openagents-cli/src/plugins.rs added +1745

@@ -0,0 +1,1745 @@

1
//! The WebAssembly capability host: the sandbox, the catalog, and the
2
//! `capability` tool that reaches them.
3
//!
4
//! This is the Rust half of OpenAgentsInc/openagents#71 and #84 that did not
5
//! exist: `crates/openagents-cli` had no WebAssembly engine, so the
6
//! `capability` tool could not be implemented and — after one round of
7
//! advertising a tool that was never in `list_tools()` — was not declared
8
//! either. The artifacts in `plugins/` are wasm modules against the bespoke
9
//! `packet-v0` ABI owned by `plugins/pdk`, with per-manifest read-only
10
//! directory mounts, host allowlists, memory ceilings, and timeouts. A host
11
//! that cannot enforce those refuses to run the plugin; it does not run it
12
//! unsandboxed.
13
//!
14
//! The contract, which is the same one `packages/openagents-cli/src/
15
//! coder-plugins.ts` states and this module ports:
16
//!
17
//! - **Manifest first.** Identity, an artifact digest pin, the `packet-v0`
18
//!   ABI declaration, typed input and output schemas, and capability
19
//!   declarations. Absence of a capability is denial, never a default grant.
20
//! - **Digest before load.** The artifact's SHA-256 is compared against the
21
//!   manifest's pin before the module is compiled. A mismatch is a refusal,
22
//!   not a warning.
23
//! - **Imports must be declared.** The compiled module's import list is read
24
//!   before anything is instantiated and must be covered by what the manifest
25
//!   declares: nothing at all for pure compute, and exactly
26
//!   `openagents.read_file`, `openagents.read_file_range`, and
27
//!   `openagents.list_dir` when the manifest declares read-only mounts.
28
//!   Anything else — a write import above all — is refused by inspection, so
29
//!   the sandbox is a property of what was loaded rather than a hope about
30
//!   what it does.
31
//! - **Mounts are read-only and confined.** A declared mount resolves to a
32
//!   real directory at load; at invocation every path is confined to it:
33
//!   absolute paths refused, `..` resolved and checked, symlinks refused,
34
//!   the canonical path re-checked against the canonical root so a symlinked
35
//!   parent cannot smuggle a read out, a byte bound per file, an entry bound
36
//!   per listing.
37
//! - **Limits are enforced, not declared.** The manifest's `timeout_ms`
38
//!   becomes a wasmtime epoch deadline with a watchdog that fires it, so a
39
//!   guest that never returns is trapped rather than waited on. Its
40
//!   `memory_max_mib` becomes a `StoreLimits` ceiling, so a guest that grows
41
//!   past it is denied the pages. Both are testable by violating them, and
42
//!   `tests/plugin_host_test.rs` violates them.
43
//! - **Typed refusals both ways.** The host refuses with `{code, reason}`;
44
//!   the guest returns `{"refusal": {...}}` inside its output packet. Both
45
//!   read as text to a model, which can act on a refusal and cannot act on a
46
//!   turn that died.
47
48
use serde::Serialize;
49
use sha2::{Digest, Sha256};
50
use std::collections::BTreeSet;
51
use std::path::{Component, Path, PathBuf};
52
use std::sync::atomic::{AtomicBool, Ordering};
53
use std::sync::mpsc;
54
use std::sync::Arc;
55
use std::time::Duration;
56
57
use wasmtime::{Caller, Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder};
58
59
/// The one packet ABI this host speaks. A manifest must declare it.
60
pub const SUPPORTED_ABI: &str = "packet-v0";
61
62
/// Ceiling on a manifest's own timeout, so a manifest cannot ask for an hour.
63
pub const TIMEOUT_CEILING_MS: u64 = 30_000;
64
65
/// Default memory ceiling when a manifest names none.
66
pub const DEFAULT_MEMORY_MIB: u64 = 64;
67
68
/// Ceiling on a manifest's own memory request.
69
pub const MEMORY_CEILING_MIB: u64 = 512;
70
71
/// Per-file byte bound for reads through a mount.
72
pub const MOUNT_FILE_LIMIT: u64 = 1_048_576;
73
74
/// Entry bound per directory listing through a mount; the rest is truncated.
75
pub const MOUNT_DIR_ENTRY_LIMIT: usize = 500;
76
77
/// How much plugin output the model is shown.
78
pub const PLUGIN_OUTPUT_LIMIT: usize = 16_000;
79
80
/// Most candidates one catalog search returns; the rest are counted.
81
const SEARCH_LIMIT: usize = 5;
82
83
/// Why the host would not do what was asked. Returned, never panicked.
84
///
85
/// The code set is the one the guest PDK and the TypeScript host already
86
/// share, so a refusal reads the same whichever side of the boundary it was
87
/// born on.
88
#[derive(Debug, Clone, PartialEq, Eq)]
89
pub struct Refusal {
90
    pub code: &'static str,
91
    pub reason: String,
92
}
93
94
impl Refusal {
95
    pub fn new(code: &'static str, reason: impl Into<String>) -> Self {
96
        Refusal {
97
            code,
98
            reason: reason.into(),
99
        }
100
    }
101
}
102
103
impl std::fmt::Display for Refusal {
104
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105
        write!(f, "({}): {}", self.code, self.reason)
106
    }
107
}
108
109
fn refuse(code: &'static str, reason: impl Into<String>) -> Refusal {
110
    Refusal::new(code, reason)
111
}
112
113
/// A read-only directory grant, as the manifest declares it.
114
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115
pub struct Mount {
116
    /// The declared path. Relative paths resolve against the manifest's own
117
    /// directory, absolute paths are taken as they are, a leading `~/` (or a
118
    /// bare `~`) expands to the invoking user's home, and the literal
119
    /// `${workspace}` resolves to the session's working directory.
120
    pub path: String,
121
}
122
123
/// The manifest fields this host reads. The file may carry more.
124
#[derive(Debug, Clone)]
125
pub struct Manifest {
126
    pub name: String,
127
    pub version: String,
128
    pub description: String,
129
    pub artifact_path: String,
130
    pub artifact_digest: String,
131
    pub abi_entry: String,
132
    pub abi_alloc: String,
133
    pub input_schema: serde_json::Value,
134
    pub mounts: Vec<Mount>,
135
    pub hosts: Vec<serde_json::Value>,
136
    pub timeout_ms: u64,
137
    pub memory_max_mib: u64,
138
}
139
140
/// A plugin that passed every check and is ready to invoke.
141
#[derive(Debug, Clone)]
142
pub struct LoadedPlugin {
143
    pub manifest: Manifest,
144
    /// The artifact bytes, held so an invocation cannot race a file rewrite.
145
    pub wasm: Vec<u8>,
146
    /// The verified digest, `sha256:<hex>`.
147
    pub digest: String,
148
    /// Declared mounts, resolved to canonical absolute directory roots.
149
    pub mounts: Vec<PathBuf>,
150
    /// Where the manifest was read from, for provenance.
151
    pub manifest_path: PathBuf,
152
}
153
154
// ───────────────────────────────────────────────────── manifest validation
155
156
fn bad(what: &str) -> Refusal {
157
    refuse(
158
        "manifest_invalid",
159
        format!("the manifest is missing or mistypes {what}"),
160
    )
161
}
162
163
fn is_plugin_name(name: &str) -> bool {
164
    let mut chars = name.chars();
165
    match chars.next() {
166
        Some(first) if first.is_ascii_lowercase() => {}
167
        _ => return false,
168
    }
169
    name.len() <= 64 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
170
}
171
172
/// Read a manifest value into the fields this host enforces.
173
///
174
/// Every failure names the field, because the alternative is an operator
175
/// staring at "invalid manifest" with thirteen plugins installed.
176
pub fn validate_manifest(value: &serde_json::Value) -> Result<Manifest, Refusal> {
177
    let record = value
178
        .as_object()
179
        .ok_or_else(|| bad("the top-level object"))?;
180
181
    let name = record.get("name").and_then(|v| v.as_str()).unwrap_or("");
182
    if !is_plugin_name(name) {
183
        return Err(bad(
184
            "`name` (lowercase identifier, it becomes the tool name)",
185
        ));
186
    }
187
    let version = record.get("version").and_then(|v| v.as_str()).unwrap_or("");
188
    if version.is_empty() {
189
        return Err(bad("`version`"));
190
    }
191
    let description = record
192
        .get("description")
193
        .and_then(|v| v.as_str())
194
        .unwrap_or("");
195
    if description.is_empty() {
196
        return Err(bad("`description`"));
197
    }
198
199
    let artifact = record.get("artifact").and_then(|v| v.as_object());
200
    let artifact_path = artifact
201
        .and_then(|a| a.get("path"))
202
        .and_then(|v| v.as_str())
203
        .unwrap_or("");
204
    let artifact_digest = artifact
205
        .and_then(|a| a.get("digest"))
206
        .and_then(|v| v.as_str())
207
        .unwrap_or("");
208
    if artifact_path.is_empty() || !artifact_digest.starts_with("sha256:") {
209
        return Err(bad("`artifact` (`path` and a `sha256:` `digest`)"));
210
    }
211
212
    let abi = record.get("abi").and_then(|v| v.as_object());
213
    let abi_kind = abi
214
        .and_then(|a| a.get("kind"))
215
        .and_then(|v| v.as_str())
216
        .unwrap_or("");
217
    let abi_entry = abi
218
        .and_then(|a| a.get("entry"))
219
        .and_then(|v| v.as_str())
220
        .unwrap_or("");
221
    let abi_alloc = abi
222
        .and_then(|a| a.get("alloc"))
223
        .and_then(|v| v.as_str())
224
        .unwrap_or("");
225
    if abi_kind.is_empty() || abi_entry.is_empty() || abi_alloc.is_empty() {
226
        return Err(bad("`abi` (`kind`, `entry`, and `alloc`)"));
227
    }
228
    if abi_kind != SUPPORTED_ABI {
229
        return Err(refuse(
230
            "abi_unsupported",
231
            format!("the manifest declares abi `{abi_kind}` and this host speaks `{SUPPORTED_ABI}` only"),
232
        ));
233
    }
234
235
    let iface = record.get("interface").and_then(|v| v.as_object());
236
    let input_schema = iface
237
        .and_then(|i| i.get("input"))
238
        .filter(|v| v.is_object())
239
        .cloned();
240
    let has_output = iface
241
        .and_then(|i| i.get("output"))
242
        .is_some_and(|v| v.is_object());
243
    let Some(input_schema) = input_schema else {
244
        return Err(bad("`interface` (`input` and `output` JSON schemas)"));
245
    };
246
    if !has_output {
247
        return Err(bad("`interface` (`input` and `output` JSON schemas)"));
248
    }
249
250
    let capabilities = record.get("capabilities").and_then(|v| v.as_object());
251
    let declared_mounts = capabilities
252
        .and_then(|c| c.get("mounts"))
253
        .and_then(|v| v.as_array());
254
    let hosts = capabilities
255
        .and_then(|c| c.get("hosts"))
256
        .and_then(|v| v.as_array());
257
    let timeout_ms = capabilities
258
        .and_then(|c| c.get("timeout_ms"))
259
        .and_then(|v| v.as_u64());
260
    let (Some(declared_mounts), Some(hosts), Some(timeout_ms)) =
261
        (declared_mounts, hosts, timeout_ms)
262
    else {
263
        return Err(bad(
264
            "`capabilities` (`mounts`, `hosts`, positive `timeout_ms`)",
265
        ));
266
    };
267
    if timeout_ms == 0 {
268
        return Err(bad(
269
            "`capabilities` (`mounts`, `hosts`, positive `timeout_ms`)",
270
        ));
271
    }
272
273
    let mut mounts = Vec::new();
274
    for entry in declared_mounts {
275
        let path = entry.get("path").and_then(|v| v.as_str()).unwrap_or("");
276
        if path.is_empty() {
277
            return Err(bad("`capabilities.mounts[]` (each mount needs a `path`)"));
278
        }
279
        // A writable mount is a capability this host does not have. Refusing
280
        // it here is what keeps "declared means enforced" honest: the
281
        // alternative is quietly downgrading the grant and letting a manifest
282
        // claim something the sandbox never gave it.
283
        if entry.get("readonly").and_then(|v| v.as_bool()) != Some(true) {
284
            return Err(refuse(
285
                "capabilities_unsupported",
286
                format!("mount `{path}` is not marked `\"readonly\": true`; only read-only mounts exist"),
287
            ));
288
        }
289
        mounts.push(Mount {
290
            path: path.to_string(),
291
        });
292
    }
293
294
    let memory_max_mib = capabilities
295
        .and_then(|c| c.get("memory_max_mib"))
296
        .and_then(|v| v.as_u64())
297
        .unwrap_or(DEFAULT_MEMORY_MIB)
298
        .clamp(1, MEMORY_CEILING_MIB);
299
300
    Ok(Manifest {
301
        name: name.to_string(),
302
        version: version.to_string(),
303
        description: description.to_string(),
304
        artifact_path: artifact_path.to_string(),
305
        artifact_digest: artifact_digest.to_string(),
306
        abi_entry: abi_entry.to_string(),
307
        abi_alloc: abi_alloc.to_string(),
308
        input_schema,
309
        mounts,
310
        hosts: hosts.clone(),
311
        timeout_ms: timeout_ms.min(TIMEOUT_CEILING_MS),
312
        memory_max_mib,
313
    })
314
}
315
316
// ─────────────────────────────────────────────────────────── mount resolution
317
318
/// Expand a `~` prefix. `~alice/...` is somebody else's home and stays
319
/// literal, so it then fails the exists-and-is-a-directory check rather than
320
/// silently reading another account.
321
fn expand_home(path: &str) -> String {
322
    let Ok(home) = std::env::var("HOME") else {
323
        return path.to_string();
324
    };
325
    if path == "~" {
326
        return home;
327
    }
328
    match path.strip_prefix("~/") {
329
        Some(rest) => Path::new(&home).join(rest).to_string_lossy().into_owned(),
330
        None => path.to_string(),
331
    }
332
}
333
334
fn resolve_mount(mount: &Mount, manifest_dir: &Path, workspace: &Path) -> Result<PathBuf, Refusal> {
335
    let expanded = if mount.path == "${workspace}" {
336
        workspace.to_string_lossy().into_owned()
337
    } else {
338
        expand_home(&mount.path)
339
    };
340
    let declared = manifest_dir.join(&expanded);
341
    let root = std::fs::canonicalize(&declared).map_err(|_| {
342
        refuse(
343
            "mount_invalid",
344
            format!(
345
                "mount `{}` does not resolve to a readable directory",
346
                mount.path
347
            ),
348
        )
349
    })?;
350
    if !root.is_dir() {
351
        return Err(refuse(
352
            "mount_invalid",
353
            format!("mount `{}` is not a directory", mount.path),
354
        ));
355
    }
356
    Ok(root)
357
}
358
359
// ───────────────────────────────────────────────────────────────── loading
360
361
/// Load a plugin from its manifest: parse, validate, verify the digest, and
362
/// prove by inspection that the module's imports are covered by its declared
363
/// capabilities.
364
///
365
/// Everything checkable before the first invocation is checked here, so the
366
/// caller either learns exactly what is wrong or holds a plugin whose next
367
/// failure can only be about the packet.
368
pub fn load_plugin(manifest_path: &Path, workspace: &Path) -> Result<LoadedPlugin, Refusal> {
369
    let raw = std::fs::read_to_string(manifest_path).map_err(|e| {
370
        refuse(
371
            "manifest_unreadable",
372
            format!("{}: {e}", manifest_path.display()),
373
        )
374
    })?;
375
    let parsed: serde_json::Value = serde_json::from_str(&raw).map_err(|_| {
376
        refuse(
377
            "manifest_invalid",
378
            format!("{} is not JSON", manifest_path.display()),
379
        )
380
    })?;
381
    let manifest = validate_manifest(&parsed)?;
382
383
    // The only host capability that exists is the read-only mount. A declared
384
    // network host is declared-but-denied, never declared-and-ignored.
385
    if !manifest.hosts.is_empty() {
386
        return Err(refuse(
387
            "capabilities_unsupported",
388
            "the manifest declares network hosts, and this host has no network capability to grant",
389
        ));
390
    }
391
392
    let manifest_dir = manifest_path.parent().unwrap_or(Path::new("."));
393
    let mut mounts = Vec::new();
394
    for mount in &manifest.mounts {
395
        mounts.push(resolve_mount(mount, manifest_dir, workspace)?);
396
    }
397
398
    let artifact_path = manifest_dir.join(&manifest.artifact_path);
399
    let wasm = std::fs::read(&artifact_path).map_err(|e| {
400
        refuse(
401
            "artifact_unreadable",
402
            format!("{}: {e}", artifact_path.display()),
403
        )
404
    })?;
405
406
    let digest = format!("sha256:{:x}", Sha256::digest(&wasm));
407
    if digest != manifest.artifact_digest {
408
        return Err(refuse(
409
            "digest_mismatch",
410
            format!(
411
                "the manifest pins {} but {} is {digest}; the artifact is not the one the manifest \
412
                 describes, so it does not load",
413
                manifest.artifact_digest, manifest.artifact_path
414
            ),
415
        ));
416
    }
417
418
    let shape = inspect_module(&wasm)?;
419
420
    // Every import must be granted by a declared capability. Mounts grant
421
    // exactly three, all of them reads.
422
    let granted: BTreeSet<&str> = if mounts.is_empty() {
423
        BTreeSet::new()
424
    } else {
425
        [
426
            "openagents.read_file",
427
            "openagents.read_file_range",
428
            "openagents.list_dir",
429
        ]
430
        .into_iter()
431
        .collect()
432
    };
433
    let undeclared: Vec<&str> = shape
434
        .imports
435
        .iter()
436
        .map(String::as_str)
437
        .filter(|name| !granted.contains(name))
438
        .collect();
439
    if !undeclared.is_empty() {
440
        let hint = if mounts.is_empty() {
441
            "the manifest declares no capabilities, so the module may import nothing"
442
        } else {
443
            "the declared mounts grant only `openagents.read_file`, `openagents.read_file_range`, \
444
             and `openagents.list_dir`, all of them reads"
445
        };
446
        return Err(refuse(
447
            "imports_undeclared",
448
            format!(
449
                "the module asks for host imports its manifest does not declare ({}); {hint}",
450
                undeclared.join(", ")
451
            ),
452
        ));
453
    }
454
455
    for name in [
456
        manifest.abi_entry.as_str(),
457
        manifest.abi_alloc.as_str(),
458
        "memory",
459
    ] {
460
        if !shape.exports.iter().any(|export| export == name) {
461
            return Err(refuse(
462
                "exports_missing",
463
                format!("the module does not export `{name}`"),
464
            ));
465
        }
466
    }
467
468
    Ok(LoadedPlugin {
469
        manifest,
470
        wasm,
471
        digest,
472
        mounts,
473
        manifest_path: manifest_path.to_path_buf(),
474
    })
475
}
476
477
/// What a compiled module declares, before anything is instantiated.
478
#[derive(Debug, Clone)]
479
pub struct ModuleShape {
480
    /// Import names as `module.name`, e.g. `openagents.read_file`.
481
    pub imports: Vec<String>,
482
    pub exports: Vec<String>,
483
}
484
485
fn engine_config() -> Config {
486
    let mut config = Config::new();
487
    // The only way to stop a guest that never returns. The watchdog in
488
    // `invoke` increments the epoch when the manifest's deadline passes and
489
    // the running instance traps.
490
    config.epoch_interruption(true);
491
    config
492
}
493
494
/// Compile the artifact and report what it asks for and what it offers.
495
pub fn inspect_module(wasm: &[u8]) -> Result<ModuleShape, Refusal> {
496
    let engine = Engine::new(&engine_config())
497
        .map_err(|e| refuse("not_wasm", format!("the wasm engine did not start: {e}")))?;
498
    let module = Module::new(&engine, wasm).map_err(|e| refuse("not_wasm", e.to_string()))?;
499
    Ok(ModuleShape {
500
        imports: module
501
            .imports()
502
            .map(|import| format!("{}.{}", import.module(), import.name()))
503
            .collect(),
504
        exports: module
505
            .exports()
506
            .map(|export| export.name().to_string())
507
            .collect(),
508
    })
509
}
510
511
// ────────────────────────────────────────────────── the sandbox and its host
512
513
/// What the guest's capability imports are answered from.
514
struct HostState {
515
    limits: StoreLimits,
516
    /// Canonical, absolute mount roots, in manifest order.
517
    mounts: Vec<PathBuf>,
518
    file_limit: u64,
519
    dir_entry_limit: usize,
520
    alloc: String,
521
}
522
523
fn ok_packet(bytes: &[u8]) -> Vec<u8> {
524
    let mut packet = Vec::with_capacity(bytes.len() + 1);
525
    packet.push(0);
526
    packet.extend_from_slice(bytes);
527
    packet
528
}
529
530
fn refusal_packet(code: &str, reason: &str) -> Vec<u8> {
531
    let body = serde_json::json!({ "code": code, "reason": reason });
532
    let mut packet = Vec::new();
533
    packet.push(1u8);
534
    packet.extend_from_slice(serde_json::to_string(&body).unwrap_or_default().as_bytes());
535
    packet
536
}
537
538
/// Resolve `rel` against `root` without touching the filesystem, applying
539
/// `..` lexically the way `path.resolve` does.
540
///
541
/// Lexical first, because the check that matters — "is this still inside the
542
/// root" — must be answerable for a path that does not exist yet, and because
543
/// resolving `..` against a real symlinked directory is how confinement gets
544
/// walked out of.
545
fn lexical_join(root: &Path, rel: &str) -> Option<PathBuf> {
546
    let mut out = root.to_path_buf();
547
    for component in Path::new(rel).components() {
548
        match component {
549
            Component::CurDir => {}
550
            Component::ParentDir => {
551
                if !out.pop() {
552
                    return None;
553
                }
554
            }
555
            Component::Normal(part) => out.push(part),
556
            // An absolute path or a Windows prefix inside a mount-relative
557
            // path is not a path in the mount at all.
558
            Component::RootDir | Component::Prefix(_) => return None,
559
        }
560
    }
561
    Some(out)
562
}
563
564
fn within(candidate: &Path, root: &Path) -> bool {
565
    candidate == root || candidate.starts_with(root)
566
}
567
568
/// The `openagents.read_file` and `openagents.read_file_range` answer.
569
///
570
/// `range` of `None` is a whole-file read, which refuses a file past the byte
571
/// bound. A range read has no such refusal: its answer is bounded by
572
/// construction, clamped to the same per-read bound, and a range past the end
573
/// answers with what remains, empty included.
574
fn read_mounted(state: &HostState, path: &str, range: Option<(u64, u32)>) -> Vec<u8> {
575
    if Path::new(path).is_absolute() {
576
        return refusal_packet(
577
            "mount_denied",
578
            "absolute paths are refused; mounted paths are relative to a declared mount root",
579
        );
580
    }
581
    for root in &state.mounts {
582
        let Some(candidate) = lexical_join(root, path) else {
583
            return refusal_packet("mount_denied", "the path escapes the mount root");
584
        };
585
        if !within(&candidate, root) {
586
            return refusal_packet("mount_denied", "the path escapes the mount root");
587
        }
588
        let Ok(meta) = std::fs::symlink_metadata(&candidate) else {
589
            continue; // Not in this mount; try the next declared root.
590
        };
591
        if meta.file_type().is_symlink() {
592
            return refusal_packet("mount_denied", "symlinks inside a mount are refused");
593
        }
594
        if !meta.is_file() {
595
            return refusal_packet("file_unreadable", "the path is not a regular file");
596
        }
597
        // A symlinked parent directory can still point outside, so the real
598
        // path of the candidate must sit under the real path of the root.
599
        let real = match std::fs::canonicalize(&candidate) {
600
            Ok(real) => real,
601
            Err(err) => return refusal_packet("file_unreadable", &err.to_string()),
602
        };
603
        if !within(&real, root) {
604
            return refusal_packet("mount_denied", "the path resolves outside the mount root");
605
        }
606
        let size = meta.len();
607
        let Some((offset, max_bytes)) = range else {
608
            if size > state.file_limit {
609
                return refusal_packet(
610
                    "file_too_large",
611
                    &format!(
612
                        "the file is {size} bytes; the per-file bound is {}",
613
                        state.file_limit
614
                    ),
615
                );
616
            }
617
            return match std::fs::read(&candidate) {
618
                Ok(bytes) => ok_packet(&bytes),
619
                Err(err) => refusal_packet("file_unreadable", &err.to_string()),
620
            };
621
        };
622
        let offset = offset.min(size);
623
        let length = u64::from(max_bytes)
624
            .min(state.file_limit)
625
            .min(size - offset);
626
        return match read_range(&candidate, offset, length) {
627
            Ok(bytes) => ok_packet(&bytes),
628
            Err(err) => refusal_packet("file_unreadable", &err.to_string()),
629
        };
630
    }
631
    refusal_packet("mount_denied", "no declared mount contains the path")
632
}
633
634
fn read_range(path: &Path, offset: u64, length: u64) -> std::io::Result<Vec<u8>> {
635
    use std::io::{Read, Seek, SeekFrom};
636
    let mut file = std::fs::File::open(path)?;
637
    file.seek(SeekFrom::Start(offset))?;
638
    let mut buffer = vec![0u8; length as usize];
639
    let mut filled = 0usize;
640
    while filled < buffer.len() {
641
        match file.read(&mut buffer[filled..])? {
642
            0 => break,
643
            got => filled += got,
644
        }
645
    }
646
    buffer.truncate(filled);
647
    Ok(buffer)
648
}
649
650
/// The `openagents.list_dir` answer.
651
///
652
/// The mount index makes the target root explicit. A scanner over two mounts
653
/// must never have a "which root answered?" ambiguity for a listing.
654
fn list_mounted(state: &HostState, mount_index: u32, path: &str) -> Vec<u8> {
655
    let Some(root) = state.mounts.get(mount_index as usize) else {
656
        return refusal_packet("mount_denied", "the mount index names no declared mount");
657
    };
658
    if Path::new(path).is_absolute() {
659
        return refusal_packet(
660
            "mount_denied",
661
            "absolute paths are refused; mounted paths are relative to a declared mount root",
662
        );
663
    }
664
    let Some(candidate) = lexical_join(root, path) else {
665
        return refusal_packet("mount_denied", "the path escapes the mount root");
666
    };
667
    if !within(&candidate, root) {
668
        return refusal_packet("mount_denied", "the path escapes the mount root");
669
    }
670
    let Ok(meta) = std::fs::symlink_metadata(&candidate) else {
671
        return refusal_packet("file_unreadable", "the mount has no such directory");
672
    };
673
    if meta.file_type().is_symlink() {
674
        return refusal_packet("mount_denied", "symlinks inside a mount are refused");
675
    }
676
    if !meta.is_dir() {
677
        return refusal_packet("file_unreadable", "the path is not a directory");
678
    }
679
    let real = match std::fs::canonicalize(&candidate) {
680
        Ok(real) => real,
681
        Err(err) => return refusal_packet("file_unreadable", &err.to_string()),
682
    };
683
    if !within(&real, root) {
684
        return refusal_packet("mount_denied", "the path resolves outside the mount root");
685
    }
686
687
    let entries = match std::fs::read_dir(&candidate) {
688
        Ok(entries) => entries,
689
        Err(err) => return refusal_packet("file_unreadable", &err.to_string()),
690
    };
691
    let mut names: Vec<String> = entries
692
        .flatten()
693
        .map(|entry| entry.file_name().to_string_lossy().into_owned())
694
        .collect();
695
    names.sort();
696
    let truncated = names.len() > state.dir_entry_limit;
697
    let mut listed = Vec::new();
698
    for name in names.into_iter().take(state.dir_entry_limit) {
699
        let (mut kind, mut size, mut mtime_ms) = ("other", 0u64, 0i64);
700
        if let Ok(entry_meta) = std::fs::symlink_metadata(candidate.join(&name)) {
701
            let file_type = entry_meta.file_type();
702
            kind = if file_type.is_symlink() {
703
                "symlink"
704
            } else if file_type.is_file() {
705
                "file"
706
            } else if file_type.is_dir() {
707
                "dir"
708
            } else {
709
                "other"
710
            };
711
            size = entry_meta.len();
712
            mtime_ms = entry_meta
713
                .modified()
714
                .ok()
715
                .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
716
                .map_or(0, |since| since.as_millis() as i64);
717
        }
718
        listed.push(serde_json::json!({
719
            "name": name, "kind": kind, "size": size, "mtime_ms": mtime_ms
720
        }));
721
    }
722
    let body = serde_json::json!({ "entries": listed, "truncated": truncated });
723
    ok_packet(serde_json::to_string(&body).unwrap_or_default().as_bytes())
724
}
725
726
/// Read a guest string out of linear memory, or `None` when the range is not
727
/// inside it.
728
fn guest_string(caller: &mut Caller<'_, HostState>, ptr: i32, len: i32) -> Option<String> {
729
    let memory = caller.get_export("memory")?.into_memory()?;
730
    let data = memory.data(&*caller);
731
    let start = usize::try_from(ptr).ok()?;
732
    let end = start.checked_add(usize::try_from(len).ok()?)?;
733
    let bytes = data.get(start..end)?;
734
    Some(String::from_utf8_lossy(bytes).into_owned())
735
}
736
737
/// Write an answer packet into guest memory through the guest's own
738
/// allocator and pack its location the way `handle_packet` does.
739
///
740
/// A null return word is the PDK's "the host answered with a null packet",
741
/// which is a refusal the guest can act on. Nothing here traps the guest for
742
/// a host-side problem.
743
fn answer_guest(caller: &mut Caller<'_, HostState>, packet: &[u8]) -> i64 {
744
    let alloc_name = caller.data().alloc.clone();
745
    let Some(alloc) = caller.get_export(&alloc_name).and_then(|e| e.into_func()) else {
746
        return 0;
747
    };
748
    let Ok(alloc) = alloc.typed::<i32, i32>(&*caller) else {
749
        return 0;
750
    };
751
    let Ok(len) = i32::try_from(packet.len()) else {
752
        return 0;
753
    };
754
    let Ok(ptr) = alloc.call(&mut *caller, len) else {
755
        return 0;
756
    };
757
    let Some(memory) = caller.get_export("memory").and_then(|e| e.into_memory()) else {
758
        return 0;
759
    };
760
    let Ok(offset) = usize::try_from(ptr) else {
761
        return 0;
762
    };
763
    if memory.write(&mut *caller, offset, packet).is_err() {
764
        return 0;
765
    }
766
    ((u64::from(ptr as u32) << 32) | packet.len() as u64) as i64
767
}
768
769
/// Call the plugin once: packet bytes in, packet bytes out, or a refusal.
770
///
771
/// Blocking. One engine, one store, one instance per invocation, so no state
772
/// survives from one call to the next and every invocation runs on memory the
773
/// previous one cannot have corrupted.
774
pub fn invoke(plugin: &LoadedPlugin, input: &[u8]) -> Result<Vec<u8>, Refusal> {
775
    let engine = Engine::new(&engine_config())
776
        .map_err(|e| refuse("not_wasm", format!("the wasm engine did not start: {e}")))?;
777
    let module =
778
        Module::new(&engine, &plugin.wasm).map_err(|e| refuse("not_wasm", e.to_string()))?;
779
780
    let memory_bytes = (plugin.manifest.memory_max_mib * 1024 * 1024) as usize;
781
    let state = HostState {
782
        limits: StoreLimitsBuilder::new().memory_size(memory_bytes).build(),
783
        mounts: plugin.mounts.clone(),
784
        file_limit: MOUNT_FILE_LIMIT,
785
        dir_entry_limit: MOUNT_DIR_ENTRY_LIMIT,
786
        alloc: plugin.manifest.abi_alloc.clone(),
787
    };
788
    let mut store = Store::new(&engine, state);
789
    store.limiter(|state| &mut state.limits);
790
    store.set_epoch_deadline(1);
791
792
    let mut linker: Linker<HostState> = Linker::new(&engine);
793
    // The capability imports exist only when the manifest declared mounts.
794
    // A module that asks for them without a mount was already refused at
795
    // load, and one that asks for them here finds nothing to link to.
796
    if !plugin.mounts.is_empty() {
797
        linker
798
            .func_wrap(
799
                "openagents",
800
                "read_file",
801
                |mut caller: Caller<'_, HostState>, ptr: i32, len: i32| -> i64 {
802
                    let packet = match guest_string(&mut caller, ptr, len) {
803
                        Some(path) => read_mounted(caller.data(), &path, None),
804
                        None => refusal_packet(
805
                            "mount_denied",
806
                            "the path argument is not inside guest memory",
807
                        ),
808
                    };
809
                    answer_guest(&mut caller, &packet)
810
                },
811
            )
812
            .and_then(|linker| {
813
                linker.func_wrap(
814
                    "openagents",
815
                    "read_file_range",
816
                    |mut caller: Caller<'_, HostState>,
817
                     ptr: i32,
818
                     len: i32,
819
                     offset: i64,
820
                     max_bytes: i32|
821
                     -> i64 {
822
                        let packet = match guest_string(&mut caller, ptr, len) {
823
                            Some(path) => read_mounted(
824
                                caller.data(),
825
                                &path,
826
                                Some((offset as u64, max_bytes as u32)),
827
                            ),
828
                            None => refusal_packet(
829
                                "mount_denied",
830
                                "the path argument is not inside guest memory",
831
                            ),
832
                        };
833
                        answer_guest(&mut caller, &packet)
834
                    },
835
                )
836
            })
837
            .and_then(|linker| {
838
                linker.func_wrap(
839
                    "openagents",
840
                    "list_dir",
841
                    |mut caller: Caller<'_, HostState>,
842
                     mount_index: i32,
843
                     ptr: i32,
844
                     len: i32|
845
                     -> i64 {
846
                        let packet = match guest_string(&mut caller, ptr, len) {
847
                            Some(path) => list_mounted(caller.data(), mount_index as u32, &path),
848
                            None => refusal_packet(
849
                                "mount_denied",
850
                                "the path argument is not inside guest memory",
851
                            ),
852
                        };
853
                        answer_guest(&mut caller, &packet)
854
                    },
855
                )
856
            })
857
            .map_err(|e| refuse("trap", format!("the capability imports did not link: {e}")))?;
858
    }
859
860
    // The watchdog is the whole timeout. A wasm call is synchronous and
861
    // cannot be preempted from the calling thread, so the deadline has to
862
    // arrive from somewhere else: this thread increments the engine's epoch,
863
    // and the running instance traps at its next check.
864
    let timed_out = Arc::new(AtomicBool::new(false));
865
    let (done_tx, done_rx) = mpsc::channel::<()>();
866
    let watchdog = {
867
        let engine = engine.clone();
868
        let timed_out = Arc::clone(&timed_out);
869
        let deadline = Duration::from_millis(plugin.manifest.timeout_ms);
870
        std::thread::spawn(move || {
871
            if let Err(mpsc::RecvTimeoutError::Timeout) = done_rx.recv_timeout(deadline) {
872
                timed_out.store(true, Ordering::SeqCst);
873
                engine.increment_epoch();
874
            }
875
        })
876
    };
877
878
    let outcome = run_packet(&mut store, &linker, &module, plugin, input);
879
880
    let _ = done_tx.send(());
881
    let _ = watchdog.join();
882
883
    outcome.map_err(|refusal| {
884
        if refusal.code == "trap" && timed_out.load(Ordering::SeqCst) {
885
            refuse(
886
                "timeout",
887
                format!(
888
                    "the plugin did not answer within {}ms, the bound its manifest declares, and \
889
                     its instance was trapped",
890
                    plugin.manifest.timeout_ms
891
                ),
892
            )
893
        } else {
894
            refusal
895
        }
896
    })
897
}
898
899
fn run_packet(
900
    store: &mut Store<HostState>,
901
    linker: &Linker<HostState>,
902
    module: &Module,
903
    plugin: &LoadedPlugin,
904
    input: &[u8],
905
) -> Result<Vec<u8>, Refusal> {
906
    let instance = linker
907
        .instantiate(&mut *store, module)
908
        .map_err(|e| refuse("trap", format!("the plugin did not instantiate: {e}")))?;
909
910
    let alloc = instance
911
        .get_typed_func::<i32, i32>(&mut *store, &plugin.manifest.abi_alloc)
912
        .map_err(|e| refuse("exports_missing", e.to_string()))?;
913
    let entry = instance
914
        .get_typed_func::<(i32, i32), i64>(&mut *store, &plugin.manifest.abi_entry)
915
        .map_err(|e| refuse("exports_missing", e.to_string()))?;
916
    let memory = instance
917
        .get_memory(&mut *store, "memory")
918
        .ok_or_else(|| refuse("exports_missing", "the module does not export `memory`"))?;
919
920
    let len = i32::try_from(input.len()).map_err(|_| {
921
        refuse(
922
            "bad_packet",
923
            "the input packet does not fit a wasm32 pointer",
924
        )
925
    })?;
926
    let ptr = alloc
927
        .call(&mut *store, len)
928
        .map_err(|e| refuse("trap", e.to_string()))?;
929
    memory
930
        .write(&mut *store, ptr as usize, input)
931
        .map_err(|e| {
932
            refuse(
933
                "trap",
934
                format!("the input packet did not fit guest memory: {e}"),
935
            )
936
        })?;
937
938
    let packed = entry
939
        .call(&mut *store, (ptr, len))
940
        .map_err(|e| refuse("trap", e.to_string()))? as u64;
941
    let out_ptr = (packed >> 32) as usize;
942
    let out_len = (packed & 0xffff_ffff) as usize;
943
944
    // Re-read the view: the call may have grown memory.
945
    let data = memory.data(&*store);
946
    let end = out_ptr
947
        .checked_add(out_len)
948
        .filter(|end| *end <= data.len())
949
        .ok_or_else(|| refuse("trap", "the output packet points outside guest memory"))?;
950
    Ok(data[out_ptr..end].to_vec())
951
}
952
953
/// The same invocation off the async runtime's worker threads.
954
pub async fn invoke_async(plugin: Arc<LoadedPlugin>, input: Vec<u8>) -> Result<Vec<u8>, Refusal> {
955
    match tokio::task::spawn_blocking(move || invoke(&plugin, &input)).await {
956
        Ok(outcome) => outcome,
957
        Err(err) => Err(refuse(
958
            "trap",
959
            format!("the plugin task did not finish: {err}"),
960
        )),
961
    }
962
}
963
964
/// Run a plugin and render its answer as the text a model reads.
965
pub async fn run_plugin_text(plugin: Arc<LoadedPlugin>, arguments: &serde_json::Value) -> String {
966
    let packet = serde_json::to_vec(arguments).unwrap_or_else(|_| b"{}".to_vec());
967
    match invoke_async(plugin, packet).await {
968
        Err(refusal) => format!("The plugin refused {refusal}"),
969
        Ok(bytes) => match String::from_utf8(bytes) {
970
            Err(err) => format!(
971
                "The plugin refused (bad_packet): the output packet is not UTF-8 ({} bytes)",
972
                err.as_bytes().len()
973
            ),
974
            Ok(text) if text.len() <= PLUGIN_OUTPUT_LIMIT => text,
975
            Ok(text) => {
976
                let mut cut = PLUGIN_OUTPUT_LIMIT;
977
                while cut > 0 && !text.is_char_boundary(cut) {
978
                    cut -= 1;
979
                }
980
                format!("{}\n…[truncated]", &text[..cut])
981
            }
982
        },
983
    }
984
}
985
986
// ─────────────────────────────────────────────────────────────── the catalog
987
988
/// One installed, digest-pinned plugin as the catalog sees it.
989
#[derive(Debug, Clone)]
990
pub struct CatalogEntry {
991
    pub name: String,
992
    pub version: String,
993
    pub description: String,
994
    pub manifest_path: PathBuf,
995
    pub digest: String,
996
    pub mounts: Vec<Mount>,
997
    pub host_count: usize,
998
}
999
1000
impl CatalogEntry {
1001
    /// Which approval tier this entry falls in.
1002
    pub fn tier(&self) -> Tier {
1003
        if self.host_count > 0 {
1004
            Tier::Hosts
1005
        } else if self.mounts.is_empty() {
1006
            Tier::PureCompute
1007
        } else {
1008
            Tier::Mounts
1009
        }
1010
    }
1011
}
1012
1013
/// The three fixed capability tiers.
1014
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1015
pub enum Tier {
1016
    /// No mounts and no declared hosts. Allowed without asking.
1017
    PureCompute,
1018
    /// Read-only directory mounts. Needs an operator.
1019
    Mounts,
1020
    /// Network hosts. This host has no network capability, so it never loads.
1021
    Hosts,
1022
}
1023
1024
/// Walk upward from `from` until a `plugins/` directory is found, then read
1025
/// every child `manifest.json` inside it.
1026
///
1027
/// Discovery, not verification: an invalid manifest is skipped rather than
1028
/// failing the walk, and verification happens at load.
1029
pub fn discover_catalog(from: &Path) -> Vec<CatalogEntry> {
1030
    let mut here = from.to_path_buf();
1031
    loop {
1032
        let candidate = here.join("plugins");
1033
        if candidate.is_dir() {
1034
            return read_catalog_dir(&candidate);
1035
        }
1036
        if !here.pop() {
1037
            return Vec::new();
1038
        }
1039
    }
1040
}
1041
1042
fn read_catalog_dir(dir: &Path) -> Vec<CatalogEntry> {
1043
    let Ok(entries) = std::fs::read_dir(dir) else {
1044
        return Vec::new();
1045
    };
1046
    let mut found = Vec::new();
1047
    for entry in entries.flatten() {
1048
        let manifest_path = entry.path().join("manifest.json");
1049
        if !manifest_path.is_file() {
1050
            continue;
1051
        }
1052
        let Ok(raw) = std::fs::read_to_string(&manifest_path) else {
1053
            continue;
1054
        };
1055
        let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&raw) else {
1056
            continue;
1057
        };
1058
        let Ok(manifest) = validate_manifest(&parsed) else {
1059
            continue;
1060
        };
1061
        found.push(CatalogEntry {
1062
            name: manifest.name,
1063
            version: manifest.version,
1064
            description: manifest.description,
1065
            manifest_path,
1066
            digest: manifest.artifact_digest,
1067
            mounts: manifest.mounts,
1068
            host_count: manifest.hosts.len(),
1069
        });
1070
    }
1071
    found.sort_by(|left, right| left.name.cmp(&right.name));
1072
    found
1073
}
1074
1075
/// Lowercased word stems of three letters or more; the rest is noise.
1076
fn tokens(text: &str) -> Vec<String> {
1077
    text.to_lowercase()
1078
        .split(|c: char| !c.is_ascii_alphanumeric())
1079
        .filter(|word| word.len() >= 3)
1080
        .map(str::to_string)
1081
        .collect()
1082
}
1083
1084
/// Score the catalog against free text, best first.
1085
///
1086
/// Retrieval narrows candidates; it never routes. The model still invokes by
1087
/// the exact returned name, the load still verifies the digest, and the
1088
/// sandbox still enforces the manifest, so nothing here decides what runs —
1089
/// it decides what is worth showing. An embedding index replaces this scoring
1090
/// without changing the surface (OpenAgentsInc/openagents#42).
1091
pub fn match_capabilities<'a>(
1092
    catalog: &'a [CatalogEntry],
1093
    text: &str,
1094
) -> Vec<(&'a CatalogEntry, usize)> {
1095
    let terms = tokens(text);
1096
    if terms.is_empty() {
1097
        return Vec::new();
1098
    }
1099
    let mut scored: Vec<(&CatalogEntry, usize)> = catalog
1100
        .iter()
1101
        .map(|entry| {
1102
            let haystack: BTreeSet<String> =
1103
                tokens(&format!("{} {}", entry.name, entry.description))
1104
                    .into_iter()
1105
                    .collect();
1106
            let hits = terms.iter().filter(|term| haystack.contains(*term)).count();
1107
            (entry, hits)
1108
        })
1109
        .filter(|(_, hits)| *hits > 0)
1110
        .collect();
1111
    scored.sort_by(|left, right| {
1112
        right
1113
            .1
1114
            .cmp(&left.1)
1115
            .then_with(|| left.0.name.cmp(&right.0.name))
1116
    });
1117
    scored
1118
}
1119
1120
/// A description's first sentence, for a one-line candidate row.
1121
fn first_sentence(text: &str) -> &str {
1122
    match text.find(". ") {
1123
        Some(at) if at > 0 => &text[..=at],
1124
        _ => text,
1125
    }
1126
}
1127
1128
fn catalog_description(catalog: &[CatalogEntry]) -> String {
1129
    if catalog.is_empty() {
1130
        return "The local catalog is empty.".to_string();
1131
    }
1132
    let rows: Vec<String> = catalog
1133
        .iter()
1134
        .map(|entry| {
1135
            format!(
1136
                "- `{}` v{}: {}",
1137
                entry.name, entry.version, entry.description
1138
            )
1139
        })
1140
        .collect();
1141
    format!("Installed capabilities:\n{}", rows.join("\n"))
1142
}
1143
1144
fn remainder(beyond: usize) -> String {
1145
    if beyond > 0 {
1146
        format!("\n…and {beyond} more; search again with other words.")
1147
    } else {
1148
        String::new()
1149
    }
1150
}
1151
1152
/// What a load reports, for a notice or a plain line.
1153
pub fn describe_load(plugin: &LoadedPlugin) -> String {
1154
    let reach = if plugin.mounts.is_empty() {
1155
        "pure compute".to_string()
1156
    } else if plugin.mounts.len() == 1 {
1157
        "1 read-only mount".to_string()
1158
    } else {
1159
        format!("{} read-only mounts", plugin.mounts.len())
1160
    };
1161
    format!(
1162
        "Loaded plugin `{}` v{} — digest verified ({}…, {} bytes, {reach}, {}ms bound, {} MiB \
1163
         memory ceiling). Experimental.",
1164
        plugin.manifest.name,
1165
        plugin.manifest.version,
1166
        &plugin.digest[..19.min(plugin.digest.len())],
1167
        plugin.wasm.len(),
1168
        plugin.manifest.timeout_ms,
1169
        plugin.manifest.memory_max_mib,
1170
    )
1171
}
1172
1173
/// One sentence describing what the plugin can reach, for the model.
1174
fn reach_description(plugin: &LoadedPlugin) -> String {
1175
    if plugin.mounts.is_empty() {
1176
        "It runs sandboxed pure computation: no file, network, or environment access.".to_string()
1177
    } else {
1178
        format!(
1179
            "It runs sandboxed with read-only access to {} mounted director{}; no writes, no \
1180
             network, no environment access.",
1181
            plugin.mounts.len(),
1182
            if plugin.mounts.len() == 1 { "y" } else { "ies" }
1183
        )
1184
    }
1185
}
1186
1187
/// The tool declaration a loaded plugin materializes for the session.
1188
///
1189
/// The manifest is the whole declaration: its name is the tool name, its
1190
/// description is what the model reads, and its input schema is the
1191
/// parameters.
1192
pub fn plugin_tool_definition(plugin: &LoadedPlugin) -> crate::tools::ToolDefinition {
1193
    crate::tools::ToolDefinition {
1194
        name: plugin.manifest.name.clone(),
1195
        description: format!(
1196
            "{}\n\nWASM plugin `{}` v{}, loaded for this session only ({}…). {} The result is a \
1197
             JSON object with either `ok` or `refusal`.",
1198
            plugin.manifest.description,
1199
            plugin.manifest.name,
1200
            plugin.manifest.version,
1201
            &plugin.digest[..19.min(plugin.digest.len())],
1202
            reach_description(plugin),
1203
        ),
1204
        parameters: plugin.manifest.input_schema.clone(),
1205
    }
1206
}
1207
1208
/// The standing `capability` tool.
1209
///
1210
/// Constant-size on purpose: the catalog is searched, never enumerated here,
1211
/// so the standing prompt does not grow as capabilities are installed.
1212
pub fn capability_tool_definition() -> crate::tools::ToolDefinition {
1213
    crate::tools::ToolDefinition {
1214
        name: "capability".to_string(),
1215
        description: "Discover and load installed plugin capabilities: sandboxed, digest-pinned \
1216
             WebAssembly programs this machine already holds for common agent work. Before writing \
1217
             a script for a task, search here first — a capability that covers it is bounded, \
1218
             reviewable, and returns structured output. Call with `query` describing what you need \
1219
             to get the best matches; then call again with `name` set to the exact returned name \
1220
             to load it and make its dedicated tool available. Every later call to the loaded \
1221
             capability uses that exact name as the tool name."
1222
            .to_string(),
1223
        parameters: serde_json::json!({
1224
            "type": "object",
1225
            "properties": {
1226
                "query": {
1227
                    "type": "string",
1228
                    "description": "Describe the capability you need. Matches are ranked by overlap with each manifest's name and description."
1229
                },
1230
                "name": {
1231
                    "type": "string",
1232
                    "description": "Exact catalog name of the capability to load. Use the exact name from a previous `query` result."
1233
                }
1234
            },
1235
            "additionalProperties": false
1236
        }),
1237
    }
1238
}
1239
1240
/// Whether a tier may load in this session.
1241
///
1242
/// Pure compute is allowed without asking. Read-only mounts need an operator,
1243
/// and if none was supplied the load refuses — the safe default, because an
1244
/// unattended session must not grant directory access on its own. Network
1245
/// hosts have no capability behind them at all, so they never load.
1246
#[derive(Debug, Clone, Copy, Default)]
1247
pub struct Approval {
1248
    /// Set by a caller that has an operator behind it, such as the
1249
    /// `--allow-mounts` flag on `oa plugin run`.
1250
    pub mounts_allowed: bool,
1251
}
1252
1253
impl Approval {
1254
    pub fn check(&self, entry: &CatalogEntry) -> Result<(), Refusal> {
1255
        match entry.tier() {
1256
            Tier::PureCompute => Ok(()),
1257
            Tier::Mounts if self.mounts_allowed => Ok(()),
1258
            Tier::Mounts => Err(refuse(
1259
                "approval_unavailable",
1260
                format!(
1261
                    "`{}` asks for {} read-only director{} and no operator approved it in this \
1262
                     session",
1263
                    entry.name,
1264
                    entry.mounts.len(),
1265
                    if entry.mounts.len() == 1 { "y" } else { "ies" }
1266
                ),
1267
            )),
1268
            Tier::Hosts => Err(refuse(
1269
                "capabilities_unsupported",
1270
                format!(
1271
                    "`{}` declares network hosts, and this host has no network capability to grant",
1272
                    entry.name
1273
                ),
1274
            )),
1275
        }
1276
    }
1277
}
1278
1279
/// Answer one `capability` call: search the catalog, or load by exact name.
1280
///
1281
/// Returns the text the model reads and, when a plugin loaded, the plugin
1282
/// itself so the session can declare its tool.
1283
pub fn answer_capability(
1284
    catalog: &[CatalogEntry],
1285
    approval: Approval,
1286
    workspace: &Path,
1287
    arguments: &serde_json::Value,
1288
) -> (String, Option<LoadedPlugin>) {
1289
    let query = arguments
1290
        .get("query")
1291
        .and_then(|v| v.as_str())
1292
        .map(str::trim)
1293
        .unwrap_or("");
1294
    let name = arguments
1295
        .get("name")
1296
        .and_then(|v| v.as_str())
1297
        .map(str::trim)
1298
        .unwrap_or("");
1299
1300
    if !name.is_empty() {
1301
        let Some(entry) = catalog.iter().find(|candidate| candidate.name == name) else {
1302
            return (
1303
                format!(
1304
                    "No capability named `{name}` is in the local catalog.\n\n{}",
1305
                    catalog_description(catalog)
1306
                ),
1307
                None,
1308
            );
1309
        };
1310
        if let Err(refusal) = approval.check(entry) {
1311
            return (
1312
                format!("Capability `{name}` was not allowed {refusal}"),
1313
                None,
1314
            );
1315
        }
1316
        match load_plugin(&entry.manifest_path, workspace) {
1317
            Err(refusal) => (format!("Plugin not loaded {refusal}"), None),
1318
            Ok(plugin) => {
1319
                let text = format!(
1320
                    "{}\n\nThe tool `{}` is available now. Call it directly for this work instead \
1321
                     of a shell script: it is sandboxed, bounded, and returns structured JSON. Its \
1322
                     parameters are in its tool declaration.",
1323
                    describe_load(&plugin),
1324
                    entry.name
1325
                );
1326
                (text, Some(plugin))
1327
            }
1328
        }
1329
    } else if !query.is_empty() {
1330
        let ranked = match_capabilities(catalog, query);
1331
        if ranked.is_empty() {
1332
            if catalog.is_empty() {
1333
                return (
1334
                    "No capabilities are installed on this machine.".to_string(),
1335
                    None,
1336
                );
1337
            }
1338
            let shown: Vec<CatalogEntry> = catalog.iter().take(SEARCH_LIMIT).cloned().collect();
1339
            return (
1340
                format!(
1341
                    "Nothing installed matches that. The full catalog:\n\n{}{}\n\nCall \
1342
                     `capability` with `name` set to an exact name to load it.",
1343
                    catalog_description(&shown),
1344
                    remainder(catalog.len().saturating_sub(SEARCH_LIMIT))
1345
                ),
1346
                None,
1347
            );
1348
        }
1349
        let rows: Vec<String> = ranked
1350
            .iter()
1351
            .take(SEARCH_LIMIT)
1352
            .map(|(entry, _)| {
1353
                format!(
1354
                    "- `{}` — {}",
1355
                    entry.name,
1356
                    first_sentence(&entry.description)
1357
                )
1358
            })
1359
            .collect();
1360
        (
1361
            format!(
1362
                "Best matches, most relevant first:\n\n{}{}\n\nCall `capability` with `name` set \
1363
                 to the exact name you want to load.",
1364
                rows.join("\n"),
1365
                remainder(catalog.len().saturating_sub(rows.len()))
1366
            ),
1367
            None,
1368
        )
1369
    } else {
1370
        (
1371
            "Provide `query` to see the catalog or `name` to load a capability by exact catalog \
1372
             name."
1373
                .to_string(),
1374
            None,
1375
        )
1376
    }
1377
}
1378
1379
// ──────────────────────────────────────────────────────────── the `oa plugin`
1380
1381
use clap::{Args, Subcommand};
1382
1383
#[derive(Args, Debug)]
1384
pub struct PluginArgs {
1385
    #[command(subcommand)]
1386
    pub action: PluginAction,
1387
}
1388
1389
#[derive(Subcommand, Debug)]
1390
pub enum PluginAction {
1391
    /// List the digest-pinned WebAssembly plugins installed on this machine
1392
    List,
1393
    /// Rank the catalog against a description of what you need
1394
    Search {
1395
        /// What the capability should do
1396
        query: String,
1397
    },
1398
    /// Verify a plugin's digest and report what its module asks for
1399
    Inspect {
1400
        /// Exact catalog name
1401
        name: String,
1402
    },
1403
    /// Load a plugin and run one packet through it under its declared limits
1404
    Run {
1405
        /// Exact catalog name
1406
        name: String,
1407
        /// The tool arguments, as a JSON object
1408
        #[arg(long, default_value = "{}")]
1409
        input: String,
1410
        /// Approve the read-only directory mounts the manifest declares
1411
        #[arg(
1412
            long,
1413
            help = "Approve the read-only mounts this plugin declares; without it a mounted plugin refuses to load"
1414
        )]
1415
        allow_mounts: bool,
1416
    },
1417
}
1418
1419
/// `oa plugin`. Every path that cannot reach its data exits 2 with `oa: …` on
1420
/// stderr rather than inventing an answer.
1421
pub async fn run(args: PluginArgs, json: bool) {
1422
    let workspace = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1423
    let catalog = discover_catalog(&workspace);
1424
1425
    match args.action {
1426
        PluginAction::List => {
1427
            if catalog.is_empty() {
1428
                crate::cli::fail(&format!(
1429
                    "no `plugins/` directory was found at or above {}, so no capability catalog \
1430
                     could be read",
1431
                    workspace.display()
1432
                ));
1433
            }
1434
            if json {
1435
                let rows: Vec<serde_json::Value> = catalog
1436
                    .iter()
1437
                    .map(|entry| {
1438
                        serde_json::json!({
1439
                            "name": entry.name,
1440
                            "version": entry.version,
1441
                            "digest": entry.digest,
1442
                            "manifest": entry.manifest_path.to_string_lossy(),
1443
                            "mounts": entry.mounts,
1444
                            "tier": format!("{:?}", entry.tier()),
1445
                        })
1446
                    })
1447
                    .collect();
1448
                println!(
1449
                    "{}",
1450
                    serde_json::to_string_pretty(&rows).unwrap_or_default()
1451
                );
1452
            } else {
1453
                for entry in &catalog {
1454
                    let reach = match entry.tier() {
1455
                        Tier::PureCompute => "pure compute".to_string(),
1456
                        Tier::Mounts => format!("{} read-only mount(s)", entry.mounts.len()),
1457
                        Tier::Hosts => "network hosts (never loads)".to_string(),
1458
                    };
1459
                    println!("{} v{} — {reach}", entry.name, entry.version);
1460
                }
1461
            }
1462
        }
1463
        PluginAction::Search { query } => {
1464
            if catalog.is_empty() {
1465
                crate::cli::fail("no capability catalog could be read from this directory");
1466
            }
1467
            let ranked = match_capabilities(&catalog, &query);
1468
            if ranked.is_empty() {
1469
                println!("Nothing installed matches that.");
1470
            } else {
1471
                for (entry, hits) in ranked.iter().take(SEARCH_LIMIT) {
1472
                    println!(
1473
                        "{} ({hits} term{}) — {}",
1474
                        entry.name,
1475
                        if *hits == 1 { "" } else { "s" },
1476
                        first_sentence(&entry.description)
1477
                    );
1478
                }
1479
            }
1480
        }
1481
        PluginAction::Inspect { name } => {
1482
            let Some(entry) = catalog.iter().find(|candidate| candidate.name == name) else {
1483
                crate::cli::fail(&format!("no capability named `{name}` is installed here"));
1484
            };
1485
            match load_plugin(&entry.manifest_path, &workspace) {
1486
                Err(refusal) => crate::cli::fail(&format!("`{name}` did not load {refusal}")),
1487
                Ok(plugin) => {
1488
                    let shape = match inspect_module(&plugin.wasm) {
1489
                        Ok(shape) => shape,
1490
                        Err(refusal) => {
1491
                            crate::cli::fail(&format!("`{name}` did not compile {refusal}"))
1492
                        }
1493
                    };
1494
                    println!("{}", describe_load(&plugin));
1495
                    println!("manifest: {}", plugin.manifest_path.display());
1496
                    println!("digest:   {}", plugin.digest);
1497
                    println!(
1498
                        "imports:  {}",
1499
                        if shape.imports.is_empty() {
1500
                            "none".to_string()
1501
                        } else {
1502
                            shape.imports.join(", ")
1503
                        }
1504
                    );
1505
                    for root in &plugin.mounts {
1506
                        println!("mount:    {} (read-only)", root.display());
1507
                    }
1508
                }
1509
            }
1510
        }
1511
        PluginAction::Run {
1512
            name,
1513
            input,
1514
            allow_mounts,
1515
        } => {
1516
            let Some(entry) = catalog.iter().find(|candidate| candidate.name == name) else {
1517
                crate::cli::fail(&format!("no capability named `{name}` is installed here"));
1518
            };
1519
            let arguments: serde_json::Value = match serde_json::from_str(&input) {
1520
                Ok(value) => value,
1521
                Err(err) => crate::cli::fail(&format!("--input is not JSON: {err}")),
1522
            };
1523
            let approval = Approval {
1524
                mounts_allowed: allow_mounts,
1525
            };
1526
            if let Err(refusal) = approval.check(entry) {
1527
                crate::cli::fail(&format!("`{name}` was not allowed {refusal}"));
1528
            }
1529
            let plugin = match load_plugin(&entry.manifest_path, &workspace) {
1530
                Ok(plugin) => plugin,
1531
                Err(refusal) => crate::cli::fail(&format!("`{name}` did not load {refusal}")),
1532
            };
1533
            eprintln!("{}", describe_load(&plugin));
1534
            match invoke_async(
1535
                Arc::new(plugin),
1536
                serde_json::to_vec(&arguments).unwrap_or_default(),
1537
            )
1538
            .await
1539
            {
1540
                Err(refusal) => crate::cli::fail(&format!("`{name}` refused {refusal}")),
1541
                Ok(bytes) => match String::from_utf8(bytes) {
1542
                    Ok(text) => println!("{text}"),
1543
                    Err(err) => crate::cli::fail(&format!(
1544
                        "`{name}` answered with {} bytes that are not UTF-8",
1545
                        err.as_bytes().len()
1546
                    )),
1547
                },
1548
            }
1549
        }
1550
    }
1551
}
1552
1553
#[cfg(test)]
1554
mod tests {
1555
    use super::*;
1556
1557
    fn manifest_json(mounts: serde_json::Value, hosts: serde_json::Value) -> serde_json::Value {
1558
        serde_json::json!({
1559
            "name": "probe_plugin",
1560
            "version": "0.1.0",
1561
            "description": "A manifest for the validator's tests.",
1562
            "artifact": {"path": "p.wasm", "digest": "sha256:00"},
1563
            "abi": {"kind": "packet-v0", "entry": "handle_packet", "alloc": "packet_alloc"},
1564
            "interface": {"input": {"type": "object"}, "output": {"type": "object"}},
1565
            "capabilities": {"mounts": mounts, "hosts": hosts, "timeout_ms": 2000}
1566
        })
1567
    }
1568
1569
    #[test]
1570
    fn a_writable_mount_is_refused_rather_than_downgraded() {
1571
        let value = manifest_json(
1572
            serde_json::json!([{"path": "data", "readonly": false}]),
1573
            serde_json::json!([]),
1574
        );
1575
        let refusal = validate_manifest(&value).unwrap_err();
1576
        assert_eq!(refusal.code, "capabilities_unsupported");
1577
        assert!(refusal.reason.contains("readonly"), "{}", refusal.reason);
1578
    }
1579
1580
    #[test]
1581
    fn a_mount_missing_the_readonly_flag_entirely_is_refused() {
1582
        let value = manifest_json(serde_json::json!([{"path": "data"}]), serde_json::json!([]));
1583
        assert_eq!(
1584
            validate_manifest(&value).unwrap_err().code,
1585
            "capabilities_unsupported"
1586
        );
1587
    }
1588
1589
    #[test]
1590
    fn an_unknown_abi_is_refused_by_name() {
1591
        let mut value = manifest_json(serde_json::json!([]), serde_json::json!([]));
1592
        value["abi"]["kind"] = serde_json::json!("packet-v9");
1593
        let refusal = validate_manifest(&value).unwrap_err();
1594
        assert_eq!(refusal.code, "abi_unsupported");
1595
        assert!(refusal.reason.contains("packet-v9"));
1596
    }
1597
1598
    #[test]
1599
    fn a_manifest_timeout_is_clamped_to_the_host_ceiling() {
1600
        let mut value = manifest_json(serde_json::json!([]), serde_json::json!([]));
1601
        value["capabilities"]["timeout_ms"] = serde_json::json!(3_600_000);
1602
        assert_eq!(
1603
            validate_manifest(&value).unwrap().timeout_ms,
1604
            TIMEOUT_CEILING_MS
1605
        );
1606
    }
1607
1608
    #[test]
1609
    fn a_manifest_memory_request_is_clamped_to_the_host_ceiling() {
1610
        let mut value = manifest_json(serde_json::json!([]), serde_json::json!([]));
1611
        value["capabilities"]["memory_max_mib"] = serde_json::json!(4096);
1612
        assert_eq!(
1613
            validate_manifest(&value).unwrap().memory_max_mib,
1614
            MEMORY_CEILING_MIB
1615
        );
1616
    }
1617
1618
    #[test]
1619
    fn a_digest_without_the_sha256_prefix_is_not_a_pin() {
1620
        let mut value = manifest_json(serde_json::json!([]), serde_json::json!([]));
1621
        value["artifact"]["digest"] = serde_json::json!("deadbeef");
1622
        assert_eq!(
1623
            validate_manifest(&value).unwrap_err().code,
1624
            "manifest_invalid"
1625
        );
1626
    }
1627
1628
    #[test]
1629
    fn a_declared_network_host_puts_the_entry_in_the_tier_that_never_loads() {
1630
        let entry = CatalogEntry {
1631
            name: "net".to_string(),
1632
            version: "1".to_string(),
1633
            description: String::new(),
1634
            manifest_path: PathBuf::new(),
1635
            digest: String::new(),
1636
            mounts: Vec::new(),
1637
            host_count: 1,
1638
        };
1639
        assert_eq!(entry.tier(), Tier::Hosts);
1640
        assert_eq!(
1641
            Approval {
1642
                mounts_allowed: true
1643
            }
1644
            .check(&entry)
1645
            .unwrap_err()
1646
            .code,
1647
            "capabilities_unsupported"
1648
        );
1649
    }
1650
1651
    #[test]
1652
    fn a_mounted_plugin_needs_an_operator_and_pure_compute_does_not() {
1653
        let mounted = CatalogEntry {
1654
            name: "reader".to_string(),
1655
            version: "1".to_string(),
1656
            description: String::new(),
1657
            manifest_path: PathBuf::new(),
1658
            digest: String::new(),
1659
            mounts: vec![Mount {
1660
                path: "data".to_string(),
1661
            }],
1662
            host_count: 0,
1663
        };
1664
        let pure = CatalogEntry {
1665
            mounts: Vec::new(),
1666
            ..mounted.clone()
1667
        };
1668
        assert_eq!(
1669
            Approval::default().check(&mounted).unwrap_err().code,
1670
            "approval_unavailable"
1671
        );
1672
        assert!(Approval {
1673
            mounts_allowed: true
1674
        }
1675
        .check(&mounted)
1676
        .is_ok());
1677
        assert!(Approval::default().check(&pure).is_ok());
1678
    }
1679
1680
    #[test]
1681
    fn lexical_join_refuses_a_path_that_climbs_out_of_the_root() {
1682
        let root = Path::new("/tmp/root");
1683
        assert_eq!(
1684
            lexical_join(root, "a/b.txt").unwrap(),
1685
            Path::new("/tmp/root/a/b.txt")
1686
        );
1687
        assert_eq!(
1688
            lexical_join(root, "a/../b.txt").unwrap(),
1689
            Path::new("/tmp/root/b.txt")
1690
        );
1691
        // Climbs one above the root: still a path, but not one inside it.
1692
        let escaped = lexical_join(root, "../secret").unwrap();
1693
        assert!(
1694
            !within(&escaped, root),
1695
            "{} is not confined",
1696
            escaped.display()
1697
        );
1698
        // An absolute component is not a path in the mount at all.
1699
        assert!(lexical_join(root, "/etc/passwd").is_none());
1700
    }
1701
1702
    #[test]
1703
    fn retrieval_ranks_by_term_overlap_and_ignores_short_noise() {
1704
        let catalog = vec![
1705
            CatalogEntry {
1706
                name: "word_stats".to_string(),
1707
                version: "1".to_string(),
1708
                description: "Count words and lines in text.".to_string(),
1709
                manifest_path: PathBuf::new(),
1710
                digest: String::new(),
1711
                mounts: Vec::new(),
1712
                host_count: 0,
1713
            },
1714
            CatalogEntry {
1715
                name: "dir_stats".to_string(),
1716
                version: "1".to_string(),
1717
                description: "List a mounted directory.".to_string(),
1718
                manifest_path: PathBuf::new(),
1719
                digest: String::new(),
1720
                mounts: Vec::new(),
1721
                host_count: 0,
1722
            },
1723
        ];
1724
        let ranked = match_capabilities(&catalog, "count the words in this text");
1725
        assert_eq!(
1726
            ranked.first().map(|(entry, _)| entry.name.as_str()),
1727
            Some("word_stats")
1728
        );
1729
        // "of a" is two stems under three letters, so nothing scores.
1730
        assert!(match_capabilities(&catalog, "of a").is_empty());
1731
    }
1732
1733
    #[test]
1734
    fn the_capability_tool_declares_only_itself() {
1735
        let definition = capability_tool_definition();
1736
        assert_eq!(definition.name, "capability");
1737
        let properties = &definition.parameters["properties"];
1738
        assert!(properties.get("query").is_some());
1739
        assert!(properties.get("name").is_some());
1740
        // The catalog is searched, never enumerated in the declaration: a
1741
        // standing prompt that grows with every installed plugin is the thing
1742
        // this shape exists to avoid.
1743
        assert!(!definition.description.contains("word_stats"));
1744
    }
1745
}
crates/openagents-cli/src/runtime.rs modified +10

@@ -352,6 +352,16 @@ impl CoderRuntimeSession {

352 352
            );
353 353
        }
354 354
355
        // Skill injection. The `skill` tool's catalog is names and
356
        // descriptions only, so a body costs nothing until it is asked for.
357
        // A skill marked `auto: true` in its front matter is the exception:
358
        // it says how to approach the work, and a session needs the method
359
        // before its first decision rather than after thinking to ask.
360
        if let Some(context) = self.tools.standing_context() {
361
            lines.push("".to_string());
362
            lines.push(context);
363
        }
364
355 365
        lines.join("\n")
356 366
    }
357 367
crates/openagents-cli/src/tools.rs modified +805 -82

@@ -1,36 +1,40 @@

1 1
//! The tools a session declares to the model, and what running them does.
2 2
//!
3
//! Four tools: `shell`, `skill`, `openagents`, and `delegate`. Each is
4
//! declared to the model and each has an implementation in
5
//! [`HarnessToolRegistry::execute_tool`]; the list and the match arms are the
6
//! same four, which is the only property that keeps a declared tool from being
7
//! a promise nothing keeps.
3
//! Five tools: `shell`, `skill`, `openagents`, `capability`, and — only where
4
//! a delegation gate exists — `delegate`. Each is declared to the model and
5
//! each has an implementation in [`HarnessToolRegistry::execute_tool`]; the
6
//! list and the match arms carry the same names, which is the only property
7
//! that keeps a declared tool from being a promise nothing keeps. This
8
//! module's header once claimed `capability` while `list_tools` did not
9
//! declare it and no arm implemented it; the rule the mistake bought is that
10
//! a name is written here only after something answers it.
8 11
//!
9
//! `capability` — the standing tool that searches the local plugin catalog and
10
//! loads a digest-pinned WebAssembly plugin — is **not** implemented here and
11
//! is **not** declared. This module's own header used to claim it was. It is
12
//! not a matter of wiring: the plugins in `plugins/` are WebAssembly artifacts
13
//! against a bespoke `packet-v0` ABI, with per-manifest mounts, host
14
//! allowlists, memory ceilings, and timeouts to enforce, and this crate has no
15
//! WebAssembly runtime to enforce them with. That is the "WASM capability
16
//! runtime integration" half of OpenAgentsInc/openagents#71, and porting it
17
//! means bringing a wasm engine into this binary. Until that happens the
18
//! honest state is a tool that is absent rather than one that is advertised
19
//! and refuses.
12
//! `capability` searches the local catalog of digest-pinned WebAssembly
13
//! plugins and loads one into the session, at which point the plugin's own
14
//! manifest declares a second tool under its own name. The sandbox that runs
15
//! it — digest verification, import inspection, confined read-only mounts,
16
//! the memory ceiling, the timeout — is [`crate::plugins`]. A plugin whose
17
//! limits cannot be enforced does not run.
20 18
//!
21 19
//! The tool runtime is the client's. The inference proxy forwards the
22 20
//! declarations and returns the calls the model asks for; nothing runs
23 21
//! server-side.
24 22
25 23
use serde::{Deserialize, Serialize};
26
use std::collections::HashMap;
24
use std::collections::BTreeMap;
27 25
use std::fs;
28 26
use std::path::{Path, PathBuf};
29 27
use std::process::Stdio;
28
use std::sync::{Arc, Mutex};
30 29
use std::time::Duration;
31 30
use tokio::process::Command;
32 31
use tokio::time::timeout;
33 32
33
use crate::plugins::{
34
    self, answer_capability, capability_tool_definition, plugin_tool_definition, Approval,
35
    CatalogEntry, LoadedPlugin,
36
};
37
34 38
pub const OUTPUT_LIMIT: usize = 30_000;
35 39
pub const DEFAULT_TIMEOUT_SECS: u64 = 120;
36 40
pub const MAXIMUM_TIMEOUT_SECS: u64 = 600;

@@ -56,13 +60,35 @@ pub struct ToolOutput {

56 60
    pub is_error: bool,
57 61
}
58 62
63
/// One skill: a directory holding a `SKILL.md` with YAML front matter naming
64
/// it and saying when it applies, then a body of instructions.
65
///
66
/// The format is shared with the other agents that read this repository, so
67
/// the same file serves all of them and none of them owns it.
59 68
#[derive(Debug, Clone)]
60 69
pub struct SkillInfo {
70
    /// The name the model asks for, from the front matter.
61 71
    pub name: String,
72
    /// When to use it, from the front matter. The sentence the model chooses
73
    /// on, and the reason a body is never in the catalog.
62 74
    pub description: String,
75
    /// The instructions, front matter removed.
63 76
    pub body: String,
77
    /// Whether the body is put in front of the model without being asked for.
78
    ///
79
    /// Set with `auto: true` in the front matter. The catalog exists so a body
80
    /// is read only when it is wanted; a skill that says how to approach the
81
    /// work is the exception, because a session needs the method before its
82
    /// first decision and will not think to ask for it. Every auto-loaded body
83
    /// is paid for on every turn, so it is used sparingly.
84
    pub auto: bool,
85
    /// Where it was read from, so a reader can open it.
86
    pub path: PathBuf,
64 87
}
65 88
89
/// How much of one skill body is handed back.
90
pub const SKILL_BODY_LIMIT: usize = 32_000;
91
66 92
/// What the `delegate` tool is allowed to start.
67 93
///
68 94
/// Present on the session the reader is talking to and absent on the children

@@ -81,9 +107,18 @@ pub struct DelegationGate {

81 107
82 108
pub struct HarnessToolRegistry {
83 109
    pub cwd: PathBuf,
84
    pub skills: HashMap<String, SkillInfo>,
110
    /// Discovered skills by name, in catalog order.
111
    pub skills: BTreeMap<String, SkillInfo>,
85 112
    /// `None` on a delegated child, so it cannot delegate further.
86 113
    pub delegation: Option<DelegationGate>,
114
    /// The digest-pinned WebAssembly plugins installed at or above `cwd`.
115
    pub catalog: Vec<CatalogEntry>,
116
    /// Which capability tiers may load in this session. Pure compute always
117
    /// may; read-only mounts need an operator and refuse without one.
118
    pub plugin_approval: Approval,
119
    /// Plugins the model loaded through `capability`, in load order. Each one
120
    /// declares a further tool under its own manifest name.
121
    loaded: Mutex<Vec<Arc<LoadedPlugin>>>,
87 122
}
88 123
89 124
impl HarnessToolRegistry {

@@ -102,55 +137,117 @@ impl HarnessToolRegistry {

102 137
        Self::build(cwd, None)
103 138
    }
104 139
140
    /// Grant the read-only mount tier, for a caller with an operator behind
141
    /// it. Without this a plugin that declares mounts refuses to load, which
142
    /// is the safe default for an unattended session.
143
    pub fn allowing_plugin_mounts(mut self) -> Self {
144
        self.plugin_approval.mounts_allowed = true;
145
        self
146
    }
147
105 148
    fn build(cwd: Option<PathBuf>, delegation: Option<DelegationGate>) -> Self {
106 149
        let root = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
150
        let catalog = plugins::discover_catalog(&root);
107 151
        let mut registry = Self {
108 152
            cwd: root,
109
            skills: HashMap::new(),
153
            skills: BTreeMap::new(),
110 154
            delegation,
155
            catalog,
156
            plugin_approval: Approval::default(),
157
            loaded: Mutex::new(Vec::new()),
111 158
        };
112 159
        registry.load_local_skills();
113 160
        registry
114 161
    }
115 162
116
    pub fn load_local_skills(&mut self) {
117
        let mut search_dirs = Vec::new();
118
        search_dirs.push(self.cwd.join(".agents").join("skills"));
119
        search_dirs.push(self.cwd.join("packages").join("openagents-cli").join("skills"));
163
    /// Where skills live, nearest first. The first source to claim a name
164
    /// keeps it, so a repository or a person replaces a shipped skill by
165
    /// writing one of the same name and nothing has to be uninstalled.
166
    fn skill_directories(&self) -> Vec<PathBuf> {
167
        let mut dirs = vec![self.cwd.join(".agents").join("skills")];
120 168
        if let Ok(home) = std::env::var("HOME") {
121
            search_dirs.push(PathBuf::from(home).join(".agents").join("skills"));
169
            dirs.push(PathBuf::from(home).join(".agents").join("skills"));
122 170
        }
171
        // The skills this CLI ships, read from the package they live in.
172
        dirs.push(self.cwd.join("packages").join("openagents-cli").join("skills"));
173
        dirs
174
    }
123 175
124
        for dir in search_dirs {
125
            if !dir.exists() || !dir.is_dir() {
176
    /// Read every skill this workspace offers.
177
    ///
178
    /// A directory that is missing, unreadable, or holds no `SKILL.md`
179
    /// contributes nothing: a skills directory is optional, and a session in a
180
    /// repository without one is a session with no skills rather than a
181
    /// session that failed to start. A `SKILL.md` with no `name` cannot be
182
    /// asked for and one with no `description` gives the model nothing to
183
    /// choose on, so both are required and a file missing either is skipped.
184
    pub fn load_local_skills(&mut self) {
185
        for dir in self.skill_directories() {
186
            let Ok(entries) = fs::read_dir(&dir) else {
126 187
                continue;
188
            };
189
            let mut names: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
190
            names.sort();
191
            for path in names {
192
                let skill_md = path.join("SKILL.md");
193
                if !skill_md.is_file() {
194
                    continue;
195
                }
196
                let Ok(content) = fs::read_to_string(&skill_md) else {
197
                    continue;
198
                };
199
                let Some((name, description, auto)) = parse_skill_front_matter(&content) else {
200
                    continue;
201
                };
202
                if self.skills.contains_key(&name) {
203
                    continue;
204
                }
205
                self.skills.insert(
206
                    name.clone(),
207
                    SkillInfo {
208
                        name,
209
                        description,
210
                        body: skill_body(&content),
211
                        auto,
212
                        path: skill_md,
213
                    },
214
                );
127 215
            }
128
            if let Ok(entries) = fs::read_dir(dir) {
129
                for entry in entries.flatten() {
130
                    let path = entry.path();
131
                    let skill_md = if path.is_dir() {
132
                        path.join("SKILL.md")
133
                    } else if path.extension().map_or(false, |ext| ext == "md") {
134
                        path.clone()
135
                    } else {
136
                        continue;
137
                    };
216
        }
217
    }
138 218
139
                    if skill_md.exists() {
140
                        if let Ok(content) = fs::read_to_string(&skill_md) {
141
                            let (name, desc, body) = parse_skill_markdown(&skill_md, &content);
142
                            self.skills.insert(name.clone(), SkillInfo {
143
                                name,
144
                                description: desc,
145
                                body,
146
                            });
147
                        }
148
                    }
149
                }
219
    /// The standing context a session starts with: every auto-loaded skill
220
    /// body, in one block, plus what this workspace is when it is one of the
221
    /// two OpenAgents repositories.
222
    ///
223
    /// `None` when there is none, so a caller adds nothing rather than an
224
    /// empty heading.
225
    pub fn standing_context(&self) -> Option<String> {
226
        let mut parts = Vec::new();
227
        if let Some(workspace) = openagents_workspace_note(&self.cwd) {
228
            parts.push(workspace);
229
        }
230
        for skill in self.skills.values() {
231
            if !skill.auto {
232
                continue;
150 233
            }
234
            parts.push(format!(
235
                "The `{}` skill, which applies to this session:\n\n{}",
236
                skill.name, skill.body
237
            ));
238
        }
239
        if parts.is_empty() {
240
            None
241
        } else {
242
            Some(parts.join("\n\n"))
151 243
        }
152 244
    }
153 245
246
    /// The plugins loaded into this session so far.
247
    pub fn loaded_plugins(&self) -> Vec<Arc<LoadedPlugin>> {
248
        self.loaded.lock().map(|held| held.clone()).unwrap_or_default()
249
    }
250
154 251
    pub fn list_tools(&self) -> Vec<ToolDefinition> {
155 252
        let mut skill_list = String::new();
156 253
        for (name, info) in &self.skills {

@@ -204,6 +301,10 @@ impl HarnessToolRegistry {

204 301
            },
205 302
        ];
206 303
304
        // The standing capability tool. Constant-size: it names no installed
305
        // plugin, so the declaration does not grow as the catalog does.
306
        tools.push(capability_tool_definition());
307
207 308
        // Declared only where it can be run. A child's registry has no gate,
208 309
        // so a child neither sees the tool nor can call it.
209 310
        if let Some(gate) = &self.delegation {

@@ -244,6 +345,14 @@ impl HarnessToolRegistry {

244 345
            });
245 346
        }
246 347
348
        // A plugin the model loaded through `capability` declares a tool of
349
        // its own, under its manifest name and over its manifest's input
350
        // schema. Nothing appears here that has not been digest-verified,
351
        // import-inspected, and instantiated at least once at load.
352
        for plugin in self.loaded_plugins() {
353
            tools.push(plugin_tool_definition(&plugin));
354
        }
355
247 356
        tools
248 357
    }
249 358

@@ -276,7 +385,7 @@ impl HarnessToolRegistry {

276 385
                if let Some(skill_info) = self.skills.get(name) {
277 386
                    ToolOutput {
278 387
                        call_id: call.id.clone(),
279
                        output: skill_info.body.clone(),
388
                        output: render_skill(skill_info),
280 389
                        is_error: false,
281 390
                    }
282 391
                } else {

@@ -347,53 +456,315 @@ impl HarnessToolRegistry {

347 456
                    is_error: false,
348 457
                }
349 458
            }
350
            _ => ToolOutput {
351
                call_id: call.id.clone(),
352
                output: format!("Unknown tool: {}", call.name),
353
                is_error: true,
354
            },
459
            "capability" => {
460
                let (text, loaded) = answer_capability(
461
                    &self.catalog,
462
                    self.plugin_approval,
463
                    &self.cwd,
464
                    &call.arguments,
465
                );
466
                // A refusal is text the model can act on, not an error the
467
                // turn dies of; the only `is_error` here is the absence of a
468
                // plugin where one was named.
469
                let is_error = loaded.is_none()
470
                    && call.arguments.get("name").and_then(|v| v.as_str()).is_some();
471
                if let Some(plugin) = loaded {
472
                    if let Ok(mut held) = self.loaded.lock() {
473
                        held.retain(|existing| existing.manifest.name != plugin.manifest.name);
474
                        held.push(Arc::new(plugin));
475
                    }
476
                }
477
                ToolOutput {
478
                    call_id: call.id.clone(),
479
                    output: text,
480
                    is_error,
481
                }
482
            }
483
            other => {
484
                // A loaded plugin answers under its own manifest name.
485
                let plugin = self
486
                    .loaded_plugins()
487
                    .into_iter()
488
                    .find(|plugin| plugin.manifest.name == other);
489
                match plugin {
490
                    Some(plugin) => ToolOutput {
491
                        call_id: call.id.clone(),
492
                        output: plugins::run_plugin_text(plugin, &call.arguments).await,
493
                        is_error: false,
494
                    },
495
                    None => ToolOutput {
496
                        call_id: call.id.clone(),
497
                        output: format!("Unknown tool: {}", call.name),
498
                        is_error: true,
499
                    },
500
                }
501
            }
355 502
        }
356 503
    }
357 504
}
358 505
359
fn parse_skill_markdown(path: &Path, content: &str) -> (String, String, String) {
360
    let fallback_name = path.parent()
361
        .and_then(|p| p.file_name())
362
        .and_then(|n| n.to_str())
363
        .unwrap_or("skill");
364
365
    if let Some(after_front) = content.strip_prefix("---") {
366
        if let Some(end_front) = after_front.find("---") {
367
            let front_matter = &after_front[..end_front];
368
            let body = after_front[end_front + 3..].trim().to_string();
369
370
            let mut name = fallback_name.to_string();
371
            let mut desc = String::new();
372
373
            for line in front_matter.lines() {
374
                let trimmed = line.trim();
375
                if let Some(val) = trimmed.strip_prefix("name:") {
376
                    name = val.trim().trim_matches('"').trim_matches('\'').to_string();
377
                } else if let Some(val) = trimmed.strip_prefix("description:") {
378
                    desc = val.trim().trim_matches('"').trim_matches('\'').to_string();
506
/// Where the front matter ends, as a byte offset of the closing `\n---`.
507
fn front_matter_end(content: &str) -> Option<usize> {
508
    if !content.starts_with("---") {
509
        return None;
510
    }
511
    content[3..].find("\n---").map(|at| at + 3)
512
}
513
514
/// Read `name`, `description`, and `auto` out of YAML front matter.
515
///
516
/// Deliberately not a YAML parser. These are three bounded scalar fields at
517
/// the top of a known file, and a dependency that can parse anchors and merge
518
/// keys is a dependency that can also do something surprising with a file
519
/// anyone may drop in a skills directory.
520
///
521
/// `>` and `|` say the value is the indented block beneath, which is how a
522
/// description longer than a line is written. Taking the marker as the value
523
/// is how a skill came to describe itself as ">-".
524
fn parse_skill_front_matter(content: &str) -> Option<(String, String, bool)> {
525
    let end = front_matter_end(content)?;
526
    let lines: Vec<&str> = content[3..end].split('\n').collect();
527
528
    let mut name: Option<String> = None;
529
    let mut description: Option<String> = None;
530
    let mut auto = false;
531
532
    for (at, line) in lines.iter().enumerate() {
533
        if let Some(value) = line.strip_prefix("auto:") {
534
            let value = value.trim();
535
            if value == "true" || value == "false" {
536
                auto = value == "true";
537
                continue;
538
            }
539
        }
540
        let (key, inline) = if let Some(rest) = line.strip_prefix("name:") {
541
            ("name", rest.trim())
542
        } else if let Some(rest) = line.strip_prefix("description:") {
543
            ("description", rest.trim())
544
        } else {
545
            continue;
546
        };
547
548
        let value = if inline.is_empty() || is_block_marker(inline) {
549
            let mut block = Vec::new();
550
            for next in &lines[at + 1..] {
551
                if next.trim().is_empty() || !next.starts_with([' ', '\t']) {
552
                    break;
379 553
                }
554
                block.push(next.trim());
380 555
            }
381
            if desc.is_empty() {
382
                desc = format!("Procedure for {}", name);
556
            if block.is_empty() {
557
                continue;
383 558
            }
384
            return (name, desc, body);
559
            // A folded block is one paragraph; a literal one keeps its breaks.
560
            block.join(if inline.starts_with('|') { "\n" } else { " " })
561
        } else {
562
            unquote(inline)
563
        };
564
565
        match key {
566
            "name" => name = Some(value),
567
            _ => description = Some(value),
385 568
        }
386 569
    }
387 570
388
    (fallback_name.to_string(), format!("Procedure for {}", fallback_name), content.to_string())
571
    // A skill with no name cannot be asked for, and one with no description
572
    // gives the model nothing to choose on. Both are required.
573
    Some((name?, description?, auto))
574
}
575
576
/// `>`, `|`, and their chomping variants: a marker, never a value.
577
fn is_block_marker(inline: &str) -> bool {
578
    let mut chars = inline.chars();
579
    matches!(chars.next(), Some('>') | Some('|'))
580
        && matches!(chars.next(), None | Some('-') | Some('+'))
581
        && chars.next().is_none()
582
}
583
584
fn unquote(value: &str) -> String {
585
    let bytes = value.as_bytes();
586
    if value.len() >= 2
587
        && (bytes[0] == b'"' || bytes[0] == b'\'')
588
        && (bytes[value.len() - 1] == b'"' || bytes[value.len() - 1] == b'\'')
589
    {
590
        return value[1..value.len() - 1].to_string();
591
    }
592
    value.to_string()
593
}
594
595
/// The body after the front matter, or the whole file when there is none.
596
fn skill_body(content: &str) -> String {
597
    match front_matter_end(content) {
598
        None => content.trim().to_string(),
599
        Some(end) => match content[end + 1..].find('\n') {
600
            Some(at) => content[end + 1 + at + 1..].trim().to_string(),
601
            None => String::new(),
602
        },
603
    }
389 604
}
390 605
606
/// What a skill hands back when it is read, bounded so one file cannot spend
607
/// a whole context on itself.
608
pub fn render_skill(skill: &SkillInfo) -> String {
609
    let body = if skill.body.len() > SKILL_BODY_LIMIT {
610
        let mut cut = SKILL_BODY_LIMIT;
611
        while cut > 0 && !skill.body.is_char_boundary(cut) {
612
            cut -= 1;
613
        }
614
        format!(
615
            "{}\n\n[truncated; the rest is in {}]",
616
            &skill.body[..cut],
617
            skill.path.display()
618
        )
619
    } else {
620
        skill.body.clone()
621
    };
622
    format!("Skill `{}` ({}):\n\n{}", skill.name, skill.path.display(), body)
623
}
624
625
/// What the two OpenAgents repositories are, when the session is in one.
626
///
627
/// A session in `openagents.com` spent turns working out that it was in the
628
/// Phoenix application, and one in `openagents` that the CLI lives under
629
/// `packages/`. Both are facts about the workspace rather than about the work,
630
/// and neither is discoverable without reading around.
631
fn openagents_workspace_note(cwd: &Path) -> Option<String> {
632
    let shown = cwd.to_string_lossy();
633
    if !shown.to_lowercase().contains("openagents") {
634
        return None;
635
    }
636
    let mut lines = vec![
637
        format!(
638
            "This session is working in {shown}, which is part of OpenAgents. Two repositories \
639
             carry most of the work, and they are easy to confuse:"
640
        ),
641
        String::new(),
642
        "- **`openagents.com`** is the web application: a Phoenix and Elixir codebase serving the"
643
            .to_string(),
644
        "  site, the forge, and the `/api/v1` API. Its issues are the site's issues.".to_string(),
645
        "- **`openagents`** is the monorepo: the `openagents` CLI lives in".to_string(),
646
        "  `packages/openagents-cli`, alongside the other packages. Its issues are the CLI's and \
647
         the monorepo's."
648
            .to_string(),
649
        String::new(),
650
        "They are separate repositories with separate issue lists, so name the one you mean when \
651
         you read or write issues, and do not assume the current directory is the one being asked \
652
         about."
653
            .to_string(),
654
    ];
655
656
    // Where the other one is, when it is checked out beside this one. Naming
657
    // the two without saying where the other lives sent a session grepping the
658
    // whole workspace root, which holds every read-only reference clone, and
659
    // it spent the tool's whole budget before being stopped.
660
    if let Some(sibling) = sibling_checkout(cwd) {
661
        let parent = sibling.parent().map(|p| p.display().to_string()).unwrap_or_default();
662
        lines.push(String::new());
663
        lines.push(format!(
664
            "The other one is checked out at `{}`. To search or read it, change directory first — \
665
             `cd {} && git grep …`. `git grep` refuses a path outside the repository it is run in, \
666
             and it is the one command most likely to be reached for here.",
667
            sibling.display(),
668
            sibling.display()
669
        ));
670
        lines.push(String::new());
671
        lines.push(format!(
672
            "Do not search `{parent}` itself. It is the workspace root, and it holds large \
673
             read-only clones of other people's repositories; a recursive grep there does not \
674
             finish. Search one repository at a time."
675
        ));
676
    }
677
678
    Some(lines.join("\n"))
679
}
680
681
/// The other OpenAgents repository, if it is checked out beside this one.
682
///
683
/// Checked rather than assumed: a machine with only one of the two would
684
/// otherwise be told to `cd` somewhere that does not exist, which is a worse
685
/// instruction than none.
686
fn sibling_checkout(cwd: &Path) -> Option<PathBuf> {
687
    let here = cwd.file_name()?.to_str()?;
688
    let other = match here {
689
        "openagents.com" => "openagents",
690
        "openagents" => "openagents.com",
691
        _ => return None,
692
    };
693
    let path = cwd.parent()?.join(other);
694
    path.join(".git").exists().then_some(path)
695
}
696
697
/// Commands that cannot be undone, and are never what was meant.
698
///
699
/// ## What this list is, and is not
700
///
701
/// It stops a small number of irreversible mistakes: erasing a home directory
702
/// or a disk, reformatting, halting the machine. It is not a security boundary
703
/// and cannot be one — a command can be assembled from variables, decoded, or
704
/// written to a file and run, and no list of patterns sees that. It catches
705
/// the accident, not the intent.
706
///
707
/// So it is kept short and aimed only at what cannot be undone. `rm -rf` on a
708
/// build directory is ordinary work and is allowed; `rm -rf` on `/` or `~` is
709
/// not, because no one means it.
710
///
711
/// Each pattern is paired with what to say, because a bare refusal reads as
712
/// the tool being broken rather than as the command being the problem.
713
const REFUSED: &[(&str, &str)] = &[
714
    // A recursive `rm` aimed at a root, a home, or everything in one. Aimed at
715
    // a build directory it is ordinary work, so the target is what decides:
716
    // `rm -rf target/debug` runs and `rm -rf ~/` does not. The flag run has to
717
    // carry an `r`, long options are allowed between the flags and the target
718
    // (`--no-preserve-root` is exactly the phrase that precedes the worst
719
    // version of this), and a trailing `/` or `/*` on the target still names
720
    // the same thing.
721
    (
722
        r"(?i)\brm\s+(--?[a-zA-Z][a-zA-Z-]*\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+(--?[a-zA-Z][a-zA-Z-]*\s+)*(/|~|\$HOME|\$\{HOME\})(/\*|/)?(\s|$)",
723
        "That would erase a root or a home directory.",
724
    ),
725
    (r"(?i)\bmkfs(\.\w+)?\b", "That would reformat a filesystem."),
726
    (
727
        r"(?i)\bdd\b[^\n]*\bof=/dev/(disk|rdisk|sd|nvme|hd)",
728
        "That would write over a raw device.",
729
    ),
730
    (
731
        r"(?i)\bdiskutil\s+(erase|reformat|partition)",
732
        "That would erase or repartition a disk.",
733
    ),
734
    // Anchored to command position — the start of the line, after a `;`, `&&`,
735
    // `||`, or a pipe, or behind `sudo`. A bare word match refused
736
    // `echo 'shutdown the server' >> notes.md`, and a gate that refuses
737
    // ordinary work is one an agent learns to route around.
738
    (
739
        r"(?i)(^|[;&|]\s*|\bsudo\s+)(shutdown|reboot|halt|poweroff)\b",
740
        "That would stop this machine.",
741
    ),
742
    (r":\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:", "That is a fork bomb."),
743
    (
744
        r"(?i)\bchmod\s+(-[a-zA-Z]+\s+)*(-R|--recursive)\s+[0-7]{3,4}\s+(/|~|\$HOME)(\s|$)",
745
        "That would change the permissions of a whole root or home directory.",
746
    ),
747
    (
748
        r"(?i)>\s*/dev/(disk|rdisk|sd|nvme|hd)",
749
        "That would write over a raw device.",
750
    ),
751
];
752
753
/// Why this command will not be run, or `None` when it will.
391 754
pub fn check_shell_refusal(cmd: &str) -> Option<String> {
392
    let lower = cmd.to_lowercase();
393
    let dangerous = ["rm -rf /", "rm -rf ~", "rm -rf $home"];
394
    for d in &dangerous {
395
        if lower.contains(d) {
396
            return Some("That would erase a root or a home directory. This session refuses it.".to_string());
755
    for (pattern, reason) in REFUSED {
756
        // A pattern that does not compile is a bug in this table, not a reason
757
        // to let the command through, so it is skipped loudly in debug and
758
        // treated as no-match otherwise.
759
        let Ok(re) = regex::Regex::new(pattern) else {
760
            debug_assert!(false, "the refusal pattern `{pattern}` does not compile");
761
            continue;
762
        };
763
        if re.is_match(cmd) {
764
            return Some(format!(
765
                "{reason} This session refuses it. If you meant something narrower, name the \
766
                 directory."
767
            ));
397 768
        }
398 769
    }
399 770
    None

@@ -458,3 +829,355 @@ async fn run_openagents_cli(args: &[String]) -> String {

458 829
        Err(e) => format!("Failed to run openagents CLI: {}", e),
459 830
    }
460 831
}
832
833
#[cfg(test)]
834
mod tests {
835
    use super::*;
836
837
    fn write_skill(root: &Path, dir: &str, source: &str) {
838
        let at = root.join(".agents").join("skills").join(dir);
839
        std::fs::create_dir_all(&at).unwrap();
840
        std::fs::write(at.join("SKILL.md"), source).unwrap();
841
    }
842
843
    // ───────────────────────────────────────────── the destructive-command gate
844
845
    #[test]
846
    fn the_gate_refuses_a_destructive_command_that_is_no_literal_it_knows() {
847
        // Every one of these passed the three-string check this replaced.
848
        for command in [
849
            "rm -fr ~/",
850
            "rm -rf --no-preserve-root /",
851
            "sudo rm -Rf $HOME/*",
852
            "mkfs.ext4 /dev/sda1",
853
            "dd if=/dev/zero of=/dev/disk2 bs=1m",
854
            "diskutil eraseDisk JHFS+ Blank /dev/disk3",
855
            "sudo shutdown -h now",
856
            ":(){ :|:& };:",
857
            "chmod -R 777 /",
858
            "cat payload > /dev/rdisk0",
859
        ] {
860
            assert!(
861
                check_shell_refusal(command).is_some(),
862
                "`{command}` should be refused"
863
            );
864
        }
865
    }
866
867
    #[test]
868
    fn the_gate_leaves_ordinary_work_alone() {
869
        // A refusal list that catches ordinary commands is one an agent learns
870
        // to work around, which is worse than not having it.
871
        for command in [
872
            "rm -rf target/debug",
873
            "rm -rf ./node_modules",
874
            "cargo test -p openagents-cli",
875
            "git rm -r --cached .",
876
            "mkdir -p /tmp/build && dd if=in of=out",
877
            "echo 'shutdown the server' >> notes.md",
878
        ] {
879
            assert_eq!(
880
                check_shell_refusal(command),
881
                None,
882
                "`{command}` is ordinary work and should run"
883
            );
884
        }
885
    }
886
887
    #[test]
888
    fn a_refusal_says_what_the_command_would_have_done() {
889
        let refusal = check_shell_refusal("rm -rf ~/").expect("refused");
890
        assert!(refusal.contains("erase a root or a home directory"), "{refusal}");
891
        assert!(refusal.contains("name the directory"), "{refusal}");
892
    }
893
894
    // ─────────────────────────────────────────────────── skills, as they are read
895
896
    #[test]
897
    fn a_block_scalar_description_is_the_block_and_not_its_marker() {
898
        // A skill written with `description: |` was catalogued as describing
899
        // itself as ">-" by a parser that took the marker for the value.
900
        let source = "---\nname: effect\ndescription: |\n  Opinionated guide for Effect v4.\n  Use when implementing workflows.\nlicense: MIT\n---\n\n# Effect\n\nBody.\n";
901
        let (name, description, auto) = parse_skill_front_matter(source).expect("parsed");
902
        assert_eq!(name, "effect");
903
        assert_eq!(
904
            description,
905
            "Opinionated guide for Effect v4.\nUse when implementing workflows."
906
        );
907
        assert!(!auto);
908
        assert_eq!(skill_body(source), "# Effect\n\nBody.");
909
    }
910
911
    #[test]
912
    fn a_folded_block_description_is_one_paragraph() {
913
        let source = "---\nname: folded\ndescription: >-\n  First line\n  second line.\n---\nBody.\n";
914
        let (_, description, _) = parse_skill_front_matter(source).expect("parsed");
915
        assert_eq!(description, "First line second line.");
916
    }
917
918
    #[test]
919
    fn a_skill_missing_a_name_or_a_description_is_not_a_skill() {
920
        // It could not be asked for, or gives the model nothing to choose on.
921
        assert!(parse_skill_front_matter("---\ndescription: no name here\n---\nBody").is_none());
922
        assert!(parse_skill_front_matter("---\nname: nameless\n---\nBody").is_none());
923
        assert!(parse_skill_front_matter("# No front matter at all\n").is_none());
924
    }
925
926
    #[test]
927
    fn the_nearest_skills_directory_keeps_a_contested_name() {
928
        let root = tempfile::tempdir().unwrap();
929
        write_skill(root.path(), "shared", "---\nname: shared\ndescription: The repository's.\n---\nRepo body.\n");
930
        let shipped = root.path().join("packages").join("openagents-cli").join("skills").join("shared");
931
        std::fs::create_dir_all(&shipped).unwrap();
932
        std::fs::write(
933
            shipped.join("SKILL.md"),
934
            "---\nname: shared\ndescription: The CLI's.\n---\nShipped body.\n",
935
        )
936
        .unwrap();
937
938
        let registry = HarnessToolRegistry::new(Some(root.path().to_path_buf()));
939
        let skill = registry.skills.get("shared").expect("found");
940
        assert_eq!(skill.description, "The repository's.");
941
        assert_eq!(skill.body, "Repo body.");
942
    }
943
944
    #[test]
945
    fn the_skill_tool_offers_names_and_descriptions_and_never_a_body() {
946
        let root = tempfile::tempdir().unwrap();
947
        write_skill(
948
            root.path(),
949
            "brewing",
950
            "---\nname: brewing\ndescription: How to make tea.\n---\nSTEEP_FOR_FOUR_MINUTES\n",
951
        );
952
        let registry = HarnessToolRegistry::new(Some(root.path().to_path_buf()));
953
        let tools = registry.list_tools();
954
        let skill_tool = tools.iter().find(|t| t.name == "skill").expect("declared");
955
956
        assert!(skill_tool.description.contains("`brewing`: How to make tea."));
957
        // The catalog is what a session pays for on every turn; a body in it
958
        // is 46 KB of instructions the model may never use.
959
        assert!(
960
            !skill_tool.description.contains("STEEP_FOR_FOUR_MINUTES"),
961
            "the catalog carried a body"
962
        );
963
    }
964
965
    #[tokio::test]
966
    async fn reading_a_skill_returns_its_body_and_says_where_it_came_from() {
967
        let root = tempfile::tempdir().unwrap();
968
        write_skill(
969
            root.path(),
970
            "brewing",
971
            "---\nname: brewing\ndescription: How to make tea.\n---\nSTEEP_FOR_FOUR_MINUTES\n",
972
        );
973
        let registry = HarnessToolRegistry::new(Some(root.path().to_path_buf()));
974
975
        let out = registry
976
            .execute_tool(&ToolCall {
977
                id: "1".to_string(),
978
                name: "skill".to_string(),
979
                arguments: serde_json::json!({"name": "brewing"}),
980
            })
981
            .await;
982
        assert!(!out.is_error);
983
        assert!(out.output.contains("STEEP_FOR_FOUR_MINUTES"), "{}", out.output);
984
        assert!(out.output.contains("SKILL.md"), "{}", out.output);
985
986
        let missing = registry
987
            .execute_tool(&ToolCall {
988
                id: "2".to_string(),
989
                name: "skill".to_string(),
990
                arguments: serde_json::json!({"name": "smelting"}),
991
            })
992
            .await;
993
        assert!(missing.is_error);
994
    }
995
996
    #[test]
997
    fn only_an_auto_skill_reaches_the_standing_context() {
998
        let root = tempfile::tempdir().unwrap();
999
        write_skill(
1000
            root.path(),
1001
            "method",
1002
            "---\nname: method\ndescription: How to approach the work.\nauto: true\n---\nWORK_THIS_WAY\n",
1003
        );
1004
        write_skill(
1005
            root.path(),
1006
            "asked-for",
1007
            "---\nname: asked-for\ndescription: Read on request.\n---\nONLY_ON_REQUEST\n",
1008
        );
1009
        let registry = HarnessToolRegistry::new(Some(root.path().to_path_buf()));
1010
1011
        let context = registry.standing_context().expect("an auto skill is injected");
1012
        assert!(context.contains("WORK_THIS_WAY"), "{context}");
1013
        assert!(
1014
            !context.contains("ONLY_ON_REQUEST"),
1015
            "a skill nobody asked for was injected: {context}"
1016
        );
1017
    }
1018
1019
    #[test]
1020
    fn a_workspace_with_no_auto_skill_injects_nothing() {
1021
        let root = tempfile::tempdir().unwrap();
1022
        write_skill(root.path(), "plain", "---\nname: plain\ndescription: Read on request.\n---\nBody.\n");
1023
        // A temporary directory is not named for either OpenAgents repository,
1024
        // so the workspace note does not apply either.
1025
        assert!(HarnessToolRegistry::new(Some(root.path().to_path_buf()))
1026
            .standing_context()
1027
            .is_none());
1028
    }
1029
1030
    #[test]
1031
    fn the_shipped_repository_skills_are_discovered_and_the_auto_one_is_injected() {
1032
        // End to end against the real `.agents/skills` tree rather than a
1033
        // fixture, because the fixture is what a broken discovery path still
1034
        // passes.
1035
        let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..");
1036
        if !repo.join(".agents").join("skills").is_dir() {
1037
            return;
1038
        }
1039
        let registry = HarnessToolRegistry::new(Some(repo.clone()));
1040
        assert!(
1041
            registry.skills.contains_key("fast-follow"),
1042
            "discovered: {:?}",
1043
            registry.skills.keys().collect::<Vec<_>>()
1044
        );
1045
        // The `effect` skill's description is a `|` block; a parser that took
1046
        // the marker would catalogue it as "|".
1047
        let effect = registry.skills.get("effect").expect("the effect skill");
1048
        assert!(effect.description.len() > 20, "{:?}", effect.description);
1049
1050
        // `superdelegate` ships with `auto: true`, so its body is the standing
1051
        // context this session starts with.
1052
        let context = registry.standing_context().expect("something is injected");
1053
        assert!(
1054
            registry.skills.get("superdelegate").is_some_and(|skill| skill.auto),
1055
            "superdelegate is the repository's auto skill"
1056
        );
1057
        let body = &registry.skills["superdelegate"].body;
1058
        assert!(context.contains(&body[..80.min(body.len())]), "the auto body was not injected");
1059
    }
1060
1061
    // ───────────────────────────────────────────── the capability tool wiring
1062
1063
    #[test]
1064
    fn every_declared_tool_has_an_arm_that_answers_it() {
1065
        // The property the missing `capability` implementation broke: a name
1066
        // in `list_tools` that no arm answers is a promise nothing keeps.
1067
        let root = tempfile::tempdir().unwrap();
1068
        let registry = HarnessToolRegistry::with_delegation(
1069
            Some(root.path().to_path_buf()),
1070
            DelegationGate { lane: "test".to_string(), user_token: None, max_count: 2 },
1071
        );
1072
        let names: Vec<String> = registry.list_tools().into_iter().map(|t| t.name).collect();
1073
        assert_eq!(names, vec!["shell", "skill", "openagents", "capability", "delegate"]);
1074
1075
        let runtime = tokio::runtime::Runtime::new().unwrap();
1076
        for name in &names {
1077
            if name == "delegate" {
1078
                continue; // Starting real children is not this test's business.
1079
            }
1080
            let out = runtime.block_on(registry.execute_tool(&ToolCall {
1081
                id: "1".to_string(),
1082
                name: name.clone(),
1083
                arguments: serde_json::json!({}),
1084
            }));
1085
            assert!(
1086
                !out.output.starts_with("Unknown tool:"),
1087
                "`{name}` is declared and unanswered"
1088
            );
1089
        }
1090
    }
1091
1092
    #[tokio::test]
1093
    async fn a_capability_search_names_a_plugin_and_loading_it_declares_its_tool() {
1094
        let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..");
1095
        if !repo.join("plugins").join("word-stats").join("manifest.json").is_file() {
1096
            return;
1097
        }
1098
        let registry = HarnessToolRegistry::new(Some(repo));
1099
        assert!(!registry.catalog.is_empty(), "the checked-in catalog was not discovered");
1100
1101
        let search = registry
1102
            .execute_tool(&ToolCall {
1103
                id: "1".to_string(),
1104
                name: "capability".to_string(),
1105
                arguments: serde_json::json!({"query": "statistics about the longest word in a piece of text"}),
1106
            })
1107
            .await;
1108
        assert!(search.output.contains("word_stats"), "{}", search.output);
1109
        // Nothing is loaded by searching, so no plugin tool is declared yet.
1110
        assert!(registry.list_tools().iter().all(|t| t.name != "word_stats"));
1111
1112
        let load = registry
1113
            .execute_tool(&ToolCall {
1114
                id: "2".to_string(),
1115
                name: "capability".to_string(),
1116
                arguments: serde_json::json!({"name": "word_stats"}),
1117
            })
1118
            .await;
1119
        assert!(load.output.contains("digest verified"), "{}", load.output);
1120
        let word_stats = registry
1121
            .list_tools()
1122
            .into_iter()
1123
            .find(|t| t.name == "word_stats")
1124
            .expect("the loaded plugin declares its tool");
1125
        assert_eq!(word_stats.parameters["properties"]["text"]["type"], "string");
1126
1127
        // And the tool the plugin declared runs the plugin.
1128
        let ran = registry
1129
            .execute_tool(&ToolCall {
1130
                id: "3".to_string(),
1131
                name: "word_stats".to_string(),
1132
                arguments: serde_json::json!({"text": "alpha beta beta"}),
1133
            })
1134
            .await;
1135
        let value: serde_json::Value = serde_json::from_str(&ran.output).expect(&ran.output);
1136
        assert_eq!(value["ok"]["words"], 3);
1137
        assert_eq!(value["ok"]["top_word"]["word"], "beta");
1138
    }
1139
1140
    #[tokio::test]
1141
    async fn a_mounted_capability_refuses_to_load_without_an_operator() {
1142
        let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..");
1143
        if !repo.join("plugins").join("file-stats").join("manifest.json").is_file() {
1144
            return;
1145
        }
1146
        let unattended = HarnessToolRegistry::new(Some(repo.clone()));
1147
        let refused = unattended
1148
            .execute_tool(&ToolCall {
1149
                id: "1".to_string(),
1150
                name: "capability".to_string(),
1151
                arguments: serde_json::json!({"name": "file_stats"}),
1152
            })
1153
            .await;
1154
        assert!(refused.output.contains("approval_unavailable"), "{}", refused.output);
1155
        assert!(unattended.list_tools().iter().all(|t| t.name != "file_stats"));
1156
1157
        let attended = HarnessToolRegistry::new(Some(repo)).allowing_plugin_mounts();
1158
        let loaded = attended
1159
            .execute_tool(&ToolCall {
1160
                id: "1".to_string(),
1161
                name: "capability".to_string(),
1162
                arguments: serde_json::json!({"name": "file_stats"}),
1163
            })
1164
            .await;
1165
        assert!(loaded.output.contains("digest verified"), "{}", loaded.output);
1166
        assert!(attended.list_tools().iter().any(|t| t.name == "file_stats"));
1167
    }
1168
1169
    #[tokio::test]
1170
    async fn a_capability_that_is_not_installed_is_said_to_be_missing() {
1171
        let root = tempfile::tempdir().unwrap();
1172
        let registry = HarnessToolRegistry::new(Some(root.path().to_path_buf()));
1173
        let out = registry
1174
            .execute_tool(&ToolCall {
1175
                id: "1".to_string(),
1176
                name: "capability".to_string(),
1177
                arguments: serde_json::json!({"name": "does_not_exist"}),
1178
            })
1179
            .await;
1180
        assert!(out.is_error);
1181
        assert!(out.output.contains("does_not_exist"), "{}", out.output);
1182
    }
1183
}
crates/openagents-cli/tests/plugin_host_test.rs added +487

@@ -0,0 +1,487 @@

1
//! The WebAssembly capability sandbox, tested by violating it.
2
//!
3
//! A sandbox is only worth the tests that break it, so every limit here is
4
//! asserted by exceeding it with a guest built for the purpose: one that grows
5
//! past the memory ceiling, one that never returns, one that asks for a write
6
//! capability, one that reaches outside its mount. A test that only watched a
7
//! well-behaved plugin succeed would pass just as happily against a host that
8
//! enforces nothing.
9
//!
10
//! The misbehaving guests are assembled from WAT rather than checked in,
11
//! because a checked-in artifact that tries to escape its sandbox is a thing
12
//! nobody should have to keep in the tree. The last test runs the real
13
//! shipped artifact so the fixtures cannot drift away from what ships.
14
15
use std::path::{Path, PathBuf};
16
use std::time::Instant;
17
18
use openagents_cli::plugins::{
19
    invoke, load_plugin, Approval, CatalogEntry, Mount, MOUNT_FILE_LIMIT,
20
};
21
use sha2::{Digest, Sha256};
22
23
/// Bump-allocating `packet-v0` scaffolding every fixture shares.
24
///
25
/// A fixed-address allocator would hand the host the same buffer it just read
26
/// the input from, which is a fine way to make a passing test that proves the
27
/// wrong thing.
28
const PREAMBLE: &str = r#"
29
  (global $next (mut i32) (i32.const 1024))
30
  (func (export "packet_alloc") (param $n i32) (result i32)
31
    (local $at i32)
32
    (local.set $at (global.get $next))
33
    (global.set $next (i32.add (global.get $next) (i32.add (local.get $n) (i32.const 8))))
34
    (local.get $at))
35
"#;
36
37
fn wasm(body: &str) -> Vec<u8> {
38
    wat::parse_str(body).expect("the fixture is valid WAT")
39
}
40
41
/// Write a manifest and artifact into `dir` and load them.
42
///
43
/// The digest is computed from the bytes actually written, so a fixture can
44
/// never pass by pinning nothing; the one test that wants a mismatch corrupts
45
/// the artifact after this has pinned it.
46
fn plant(
47
    dir: &Path,
48
    name: &str,
49
    artifact: &[u8],
50
    mounts: serde_json::Value,
51
    timeout_ms: u64,
52
    memory_max_mib: u64,
53
) -> PathBuf {
54
    let artifact_path = dir.join("guest.wasm");
55
    std::fs::write(&artifact_path, artifact).expect("the artifact writes");
56
    let manifest = serde_json::json!({
57
        "manifest_version": 1,
58
        "name": name,
59
        "version": "0.0.1",
60
        "description": "A guest built to break one rule on purpose.",
61
        "artifact": {
62
            "path": "guest.wasm",
63
            "digest": format!("sha256:{:x}", Sha256::digest(artifact)),
64
        },
65
        "abi": {"kind": "packet-v0", "entry": "handle_packet", "alloc": "packet_alloc"},
66
        "interface": {"input": {"type": "object"}, "output": {"type": "object"}},
67
        "capabilities": {
68
            "mounts": mounts,
69
            "hosts": [],
70
            "timeout_ms": timeout_ms,
71
            "memory_max_mib": memory_max_mib,
72
        },
73
    });
74
    let manifest_path = dir.join("manifest.json");
75
    std::fs::write(
76
        &manifest_path,
77
        serde_json::to_vec_pretty(&manifest).unwrap(),
78
    )
79
    .expect("the manifest writes");
80
    manifest_path
81
}
82
83
/// The repository's checked-in plugin catalog.
84
fn shipped_plugins() -> PathBuf {
85
    Path::new(env!("CARGO_MANIFEST_DIR"))
86
        .join("..")
87
        .join("..")
88
        .join("plugins")
89
}
90
91
// ─────────────────────────────────────────────────── imports and the digest
92
93
#[test]
94
fn a_guest_that_asks_to_write_never_loads_however_it_is_mounted() {
95
    // The one thing "read-only mount" has to mean. The guest declares a
96
    // read-only mount, which is the most generous capability this host grants,
97
    // and then imports a write. There is no write import to link to and the
98
    // load refuses by inspection, before the module is ever instantiated.
99
    let dir = tempfile::tempdir().unwrap();
100
    let mount = dir.path().join("data");
101
    std::fs::create_dir(&mount).unwrap();
102
    std::fs::write(mount.join("kept.txt"), b"untouched").unwrap();
103
104
    let artifact = wasm(&format!(
105
        r#"(module
106
             (import "openagents" "write_file" (func $write (param i32 i32) (result i64)))
107
             (memory (export "memory") 1)
108
             {PREAMBLE}
109
             (func (export "handle_packet") (param i32 i32) (result i64)
110
               (call $write (local.get 0) (local.get 1))))"#
111
    ));
112
    let manifest = plant(
113
        dir.path(),
114
        "writer",
115
        &artifact,
116
        serde_json::json!([{"path": "data", "readonly": true}]),
117
        2000,
118
        16,
119
    );
120
121
    let refusal = load_plugin(&manifest, dir.path()).unwrap_err();
122
    assert_eq!(refusal.code, "imports_undeclared");
123
    assert!(
124
        refusal.reason.contains("openagents.write_file"),
125
        "the refusal should name the import it refused: {}",
126
        refusal.reason
127
    );
128
    // And the file it wanted is still what it was.
129
    assert_eq!(std::fs::read(mount.join("kept.txt")).unwrap(), b"untouched");
130
}
131
132
#[test]
133
fn a_pure_compute_manifest_grants_no_imports_at_all() {
134
    let dir = tempfile::tempdir().unwrap();
135
    let artifact = wasm(&format!(
136
        r#"(module
137
             (import "openagents" "read_file" (func $read (param i32 i32) (result i64)))
138
             (memory (export "memory") 1)
139
             {PREAMBLE}
140
             (func (export "handle_packet") (param i32 i32) (result i64)
141
               (call $read (local.get 0) (local.get 1))))"#
142
    ));
143
    let manifest = plant(
144
        dir.path(),
145
        "sneaky",
146
        &artifact,
147
        serde_json::json!([]),
148
        2000,
149
        16,
150
    );
151
152
    let refusal = load_plugin(&manifest, dir.path()).unwrap_err();
153
    assert_eq!(refusal.code, "imports_undeclared");
154
    assert!(
155
        refusal.reason.contains("may import nothing"),
156
        "{}",
157
        refusal.reason
158
    );
159
}
160
161
#[test]
162
fn a_module_without_the_abi_exports_does_not_load() {
163
    let dir = tempfile::tempdir().unwrap();
164
    // Everything but `memory`, which the host needs to move packets at all.
165
    let artifact = wasm(&format!(
166
        r#"(module
167
             (memory 1)
168
             {PREAMBLE}
169
             (func (export "handle_packet") (param i32 i32) (result i64) (i64.const 0)))"#
170
    ));
171
    let manifest = plant(
172
        dir.path(),
173
        "hidden",
174
        &artifact,
175
        serde_json::json!([]),
176
        2000,
177
        16,
178
    );
179
180
    let refusal = load_plugin(&manifest, dir.path()).unwrap_err();
181
    assert_eq!(refusal.code, "exports_missing");
182
    assert!(refusal.reason.contains("memory"), "{}", refusal.reason);
183
}
184
185
#[test]
186
fn one_changed_byte_in_the_artifact_is_refused_against_the_pin() {
187
    let dir = tempfile::tempdir().unwrap();
188
    let artifact = wasm(&format!(
189
        r#"(module (memory (export "memory") 1) {PREAMBLE}
190
             (func (export "handle_packet") (param i32 i32) (result i64) (i64.const 0)))"#
191
    ));
192
    let manifest = plant(
193
        dir.path(),
194
        "pinned",
195
        &artifact,
196
        serde_json::json!([]),
197
        2000,
198
        16,
199
    );
200
    // It loads while the bytes are the bytes.
201
    assert!(load_plugin(&manifest, dir.path()).is_ok());
202
203
    // Append one byte the manifest never saw. Still valid wasm; not the wasm
204
    // the manifest describes.
205
    let mut tampered = artifact.clone();
206
    tampered.push(0);
207
    std::fs::write(dir.path().join("guest.wasm"), &tampered).unwrap();
208
209
    let refusal = load_plugin(&manifest, dir.path()).unwrap_err();
210
    assert_eq!(refusal.code, "digest_mismatch");
211
    assert!(
212
        refusal.reason.contains("does not load"),
213
        "{}",
214
        refusal.reason
215
    );
216
}
217
218
// ─────────────────────────────────────────────────────── the memory ceiling
219
220
/// A guest whose whole purpose is to ask for 100 pages — 6.4 MiB — more than
221
/// it started with, and report whether it got them.
222
fn memory_hog() -> Vec<u8> {
223
    wasm(&format!(
224
        r#"(module
225
             (memory (export "memory") 1)
226
             (data (i32.const 0) "{{\"ok\":{{\"grew\":true}}}}")
227
             (data (i32.const 64) "{{\"ok\":{{\"grew\":false}}}}")
228
             {PREAMBLE}
229
             (func (export "handle_packet") (param i32 i32) (result i64)
230
               (if (result i64)
231
                 (i32.eq (memory.grow (i32.const 100)) (i32.const -1))
232
                 (then (i64.const 274877906965))
233
                 (else (i64.const 20)))))"#
234
    ))
235
}
236
237
#[test]
238
fn a_guest_is_denied_the_pages_that_would_cross_its_ceiling() {
239
    let dir = tempfile::tempdir().unwrap();
240
    let artifact = memory_hog();
241
242
    // 1 MiB ceiling: the 6.4 MiB it asks for is not there to be had.
243
    let manifest = plant(dir.path(), "hog", &artifact, serde_json::json!([]), 2000, 1);
244
    let plugin = load_plugin(&manifest, dir.path()).unwrap();
245
    let packet = invoke(&plugin, b"{}").unwrap();
246
    assert_eq!(
247
        String::from_utf8_lossy(&packet),
248
        r#"{"ok":{"grew":false}}"#,
249
        "the ceiling did not stop the growth"
250
    );
251
252
    // The same artifact under a ceiling that covers the request grows fine, so
253
    // the refusal above is the ceiling and not a broken fixture.
254
    let roomy = tempfile::tempdir().unwrap();
255
    let manifest = plant(
256
        roomy.path(),
257
        "hog",
258
        &artifact,
259
        serde_json::json!([]),
260
        2000,
261
        64,
262
    );
263
    let plugin = load_plugin(&manifest, roomy.path()).unwrap();
264
    let packet = invoke(&plugin, b"{}").unwrap();
265
    assert_eq!(String::from_utf8_lossy(&packet), r#"{"ok":{"grew":true}}"#);
266
}
267
268
// ────────────────────────────────────────────────────────────── the timeout
269
270
#[test]
271
fn a_guest_that_never_returns_is_trapped_at_its_declared_deadline() {
272
    let dir = tempfile::tempdir().unwrap();
273
    let artifact = wasm(&format!(
274
        r#"(module
275
             (memory (export "memory") 1)
276
             {PREAMBLE}
277
             (func (export "handle_packet") (param i32 i32) (result i64)
278
               (loop $spin (br $spin))
279
               (i64.const 0)))"#
280
    ));
281
    let manifest = plant(
282
        dir.path(),
283
        "spinner",
284
        &artifact,
285
        serde_json::json!([]),
286
        400,
287
        16,
288
    );
289
    let plugin = load_plugin(&manifest, dir.path()).unwrap();
290
291
    let started = Instant::now();
292
    let refusal = invoke(&plugin, b"{}").unwrap_err();
293
    let elapsed = started.elapsed();
294
295
    assert_eq!(refusal.code, "timeout", "{}", refusal.reason);
296
    assert!(refusal.reason.contains("400ms"), "{}", refusal.reason);
297
    // The point of the deadline is that it arrives. A host that waited for the
298
    // guest would still be in that loop.
299
    assert!(
300
        elapsed.as_secs() < 10,
301
        "the deadline took {elapsed:?} to arrive, which is not a deadline"
302
    );
303
}
304
305
// ────────────────────────────────────────────────────── the confined mount
306
307
/// A guest that hands the host's whole answer packet straight back, so a test
308
/// sees exactly what the capability import answered: `0x00` and the bytes, or
309
/// `0x01` and a `{code, reason}` refusal.
310
fn passthrough_reader() -> Vec<u8> {
311
    wasm(&format!(
312
        r#"(module
313
             (import "openagents" "read_file" (func $read (param i32 i32) (result i64)))
314
             (memory (export "memory") 4)
315
             {PREAMBLE}
316
             (func (export "handle_packet") (param i32 i32) (result i64)
317
               (call $read (local.get 0) (local.get 1))))"#
318
    ))
319
}
320
321
/// Split a host answer packet into its status byte and its body.
322
fn answer(packet: &[u8]) -> (u8, String) {
323
    let (status, body) = packet
324
        .split_first()
325
        .expect("the host answered with a packet");
326
    (*status, String::from_utf8_lossy(body).into_owned())
327
}
328
329
fn mounted_reader(dir: &Path) -> (PathBuf, PathBuf) {
330
    let mount = dir.join("data");
331
    std::fs::create_dir_all(&mount).unwrap();
332
    let manifest = plant(
333
        dir,
334
        "reader",
335
        &passthrough_reader(),
336
        serde_json::json!([{"path": "data", "readonly": true}]),
337
        4000,
338
        16,
339
    );
340
    (manifest, mount)
341
}
342
343
#[test]
344
fn a_mounted_guest_reads_inside_the_root_and_is_denied_everywhere_else() {
345
    let dir = tempfile::tempdir().unwrap();
346
    let (manifest, mount) = mounted_reader(dir.path());
347
    std::fs::write(mount.join("inside.txt"), b"in the mount").unwrap();
348
    // A secret one directory above the mount root, which is where a `..`
349
    // lands and where an absolute path can point.
350
    std::fs::write(dir.path().join("secret.txt"), b"outside the mount").unwrap();
351
352
    let plugin = load_plugin(&manifest, dir.path()).unwrap();
353
354
    let (status, body) = answer(&invoke(&plugin, b"inside.txt").unwrap());
355
    assert_eq!(status, 0, "a file in the mount should read: {body}");
356
    assert_eq!(body, "in the mount");
357
358
    for (path, why) in [
359
        ("../secret.txt", "a `..` that climbs out of the root"),
360
        (
361
            "./nested/../../secret.txt",
362
            "a `..` that climbs out after descending",
363
        ),
364
    ] {
365
        let (status, body) = answer(&invoke(&plugin, path.as_bytes()).unwrap());
366
        assert_eq!(
367
            status, 1,
368
            "{why} should be refused, not answered with {body}"
369
        );
370
        assert!(body.contains("mount_denied"), "{why}: {body}");
371
        assert!(
372
            !body.contains("outside the mount"),
373
            "{why} leaked the file: {body}"
374
        );
375
    }
376
377
    let absolute = dir.path().join("secret.txt");
378
    let (status, body) = answer(&invoke(&plugin, absolute.to_string_lossy().as_bytes()).unwrap());
379
    assert_eq!(status, 1, "an absolute path should be refused: {body}");
380
    assert!(body.contains("absolute paths are refused"), "{body}");
381
}
382
383
#[cfg(unix)]
384
#[test]
385
fn a_symlink_planted_in_the_mount_does_not_carry_a_read_out_of_it() {
386
    let dir = tempfile::tempdir().unwrap();
387
    let (manifest, mount) = mounted_reader(dir.path());
388
    std::fs::write(dir.path().join("secret.txt"), b"outside the mount").unwrap();
389
    std::os::unix::fs::symlink(dir.path().join("secret.txt"), mount.join("bridge.txt")).unwrap();
390
391
    let plugin = load_plugin(&manifest, dir.path()).unwrap();
392
    let (status, body) = answer(&invoke(&plugin, b"bridge.txt").unwrap());
393
394
    assert_eq!(
395
        status, 1,
396
        "a symlink out of the mount should be refused: {body}"
397
    );
398
    assert!(
399
        body.contains("symlinks inside a mount are refused"),
400
        "{body}"
401
    );
402
    assert!(
403
        !body.contains("outside the mount"),
404
        "the symlink leaked the file: {body}"
405
    );
406
}
407
408
#[test]
409
fn a_whole_file_read_past_the_byte_bound_is_refused_and_a_range_read_is_not() {
410
    let dir = tempfile::tempdir().unwrap();
411
    let (manifest, mount) = mounted_reader(dir.path());
412
    let oversized = vec![b'x'; (MOUNT_FILE_LIMIT + 1) as usize];
413
    std::fs::write(mount.join("big.txt"), &oversized).unwrap();
414
415
    let plugin = load_plugin(&manifest, dir.path()).unwrap();
416
    let (status, body) = answer(&invoke(&plugin, b"big.txt").unwrap());
417
    assert_eq!(status, 1, "an oversized whole-file read should be refused");
418
    assert!(body.contains("file_too_large"), "{body}");
419
    assert!(body.contains(&(MOUNT_FILE_LIMIT + 1).to_string()), "{body}");
420
}
421
422
// ───────────────────────────────────────────────── approval and the catalog
423
424
#[test]
425
fn a_mounted_capability_needs_an_operator_before_it_can_be_loaded() {
426
    let entry = CatalogEntry {
427
        name: "reader".to_string(),
428
        version: "0.0.1".to_string(),
429
        description: "reads a mount".to_string(),
430
        manifest_path: PathBuf::new(),
431
        digest: String::new(),
432
        mounts: vec![Mount {
433
            path: "data".to_string(),
434
        }],
435
        host_count: 0,
436
    };
437
    assert_eq!(
438
        Approval::default().check(&entry).unwrap_err().code,
439
        "approval_unavailable",
440
        "an unattended session must not grant a directory on its own"
441
    );
442
    assert!(Approval {
443
        mounts_allowed: true
444
    }
445
    .check(&entry)
446
    .is_ok());
447
}
448
449
// ─────────────────────────────────────────────────── what actually ships
450
451
#[test]
452
fn the_checked_in_word_stats_artifact_loads_and_computes() {
453
    let manifest = shipped_plugins().join("word-stats").join("manifest.json");
454
    if !manifest.is_file() {
455
        // A published crate has no `plugins/` beside it; there is nothing to
456
        // check rather than something to fail.
457
        return;
458
    }
459
    let plugin = load_plugin(&manifest, Path::new(".")).expect("the shipped artifact loads");
460
    assert!(
461
        plugin.mounts.is_empty(),
462
        "word_stats is declared pure compute"
463
    );
464
465
    let packet = invoke(&plugin, br#"{"text":"one two two three three three"}"#).unwrap();
466
    let value: serde_json::Value = serde_json::from_slice(&packet).expect("an output packet");
467
    assert_eq!(value["ok"]["words"], 6);
468
    assert_eq!(value["ok"]["top_word"]["word"], "three");
469
}
470
471
#[test]
472
fn the_checked_in_file_stats_artifact_reads_only_its_own_mount() {
473
    let manifest = shipped_plugins().join("file-stats").join("manifest.json");
474
    if !manifest.is_file() {
475
        return;
476
    }
477
    let plugin = load_plugin(&manifest, Path::new(".")).expect("the shipped artifact loads");
478
    assert_eq!(plugin.mounts.len(), 1);
479
480
    let inside = invoke(&plugin, br#"{"path":"sample.txt"}"#).unwrap();
481
    let value: serde_json::Value = serde_json::from_slice(&inside).unwrap();
482
    assert!(value["ok"]["bytes"].as_u64().unwrap_or(0) > 0, "{value}");
483
484
    let outside = invoke(&plugin, br#"{"path":"../manifest.json"}"#).unwrap();
485
    let value: serde_json::Value = serde_json::from_slice(&outside).unwrap();
486
    assert_eq!(value["refusal"]["code"], "mount_denied", "{value}");
487
}

This page updates live while a promote is in flight · changelog