From 81a7ad194bfdb943c8062b1492f4048537736cbf Mon Sep 17 00:00:00 2001 From: Marc Brooker Date: Tue, 16 Jun 2026 16:42:29 -0700 Subject: [PATCH 1/4] feat: add Cedar authorization policies alongside TOML config Operators can now express sandbox authorization in the Cedar policy language in addition to the existing TOML configuration. Policies gate the full agent-sandbox action vocabulary at the Kernel boundary: fs:read/stat/write/list/create/delete/rename, env:read, net:request, and mcp:call. Semantics are additive-restriction-only: with no policy, behavior is unchanged (default-allow); with a policy loaded, a gated action must be permitted by Cedar or it is denied. This layers on top of the built-in SSRF and VFS-permission checks and can never weaken them. A policy can be supplied three ways: the `--policy ` CLI flag, `ShellBuilder::policy_file()`/`policy_str()`, or a `policy = "file.cedar"` key in the TOML config (resolved relative to the config file). Only the generic, public cedar-policy API is used. cedar-policy is not wasm-safe, so the dependency, the `policy` module, and all plumbing are gated to non-wasm; the `Kernel::check_policy` trait default is a no-op, so wasm builds compile with policy disabled. --- Cargo.lock | 767 +++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + schemas/agent.cedarschema | 41 ++ src/builtins/lua.rs | 12 + src/cli.rs | 18 +- src/commands/env.rs | 5 +- src/lib.rs | 2 + src/os.rs | 12 + src/policy.rs | 237 +++++++++++ src/shell.rs | 54 +++ src/vfs_config.rs | 6 + src/vfs_kernel.rs | 51 +++ tests/policy_integration.rs | 111 ++++++ 13 files changed, 1310 insertions(+), 7 deletions(-) create mode 100644 schemas/agent.cedarschema create mode 100644 src/policy.rs create mode 100644 tests/policy_integration.rs diff --git a/Cargo.lock b/Cargo.lock index 30c053b..406e9e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +dependencies = [ + "object", +] + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -151,12 +175,55 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -189,6 +256,69 @@ dependencies = [ "shlex", ] +[[package]] +name = "cedar-policy" +version = "4.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "716a5103f447735b4cef15df847dc2a7ed8752e8a95febab5a6bfe1812054e43" +dependencies = [ + "cedar-policy-core", + "cedar-policy-formatter", + "itertools", + "linked-hash-map", + "miette", + "ref-cast", + "semver", + "serde", + "serde_json", + "serde_with", + "smol_str", + "thiserror", +] + +[[package]] +name = "cedar-policy-core" +version = "4.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7935861e764efcba26f73ac4d9d284e30b2b4a9a95d3c961e908e3ed7889e391" +dependencies = [ + "chrono", + "educe", + "either", + "itertools", + "lalrpop", + "lalrpop-util", + "linked-hash-map", + "linked_hash_set", + "miette", + "nonempty", + "ref-cast", + "regex", + "rustc-literal-escaper", + "serde", + "serde_json", + "serde_with", + "smol_str", + "stacker", + "thiserror", + "unicode-security", +] + +[[package]] +name = "cedar-policy-formatter" +version = "4.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca436a4803977e69b12a8e0e7f5d7c648e578b59fa0f2ed3bbe3c0054b9e036" +dependencies = [ + "cedar-policy-core", + "itertools", + "logos", + "miette", + "pretty", + "regex", + "smol_str", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -209,6 +339,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", + "serde", "windows-link", ] @@ -282,12 +413,84 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctor" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -305,18 +508,59 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "endian-type" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -345,6 +589,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -448,6 +704,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -475,6 +741,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.17.1" @@ -487,6 +759,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hifijson" version = "0.2.3" @@ -713,6 +991,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -734,6 +1018,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -741,7 +1036,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -765,6 +1062,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -790,7 +1096,7 @@ checksum = "01dbdbd07b076e8403abac68ce7744d93e2ecd953bbc44bf77bf00e1e81172bc" dependencies = [ "foldhash", "hifijson", - "indexmap", + "indexmap 2.14.0", "jaq-core", "jaq-std", "serde_json", @@ -823,6 +1129,47 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + [[package]] name = "lexopt" version = "0.3.2" @@ -851,6 +1198,24 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +dependencies = [ + "serde", +] + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "litemap" version = "0.8.2" @@ -872,6 +1237,38 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -909,6 +1306,29 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "serde", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.17" @@ -1015,6 +1435,12 @@ dependencies = [ "libloading", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nibble_vec" version = "0.1.0" @@ -1042,6 +1468,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +dependencies = [ + "serde", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1051,6 +1492,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1092,6 +1542,31 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1119,6 +1594,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1128,6 +1609,23 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec", + "typed-arena", + "unicode-width 0.2.2", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1137,6 +1635,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pyo3" version = "0.29.0" @@ -1312,6 +1820,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.4" @@ -1405,6 +1933,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc-literal-escaper" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be87abb9e40db7466e0681dc8ecd9dcfd40360cb10b4c8fe24a7c4c3669b198" + [[package]] name = "rustls" version = "0.23.40" @@ -1462,7 +1996,7 @@ dependencies = [ "nix", "radix_trie", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", "utf8parse", "windows-sys 0.61.2", ] @@ -1473,6 +2007,39 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1521,6 +2088,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -1560,6 +2128,48 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1576,6 +2186,12 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1588,6 +2204,16 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.6.4" @@ -1604,6 +2230,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "strands-shell" version = "0.0.0" @@ -1611,6 +2250,7 @@ dependencies = [ "async-trait", "axum", "bytes", + "cedar-policy", "clap", "inventory", "jaq-core", @@ -1643,6 +2283,18 @@ dependencies = [ "syn", ] +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1692,6 +2344,15 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1712,6 +2373,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1780,7 +2471,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime", @@ -1891,24 +2582,67 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -1945,6 +2679,22 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2071,6 +2821,15 @@ dependencies = [ "libc", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 351a35d..d642be4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ jaq-json = { version = "1", features = ["serde_json"] } # ----- Native-only dependencies ----- [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +cedar-policy = "4" inventory = "0.3" napi = { version = "3", features = ["napi9", "tokio_rt"], optional = true } napi-derive = { version = "3", optional = true } diff --git a/schemas/agent.cedarschema b/schemas/agent.cedarschema new file mode 100644 index 0000000..e1a317d --- /dev/null +++ b/schemas/agent.cedarschema @@ -0,0 +1,41 @@ +// Cedar schema for strands-shell authorization policies. +// +// Defines the action vocabulary used to gate sandbox operations at the +// `Kernel` boundary: fs:read, fs:stat, fs:write, fs:list, fs:create, +// fs:delete, fs:rename, env:read, net:request, mcp:call. +// +// Resources are attribute-less singletons: every fs:* request targets the +// same `Agent::Filesystem::"global"` UID; every net:request targets +// `Agent::Network::"global"`; etc. Per-call data — the path being read, the +// URL being fetched, the MCP server/tool being invoked — lives in +// `context.input`, so policies match against `context.input.` rather +// than `resource.`. +// +// There is a single principal type, `Shell`, used with the fixed UID +// `Agent::Shell::"default"`. + +namespace Agent { + type FsPathInput = { path: String }; + type FsRenameInput = { src: String, dst: String }; + type EnvReadInput = { name: String }; + type NetRequestInput = { url: String, method: String }; + type McpCallInput = { server: String, tool: String }; + + entity Shell; + + entity Filesystem; + entity Network; + entity Environment; + entity McpService; + + action "fs:read" appliesTo { principal: [Shell], resource: [Filesystem], context: { input: FsPathInput } }; + action "fs:stat" appliesTo { principal: [Shell], resource: [Filesystem], context: { input: FsPathInput } }; + action "fs:write" appliesTo { principal: [Shell], resource: [Filesystem], context: { input: FsPathInput } }; + action "fs:list" appliesTo { principal: [Shell], resource: [Filesystem], context: { input: FsPathInput } }; + action "fs:create" appliesTo { principal: [Shell], resource: [Filesystem], context: { input: FsPathInput } }; + action "fs:delete" appliesTo { principal: [Shell], resource: [Filesystem], context: { input: FsPathInput } }; + action "fs:rename" appliesTo { principal: [Shell], resource: [Filesystem], context: { input: FsRenameInput } }; + action "net:request" appliesTo { principal: [Shell], resource: [Network], context: { input: NetRequestInput } }; + action "env:read" appliesTo { principal: [Shell], resource: [Environment], context: { input: EnvReadInput } }; + action "mcp:call" appliesTo { principal: [Shell], resource: [McpService], context: { input: McpCallInput } }; +} diff --git a/src/builtins/lua.rs b/src/builtins/lua.rs index 9307bf8..7e98203 100644 --- a/src/builtins/lua.rs +++ b/src/builtins/lua.rs @@ -995,8 +995,10 @@ fn setup_sandbox( if let Some(clients) = sio::mcp_clients() { for client in clients.iter() { let mod_table = lua.create_table()?; + let server_name = client.module_name.clone(); for tool in &client.client.tools { let tool_name = tool.name.clone(); + let server_name = server_name.clone(); let clients_ref = clients.clone(); let mod_idx = clients .iter() @@ -1006,8 +1008,18 @@ fn setup_sandbox( tool.name.as_str(), lua.create_async_function(move |lua, args: Option| { let tool_name = tool_name.clone(); + let server_name = server_name.clone(); let clients_ref = clients_ref.clone(); async move { + // Policy gate: mcp:call with the server (module) name + // and tool name. `server` is the de-hyphenated entry + // name (e.g. `my-server` -> `my_server`). + sio::kernel() + .check_policy( + "mcp:call", + &[("server", &server_name), ("tool", &tool_name)], + ) + .map_err(|e| LuaError::external(e.to_string()))?; let json_args = match args { Some(t) => lua_table_to_json(&lua, &t)?, None => serde_json::Value::Object(serde_json::Map::new()), diff --git a/src/cli.rs b/src/cli.rs index 29442ef..6ba22df 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -22,6 +22,10 @@ struct Cli { #[arg(long)] config: Option, + /// Path to a Cedar authorization-policy file (additional restrictions) + #[arg(long)] + policy: Option, + /// Execute a command string and exit #[arg(short = 'c')] command: Option, @@ -40,7 +44,7 @@ enum Commands { ListCommands, } -fn build_shell(config: Option<&str>) -> Shell { +fn build_shell(config: Option<&str>, policy: Option<&str>) -> Shell { let builder = Shell::builder(); let builder = match config { Some(path) => match builder.config_file(path) { @@ -52,6 +56,16 @@ fn build_shell(config: Option<&str>) -> Shell { }, None => builder, }; + let builder = match policy { + Some(path) => match builder.policy_file(path) { + Ok(b) => b, + Err(e) => { + eprintln!("strands-shell: --policy: {e}"); + std::process::exit(1); + } + }, + None => builder, + }; match builder.build() { Ok(s) => s, Err(e) => { @@ -87,7 +101,7 @@ where } } - let mut shell = build_shell(cli.config.as_deref()); + let mut shell = build_shell(cli.config.as_deref(), cli.policy.as_deref()); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/src/commands/env.rs b/src/commands/env.rs index d6d1198..508ec42 100644 --- a/src/commands/env.rs +++ b/src/commands/env.rs @@ -4,7 +4,7 @@ const HELP: &str = "Usage: env Print the environment."; #[command("env")] -async fn cmd_env(_os: &dyn Kernel, args: &[String]) -> CommandResult { +async fn cmd_env(os: &dyn Kernel, args: &[String]) -> CommandResult { let mut parser = lexopt::Parser::from_args(args); if let Some(arg) = parser.next()? { match arg { @@ -16,6 +16,9 @@ async fn cmd_env(_os: &dyn Kernel, args: &[String]) -> CommandResult { _ => return Err(arg.unexpected().into()), } } + // `env` enumerates the whole environment table — gate it as an explicit + // environment read. Shell `$VAR` interpolation is deliberately not gated. + os.check_policy("env:read", &[("name", "*")])?; let mut vars: Vec<(String, String)> = io::with_process(|proc| { proc.env .iter() diff --git a/src/lib.rs b/src/lib.rs index 7a72667..6ec0ce9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -151,6 +151,8 @@ pub mod mcp; pub mod mcp_client; pub mod os; pub mod parser; +#[cfg(not(target_arch = "wasm32"))] +pub mod policy; pub mod prelude; pub mod shell; pub mod vfs; diff --git a/src/os.rs b/src/os.rs index decc5ed..7198997 100644 --- a/src/os.rs +++ b/src/os.rs @@ -928,4 +928,16 @@ pub trait Kernel: Send + Sync { "HTTP not available", )) } + + /// Authorization-policy chokepoint. + /// + /// Gated sandbox operations call this with their Cedar action name (e.g. + /// `"fs:read"`) and the per-call input fields (e.g. `[("path", "/x")]`). + /// The default permits everything, so a kernel with no policy loaded is + /// unaffected. An implementation that returns `Err` denies the operation; + /// this only ever *adds* restrictions on top of the kernel's built-in + /// checks. See [`crate::policy`]. + fn check_policy(&self, _action: &str, _fields: &[(&str, &str)]) -> io::Result<()> { + Ok(()) + } } diff --git a/src/policy.rs b/src/policy.rs new file mode 100644 index 0000000..5e6d3bb --- /dev/null +++ b/src/policy.rs @@ -0,0 +1,237 @@ +//! Cedar authorization-policy support. +//! +//! This is an *additional* restriction layer that sits alongside the TOML +//! configuration. When no policy is loaded, behavior is unchanged +//! (default-allow). When a policy is loaded, every gated action must be +//! permitted by the policy or it is denied (Cedar's normal default-deny) — +//! and this layers *on top of* the built-in SSRF and VFS-permission checks, +//! which it can never weaken. +//! +//! Only the generic, public `cedar-policy` API is used: parse the baked +//! schema, parse and schema-validate the policy text, then evaluate requests +//! with [`cedar_policy::Authorizer::is_authorized`]. The action vocabulary and +//! singleton-resource model are described in `schemas/agent.cedarschema`. +//! +//! Not compiled for `wasm32` — `cedar-policy` is not wasm-safe. + +use std::io; +use std::str::FromStr; + +use cedar_policy::{ + Authorizer, Context, Decision, Entities, Entity, EntityId, EntityTypeName, EntityUid, + PolicySet, Request, RestrictedExpression, Schema, ValidationMode, Validator, +}; + +/// The baked Cedar schema, embedded at compile time. The engine validates +/// every loaded policy against this, so a policy that references an unknown +/// action or a malformed context is rejected at load time. +const SCHEMA_SRC: &str = include_str!("../schemas/agent.cedarschema"); + +/// The fixed principal UID used for every authorization request: +/// `Agent::Shell::"default"`. +const PRINCIPAL_ID: &str = "default"; + +/// The singleton resource UID shared by every resource type: `..::"global"`. +const SINGLETON_RESOURCE_ID: &str = "global"; + +/// A loaded, schema-validated Cedar policy set plus an authorizer. +/// +/// Cheap to share: [`check`](Self::check) takes `&self`, so a single engine is +/// wrapped in an `Arc` and held by the kernel. +pub struct PolicyEngine { + policy_set: PolicySet, + authorizer: Authorizer, +} + +impl PolicyEngine { + /// Parse and schema-validate `policy_text`, returning a ready engine. + /// + /// # Errors + /// + /// Returns an error if the baked schema fails to parse (a build-time bug), + /// if `policy_text` is not valid Cedar, or if the policy fails validation + /// against the schema (e.g. it references an unknown action). + // Not `std::str::FromStr`: this is fallible-with-`io::Error` and reads + // naturally as `PolicyEngine::from_str(text)`. + #[allow(clippy::should_implement_trait)] + pub fn from_str(policy_text: &str) -> io::Result { + let schema = Schema::from_cedarschema_str(SCHEMA_SRC) + .map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid baked Cedar schema: {e}"), + ) + })? + .0; + + let policy_set = PolicySet::from_str(policy_text).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid Cedar policy: {e}"), + ) + })?; + + let validator = Validator::new(schema); + let result = validator.validate(&policy_set, ValidationMode::default()); + if !result.validation_passed() { + let errors: Vec = result.validation_errors().map(|e| e.to_string()).collect(); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Cedar policy failed schema validation: {}", + errors.join("; ") + ), + )); + } + + Ok(Self { + policy_set, + authorizer: Authorizer::new(), + }) + } + + /// Evaluate `action` with the given `context.input` `fields`. + /// + /// Returns `Ok(())` if the policy permits the action, or a + /// `PermissionDenied` error otherwise. `fields` are the per-call inputs the + /// schema declares for the action (e.g. `[("path", "/home/lash/x")]`). + pub fn check(&self, action: &str, fields: &[(&str, &str)]) -> io::Result<()> { + let principal = entity_uid("Shell", PRINCIPAL_ID)?; + let resource = entity_uid(resource_type_for(action), SINGLETON_RESOURCE_ID)?; + let action_uid = EntityUid::from_str(&format!("Agent::Action::\"{action}\"")) + .map_err(|e| io::Error::other(format!("invalid action UID for {action}: {e}")))?; + + let input_record = RestrictedExpression::new_record(fields.iter().map(|(k, v)| { + ( + (*k).to_owned(), + RestrictedExpression::new_string((*v).to_owned()), + ) + })) + .map_err(|e| io::Error::other(format!("invalid context.input record: {e}")))?; + let context = Context::from_pairs([("input".to_owned(), input_record)]) + .map_err(|e| io::Error::other(format!("invalid Cedar context: {e}")))?; + + let request = Request::new( + principal.clone(), + action_uid, + resource.clone(), + context, + None, + ) + .map_err(|e| io::Error::other(format!("invalid Cedar request: {e}")))?; + + let entities = Entities::from_entities( + [ + Entity::new_no_attrs(principal, Default::default()), + Entity::new_no_attrs(resource, Default::default()), + ], + None, + ) + .map_err(|e| io::Error::other(format!("invalid Cedar entities: {e}")))?; + + let response = self + .authorizer + .is_authorized(&request, &self.policy_set, &entities); + + if response.decision() == Decision::Allow { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("policy denied: {action} {}", describe(fields)), + )) + } + } +} + +/// Build an `Agent::::""` entity UID. +fn entity_uid(type_name: &str, id: &str) -> io::Result { + let type_name = EntityTypeName::from_str(&format!("Agent::{type_name}")) + .map_err(|e| io::Error::other(format!("invalid entity type Agent::{type_name}: {e}")))?; + Ok(EntityUid::from_type_name_and_id( + type_name, + EntityId::new(id), + )) +} + +/// Map an action name to its singleton resource type. +fn resource_type_for(action: &str) -> &'static str { + match action { + "net:request" => "Network", + "env:read" => "Environment", + "mcp:call" => "McpService", + // All fs:* actions, and any unknown action, target Filesystem; an + // unknown action would already have failed validation at load time. + _ => "Filesystem", + } +} + +/// Render the input fields for an error message, e.g. `path=/x`. +fn describe(fields: &[(&str, &str)]) -> String { + fields + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn baked_schema_parses() { + // A trivial valid policy proves the schema parses and validation runs. + let engine = PolicyEngine::from_str("permit(principal, action, resource);").unwrap(); + // Blanket permit allows any action. + assert!(engine.check("fs:read", &[("path", "/x")]).is_ok()); + assert!( + engine + .check("net:request", &[("url", "http://x/"), ("method", "GET")]) + .is_ok() + ); + } + + #[test] + fn empty_policy_denies_everything() { + let engine = PolicyEngine::from_str("").unwrap(); + assert!(engine.check("fs:read", &[("path", "/x")]).is_err()); + } + + #[test] + fn permit_matches_specific_path() { + let policy = r#" + permit(principal, action == Agent::Action::"fs:read", resource) + when { context.input.path == "/home/lash/ok.txt" }; + "#; + let engine = PolicyEngine::from_str(policy).unwrap(); + assert!( + engine + .check("fs:read", &[("path", "/home/lash/ok.txt")]) + .is_ok() + ); + assert!( + engine + .check("fs:read", &[("path", "/home/lash/secret.txt")]) + .is_err() + ); + // A different action is not permitted by this policy. + assert!( + engine + .check("fs:write", &[("path", "/home/lash/ok.txt")]) + .is_err() + ); + } + + #[test] + fn malformed_policy_is_rejected() { + assert!(PolicyEngine::from_str("permit(garbage").is_err()); + } + + #[test] + fn unknown_action_fails_validation() { + // Syntactically valid, but references an action absent from the schema. + let policy = r#"permit(principal, action == Agent::Action::"fs:bogus", resource);"#; + assert!(PolicyEngine::from_str(policy).is_err()); + } +} diff --git a/src/shell.rs b/src/shell.rs index cdd72dc..fed064b 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -604,6 +604,10 @@ pub struct ShellBuilder { max_inodes: usize, timeout: Option, allowed_url_prefixes: Vec, + /// Cedar policy source text, if any. Compiled into a `PolicyEngine` at + /// [`build`](Self::build). + #[cfg(not(target_arch = "wasm32"))] + policy_text: Option, } impl Default for ShellBuilder { @@ -624,6 +628,8 @@ impl Default for ShellBuilder { max_inodes: 10_000, timeout: Some(std::time::Duration::from_secs(30)), allowed_url_prefixes: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + policy_text: None, } } } @@ -867,6 +873,34 @@ impl ShellBuilder { self } + /// Load a Cedar authorization policy from a file. + /// + /// The policy is an *additional* restriction layer: with no policy, every + /// operation is allowed (unchanged behavior); with a policy, gated actions + /// (filesystem, network, env, MCP) must be permitted by the policy or they + /// are denied. It never weakens the built-in SSRF / VFS-permission checks. + /// The policy is parsed and schema-validated at [`build`](Self::build). + /// + /// See `schemas/agent.cedarschema` for the action vocabulary. + /// + /// # Errors + /// + /// Returns an error if the file cannot be read. (Parse/validation errors + /// surface from [`build`](Self::build).) + #[cfg(not(target_arch = "wasm32"))] + pub fn policy_file(mut self, path: impl AsRef) -> io::Result { + self.policy_text = Some(std::fs::read_to_string(path)?); + Ok(self) + } + + /// Set the Cedar authorization policy from a string. See + /// [`policy_file`](Self::policy_file) for semantics. + #[cfg(not(target_arch = "wasm32"))] + pub fn policy_str(mut self, text: impl Into) -> Self { + self.policy_text = Some(text.into()); + self + } + /// Load additional configuration from a TOML file. /// /// Bind mounts, credentials, and `allowed_urls` from the file are @@ -886,6 +920,7 @@ impl ShellBuilder { /// or contains an unknown key (typos fail the parse rather than being /// silently ignored). pub fn config_file(mut self, path: impl AsRef) -> io::Result { + let path = path.as_ref(); let content = std::fs::read_to_string(path)?; let config: VfsConfig = crate::vfs_config::parse_config(&content)?; self.config.bind.extend(config.bind); @@ -894,6 +929,16 @@ impl ShellBuilder { self.mcp.extend(config.mcp); self.config.umask = config.umask; self.allowed_url_prefixes.extend(config.allowed_urls); + // A `policy = "file.cedar"` key points at a Cedar file resolved relative + // to this config file's directory. Compiled/validated at build(). + #[cfg(not(target_arch = "wasm32"))] + if let Some(rel) = config.policy { + let policy_path = match path.parent() { + Some(dir) if !rel.starts_with('/') => dir.join(&rel), + _ => std::path::PathBuf::from(&rel), + }; + self.policy_text = Some(std::fs::read_to_string(&policy_path)?); + } // Env: an explicitly-passed `.env()` value always wins over the file, // regardless of whether `.env()` or `.config_file()` was called first // (matches the "code wins" rule for umask/timeout). Only take a TOML @@ -962,6 +1007,13 @@ impl ShellBuilder { )); } let resolved_creds = resolve_creds(&self.creds)?; + #[cfg(not(target_arch = "wasm32"))] + let policy = match self.policy_text { + Some(text) => Some(std::sync::Arc::new(crate::policy::PolicyEngine::from_str( + &text, + )?)), + None => None, + }; let mut vfs = build_vfs(&self.config)?; vfs.max_file_size = self.max_file_size; vfs.max_inodes = self.max_inodes; @@ -969,6 +1021,8 @@ impl ShellBuilder { vfs: std::sync::Arc::new(tokio::sync::Mutex::new(vfs)), creds: resolved_creds, allowed_url_prefixes: self.allowed_url_prefixes, + #[cfg(not(target_arch = "wasm32"))] + policy, }); let mut proc = kernel.new_process(); diff --git a/src/vfs_config.rs b/src/vfs_config.rs index 5d8156b..7bbf6e3 100644 --- a/src/vfs_config.rs +++ b/src/vfs_config.rs @@ -83,6 +83,11 @@ pub struct VfsConfig { /// `allow_url` / the bindings' `allowed_urls`. #[serde(default)] pub allowed_urls: Vec, + /// Path to a Cedar authorization-policy file, resolved relative to this + /// config file's directory. An *additional* restriction layer on top of + /// the SSRF/VFS checks; omit for unchanged (default-allow) behavior. + #[serde(default)] + pub policy: Option, /// Environment variables seeded into the shell. A TOML `[env]` table. /// Ordered (BTreeMap) so config application is deterministic. #[serde(default)] @@ -103,6 +108,7 @@ impl Default for VfsConfig { mcp: Vec::new(), limits: None, allowed_urls: Vec::new(), + policy: None, env: std::collections::BTreeMap::new(), } } diff --git a/src/vfs_kernel.rs b/src/vfs_kernel.rs index 82c5b74..fcb3a10 100644 --- a/src/vfs_kernel.rs +++ b/src/vfs_kernel.rs @@ -15,6 +15,11 @@ pub struct VfsKernel { pub vfs: Arc>, pub creds: Vec, pub allowed_url_prefixes: Vec, + /// Optional Cedar authorization policy. When `None`, all `check_policy` + /// calls allow (unchanged behavior); when `Some`, gated actions must be + /// permitted by the policy. Layers on top of the SSRF/VFS checks. + #[cfg(not(target_arch = "wasm32"))] + pub policy: Option>, } impl VfsKernel { @@ -23,6 +28,8 @@ impl VfsKernel { vfs: Arc::new(Mutex::new(vfs)), creds, allowed_url_prefixes: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + policy: None, } } @@ -252,6 +259,22 @@ impl Kernel for VfsKernel { async fn open(&self, proc: &mut Process, path: &str, flags: OpenFlags) -> io::Result { let abs = Self::abs(proc, path); + // Policy gate. A pure read is fs:read; otherwise it's a mutation — and + // we distinguish creating a new file (fs:create) from modifying an + // existing one (fs:write) by probing existence. Read-write (`<>`) maps + // to fs:write since it can mutate. + { + let action = if !flags.write && !flags.create { + "fs:read" + } else if flags.create { + let exists = self.vfs.lock().await.resolve(&abs, true).is_ok(); + if exists { "fs:write" } else { "fs:create" } + } else { + "fs:write" + }; + self.check_policy(action, &[("path", &abs)])?; + } + // Check for host-backed path (bind_direct passthrough) { let vfs = self.vfs.lock().await; @@ -402,6 +425,7 @@ impl Kernel for VfsKernel { async fn list_dir(&self, proc: &Process, path: &str) -> io::Result> { let abs = Self::abs(proc, path); + self.check_policy("fs:list", &[("path", &abs)])?; let vfs = self.vfs.lock().await; if let Some((host_path, _ro, _)) = Self::resolve_host(&vfs, &abs) { @@ -434,6 +458,7 @@ impl Kernel for VfsKernel { async fn change_dir(&self, proc: &mut Process, path: &str) -> io::Result<()> { let abs = Self::abs(proc, path); + self.check_policy("fs:list", &[("path", &abs)])?; let vfs = self.vfs.lock().await; if let Some((host_path, _ro, _)) = Self::resolve_host(&vfs, &abs) { @@ -463,6 +488,10 @@ impl Kernel for VfsKernel { async fn stat(&self, proc: &Process, path: &str) -> FileStat { let abs = Self::abs(proc, path); + // stat is infallible; a policy denial fails closed to "does not exist". + if self.check_policy("fs:stat", &[("path", &abs)]).is_err() { + return FileStat::default(); + } let vfs = self.vfs.lock().await; if let Some((host_path, _ro, _)) = Self::resolve_host(&vfs, &abs) { @@ -481,6 +510,10 @@ impl Kernel for VfsKernel { async fn lstat(&self, proc: &Process, path: &str) -> FileStat { let abs = Self::abs(proc, path); + // lstat is infallible; a policy denial fails closed to "does not exist". + if self.check_policy("fs:stat", &[("path", &abs)]).is_err() { + return FileStat::default(); + } let vfs = self.vfs.lock().await; if let Some((host_path, _ro, _)) = Self::resolve_host(&vfs, &abs) { @@ -591,6 +624,7 @@ impl Kernel for VfsKernel { async fn remove_file(&self, proc: &Process, path: &str) -> io::Result<()> { let abs = Self::abs(proc, path); + self.check_policy("fs:delete", &[("path", &abs)])?; let vfs = self.vfs.lock().await; if let Some((host_path, ro, _)) = Self::resolve_host(&vfs, &abs) { drop(vfs); @@ -609,6 +643,7 @@ impl Kernel for VfsKernel { async fn remove_dir(&self, proc: &Process, path: &str) -> io::Result<()> { let abs = Self::abs(proc, path); + self.check_policy("fs:delete", &[("path", &abs)])?; let vfs = self.vfs.lock().await; if let Some((host_path, ro, _)) = Self::resolve_host(&vfs, &abs) { drop(vfs); @@ -627,6 +662,7 @@ impl Kernel for VfsKernel { async fn create_dir(&self, proc: &Process, path: &str) -> io::Result<()> { let abs = Self::abs(proc, path); + self.check_policy("fs:create", &[("path", &abs)])?; let vfs = self.vfs.lock().await; if let Some((host_path, ro, _)) = Self::resolve_host(&vfs, &abs) { drop(vfs); @@ -650,6 +686,7 @@ impl Kernel for VfsKernel { async fn rename(&self, proc: &Process, from: &str, to: &str) -> io::Result<()> { let abs_from = Self::abs(proc, from); let abs_to = Self::abs(proc, to); + self.check_policy("fs:rename", &[("src", &abs_from), ("dst", &abs_to)])?; let vfs = self.vfs.lock().await; let host_from = Self::resolve_host(&vfs, &abs_from); let host_to = Self::resolve_host(&vfs, &abs_to); @@ -674,6 +711,7 @@ impl Kernel for VfsKernel { async fn symlink(&self, proc: &Process, target: &str, link: &str) -> io::Result<()> { let abs_link = Self::abs(proc, link); + self.check_policy("fs:create", &[("path", &abs_link)])?; let mut vfs = self.vfs.lock().await; Self::check_parent_write(&vfs, &abs_link)?; vfs.symlink(&abs_link, target, LASH_UID, LASH_GID)?; @@ -695,6 +733,7 @@ impl Kernel for VfsKernel { async fn set_permissions(&self, proc: &Process, path: &str, mode: u32) -> io::Result<()> { let abs = Self::abs(proc, path); + self.check_policy("fs:write", &[("path", &abs)])?; let mut vfs = self.vfs.lock().await; let ino = vfs.resolve(&abs, true)?; let inode = vfs.get_mut(ino)?; @@ -714,6 +753,14 @@ impl Kernel for VfsKernel { std::time::SystemTime::now() } + #[cfg(not(target_arch = "wasm32"))] + fn check_policy(&self, action: &str, fields: &[(&str, &str)]) -> io::Result<()> { + match &self.policy { + Some(engine) => engine.check(action, fields), + None => Ok(()), + } + } + fn check_url(&self, url: &str) -> io::Result<()> { // Match the allowlist on the *parsed* URL, not a raw string prefix. // String-prefix matching is fooled by userinfo injection: against an @@ -768,6 +815,10 @@ impl Kernel for VfsKernel { } async fn http_request(&self, req: HttpRequest) -> io::Result { + // Policy gate. This runs *before* the SSRF check below, which always + // still runs — Cedar can only further restrict, never weaken SSRF. + self.check_policy("net:request", &[("url", &req.url), ("method", &req.method)])?; + #[cfg(not(target_arch = "wasm32"))] { // 1. Check URL via self.check_url diff --git a/tests/policy_integration.rs b/tests/policy_integration.rs new file mode 100644 index 0000000..2eff9a4 --- /dev/null +++ b/tests/policy_integration.rs @@ -0,0 +1,111 @@ +//! Integration tests for Cedar authorization-policy support. +//! +//! These exercise the additive-restriction semantics end-to-end through the +//! public `Shell` API: no policy means unchanged behavior; a loaded policy can +//! only further restrict, never weaken the built-in SSRF / VFS checks. + +use strands_shell::Shell; + +fn rt() -> (tokio::runtime::Runtime, tokio::task::LocalSet) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let local = tokio::task::LocalSet::new(); + (rt, local) +} + +/// No policy loaded → behavior is unchanged (default-allow). +#[test] +fn no_policy_is_unchanged() { + let (rt, local) = rt(); + rt.block_on(local.run_until(async { + let mut shell = Shell::builder().build().unwrap(); + let out = shell + .run("echo hi > /home/lash/f.txt && cat /home/lash/f.txt") + .await; + assert_eq!(out.stdout, "hi\n"); + assert_eq!(out.status, 0); + })); +} + +/// A policy that permits fs:read only for a specific path allows the matching +/// read and denies a non-matching one. A blanket permit for non-read actions +/// keeps the rest of the shell (writes, stats) functional. +#[test] +fn fs_read_permit_matches_path() { + let policy = r#" + permit(principal, action == Agent::Action::"fs:read", resource) + when { context.input.path == "/home/lash/ok.txt" }; + permit(principal, action, resource) + when { action != Agent::Action::"fs:read" }; + "#; + let (rt, local) = rt(); + rt.block_on(local.run_until(async { + let mut shell = Shell::builder().policy_str(policy).build().unwrap(); + // Setup writes are permitted by the blanket non-read rule. + let setup = shell + .run("echo ok > /home/lash/ok.txt; echo secret > /home/lash/secret.txt") + .await; + assert_eq!(setup.status, 0, "setup stderr: {}", setup.stderr); + + // Permitted read succeeds. + let ok = shell.run("cat /home/lash/ok.txt").await; + assert_eq!(ok.stdout, "ok\n"); + assert_eq!(ok.status, 0); + + // Non-matching read is denied. + let denied = shell.run("cat /home/lash/secret.txt").await; + assert_ne!(denied.status, 0); + assert_eq!(denied.stdout, ""); + })); +} + +/// Even when Cedar permits all net:request, the built-in SSRF protection still +/// blocks link-local / IMDS addresses — Cedar layers on top, never replaces it. +#[test] +fn ssrf_still_enforced_under_permit() { + let policy = "permit(principal, action, resource);"; + let (rt, local) = rt(); + rt.block_on(local.run_until(async { + let mut shell = Shell::builder().policy_str(policy).build().unwrap(); + let out = shell.run("curl http://169.254.169.254/").await; + assert_ne!(out.status, 0, "IMDS fetch should be blocked"); + assert!( + out.stderr.contains("curl:"), + "expected curl error, got: {}", + out.stderr + ); + })); +} + +/// A net:request that Cedar does NOT permit is denied even though the URL would +/// otherwise pass the SSRF check. +#[test] +fn net_request_denied_by_policy() { + // Permits everything except network access. + let policy = + r#"permit(principal, action, resource) when { action != Agent::Action::"net:request" };"#; + let (rt, local) = rt(); + rt.block_on(local.run_until(async { + let mut shell = Shell::builder().policy_str(policy).build().unwrap(); + let out = shell.run("curl http://example.com/").await; + assert_ne!(out.status, 0, "network should be denied by policy"); + })); +} + +/// A malformed policy fails `build()`. +#[test] +fn malformed_policy_rejected_at_build() { + let err = Shell::builder().policy_str("permit(garbage").build(); + assert!(err.is_err()); +} + +/// A syntactically valid policy referencing an action absent from the schema +/// fails schema validation at `build()`. +#[test] +fn unknown_action_rejected_at_build() { + let policy = r#"permit(principal, action == Agent::Action::"fs:bogus", resource);"#; + let err = Shell::builder().policy_str(policy).build(); + assert!(err.is_err()); +} From c95c4c88491e2bb2f9ef1223232683ce68b36e58 Mon Sep 17 00:00:00 2001 From: Marc Brooker Date: Tue, 16 Jun 2026 16:50:07 -0700 Subject: [PATCH 2/4] docs: document Cedar policies in README and add runnable examples Add an "Authorization Policies (Cedar)" section to the README covering the action vocabulary, the three ways to load a policy, and the additive-only composition with the SSRF guard and filesystem permissions. Add examples/ with three Cedar policies of increasing complexity (read-only, a workspace jail, and mixed controls with a forbid override) and a run-policies.sh script that runs each against strands-shell and shows which commands are allowed and which are denied. --- README.md | 57 ++++++++++++++++++ examples/README.md | 28 +++++++++ examples/policies/01-read-only.cedar | 20 +++++++ examples/policies/02-workspace-jail.cedar | 37 ++++++++++++ examples/policies/03-mixed-controls.cedar | 69 +++++++++++++++++++++ examples/run-policies.sh | 73 +++++++++++++++++++++++ 6 files changed, 284 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/policies/01-read-only.cedar create mode 100644 examples/policies/02-workspace-jail.cedar create mode 100644 examples/policies/03-mixed-controls.cedar create mode 100755 examples/run-policies.sh diff --git a/README.md b/README.md index f467320..85d40d5 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,63 @@ command = "/path/to/mcp-server" args = ["--stdio"] ``` +## Authorization Policies (Cedar) + +Bind mounts and `allowed_urls` decide what *exists* in the sandbox. For finer +control over what the agent may *do* with it, you can attach a policy written +in [Cedar](https://www.cedarpolicy.com/), AWS's open-source authorization +language. A policy gates individual operations at the Kernel boundary: + +| Action | Covers | +|---|---| +| `fs:read` `fs:stat` `fs:list` | reading, statting, and listing paths | +| `fs:write` `fs:create` `fs:delete` `fs:rename` | mutating the filesystem | +| `net:request` | `curl` / HTTP requests | +| `env:read` | the `env` builtin | +| `mcp:call` | calling a configured MCP tool | + +Per-call details live in `context.input` — `path` for filesystem actions, +`url` and `method` for `net:request`, `server` and `tool` for `mcp:call`, and +so on. A read-only sandbox is one rule: + +```cedar +permit( + principal, + action in [Agent::Action::"fs:read", Agent::Action::"fs:stat", Agent::Action::"fs:list"], + resource +); +``` + +Load it from the CLI, the Rust builder, or a TOML key: + +```sh +strands-shell --policy read-only.cedar -c 'ls /' +``` + +```rust +let shell = Shell::builder().policy_file("read-only.cedar")?.build()?; +``` + +```toml +# in your config file, resolved relative to it; picked up by --config and by +# the Python/Node config_file option +policy = "read-only.cedar" +``` + +**Policies only ever add restrictions.** With no policy, behavior is unchanged. +With a policy, a gated action must be permitted or it is denied (Cedar's +default-deny) — and this layers *on top of* the SSRF guard and filesystem +permissions, which it can never weaken. A policy that permits all +`net:request` still can't reach `169.254.169.254`. + +Three worked examples (read-only, a workspace jail, and mixed controls with a +`forbid` override) plus a runnable demo live in +[`examples/`](examples/README.md): + +```sh +./examples/run-policies.sh +``` + ## MCP Server The built-in [MCP](https://modelcontextprotocol.io/) server exposes the shell over JSON-RPC on stdio, working with anything that speaks MCP. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b59db28 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,28 @@ +# Examples + +## Cedar authorization policies + +[`policies/`](policies/) contains three Cedar policies of increasing +complexity, and [`run-policies.sh`](run-policies.sh) demonstrates each one by +running a few commands and showing which are allowed and which are denied. + +```sh +./examples/run-policies.sh +``` + +The script builds the debug `strands-shell` binary and runs the examples. To +use a prebuilt binary instead: + +```sh +SHELL_BIN=/path/to/strands-shell ./examples/run-policies.sh +``` + +| Policy | What it shows | +|---|---| +| [`01-read-only.cedar`](policies/01-read-only.cedar) | A single `permit` for read-only actions; everything else is denied by default. | +| [`02-workspace-jail.cedar`](policies/02-workspace-jail.cedar) | Matching `context.input.path` with `like`, scoped to the actions that carry a path. | +| [`03-mixed-controls.cedar`](policies/03-mixed-controls.cedar) | Layered permits, a `forbid` override for `*.secret`, and a scoped network rule. | + +See the [Authorization Policies](../README.md#authorization-policies-cedar) +section of the main README for the action vocabulary and how policies compose +with the rest of the sandbox. diff --git a/examples/policies/01-read-only.cedar b/examples/policies/01-read-only.cedar new file mode 100644 index 0000000..81c4c80 --- /dev/null +++ b/examples/policies/01-read-only.cedar @@ -0,0 +1,20 @@ +// Example 1 — Read-only sandbox. +// +// The agent may inspect the filesystem but cannot change it, reach the +// network, read the environment, or call MCP tools. This is the simplest +// useful policy: a single `permit` that names the read-only actions and +// nothing else. Every other action falls through to Cedar's default-deny. +// +// Run it: +// strands-shell --policy examples/policies/01-read-only.cedar -c 'ls /' # allowed +// strands-shell --policy examples/policies/01-read-only.cedar -c 'echo hi > /home/lash/x' # denied + +permit( + principal, + action in [ + Agent::Action::"fs:read", + Agent::Action::"fs:stat", + Agent::Action::"fs:list" + ], + resource +); diff --git a/examples/policies/02-workspace-jail.cedar b/examples/policies/02-workspace-jail.cedar new file mode 100644 index 0000000..18fa050 --- /dev/null +++ b/examples/policies/02-workspace-jail.cedar @@ -0,0 +1,37 @@ +// Example 2 — Workspace jail. +// +// The agent gets full read/write access, but only inside +// `/home/lash/workspace`. Reads and writes anywhere else are denied. This +// shows matching on per-call input with the `like` operator and scoping a +// clause to the actions that actually carry a `path` field. +// +// Note: a clause that reads `context.input.path` must apply only to actions +// whose schema has that field. `fs:rename` uses `src`/`dst` (not `path`) and +// the non-fs actions have no path at all, so they are intentionally left out +// of the action list here — including them would fail schema validation. +// +// The `== "/home/lash/workspace"` term covers the directory itself; the +// `like "/home/lash/workspace/*"` term covers everything beneath it. +// +// Run it: +// strands-shell --policy examples/policies/02-workspace-jail.cedar \ +// -c 'mkdir /home/lash/workspace; echo hi > /home/lash/workspace/f; cat /home/lash/workspace/f' # allowed +// strands-shell --policy examples/policies/02-workspace-jail.cedar \ +// -c 'echo hi > /home/lash/escape' # denied + +permit( + principal, + action in [ + Agent::Action::"fs:read", + Agent::Action::"fs:stat", + Agent::Action::"fs:list", + Agent::Action::"fs:write", + Agent::Action::"fs:create", + Agent::Action::"fs:delete" + ], + resource +) +when { + context.input.path == "/home/lash/workspace" || + context.input.path like "/home/lash/workspace/*" +}; diff --git a/examples/policies/03-mixed-controls.cedar b/examples/policies/03-mixed-controls.cedar new file mode 100644 index 0000000..e7569d6 --- /dev/null +++ b/examples/policies/03-mixed-controls.cedar @@ -0,0 +1,69 @@ +// Example 3 — Mixed controls with a forbid override. +// +// A more realistic policy that combines several rules: +// - read/stat/list anywhere; +// - write/create/delete only under the home directory; +// - but NEVER read files ending in `.secret` (a `forbid` that overrides the +// broad read permit — in Cedar, any matching `forbid` wins over every +// `permit`); +// - GET requests to `https://example.com/...` only — all other network +// access (other hosts, other methods) is denied; +// - environment enumeration (`env`) and MCP tool calls are denied, since no +// clause permits `env:read` or `mcp:call`. +// +// This demonstrates layering permits, scoping by action and by input field, +// and using `forbid` to carve an exception out of a broad allow. +// +// Run it: +// strands-shell --policy examples/policies/03-mixed-controls.cedar \ +// -c 'echo hi > /home/lash/n.txt; cat /home/lash/n.txt' # allowed +// strands-shell --policy examples/policies/03-mixed-controls.cedar \ +// -c 'echo s > /home/lash/k.secret; cat /home/lash/k.secret' # write ok, read denied +// strands-shell --policy examples/policies/03-mixed-controls.cedar \ +// -c 'env' # denied + +// Read the filesystem freely... +permit( + principal, + action in [ + Agent::Action::"fs:read", + Agent::Action::"fs:stat", + Agent::Action::"fs:list" + ], + resource +); + +// ...write only under the home directory... +permit( + principal, + action in [ + Agent::Action::"fs:write", + Agent::Action::"fs:create", + Agent::Action::"fs:delete" + ], + resource +) +when { context.input.path like "/home/lash/*" }; + +// ...but secrets are never readable, even though the broad read permit above +// would otherwise allow it. A matching forbid always wins. +forbid( + principal, + action in [ + Agent::Action::"fs:read", + Agent::Action::"fs:stat" + ], + resource +) +when { context.input.path like "*.secret" }; + +// Network: GET to example.com only. +permit( + principal, + action == Agent::Action::"net:request", + resource +) +when { + context.input.url like "https://example.com/*" && + context.input.method == "GET" +}; diff --git a/examples/run-policies.sh b/examples/run-policies.sh new file mode 100755 index 0000000..ffbfdac --- /dev/null +++ b/examples/run-policies.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# +# Demonstrate Cedar authorization policies in strands-shell. +# +# For each example policy under examples/policies/, this runs a couple of +# commands through `strands-shell --policy ` and shows which are allowed +# and which are denied. Denials print `policy denied: ...` to stderr +# and exit non-zero; the built-in SSRF and filesystem protections still apply +# underneath, so a policy can only ever *add* restrictions. +# +# Usage: +# ./examples/run-policies.sh # builds the debug binary, then runs +# SHELL_BIN=/path/to/strands-shell ./examples/run-policies.sh # use a prebuilt binary +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +POLICIES="$ROOT/examples/policies" + +# Locate the binary: honor SHELL_BIN, otherwise build the debug binary. +if [[ -n "${SHELL_BIN:-}" ]]; then + BIN="$SHELL_BIN" +else + echo "Building strands-shell (debug)..." >&2 + cargo build -q --bin strands-shell --manifest-path "$ROOT/Cargo.toml" + BIN="$ROOT/target/debug/strands-shell" +fi + +# Run one command under a policy and report allowed/denied based on exit code. +# Usage: demo +# is "allow" or "deny" — printed so output is self-checking. +demo() { + local policy="$1" expect="$2" cmd="$3" + printf ' $ strands-shell --policy %s -c %q\n' "$(basename "$policy")" "$cmd" + local out status + out="$("$BIN" --policy "$policy" -c "$cmd" 2>&1)" && status=0 || status=$? + if [[ -n "$out" ]]; then + sed 's/^/ /' <<<"$out" + fi + if [[ $status -eq 0 ]]; then + printf ' => exit 0 (allowed) [expected: %s]\n\n' "$expect" + else + printf ' => exit %d (denied) [expected: %s]\n\n' "$status" "$expect" + fi +} + +echo +echo "=== Example 1: read-only sandbox ===========================================" +echo "Reads are allowed; any mutation, network, or env access is denied." +echo +demo "$POLICIES/01-read-only.cedar" allow 'ls / | sort | head -n 5' +demo "$POLICIES/01-read-only.cedar" deny 'echo hi > /home/lash/x.txt' +demo "$POLICIES/01-read-only.cedar" deny 'curl https://example.com/' + +echo "=== Example 2: workspace jail ==============================================" +echo "Full read/write inside /home/lash/workspace; everything else is denied." +echo +demo "$POLICIES/02-workspace-jail.cedar" allow \ + 'mkdir /home/lash/workspace; echo hi > /home/lash/workspace/f.txt; cat /home/lash/workspace/f.txt' +demo "$POLICIES/02-workspace-jail.cedar" deny \ + 'echo hi > /home/lash/escape.txt' + +echo "=== Example 3: mixed controls with a forbid override =======================" +echo "Read anywhere, write under home, never read *.secret, GET example.com only." +echo +demo "$POLICIES/03-mixed-controls.cedar" allow \ + 'echo note > /home/lash/n.txt; cat /home/lash/n.txt' +demo "$POLICIES/03-mixed-controls.cedar" deny \ + 'echo s > /home/lash/k.secret; cat /home/lash/k.secret' +demo "$POLICIES/03-mixed-controls.cedar" deny \ + 'env' + +echo "Done. Denied commands printed 'policy denied: ...' and exited non-zero." From e404fb947becf1ddbb99b023c62e2a6a95ade16e Mon Sep 17 00:00:00 2001 From: Marc Brooker Date: Tue, 16 Jun 2026 16:56:31 -0700 Subject: [PATCH 3/4] feat: expose Cedar policy_file/policy from the Python API Add `policy_file` and `policy_str` to the native PyO3 ShellBuilder, and surface them on the customer-facing `Shell` constructor as the `policy_file` (path) and `policy` (inline text) keyword args. Passing both raises; an explicit policy here overrides a `policy` key from a config_file. Update the README's policy section to show the Python API, and add pytest coverage for the read-only-from-Python, file-vs-inline, no-policy, mutual-exclusion, and malformed-policy cases. --- README.md | 10 +++++-- python/strands_shell/__init__.py | 17 ++++++++++++ src/python.rs | 18 ++++++++++++ tests/python/test_bindings.py | 47 ++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 85d40d5..e8ba7b6 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,12 @@ permit( ); ``` -Load it from the CLI, the Rust builder, or a TOML key: +Load it from Python, the CLI, the Rust builder, or a TOML key: + +```python +shell = strands_shell.Shell(policy_file="read-only.cedar") +# or inline: strands_shell.Shell(policy=open("read-only.cedar").read()) +``` ```sh strands-shell --policy read-only.cedar -c 'ls /' @@ -201,8 +206,7 @@ let shell = Shell::builder().policy_file("read-only.cedar")?.build()?; ``` ```toml -# in your config file, resolved relative to it; picked up by --config and by -# the Python/Node config_file option +# in your config file, resolved relative to it; also picked up by --config policy = "read-only.cedar" ``` diff --git a/python/strands_shell/__init__.py b/python/strands_shell/__init__.py index 1eefa83..77b2b3b 100644 --- a/python/strands_shell/__init__.py +++ b/python/strands_shell/__init__.py @@ -162,6 +162,11 @@ class Shell: go in a single :class:`Limits` bundle; behavioral settings (``env``, ``umask``, ``timeout``) are top-level keyword args. + A Cedar authorization policy can be attached with ``policy_file`` (a path) + or ``policy`` (the policy text inline) — pass at most one. A policy only + ever *adds* restrictions on top of the mounts and SSRF guard; with none, + behavior is unchanged. See the README's "Authorization Policies" section. + State (cwd, env, functions, open fds) persists across :meth:`run` calls. There is no ``close()`` — the embedded interpreter and in-process VFS are released by refcounting when the last reference drops. @@ -178,7 +183,12 @@ def __init__( timeout: float | None = None, limits: Limits | None = None, config_file: str | None = None, + policy_file: str | None = None, + policy: str | None = None, ) -> None: + if policy_file is not None and policy is not None: + raise ValueError("pass at most one of `policy_file` or `policy`") + builder = _native.Shell.builder() # config_file merges in first; explicit args below win over it. Each @@ -189,6 +199,13 @@ def __init__( if config_file is not None: builder.config_file(config_file) + # A Cedar policy passed explicitly here overrides any `policy` key the + # config file set (same "explicit args win" rule as above). + if policy_file is not None: + builder.policy_file(policy_file) + elif policy is not None: + builder.policy_str(policy) + for b in binds or []: if b.mode == "direct" and b.readonly: builder.bind_direct_readonly(b.source, b.destination) diff --git a/src/python.rs b/src/python.rs index 82548cd..dc10f43 100644 --- a/src/python.rs +++ b/src/python.rs @@ -261,6 +261,24 @@ impl ShellBuilder { Ok(slf) } + /// Load a Cedar authorization policy from a file. + fn policy_file<'py>(mut slf: PyRefMut<'py, Self>, path: &str) -> PyResult> { + let b = slf + .inner + .take() + .ok_or_else(|| PyRuntimeError::new_err("builder consumed"))?; + let updated = b + .policy_file(path) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + slf.inner = Some(updated); + Ok(slf) + } + + /// Set a Cedar authorization policy from a string. + fn policy_str<'py>(slf: PyRefMut<'py, Self>, text: &str) -> PyResult> { + chain(slf, |b| b.policy_str(text)) + } + /// Build the Shell. fn build(&mut self) -> PyResult { let builder = self diff --git a/tests/python/test_bindings.py b/tests/python/test_bindings.py index 45b304a..547420b 100644 --- a/tests/python/test_bindings.py +++ b/tests/python/test_bindings.py @@ -266,3 +266,50 @@ def test_omitted_and_positive_timeout_allowed(): # Omitted timeout => no limit; a positive value => bounded. Both build. assert strands_shell.Shell().run("echo ok").stdout.strip() == "ok" assert strands_shell.Shell(timeout=5.0).run("echo ok").stdout.strip() == "ok" + + +# --------------------------------------------------------------------------- # +# Cedar authorization policies +# --------------------------------------------------------------------------- # + +_READ_ONLY_POLICY = ( + 'permit(principal, action in [' + 'Agent::Action::"fs:read", Agent::Action::"fs:stat", Agent::Action::"fs:list"' + '], resource);' +) + + +def test_policy_str_denies_unpermitted_action(): + """An inline read-only policy allows reads but denies writes.""" + shell = strands_shell.Shell(policy=_READ_ONLY_POLICY) + assert shell.run("ls /").status == 0 + # A write is not permitted by the policy => denied, non-zero exit. + assert shell.run("echo hi > /home/lash/x.txt").status != 0 + + +def test_policy_file_loads_from_path(tmp_path): + """A policy supplied as a file path behaves like the inline form.""" + policy = tmp_path / "read-only.cedar" + policy.write_text(_READ_ONLY_POLICY) + shell = strands_shell.Shell(policy_file=str(policy)) + assert shell.run("ls /").status == 0 + assert shell.run("echo hi > /home/lash/x.txt").status != 0 + + +def test_no_policy_is_unchanged(): + """With no policy, writes are allowed (default-allow).""" + shell = strands_shell.Shell() + assert shell.run("echo hi > /home/lash/x.txt && cat /home/lash/x.txt").status == 0 + + +def test_policy_and_policy_file_are_mutually_exclusive(tmp_path): + policy = tmp_path / "p.cedar" + policy.write_text(_READ_ONLY_POLICY) + with pytest.raises(ValueError, match="at most one"): + strands_shell.Shell(policy=_READ_ONLY_POLICY, policy_file=str(policy)) + + +def test_malformed_policy_raises(): + """A malformed policy fails at construction (build()).""" + with pytest.raises(Exception): + strands_shell.Shell(policy="permit(garbage") From 5f60f4053974ab1ba42d2e881ffa29c6f2fae1ad Mon Sep 17 00:00:00 2001 From: Marc Brooker Date: Wed, 17 Jun 2026 09:23:11 -0700 Subject: [PATCH 4/4] docs: add egress-allowlist and shield-secrets policy examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two examples grounded in the "lethal trifecta" of agent security (private data + untrusted input + external communication), each cutting one leg: - 04-egress-allowlist.cedar — network only as GET to a single host; other hosts and non-GET methods are denied, closing the exfiltration channel. - 05-shield-secrets.cedar — full read/write of a project tree but a forbid override that blocks reading .env / .pem / .ssh / credentials. Extend run-policies.sh to demonstrate both and update the example and main READMEs. --- README.md | 7 +-- examples/README.md | 6 +++ examples/policies/04-egress-allowlist.cedar | 47 ++++++++++++++++ examples/policies/05-shield-secrets.cedar | 60 +++++++++++++++++++++ examples/run-policies.sh | 22 ++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 examples/policies/04-egress-allowlist.cedar create mode 100644 examples/policies/05-shield-secrets.cedar diff --git a/README.md b/README.md index 182d0af..f1d302c 100644 --- a/README.md +++ b/README.md @@ -216,9 +216,10 @@ default-deny) — and this layers *on top of* the SSRF guard and filesystem permissions, which it can never weaken. A policy that permits all `net:request` still can't reach `169.254.169.254`. -Three worked examples (read-only, a workspace jail, and mixed controls with a -`forbid` override) plus a runnable demo live in -[`examples/`](examples/README.md): +Five worked examples — from a read-only sandbox up to anti-exfiltration egress +allowlists and shielding secrets from the agent (the +["lethal trifecta"](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/) +mitigations) — plus a runnable demo live in [`examples/`](examples/README.md): ```sh ./examples/run-policies.sh diff --git a/examples/README.md b/examples/README.md index b59db28..367740b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,6 +22,12 @@ SHELL_BIN=/path/to/strands-shell ./examples/run-policies.sh | [`01-read-only.cedar`](policies/01-read-only.cedar) | A single `permit` for read-only actions; everything else is denied by default. | | [`02-workspace-jail.cedar`](policies/02-workspace-jail.cedar) | Matching `context.input.path` with `like`, scoped to the actions that carry a path. | | [`03-mixed-controls.cedar`](policies/03-mixed-controls.cedar) | Layered permits, a `forbid` override for `*.secret`, and a scoped network rule. | +| [`04-egress-allowlist.cedar`](policies/04-egress-allowlist.cedar) | Anti-exfiltration: network only as `GET` to one host — no other hosts, no `POST`. | +| [`05-shield-secrets.cedar`](policies/05-shield-secrets.cedar) | Read/write a project tree but `forbid` reading `.env` / `.pem` / `.ssh` / credentials. | + +Examples 4 and 5 target the ["lethal trifecta"](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/) +— private data + untrusted input + external communication. Each cuts one leg: +04 removes the exfiltration channel, 05 removes the agent's access to secrets. See the [Authorization Policies](../README.md#authorization-policies-cedar) section of the main README for the action vocabulary and how policies compose diff --git a/examples/policies/04-egress-allowlist.cedar b/examples/policies/04-egress-allowlist.cedar new file mode 100644 index 0000000..06b2641 --- /dev/null +++ b/examples/policies/04-egress-allowlist.cedar @@ -0,0 +1,47 @@ +// Example 4 — Egress allowlist (cut off the exfiltration leg). +// +// Prompt injection turns into data theft when an agent that has seen private +// data can also reach the open network: it gets tricked into POSTing your +// secrets to an attacker, or smuggling them out inside a constructed URL. +// (Simon Willison calls private data + untrusted input + external comms the +// "lethal trifecta" — https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/.) +// The robust fix is architectural: deny the network by default and allow only +// the exact egress the task needs, rather than trying to detect bad requests. +// +// This policy permits the filesystem and allows network access ONLY for GET +// requests to the documentation host. Two exfiltration vectors are closed: +// - a POST (or PUT/DELETE) to the allowed host is denied — no upload channel; +// - a GET to any OTHER host is denied — no constructed-URL smuggling. +// This is the policy layer; the built-in SSRF guard still blocks private, +// loopback, and IMDS addresses underneath, so a policy can only tighten egress. +// +// Run it: +// strands-shell --policy examples/policies/04-egress-allowlist.cedar \ +// -c 'curl -sI https://docs.python.org/3/' # allowed +// strands-shell --policy examples/policies/04-egress-allowlist.cedar \ +// -c 'curl -s https://example.com/' # denied (host) +// strands-shell --policy examples/policies/04-egress-allowlist.cedar \ +// -c 'curl -s -X POST -d @secrets https://docs.python.org/' # denied (method) + +permit( + principal, + action in [ + Agent::Action::"fs:read", + Agent::Action::"fs:stat", + Agent::Action::"fs:list", + Agent::Action::"fs:write", + Agent::Action::"fs:create", + Agent::Action::"fs:delete" + ], + resource +); + +permit( + principal, + action == Agent::Action::"net:request", + resource +) +when { + context.input.method == "GET" && + context.input.url like "https://docs.python.org/*" +}; diff --git a/examples/policies/05-shield-secrets.cedar b/examples/policies/05-shield-secrets.cedar new file mode 100644 index 0000000..0fd5fd7 --- /dev/null +++ b/examples/policies/05-shield-secrets.cedar @@ -0,0 +1,60 @@ +// Example 5 — Shield secrets from an untrusted-data agent (cut off the +// private-data leg). +// +// A coding agent often needs broad read/write over a project tree, but that +// same tree usually contains things it should never read: `.env` files, API +// keys, SSH private keys, cloud credentials. If an injected instruction can +// make the agent read those and then leak them, you've lost. Rather than +// hoping the agent "won't look," deny reads of secret-shaped paths outright — +// a `forbid` always wins over the broad read permit, so the carve-out holds no +// matter what the agent is talked into doing. +// +// The agent keeps full read/write of ordinary files (and listing a directory +// still works — it just can't read the secret's contents). Pair this with +// Example 4's egress allowlist for defense in depth: even a leaked secret has +// nowhere to go. Note there is NO `net:request` permit here, so this policy +// also denies the network entirely. +// +// Matching note: `like` is a glob over the whole path. `*.env` catches +// `config.env`; `*/.env` catches a bare `.env` in any directory; `*/.ssh/*` +// catches anything under an `.ssh` directory. +// +// Run it: +// strands-shell --policy examples/policies/05-shield-secrets.cedar \ +// -c 'cat /home/lash/project/app.py' # allowed +// strands-shell --policy examples/policies/05-shield-secrets.cedar \ +// -c 'cat /home/lash/project/.env' # denied +// strands-shell --policy examples/policies/05-shield-secrets.cedar \ +// -c 'cat /home/lash/.ssh/id_rsa' # denied + +// Read and write the working tree freely... +permit( + principal, + action in [ + Agent::Action::"fs:read", + Agent::Action::"fs:stat", + Agent::Action::"fs:list", + Agent::Action::"fs:write", + Agent::Action::"fs:create", + Agent::Action::"fs:delete" + ], + resource +); + +// ...but never read credentials, env files, or private keys, anywhere. A +// matching forbid overrides every permit above. +forbid( + principal, + action in [ + Agent::Action::"fs:read", + Agent::Action::"fs:stat" + ], + resource +) +when { + context.input.path like "*.env" || + context.input.path like "*/.env" || + context.input.path like "*.pem" || + context.input.path like "*/.ssh/*" || + context.input.path like "*credentials*" +}; diff --git a/examples/run-policies.sh b/examples/run-policies.sh index ffbfdac..440d26f 100755 --- a/examples/run-policies.sh +++ b/examples/run-policies.sh @@ -70,4 +70,26 @@ demo "$POLICIES/03-mixed-controls.cedar" deny \ demo "$POLICIES/03-mixed-controls.cedar" deny \ 'env' +echo "=== Example 4: egress allowlist (anti-exfiltration) ========================" +echo "Network only as GET to docs.python.org; other hosts and POST are denied." +echo "(The allowed GET needs connectivity; the denials are what matter and are" +echo " enforced offline by the policy, before any request leaves the process.)" +echo +demo "$POLICIES/04-egress-allowlist.cedar" allow \ + 'curl -s -o /dev/null -w "HTTP %{http_code}\n" https://docs.python.org/3/' +demo "$POLICIES/04-egress-allowlist.cedar" deny \ + 'curl -s https://example.com/' +demo "$POLICIES/04-egress-allowlist.cedar" deny \ + 'curl -s -X POST -d secret=hunter2 https://docs.python.org/' + +echo "=== Example 5: shield secrets from the agent ===============================" +echo "Read/write the project tree, but never read .env / .pem / .ssh / credentials." +echo +demo "$POLICIES/05-shield-secrets.cedar" allow \ + 'mkdir -p /home/lash/project; echo "print(1)" > /home/lash/project/app.py; cat /home/lash/project/app.py' +demo "$POLICIES/05-shield-secrets.cedar" deny \ + 'echo "SECRET=hunter2" > /home/lash/project/.env; cat /home/lash/project/.env' +demo "$POLICIES/05-shield-secrets.cedar" deny \ + 'mkdir -p /home/lash/.ssh; echo key > /home/lash/.ssh/id_rsa; cat /home/lash/.ssh/id_rsa' + echo "Done. Denied commands printed 'policy denied: ...' and exited non-zero."