From e66b85d089065ea0937ab308c4020f36194b9593 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 18:19:49 +0300 Subject: [PATCH 1/4] feat(security): bootstrap the native policy bus and contracts Co-authored-by: Medulla --- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/workflows/ci.yml | 82 ++- .github/workflows/release.yml | 14 +- AGENTS.md | 144 +--- Cargo.lock | 60 +- Cargo.toml | 17 +- MODULE.md | 108 ++- README.md | 182 +---- ROADMAP.md | 33 +- crates/template-bus/Cargo.toml | 24 - crates/template-bus/README.md | 100 --- crates/template-bus/src/greeting/mod.rs | 17 - crates/template-bus/src/greeting/test.rs | 65 -- crates/template-bus/src/greeting/types.rs | 54 -- crates/template-bus/src/lib.rs | 74 -- crates/template-bus/src/names/mod.rs | 33 - crates/template-bus/src/names/test.rs | 28 - crates/template-bus/src/version/mod.rs | 46 -- crates/template-bus/src/version/test.rs | 34 - crates/template/Cargo.toml | 39 - crates/template/examples/basic.rs | 22 - .../examples/verify_github_release.rs | 93 --- crates/template/examples/verify_module.rs | 74 -- crates/template/src/error/mod.rs | 28 - crates/template/src/error/test.rs | 17 - crates/template/src/greeting/mod.rs | 38 - crates/template/src/greeting/test.rs | 28 - crates/template/src/lib.rs | 62 -- crates/template/src/tinybus_module/README.md | 20 - crates/template/src/tinybus_module/mod.rs | 43 -- crates/template/src/tinybus_module/test.rs | 64 -- crates/template/tests/public_api.rs | 20 - crates/tinysecurity-bus/Cargo.toml | 21 + crates/tinysecurity-bus/src/error.rs | 33 + crates/tinysecurity-bus/src/future.rs | 503 +++++++++++++ crates/tinysecurity-bus/src/lib.rs | 18 + crates/tinysecurity-bus/src/names.rs | 55 ++ crates/tinysecurity-bus/src/policy.rs | 243 ++++++ .../tinysecurity-bus/tests/fixtures/v1.json | 695 ++++++++++++++++++ crates/tinysecurity-bus/tests/wire.rs | 322 ++++++++ crates/tinysecurity-module/Cargo.toml | 28 + .../examples/verify_module.rs | 199 +++++ crates/tinysecurity-module/src/adapter.rs | 101 +++ .../tinysecurity-module/src/adapter_tests.rs | 128 ++++ crates/tinysecurity-module/src/lib.rs | 5 + crates/tinysecurity-policy/Cargo.toml | 22 + crates/tinysecurity-policy/src/command.rs | 25 + crates/tinysecurity-policy/src/engine.rs | 164 +++++ crates/tinysecurity-policy/src/lib.rs | 7 + crates/tinysecurity-policy/src/path.rs | 134 ++++ crates/tinysecurity-policy/tests/policy.rs | 413 +++++++++++ crates/tinysecurity/Cargo.toml | 21 + crates/tinysecurity/src/lib.rs | 2 + deny.toml | 2 +- docs/adr/0002-bus-only-delivery.md | 29 + docs/adr/0003-fail-closed.md | 30 + docs/adr/0004-policy-vs-sandbox.md | 28 + docs/adr/0005-jev-judge-only.md | 30 + docs/performance.md | 38 + docs/plans/README.md | 2 - docs/plans/example-retry-policy.md | 71 -- docs/plans/security-module.md | 266 +++++++ docs/plans/tinybus-module-release.md | 11 - docs/specs/README.md | 2 - docs/specs/example-retry-policy.md | 63 -- docs/specs/security-module.md | 190 +++++ docs/specs/tinybus-module-release.md | 33 - 67 files changed, 4041 insertions(+), 1558 deletions(-) delete mode 100644 crates/template-bus/Cargo.toml delete mode 100644 crates/template-bus/README.md delete mode 100644 crates/template-bus/src/greeting/mod.rs delete mode 100644 crates/template-bus/src/greeting/test.rs delete mode 100644 crates/template-bus/src/greeting/types.rs delete mode 100644 crates/template-bus/src/lib.rs delete mode 100644 crates/template-bus/src/names/mod.rs delete mode 100644 crates/template-bus/src/names/test.rs delete mode 100644 crates/template-bus/src/version/mod.rs delete mode 100644 crates/template-bus/src/version/test.rs delete mode 100644 crates/template/Cargo.toml delete mode 100644 crates/template/examples/basic.rs delete mode 100644 crates/template/examples/verify_github_release.rs delete mode 100644 crates/template/examples/verify_module.rs delete mode 100644 crates/template/src/error/mod.rs delete mode 100644 crates/template/src/error/test.rs delete mode 100644 crates/template/src/greeting/mod.rs delete mode 100644 crates/template/src/greeting/test.rs delete mode 100644 crates/template/src/lib.rs delete mode 100644 crates/template/src/tinybus_module/README.md delete mode 100644 crates/template/src/tinybus_module/mod.rs delete mode 100644 crates/template/src/tinybus_module/test.rs delete mode 100644 crates/template/tests/public_api.rs create mode 100644 crates/tinysecurity-bus/Cargo.toml create mode 100644 crates/tinysecurity-bus/src/error.rs create mode 100644 crates/tinysecurity-bus/src/future.rs create mode 100644 crates/tinysecurity-bus/src/lib.rs create mode 100644 crates/tinysecurity-bus/src/names.rs create mode 100644 crates/tinysecurity-bus/src/policy.rs create mode 100644 crates/tinysecurity-bus/tests/fixtures/v1.json create mode 100644 crates/tinysecurity-bus/tests/wire.rs create mode 100644 crates/tinysecurity-module/Cargo.toml create mode 100644 crates/tinysecurity-module/examples/verify_module.rs create mode 100644 crates/tinysecurity-module/src/adapter.rs create mode 100644 crates/tinysecurity-module/src/adapter_tests.rs create mode 100644 crates/tinysecurity-module/src/lib.rs create mode 100644 crates/tinysecurity-policy/Cargo.toml create mode 100644 crates/tinysecurity-policy/src/command.rs create mode 100644 crates/tinysecurity-policy/src/engine.rs create mode 100644 crates/tinysecurity-policy/src/lib.rs create mode 100644 crates/tinysecurity-policy/src/path.rs create mode 100644 crates/tinysecurity-policy/tests/policy.rs create mode 100644 crates/tinysecurity/Cargo.toml create mode 100644 crates/tinysecurity/src/lib.rs create mode 100644 docs/adr/0002-bus-only-delivery.md create mode 100644 docs/adr/0003-fail-closed.md create mode 100644 docs/adr/0004-policy-vs-sandbox.md create mode 100644 docs/adr/0005-jev-judge-only.md create mode 100644 docs/performance.md delete mode 100644 docs/plans/example-retry-policy.md create mode 100644 docs/plans/security-module.md delete mode 100644 docs/plans/tinybus-module-release.md delete mode 100644 docs/specs/example-retry-policy.md create mode 100644 docs/specs/security-module.md delete mode 100644 docs/specs/tinybus-module-release.md diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c808685..5da9929 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Security reports - url: https://github.com/tinyhumansai/rust-template/security/policy + url: https://github.com/tinyhumansai/tinysecurity/security/policy about: Please do not report vulnerabilities through public issues. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba8c2fc..3e51b0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,14 @@ env: jobs: rust: name: Rust - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash steps: - uses: actions/checkout@v7 with: @@ -54,27 +61,76 @@ jobs: run: cargo test # `cargo build --all-targets` only *compiles* an example. `AGENTS.md` - # promises `cargo run -p template --example basic` works, and a compiled + # promises the native loader verifier works, and a compiled # example can still fail on its first line. - name: Run the bundled example - run: cargo run -p template --example basic - - # `crates/template-bus` exists so a host can name the payload types + if: ${{ runner.os != 'Windows' }} + shell: bash + run: | + case "$RUNNER_OS" in + Linux) module=target/debug/libtinysecurity_module.so ;; + macOS) module=target/debug/libtinysecurity_module.dylib ;; + esac + cargo run -p tinysecurity-module --example verify_module -- "$module" + + - name: Verify Windows module through the restricted native copy + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $libraryName = 'tinysecurity_module' + $module = "target/debug/$libraryName.dll" + $verifyRoot = Join-Path $env:RUNNER_TEMP 'tinysecurity-module-ci-verify' + New-Item -ItemType Directory -Force $verifyRoot | Out-Null + + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $security = [System.Security.AccessControl.DirectorySecurity]::new() + $security.SetOwner($identity.User) + $security.SetAccessRuleProtection($true, $false) + $rights = [System.Security.AccessControl.FileSystemRights]::FullControl + $inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $propagation = [System.Security.AccessControl.PropagationFlags]::None + $access = [System.Security.AccessControl.AccessControlType]::Allow + foreach ($sidValue in @( + $identity.User.Value, + 'S-1-5-18', + 'S-1-5-32-544' + )) { + $sid = [System.Security.Principal.SecurityIdentifier]::new($sidValue) + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, + $rights, + $inheritance, + $propagation, + $access + ) + [void]$security.AddAccessRule($rule) + } + Set-Acl -LiteralPath $verifyRoot -AclObject $security + + $verifiedModule = Join-Path $verifyRoot "$libraryName.dll" + Copy-Item -LiteralPath $module -Destination $verifiedModule + cargo run --locked --package tinysecurity-module --example verify_module -- $verifiedModule + + # `crates/tinysecurity-bus` exists so a host can name the payload types # without compiling the module. That promise is invisible in a diff, # because a forbidden dependency arrives transitively through a feature # someone enabled one crate away — so it is asserted rather than # documented. # - # The FORWARD form is required. `cargo tree -i -p template-bus` + # The FORWARD form is required. `cargo tree -i -p tinysecurity-bus` # discards the `-p` scope, prints the whole-workspace inverse tree, and # exits 0 looking clean even when this crate is the one at fault. - name: Assert the contract crate stays transport-free run: | set -euo pipefail - forbidden="$(cargo tree -p template-bus -e normal,build --prefix none \ + cargo metadata --format-version 1 --no-deps | jq -e ' + [.packages[] | select(.name == "tinysecurity-bus") | .dependencies[] + | select(.kind == null) | .name] | sort == ["serde", "thiserror"]' + forbidden="$(cargo tree -p tinysecurity-bus -e normal,build --prefix none \ | grep -Ei 'tinybus|tokio|reqwest|ureq|hyper|rusqlite|git2' || true)" if [ -n "$forbidden" ]; then - echo "template-bus pulled in a dependency its manifest forbids:" >&2 + echo "tinysecurity-bus pulled in a dependency its manifest forbids:" >&2 echo "$forbidden" >&2 echo >&2 echo "The contract is what a host compiles against. It must stay free" >&2 @@ -82,14 +138,18 @@ jobs: exit 1 fi + # LLVM paths on Windows use native drive prefixes while Git Bash pwd + # uses /d/... . The Linux lane owns the exact per-file coverage gate; + # all three native lanes still build, test and load the real module. - name: Require 90% line coverage in every source file + if: ${{ runner.os == 'Linux' }} run: .github/scripts/check-file-coverage.sh 90 coverage.json - name: Upload coverage report - if: ${{ always() }} + if: ${{ always() && runner.os == 'Linux' }} uses: actions/upload-artifact@v7 with: - name: coverage-json + name: coverage-json-${{ matrix.os }} path: coverage.json if-no-files-found: ignore @@ -128,7 +188,7 @@ jobs: run: | set -euo pipefail msrv="$(cargo metadata --format-version 1 --no-deps \ - | jq -r '.packages[] | select(.name == "template") | .rust_version')" + | jq -r '.packages[] | select(.name == "tinysecurity-module") | .rust_version')" if [[ -z "$msrv" || "$msrv" == "null" ]]; then echo "workspace.package.rust-version is not set in Cargo.toml" >&2 exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4acf379..cd58ba8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,9 @@ permissions: env: # The workspace member that ships as the loadable module. Its package name is - # the artifact name and the library name; `crates/template-bus` rides along on + # the artifact name and the library name; `crates/tinysecurity-bus` rides along on # the same inherited version and is not packaged separately. - RELEASE_PACKAGE: template + RELEASE_PACKAGE: tinysecurity-module jobs: prepare: @@ -259,7 +259,7 @@ jobs: macOS) module="target/release/lib${library_name}.dylib" ;; *) echo "unsupported Unix runner: ${RUNNER_OS}" >&2; exit 1 ;; esac - cargo run --locked --package template --example verify_module -- "$module" + cargo run --locked --package tinysecurity-module --example verify_module -- "$module" - name: Verify Windows module through TinyBus loader if: ${{ runner.os == 'Windows' }} @@ -270,7 +270,7 @@ jobs: $ErrorActionPreference = 'Stop' $libraryName = $env:CRATE_NAME.Replace('-', '_') $module = "target/release/$libraryName.dll" - $verifyRoot = Join-Path $env:RUNNER_TEMP 'template-module-verify' + $verifyRoot = Join-Path $env:RUNNER_TEMP 'tinysecurity-module-verify' New-Item -ItemType Directory -Force $verifyRoot | Out-Null $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() @@ -300,7 +300,7 @@ jobs: $verifiedModule = Join-Path $verifyRoot "$libraryName.dll" Copy-Item -LiteralPath $module -Destination $verifiedModule - cargo run --locked --package template --example verify_module -- $verifiedModule + cargo run --locked --package tinysecurity-module --example verify_module -- $verifiedModule - name: Assemble Unix module package if: ${{ runner.os != 'Windows' }} @@ -463,7 +463,7 @@ jobs: verify_root="/opt/${CRATE_NAME}-module-verify" install -d -m 700 "$verify_root" install -m 755 "target/release/lib${library_name}.so" "$verify_root/" - cargo run --locked --package template --example verify_module -- \ + cargo run --locked --package tinysecurity-module --example verify_module -- \ "$verify_root/lib${library_name}.so" - name: Assemble distribution module package @@ -587,5 +587,5 @@ jobs: cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ --package tinybus --all-features --example github_module_host -- \ "$release_url" "$archive" "$sha256" - cargo run --locked --package template --example verify_github_release -- \ + cargo run --locked --package tinysecurity-module --example verify_module -- \ "$release_url" "$archive" "$sha256" diff --git a/AGENTS.md b/AGENTS.md index ee8fdfc..b851fab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,118 +4,23 @@ This file is the single source of truth for how humans and coding agents work in this repository. `CLAUDE.md` is a symlink to this file, so every agent reads the same instructions. -When you generate a new project from this template, keep this file and adapt -the project-specific parts (crate name, module map, feature flags, commands). -Delete guidance that no longer applies rather than leaving it to rot. - -## Template Checklist - -Do this once, in a single commit, before writing feature code: - -- [ ] Rename `crates/template` and `crates/template-bus` to the project's crate - names, and update `name` in each manifest plus the `template-bus` entry in - the root `[workspace.dependencies]`. -- [ ] Set `description`, `keywords`, and `categories` in each manifest, and - `repository` in the root `[workspace.package]`. -- [ ] Rename the crate references in `README.md`, both `src/lib.rs` files, - `crates/template/examples/`, and `crates/template/tests/` (search for - `template` and `template_bus`). -- [ ] Replace the placeholder `greeting` module in both crates with the first - real feature area — payload types in the contract crate, behavior in the - module crate — keeping the `mod.rs` / `types.rs` / `test.rs` layout. -- [ ] Confirm `license` and `LICENSE` match the project's intended license. -- [ ] Update the security contact in `SECURITY.md`. -- [ ] Rename the TinyBus interface, object path, and member constants in - `crates/template-bus/src/names/`, and the matching `provides` / `methods` - declarations in `crates/template/src/tinybus_module/`, while keeping - `vendor/tinybus` pinned. -- [ ] Reset `CONTRACT_VERSION` in `crates/template-bus/src/version/` for the new - contract. -- [ ] Replace `ROADMAP.md` with the real plan, or delete it. -- [ ] Rewrite the "Project Structure" section below to describe this workspace. - ## Project Structure -This is a Rust 2024 cargo workspace rooted at a virtual `Cargo.toml`. Every -crate lives under `crates/`, one directory per package, each directory named for -the package it holds. There is no root package: the crate that ships as the -loadable module is `crates/template`, the same as any other member. - -```text -Cargo.toml # virtual workspace: members, [workspace.package], - # [workspace.dependencies], [workspace.lints] -crates/ -├── template-bus/ # the wire contract: what crosses the bus, nothing else -│ ├── README.md # why the contract is its own crate -│ └── src/ -│ ├── lib.rs # crate docs + the entire public re-export surface -│ ├── names/ # interface, object path, one constant per member -│ ├── version/ # contract version and the host bind rule -│ └── / # one directory per payload family -└── template/ # the module: behavior, adapter, and the cdylib - ├── src/ - │ ├── lib.rs # crate docs + public surface, re-exporting the contract - │ ├── error/mod.rs # crate-wide `Error` and `Result` - │ ├── tinybus_module/ # TinyBus interface, ABI exports, integration tests - │ └── / # one directory per feature area - │ ├── mod.rs # module docs, wiring, smallest useful public API - │ ├── types.rs # substantial type definitions - │ └── test.rs # module-local unit tests - ├── tests/ # integration tests against the public API only - └── examples/ # runnable, compiled-in-CI usage examples -vendor/tinybus/ # pinned TinyBus host types and module SDK -docs/ -├── specs/ # behavior and architecture specifications -├── plans/ # test-first implementation plans -└── adr/ # immutable architecture decision records -``` - -### The two-crate split - -`crates/template-bus` holds every type that crosses the bus and the names of the -members that carry them. It has no transport, no runtime, and no behavior, and -CI asserts it stays that way. A host that only makes calls depends on it alone. - -`crates/template` depends on it and re-exports all of it, so -`template::GreetRequest` and `template_bus::GreetRequest` are the *same* type -rather than structural twins. That direction is load-bearing: a parallel set of -payload types for hosts would mean a conversion at every call site that nothing -checks. - -The rule for deciding where something goes: a payload type describes what a -frame carries and belongs in the contract; anything that answers a frame, holds -a connection, or touches an engine belongs in the module crate. - -Add a crate by creating `crates//` — `members = ["crates/*"]` picks it up -by existing. Inherit `version`, `edition`, `rust-version`, `license`, and -`repository` from `[workspace.package]`, take shared dependencies from -`[workspace.dependencies]`, and opt into the shared lint set with: - -```toml -[lints] -workspace = true -``` - -Each feature area belongs in a focused module directory under a crate's `src/`. -A module root explains the module, wires its pieces together, and exposes the -smallest useful API. Move substantial type definitions into `types.rs` and put -module-local unit tests in a dedicated `test.rs`, wired from the bottom of the -module root with: - -```rust -#[cfg(test)] -mod test; -``` +This Rust 2024 workspace ships a native TinyBus security module. -Do not accumulate inline `mod tests` blocks in implementation files, and do not -let a general-purpose `utils.rs` or `helpers.rs` grow — those are a symptom of a -missing module. Prefer many small modules that each do one thing well over few -broad ones. +- `crates/tinysecurity-bus`: versioned transport-free contract, normal dependencies serde and thiserror only. +- `crates/tinysecurity-policy`: conservative native policy engine. +- `crates/tinysecurity`: internal engine re-export, never host-linked. +- `crates/tinysecurity-module`: configured TinyBus SDK adapter and cdylib. +- `vendor/tinybus`: pinned module SDK and loader, maintained upstream. +- `docs/specs`, `docs/plans`, `docs/adr`: accepted design, ordered implementation and decisions. -Keep public exports centralized in each crate's `src/lib.rs` so downstream users -have one predictable surface. Put shared error variants in -`crates/template/src/error/mod.rs` and return the crate-wide `Result` from -fallible public APIs. +Only implemented engines may advertise methods. Unknown effects deny. Module +configuration uses SDK init/reinit, not lifecycle RPCs. Hosts link only the +contract, and production migration requires released artifacts/checksums. +Tests live in sibling `*_tests.rs` files or crate `tests/` integration suites; +never use inline modules or `test.rs` filenames. Public types have rustdoc. +Do not create empty engine crates or claim full tracker completion at bootstrap. ## Build And Test @@ -133,8 +38,8 @@ Supporting commands: - `cargo fmt --all` — format before committing. - `cargo test ` — run a focused subset while iterating. -- `cargo test -p template-bus` — run one crate's suite. -- `cargo run -p template --example basic` — run the bundled example. +- `cargo test -p tinysecurity-bus` — run one crate's suite. +- `cargo run -p tinysecurity-module --example verify_module -- ` — run the bundled example. - `cargo doc --no-deps --all-features` — build the rustdoc CI also builds with `RUSTDOCFLAGS="-D warnings"`. - `cargo test --doc` — run doctests alone when editing documentation examples. @@ -185,7 +90,7 @@ add one: - gate anything optional behind a Cargo feature, documented in `Cargo.toml`; - declare it once in the root `[workspace.dependencies]` when more than one crate needs it, and take it with `{ workspace = true }`; -- never add one to `crates/template-bus` that pulls in a transport, an async +- never add one to `crates/tinysecurity-bus` that pulls in a transport, an async runtime, an HTTP client, or a native library — CI fails the build if you do; - leave a comment above the entry explaining *why* the crate is needed and what uses it — see the existing entries for the expected tone; @@ -211,15 +116,20 @@ new module capability requires more. ## Testing -- Module-local unit tests live in `crates//src//test.rs` and may - touch private items. +- Module-local unit tests live in sibling `*_tests.rs` files and may touch + private items: use `src//mod_tests.rs` beside `mod.rs`, or + `src/_tests.rs` beside `.rs`. Declare them at the bottom of + the source module with `#[cfg(test)]`, `#[path = "_tests.rs"]` + (or `"mod_tests.rs"`), and `mod tests;`. The test file starts with + `use super::*;` and carries no `#[cfg(test)]` of its own. Never use inline + test modules or files named `test.rs`, `tests.rs`, or `_test.rs`. - Integration tests live in `crates//tests/` and exercise only the public API — they are the regression suite for the crate's contract. - Payload types pin their serde representation in a unit test. That representation is the wire form: a host and a module that disagree about a field name fail at runtime with a decode error. - Use descriptive, behavioral test names: `rejects_an_empty_name`, not - `test_greet_2`. + `test_policy_2`. - Cover the failure paths, not just the happy path. Every new error variant needs a test that produces it. - For async behavior, standardize on one runtime (`tokio` as a dev-dependency @@ -240,8 +150,8 @@ Write documentation for the reader who has never seen the code. - Every public item gets a rustdoc comment. `missing_docs` is a warning that CI treats as an error. -- Start every `mod.rs` and `test.rs` with a concise module-level `//!` - description. +- Start every `mod.rs` with a concise module-level `//!` description. + Sibling `*_tests.rs` files start with `use super::*;` as specified above. - Each crate's `src/lib.rs` carries its crate-level overview: what the crate does, the primary entry points, and a short runnable example. It should also say what the crate deliberately does *not* hold, and why. @@ -295,7 +205,7 @@ Releases run from `.github/workflows/release.yml` via a manual an interrupted release after its version commit and tag exist. The workflow re-runs the full validation suite, computes the next version, updates the root `[workspace.package]` version and `Cargo.lock`, commits and tags -`vX.Y.Z`, builds `crates/template` as a TinyBus module for every supported +`vX.Y.Z`, builds `crates/tinysecurity-module` as a TinyBus module for every supported platform, pushes, and creates an immutable GitHub release with installable native packages. diff --git a/Cargo.lock b/Cargo.lock index 123aab5..3351d3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -467,26 +467,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "template" -version = "0.2.1" -dependencies = [ - "serde_json", - "template-bus", - "thiserror", - "tinybus", - "tinybus-module", - "tokio", -] - -[[package]] -name = "template-bus" -version = "0.2.1" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "thiserror" version = "2.0.20" @@ -547,6 +527,46 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinysecurity" +version = "0.2.1" +dependencies = [ + "serde_json", + "tinysecurity-bus", + "tinysecurity-policy", +] + +[[package]] +name = "tinysecurity-bus" +version = "0.2.1" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "tinysecurity-module" +version = "0.2.1" +dependencies = [ + "serde_json", + "tinybus", + "tinybus-module", + "tinysecurity", + "tinysecurity-bus", + "tokio", +] + +[[package]] +name = "tinysecurity-policy" +version = "0.2.1" +dependencies = [ + "serde_json", + "tempfile", + "thiserror", + "tinysecurity-bus", +] + [[package]] name = "tokio" version = "1.53.1" diff --git a/Cargo.toml b/Cargo.toml index fae3c38..a71d537 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ resolver = "3" # Every crate in this repository lives under `crates/`, one directory per # package, each directory named for the package it holds. There is no root -# package: the crate a host loads is `crates/template`, the same as any other +# package: the crate a host loads is `crates/tinysecurity-module`, the same as any other # member. Keeping the root virtual is what makes that uniform — a root package # would make one crate structurally different from the rest for no reason other # than history, and it is the arrangement this template moved away from. @@ -22,15 +22,18 @@ version = "0.2.1" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" -repository = "https://github.com/tinyhumansai/rust-template" +repository = "https://github.com/tinyhumansai/tinysecurity" [workspace.dependencies] -# The wire contract. `crates/template` depends on it and re-exports it, so a +# The wire contract. `crates/tinysecurity-module` depends on it and re-exports it, so a # host that only makes calls takes this crate alone. # No `version` requirement on purpose: the workspace version moves on every # release, and a pinned requirement here would stop resolving the moment it did. # Nothing in this workspace is published, so the path is the whole address. -template-bus = { path = "crates/template-bus" } +tinysecurity-bus = { path = "crates/tinysecurity-bus" } +# Policy implementation and internal umbrella, never linked by hosts. +tinysecurity-policy = { path = "crates/tinysecurity-policy" } +tinysecurity = { path = "crates/tinysecurity" } # TinyBus defines the message types, interface macro, and frozen module ABI # used by the generated integration. Socket and CLI features are unnecessary # here. @@ -41,7 +44,7 @@ tinybus = { path = "vendor/tinybus/crates/tinybus", version = "0.1.0", default-f # The module-side SDK owns the isolated runtime and exports the ABI entrypoints # required by TinyBus's dynamic loader. tinybus-module = { path = "vendor/tinybus/crates/tinybus-module", version = "0.1.0" } -# Derive macros for the crate-wide error type in `crates/template/src/error/`. +# Derive macros for the crate-wide error type in `crates/tinysecurity-module/src/error/`. # Every dependency entry should carry a comment like this one saying why it is # here. thiserror = "2" @@ -52,6 +55,10 @@ serde_json = "1" # Module integration tests exercise the real asynchronous in-memory TinyBus. tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +# Disposable filesystem fixtures for native path policy tests. +[workspace.dependencies.tempfile] +version = "3" + # Lints apply to every member that opts in with `[lints] workspace = true`, and # to every target of that member. CI runs clippy with `-D warnings`, so anything # set to "warn" here fails the build in CI. diff --git a/MODULE.md b/MODULE.md index 651906e..357e769 100644 --- a/MODULE.md +++ b/MODULE.md @@ -1,31 +1,93 @@ -# Template TinyBus Module +# Native security module -This package contains the native `template` module for TinyBus module ABI -v1. Install only the archive matching the host operating system and -architecture. +The artifact is `tinysecurity-module`, exporting TinyBus ABI v1. Its interface +is `ai.tinyhumans.tinysecurity.Policy`, with object +`/ai/tinyhumans/tinysecurity/Policy` and methods `Evaluate`, `Check`, `PolicyInfo`. +Accepted SDK reinit advances a generation and emits `PolicyChanged`; rejected +configurations retain the old policy and generation. There are no Init/Reinit +RPC methods. Configuration must include `contract_version`, `policy`, and +`judge` (`null` in this bootstrap). -The module claims `ai.tinyhumans.template.Greeting`, serves the object at -`/ai/tinyhumans/template/Greeting`, and provides the `Greet` method. The -method accepts a `GreetRequest` and returns a `GreetResponse` carrying -`Hello, !`; empty names are rejected. Both payload types, the interface -name, the object path, and the member names are published as the `template-bus` -crate, so a host names them from a library rather than by string literal. +```json +{ + "contract_version": [1, 0], + "policy": { + "enabled": false, + "action_dir": "", + "workspace_dir": "", + "allowed_commands": ["pwd", "date", "uname", "whoami"], + "forbidden_paths": [] + }, + "judge": null +} +``` + +Judge configuration has redacted Debug, travels only in SDK config, and is +rejected as unavailable rather than accepted without a functioning engine. +Invalid thresholds/timeouts are rejected before capability admission. +`PolicyInfo` never returns credentials. The wire version is separate from +release version. Hosts require matching major and module minor >= host minor; +unknown security input fields and enum tags reject rather than authorize. +SDK configuration states the host's required version: initialization and reinit +reject a required minor newer than this module supports, preserving the old +configuration and generation on rejected reinit. + +## Trust and supported behavior + +Caller agent, thread, flow, call ID, origin and access tier are verified host +facts. Only trusted host adapters may construct checks; model arguments never +supply authority. The original arguments travel as JSON text, keeping the +contract free of serde_json as a normal dependency. Supported bootstrap tool +identities are `local_diagnostic`, `path_check`, and `command_check`, each +requiring matching nonempty checks. Unknown tools deny even with claimed safe +checks. Check is an internal trusted-host operation, not a model-visible tool. + +Path checks resolve existing ancestors, including symlinks, and enforce the +credential/system/traversal floor on lexical and resolved forms even when +policy is disabled. Unknown native path dialects, broken symlinks and failed +resolution deny. Enabled action roots must resolve to existing directories; +workspace and configured forbidden roots deny before approval. New files can +be checked through their existing parent. Permission does not guarantee +confinement against a later concurrent filesystem mutation; execution must +retain its own safe-open/jail boundary. Full Windows alias/ADS/device handling +and rich shell grammar parity remain T2. Bootstrap commands allow only exact, +argument-free `pwd`, `date`, `uname`, `whoami` through a trusted host executable +search path. Compound commands, expansions, heredocs, arguments, executable +paths and unrecognized commands deny conservatively on every OS. + +Enabled writes deny with `approval_unavailable`; no unusable request IDs are +minted. Unknown tools/effects deny. Cron writes deny even with disabled policy. +Readonly callers cannot write. Decisions include generation and are uncached. +The host must reject stale generations and never retry/fallback after module +faults/timeouts. SDK faults remain latched; ordinary denials do not fault. + +## Reserved capabilities + +Approval lifecycle/flow/reply, redaction/scanning, privacy/URL guard, sandbox, +audit and host callbacks have typed versioned wire payloads and golden +fixtures. Their engines are not implemented and their member names are not +advertised. No network/DNS pinning, sandbox isolation, audit persistence, +approval expiry, judge verdict or keyring capability is claimed at bootstrap. +Unsupported member calls return the bus's unknown-member error. -The archive contains one `.so`, `.dylib`, or `.dll` plus `modules.toml`. Keep -those files together when copying them into a TinyBus module directory. The -allowlist binds the native library filename to its SHA-256 digest so TinyBus can -reject a missing, renamed, or modified artifact before initialization. +## Packaging and smoke checks -The GitHub release also publishes `checksum.toml` as a separate asset. TinyBus -checks that manifest before downloading and extracting the selected platform -archive. Install directly from a tagged release with: +CI builds/tests on Linux, macOS and Windows, checks the host contract dependency +tree and enforces per-file coverage. The release workflow retains eleven +native platform keys and produces platform archives, modules.toml and +checksum.toml. No artifact/digest is treated as production-published merely +because a local library builds. ```sh -tinybus modules load-github \ - https://github.com/tinyhumansai/rust-template/releases/tag/v0.1.5 \ - template-0.1.5-ubuntu-24.04-x86_64.tar.gz \ - +cargo run -p tinysecurity-module --example verify_module -- +cargo run -p tinysecurity-module --example verify_module -- ``` -TinyBus modules are trusted in-process code. Install release artifacts only -from a trusted source and restart the host after replacing a loaded module. +The same verifier is used for local and downloaded release artifacts. Local +verification also rejects a wrong digest and tampered bytes before activation, +rejects missing SDK configuration, and confirms stopped modules refuse reinit. +Fault-injection and timeout latching remain SDK conformance work, not a +bootstrap claim. Timing +uses 500 sequential calls per method, one Evaluate call for one diagnostic or +one Check call for two checks per sample. See measured results and migration +budgets in [performance](docs/performance.md). diff --git a/README.md b/README.md index 67a4e39..bfc08c4 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,48 @@ -# Rust Template - -A production-ready Rust 2024 TinyBus module template used by TinyHumans AI. It -ships the workspace layout, TinyBus ABI adapter, error handling, testing, -documentation, CI, and multi-platform release workflow that every new -integration in this organization starts from. - -It is a two-crate cargo workspace. `crates/template-bus` is the wire contract — -member names, payload types, and the contract version, with no transport and no -behavior — and `crates/template` is the implementation, built as both an `rlib` -and the `cdylib` TinyBus loads. A host that only makes calls depends on the -contract crate alone and compiles neither the module nor `tinybus` itself. - -## Use This Template - -Choose **Use this template** on GitHub, create a repository, then work through -the checklist at the top of [`AGENTS.md`](AGENTS.md): - -- rename the `crates/template` and `crates/template-bus` directories and the - `name` fields in their manifests, and set the shared `description`, - `repository`, `keywords`, and `categories`; -- update this README and the crate documentation in `crates/template/src/lib.rs`; -- replace the placeholder `greeting` module with the first real feature area, in - both crates: the payload types in the contract, the behavior in the module; -- rename the TinyBus interface, object path, and member constants in - `crates/template-bus/src/names/`, and the matching `provides` / `methods` - declarations in `crates/template/src/tinybus_module/`; -- update the security contact and repository links in the community files; -- replace `ROADMAP.md` with the real plan, or delete it; -- change the license if GPL-3.0-only is not appropriate. - -Search for `template` and `template_bus` to find every remaining -template-specific value. - -## What You Get - -| Area | What is configured | +# TinySecurity + +TinySecurity supplies security policy through a native TinyBus module. Hosts +compile the transport-free `tinysecurity-bus` contract and load the isolated +`tinysecurity-module` artifact. The bootstrap implements `Evaluate`, batched +`Check`, `PolicyInfo`, validated SDK configuration and policy generations. + +The engine denies unknown tools and unsupported effects. Argument-free `pwd`, +`date`, `uname`, and `whoami` are its supported shell diagnostics. Paths pass +an unconditional credential/system/traversal floor and native ancestor +resolution; enabled policy adds action-root, workspace and forbidden-root +checks. Enabled writes deny until the approval engine exists. Pure local +diagnostics work with disabled autonomy, which is the default. + +This is bootstrap infrastructure for [OpenHuman #7328](https://github.com/tinyhumansai/openhuman/issues/7328) +and [TinySecurity #1](https://github.com/tinyhumansai/tinysecurity/issues/1). +It does **not** complete the migration. Approval persistence, redaction, +network connection pinning, sandbox planning, audit, judge and crypto engines +remain unavailable. Reserved wire types do not advertise callable handlers. +There is no production host migration until an upstream release and checksum +manifest exist. + +## Workspace + +| Crate | Role | | --- | --- | -| Layout | A cargo workspace under `crates/`, split into a dependency-light wire contract and the module that implements it; directory modules with `mod.rs` / `types.rs` / `test.rs`, a crate-wide error type, integration tests, and a runnable example | -| Lints | `unsafe_code` forbidden, `missing_docs`, clippy `all` + `pedantic`, no `unwrap`/`expect`/`panic`/`todo` in library code — all declared once in `[workspace.lints]` so every crate, local run, and CI run agree | -| CI | Format, clippy, build, test (default and all features), a run of the bundled example, an assertion that the contract crate stays transport-free, at least 90% line coverage in every source file, rustdoc with `-D warnings`, an MSRV build, and a `cargo-deny` supply-chain check | -| Release | Manual `workflow_dispatch` bump that validates, versions, tags, and creates installable native module packages for every supported platform | -| Community | Issue and pull request templates, Dependabot, contributing, security, support, and code of conduct docs | -| Agents | [`AGENTS.md`](AGENTS.md) as the single source of truth, symlinked as `CLAUDE.md`, plus a `.claude/settings.json` allowlist for the standard commands | -| Vendor | TinyBus host types and module SDK pinned as the `vendor/tinybus` build-time submodule | - -## Layout - -```text -Cargo.toml # virtual workspace: members, shared metadata, lints -crates/ -├── template-bus/ # the wire contract — what crosses the bus -│ ├── README.md # why the contract is its own crate -│ └── src/ -│ ├── lib.rs # crate docs + the entire public re-export surface -│ ├── names/ # interface, object path, one constant per member -│ ├── greeting/ # payload types, one directory per family -│ │ ├── mod.rs -│ │ ├── types.rs -│ │ └── test.rs -│ └── version/ # contract version and the host bind rule -└── template/ # the module — behavior, adapter, and the cdylib - ├── src/ - │ ├── lib.rs # crate docs + public surface, re-exporting the contract - │ ├── error/ # crate-wide `Error` and `Result` - │ ├── greeting/ # one directory per feature area - │ └── tinybus_module/ # bus interface, setup, and ABI v1 exports - ├── tests/ - │ └── public_api.rs # integration tests against the public API only - └── examples/ - ├── basic.rs # ordinary library API usage - ├── verify_module.rs # local dynamic-module verification - └── verify_github_release.rs # tagged-release download and bus call -vendor/ -└── tinybus/ # pinned TinyBus git submodule -docs/ -├── README.md # documentation index and conventions -├── specs/ # behavior and architecture specifications -├── plans/ # implementation-ordered delivery plans -└── adr/ # immutable architecture decision records -``` - -The split is the point. A payload type describes what a frame carries; the -behavior that answers it is a different obligation. `template` depends on -`template-bus` and re-exports all of it, so `template::GreetRequest` and -`template_bus::GreetRequest` are the *same* type rather than structural twins, -and a host is never forced to choose between linking the whole module and -redefining the vocabulary. See -[`crates/template-bus/README.md`](crates/template-bus/README.md). - -Within each crate, feature areas use directory modules: implementation and -exports live in `mod.rs`, substantial types move to `types.rs`, and unit tests -live in `test.rs`. [`AGENTS.md`](AGENTS.md) holds the complete repository -guidance, and `CLAUDE.md` is a symlink to it so every coding agent reads one -source of truth. +| `tinysecurity-bus` | Wire types, errors, names and compatibility; normal dependencies only serde and thiserror | +| `tinysecurity-policy` | Native conservative policy engine | +| `tinysecurity` | Internal implementation re-export | +| `tinysecurity-module` | SDK initialization, bus dispatch and ABI cdylib | -## Development - -Clone with submodules, or initialize them before building: - -```sh -git submodule update --init --recursive -``` +## Validation ```sh cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features cargo test --all-features -cargo run -p template --example basic -cargo build -p template --release --lib # produces the installable cdylib -``` - -Those four checks are exactly what CI runs. Optional extras: - -```sh -cargo doc --no-deps --all-features # CI builds this with RUSTDOCFLAGS="-D warnings" -cargo deny check all # supply-chain check; see deny.toml -cargo install cargo-llvm-cov # once, before running the coverage gate -.github/scripts/check-file-coverage.sh 90 coverage.json +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features +cargo run -p tinysecurity-module --example verify_module -- target/debug/libtinysecurity_module.so ``` -## Releasing - -Run the **Release** workflow from the Actions tab with a `patch`, `minor`, or -`major` bump. Use `current` only to resume an interrupted release whose version -commit and tag already exist. The workflow revalidates the workspace, versions -and tags it — one `[workspace.package]` version that every member inherits — -builds `crates/template` as a TinyBus `cdylib`, and creates a GitHub release. -Assets follow `template--.` and contain the -native module, its SHA-256 `modules.toml`, license, and -[`MODULE.md`](MODULE.md). Every release also publishes `checksum.toml`, which -TinyBus uses to verify an archive before extraction. The workflow loads the -published Ubuntu archive through TinyBus's GitHub release API and calls its -`Greet` method before declaring the release successful. TinyBus itself is not -shipped by this repository; the pinned submodule is the build-time SDK. The stable native -matrix covers Ubuntu 22.04 and 24.04 on x86_64 and ARM64; Fedora 43 and 44 on -x86_64 and ARM64; rolling Arch Linux on its officially supported x86_64 -architecture; macOS 15 and 26 on Intel and Apple Silicon; Windows Server 2022 -and 2025 on x86_64; and Windows 11 on ARM64. Preview, deprecated, and unofficial -architecture images are not release gates. Do not hand-edit the version in the -root `Cargo.toml`. - -## Documentation - -- [`AGENTS.md`](AGENTS.md) — repository guidelines for humans and agents -- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to propose a change -- [`docs/specs/`](docs/specs/README.md) — behavior and architecture specs -- [`docs/plans/`](docs/plans/README.md) — test-first implementation plans -- [`docs/adr/`](docs/adr/0001-record-architecture-decisions.md) — architecture - decision records -- [`SECURITY.md`](SECURITY.md) — how to report a vulnerability - -## License +Use `.dylib` on macOS and `tinysecurity_module.dll` on Windows. The loader +example proves allow/deny, malformed/unsupported invocation rejection, +reconfiguration retention and generation, then prints real bus latency. -GPL-3.0-only. See [LICENSE](LICENSE). +See [module boundary](MODULE.md), [accepted specification](docs/specs/security-module.md), +[implementation sequence](docs/plans/security-module.md), and [roadmap](ROADMAP.md). diff --git a/ROADMAP.md b/ROADMAP.md index 1134024..a5456cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,26 +1,13 @@ -# Roadmap +# TinySecurity roadmap -Replace this file with the real plan for the crate generated from this -template, or delete it if the project does not need a public roadmap. +The bootstrap implements conservative policy evaluation, ordered checks, +versioned contracts, SDK init/reinit and native loading. It intentionally does +not close the security extraction tracker. -Keep it short and honest: what exists, what is next, and what is deliberately -out of scope. A roadmap that lists everything is a roadmap nobody trusts. +The [implementation plan](docs/plans/security-module.md) orders the remaining +work: full host policy/command parity, unified redaction, connection-pinned +egress, TinyBox sandbox planning, durable audit/approvals, optional Jev, +native conformance and release gates, reviewed policy expansion, crypto last. -## Shipped - -- module layout, crate-wide error type, and the public re-export surface -- lint configuration in `[lints]`, enforced identically locally and in CI -- CI: format, clippy, build, test, per-file coverage, rustdoc, MSRV, and - supply-chain checks -- a manual release workflow that versions, tags, publishes to crates.io, and - creates a GitHub release with crate and TinyBus runtime/module assets - -## Next - -- the first real feature area, replacing the placeholder `greeting` module -- module-level `README.md` and `docs/spec/` entries as modules grow - -## Out Of Scope - -- anything that cannot be tested deterministically -- convenience wrappers that hide the crate's error taxonomy from callers +Production OpenHuman callers move only after upstream capabilities are merged, +released and validated with published checksums and native latency budgets. diff --git a/crates/template-bus/Cargo.toml b/crates/template-bus/Cargo.toml deleted file mode 100644 index a30dd85..0000000 --- a/crates/template-bus/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "template-bus" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "The TinyBus wire contract for the template module: member names, payload types, and the contract version." -documentation = "https://docs.rs/template-bus" -readme = "README.md" -keywords = ["tinybus", "module", "contract", "template"] -categories = ["development-tools"] -publish = false - -# Deliberately dependency-light: this is the crate a host links to talk to the -# loadable module, so it must cost that host almost nothing. Nothing here may -# pull in `tinybus`, an async runtime, an HTTP client, or a native library — -# see `src/lib.rs` for why the transport in particular is absent. CI asserts it. -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } - -[lints] -workspace = true diff --git a/crates/template-bus/README.md b/crates/template-bus/README.md deleted file mode 100644 index 7f8e99c..0000000 --- a/crates/template-bus/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# template-bus - -Every type that crosses the template module's `TinyBus` boundary, and the names -of the members that carry them. - -The template ships as a loadable module so a host does not compile the -implementation: `crates/template` is built as a `cdylib` and exports one object. -A host can load that binary but cannot `use` anything out of it, so the payload -vocabulary has to be published as an ordinary library. This is it. - -| module | what it holds | -| ---------- | ------------------------------------------------------------ | -| `names` | interface name, object path, one constant per member | -| `greeting` | the value vocabulary: the `Greet` request and response | -| `version` | `CONTRACT_VERSION` and the bind rule a host applies to it | - -Two dependencies, both pure Rust: `serde` and `serde_json`. - -## This crate sits underneath `template` - -`template` **depends on this crate and re-exports all of it**. That direction -matters, and it is the opposite of the obvious one. - -A *host* needs the payload types and needs nothing else: it loads the module and -makes calls, so it names `GreetRequest` and `GreetResponse` but implements no -behavior and links no transport. Making it depend on the whole module crate — and -through it on `tinybus`, `tokio`, and the module SDK — to spell a payload type -would be the wrong shape. - -The alternative, a parallel set of payload types for hosts, is worse: a -`GreetRequest` defined twice is two distinct types, with a conversion at every -call site that nothing checks. One definition, here, at the bottom. - -Because the re-export is by module as well as by item, `template::GreetRequest`, -`template::names::OBJECT_PATH`, and `template_bus::greeting::GreetRequest` all -resolve to the same items, not twins. - -So: a module author depends on `template` and gets behavior and vocabulary. A -host depends on `template-bus` and gets vocabulary alone. - -## What is deliberately absent - -**No behavior.** `greet` lives in `crates/template`. A payload type describes -what a frame carries, not what the module does with it. The split is readable -off the path: a name here is data, a name there is an obligation. - -**No transport.** This crate does not depend on `tinybus` and holds no -connection, client, or codec. A host already owns its connection — its reconnect -policy, its timeouts, its tracing — and the useful part is the vocabulary. - -That is also structural, not just preference: `tinybus` is vendored as a -submodule whose manifest inherits fields from its own nested -`[workspace.package]`. Keeping the contract crate transport-free is what keeps -it down to two dependencies and what lets anything in the workspace — or outside -it — depend on it freely. CI asserts the dependency tree stays that way. - -## Making a call - -Arguments travel as a positional JSON array — `#[tinybus::interface]` decodes -them into a tuple — and the member name comes from `names`: - -```rust,ignore -use template_bus::{names, GreetRequest, GreetResponse}; - -let proxy = connection.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; -let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("Ferris"),)) - .await?; -assert_eq!(reply.greeting, "Hello, Ferris!"); -``` - -Nothing above is a string literal at a call site. Renaming the interface, the -path, or a member is therefore a compile error in every consumer rather than an -`UnknownMethod` discovered at runtime. - -## Staying in step with the module - -`names::METHODS` lists every member in dispatch order. `crates/template` asserts -its served members against that list, so a method added to the interface without -an entry here fails that crate's tests rather than surfacing in a host. - -## Versioning - -`CONTRACT_VERSION` describes *this vocabulary*, not the package. Bump its major -component when a payload's wire form changes incompatibly or a member is removed -or renamed, and its minor component when a member or an optional field is added. -It is deliberately independent of the package version the release workflow owns, -which tracks the shipped artifact. - -The payload tests pin the serde representation, because that representation is -the wire form: a host and a module that disagree about a field name fail at -runtime with a decode error, so the shape is asserted rather than assumed. - -## Generating a project from the template - -Rename the interface, the object path, and the member constants in `names` -together, replace `greeting` with the first real payload family, and reset -`CONTRACT_VERSION` to `(1, 0)` for the new contract. Keep the crate -dependency-light: the moment it links a transport or a runtime, the reason it -exists is gone. diff --git a/crates/template-bus/src/greeting/mod.rs b/crates/template-bus/src/greeting/mod.rs deleted file mode 100644 index f810aab..0000000 --- a/crates/template-bus/src/greeting/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! The payloads the `Greet` member exchanges. -//! -//! A module root like this one documents the module, wires its pieces together, -//! and exposes the smallest useful API. The type definitions live in the -//! sibling `types.rs`, and the unit tests in `test.rs`, wired in at the bottom -//! of this file. -//! -//! Replace this module with the first real payload family the module carries. -//! Payload types are `serde`-derived, `#[non_exhaustive]`, and hold owned data: -//! they are decoded from a frame, so they can borrow nothing from the caller. - -mod types; - -pub use types::{GreetRequest, GreetResponse}; - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/greeting/test.rs b/crates/template-bus/src/greeting/test.rs deleted file mode 100644 index 1a30000..0000000 --- a/crates/template-bus/src/greeting/test.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Unit tests for the `Greet` payloads. -//! -//! These pin the serde representation. It is the wire form: a host and a module -//! that disagree about a field name fail at runtime with a decode error, so the -//! shape is asserted here rather than assumed. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{GreetRequest, GreetResponse}; - -#[test] -fn a_request_serializes_to_its_wire_form() { - let encoded = serde_json::to_value(GreetRequest::new("Ferris")).unwrap(); - assert_eq!(encoded, serde_json::json!({ "name": "Ferris" })); -} - -#[test] -fn a_response_serializes_to_its_wire_form() { - let encoded = serde_json::to_value(GreetResponse::new("Hello, Ferris!")).unwrap(); - assert_eq!(encoded, serde_json::json!({ "greeting": "Hello, Ferris!" })); -} - -#[test] -fn a_request_round_trips_through_json() { - let request = GreetRequest::new(" Ferris "); - let encoded = serde_json::to_string(&request).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - request - ); -} - -#[test] -fn a_response_round_trips_through_json() { - let response = GreetResponse::new("Hello, Ferris!"); - let encoded = serde_json::to_string(&response).unwrap(); - assert_eq!( - serde_json::from_str::(&encoded).unwrap(), - response - ); -} - -#[test] -fn a_request_missing_its_name_is_rejected() { - let decoded = serde_json::from_value::(serde_json::json!({})); - assert!(decoded.is_err()); -} - -#[test] -fn a_response_missing_its_greeting_is_rejected() { - let decoded = serde_json::from_value::(serde_json::json!({})); - assert!(decoded.is_err()); -} - -#[test] -fn constructors_accept_both_borrowed_and_owned_names() { - assert_eq!( - GreetRequest::new(String::from("Ferris")), - GreetRequest::new("Ferris") - ); - assert_eq!( - GreetResponse::new(String::from("Hi")), - GreetResponse::new("Hi") - ); -} diff --git a/crates/template-bus/src/greeting/types.rs b/crates/template-bus/src/greeting/types.rs deleted file mode 100644 index d70b376..0000000 --- a/crates/template-bus/src/greeting/types.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Request and response types for the `Greet` member. - -use serde::{Deserialize, Serialize}; - -/// The argument to [`crate::names::methods::GREET`]. -/// -/// The module trims surrounding whitespace from [`GreetRequest::name`] and -/// rejects a name that is empty once trimmed. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct GreetRequest { - /// The name to greet. - pub name: String, -} - -impl GreetRequest { - /// Builds a request greeting `name`. - /// - /// # Examples - /// - /// ``` - /// # use template_bus::GreetRequest; - /// assert_eq!(GreetRequest::new("Ferris").name, "Ferris"); - /// ``` - #[must_use] - pub fn new(name: impl Into) -> Self { - Self { name: name.into() } - } -} - -/// The reply from [`crate::names::methods::GREET`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct GreetResponse { - /// The rendered greeting. - pub greeting: String, -} - -impl GreetResponse { - /// Builds a reply carrying `greeting`. - /// - /// # Examples - /// - /// ``` - /// # use template_bus::GreetResponse; - /// assert_eq!(GreetResponse::new("Hello, Ferris!").greeting, "Hello, Ferris!"); - /// ``` - #[must_use] - pub fn new(greeting: impl Into) -> Self { - Self { - greeting: greeting.into(), - } - } -} diff --git a/crates/template-bus/src/lib.rs b/crates/template-bus/src/lib.rs deleted file mode 100644 index a1857d1..0000000 --- a/crates/template-bus/src/lib.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Every type that crosses the template module's `TinyBus` boundary, and the -//! names of the members that carry them. -//! -//! This crate ships as a loadable `TinyBus` module: `crates/template` is built -//! as a `cdylib` and exports one object. A host that loads that binary can call -//! into it but cannot `use` anything out of it, so the payload vocabulary has -//! to be published as an ordinary library. This is that library. -//! -//! # What is here -//! -//! - [`names`] — the interface name, the object path, and one constant per -//! member, plus [`names::METHODS`] listing them in dispatch order. -//! - [`greeting`] — the value vocabulary: the request and response payloads the -//! `Greet` member exchanges. -//! - [`version`] — [`CONTRACT_VERSION`] and the [`is_compatible`] bind rule. -//! -//! # What is deliberately not here -//! -//! **No behavior.** The `greet` implementation lives in `crates/template`, -//! which depends on this crate and re-exports it. A payload type describes what -//! a frame carries, not what the module does with it. -//! -//! **No transport.** This crate does not depend on `tinybus` and holds no -//! connection, client, or codec. A host already owns its connection — its -//! reconnect policy, its timeouts, its tracing — and the useful part is the -//! vocabulary, not another wrapper around it. -//! -//! That is also a structural necessity, not only a preference: `tinybus` is -//! vendored as a submodule whose manifest inherits fields from its own nested -//! `[workspace.package]`. A crate that every workspace member can depend on has -//! to stay transport-free, and staying transport-free is what keeps this crate -//! down to two pure-Rust dependencies. -//! -//! # This crate sits underneath the implementation, not beside it -//! -//! `template` **depends on this crate and re-exports all of it**, so -//! `template::GreetRequest` and `template_bus::greeting::GreetRequest` are the -//! *same type*, not structural twins. Defining a parallel set of payload types -//! for hosts would mean a conversion at every call site that nothing checks. -//! One definition, here, at the bottom. -//! -//! So: a module author depends on `template` and gets behavior and vocabulary. -//! A host depends on `template-bus` and gets vocabulary alone. -//! -//! # Staying in step with the module -//! -//! [`names::METHODS`] lists every member. `crates/template` asserts its served -//! members against that list, in order, so a method added to the interface -//! without an entry here fails that crate's tests rather than surfacing as an -//! unknown method in a host at runtime. -//! -//! # Example -//! -//! ``` -//! use template_bus::{names, GreetRequest, GreetResponse}; -//! -//! let body = serde_json::to_value([GreetRequest::new("Ferris")])?; -//! assert_eq!(names::methods::GREET, "Greet"); -//! assert_eq!(names::OBJECT_PATH, "/ai/tinyhumans/template/Greeting"); -//! -//! let reply: GreetResponse = serde_json::from_value( -//! serde_json::json!({ "greeting": "Hello, Ferris!" }), -//! )?; -//! assert_eq!(reply.greeting, "Hello, Ferris!"); -//! # Ok::<(), serde_json::Error>(()) -//! ``` - -pub mod greeting; -pub mod names; -pub mod version; - -pub use greeting::{GreetRequest, GreetResponse}; -pub use names::{INTERFACE, METHODS, OBJECT_PATH}; -pub use version::{CONTRACT_VERSION, is_compatible}; diff --git a/crates/template-bus/src/names/mod.rs b/crates/template-bus/src/names/mod.rs deleted file mode 100644 index 4da1547..0000000 --- a/crates/template-bus/src/names/mod.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! The bus identity of the template module: interface name, object path, and -//! one constant per member. -//! -//! Nothing here is a string literal at a call site. A host names a member -//! through [`methods`] and the object through [`OBJECT_PATH`], so a rename is a -//! compile error in every consumer rather than a runtime "unknown method". -//! -//! When generating a project from this template, rename all three together — -//! the interface, the path, and the member constants — and keep -//! [`METHODS`] in the same order as the interface's dispatch table. - -/// The well-known interface name the module claims on the bus. -pub const INTERFACE: &str = "ai.tinyhumans.template.Greeting"; - -/// The object path the module serves its interface at. -pub const OBJECT_PATH: &str = "/ai/tinyhumans/template/Greeting"; - -/// One constant per member of [`INTERFACE`]. -pub mod methods { - /// Builds a greeting for a name. - /// - /// Takes a [`crate::GreetRequest`] and returns a [`crate::GreetResponse`]. - pub const GREET: &str = "Greet"; -} - -/// Every member of [`INTERFACE`], in the order the interface dispatches them. -/// -/// `crates/template` asserts its declared manifest methods against this list, -/// so the two cannot drift. -pub const METHODS: &[&str] = &[methods::GREET]; - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/names/test.rs b/crates/template-bus/src/names/test.rs deleted file mode 100644 index bf7bea2..0000000 --- a/crates/template-bus/src/names/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the bus name table. - -use super::{INTERFACE, METHODS, OBJECT_PATH, methods}; - -#[test] -fn the_object_path_is_the_interface_in_path_form() { - let expected = format!("/{}", INTERFACE.replace('.', "/")); - assert_eq!(OBJECT_PATH, expected); -} - -#[test] -fn every_member_is_listed_exactly_once() { - let mut sorted = METHODS.to_vec(); - sorted.sort_unstable(); - let mut deduplicated = sorted.clone(); - deduplicated.dedup(); - assert_eq!(sorted, deduplicated); -} - -#[test] -fn the_method_table_holds_the_declared_members() { - assert_eq!(METHODS, [methods::GREET]); -} - -#[test] -fn no_member_name_is_empty() { - assert!(METHODS.iter().all(|method| !method.is_empty())); -} diff --git a/crates/template-bus/src/version/mod.rs b/crates/template-bus/src/version/mod.rs deleted file mode 100644 index ada372d..0000000 --- a/crates/template-bus/src/version/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! The contract version, and the rule a host uses to decide whether it can bind -//! to a module that reports one. -//! -//! The version describes *this vocabulary*, not the crate: bump the major -//! component when a payload's wire form changes incompatibly or a member is -//! removed or renamed, and the minor component when a member or an optional -//! field is added. It is deliberately independent of the package version the -//! release workflow bumps, which tracks the shipped artifact. - -/// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 0); - -/// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module -/// reporting `module`. -/// -/// Compatibility is the ordinary semantic-version rule for a pre-release-free -/// contract: the majors must match, and the module must be at least as new as -/// the host, because a host cannot call a member a module does not serve. -/// -/// # Examples -/// -/// ``` -/// # use template_bus::{is_compatible, CONTRACT_VERSION}; -/// assert!(is_compatible(CONTRACT_VERSION)); -/// assert!(is_compatible((1, 4))); -/// assert!(!is_compatible((2, 0))); -/// ``` -#[must_use] -pub fn is_compatible(module: (u32, u32)) -> bool { - binds(CONTRACT_VERSION, module) -} - -/// The bind rule with the host version supplied explicitly. -/// -/// [`is_compatible`] is this function applied to [`CONTRACT_VERSION`]. It is -/// split out so the unit tests can exercise both directions of the comparison -/// without pinning them to whatever the shipped version happens to be. -fn binds(host: (u32, u32), module: (u32, u32)) -> bool { - let (host_major, host_minor) = host; - let (module_major, module_minor) = module; - - module_major == host_major && module_minor >= host_minor -} - -#[cfg(test)] -mod test; diff --git a/crates/template-bus/src/version/test.rs b/crates/template-bus/src/version/test.rs deleted file mode 100644 index 3fd3edf..0000000 --- a/crates/template-bus/src/version/test.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Unit tests for the contract version and its bind rule. - -use super::{CONTRACT_VERSION, binds, is_compatible}; - -#[test] -fn the_shipped_contract_version_is_pinned() { - assert_eq!(CONTRACT_VERSION, (1, 0)); -} - -#[test] -fn the_contract_binds_to_itself() { - assert!(is_compatible(CONTRACT_VERSION)); -} - -#[test] -fn a_newer_minor_on_the_module_side_binds() { - assert!(is_compatible((1, 1))); - assert!(is_compatible((1, 97))); -} - -#[test] -fn an_older_minor_on_the_module_side_is_rejected() { - // A host built against 1.4 cannot call a 1.2 module: the members it names - // may not be served. - assert!(!binds((1, 4), (1, 2))); - assert!(binds((1, 4), (1, 4))); -} - -#[test] -fn a_different_major_is_rejected() { - assert!(!is_compatible((0, 0))); - assert!(!is_compatible((2, 0))); - assert!(!is_compatible((2, 97))); -} diff --git a/crates/template/Cargo.toml b/crates/template/Cargo.toml deleted file mode 100644 index e1bcdf4..0000000 --- a/crates/template/Cargo.toml +++ /dev/null @@ -1,39 +0,0 @@ -[package] -name = "template" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "A production-ready template for installable TinyBus modules." -documentation = "https://docs.rs/template" -readme = "../../README.md" -keywords = ["tinybus", "module", "plugin", "template"] -categories = ["development-tools"] -publish = false - -[lib] -# Keep the ordinary Rust library for tests and downstream reuse while also -# producing the native module artifact that TinyBus loads at runtime. -crate-type = ["rlib", "cdylib"] - -[dependencies] -# The wire contract: member names, payload types, and the contract version. -# Re-exported wholesale from `src/lib.rs` so a consumer takes one dependency -# rather than two, and so `template::GreetRequest` and -# `template_bus::GreetRequest` are the same type. -template-bus = { workspace = true } -tinybus = { workspace = true } -tinybus-module = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true } -# The GitHub release verifier passes an explicit empty module configuration. -serde_json = { workspace = true } - -[features] -default = [] - -[lints] -workspace = true diff --git a/crates/template/examples/basic.rs b/crates/template/examples/basic.rs deleted file mode 100644 index 99233ec..0000000 --- a/crates/template/examples/basic.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Minimal end-to-end usage of the crate. -//! -//! Examples are compiled and linted in CI, so they cannot drift from the API. -//! Run it with: -//! -//! ```sh -//! cargo run --example basic -//! ``` - -use template::{Result, greet}; - -fn main() -> Result<()> { - println!("{}", greet("Rust")?); - - // Failure modes are part of the public contract; show them too. - match greet(" ") { - Ok(greeting) => println!("{greeting}"), - Err(error) => println!("expected failure: {error}"), - } - - Ok(()) -} diff --git a/crates/template/examples/verify_github_release.rs b/crates/template/examples/verify_github_release.rs deleted file mode 100644 index 9b173fe..0000000 --- a/crates/template/examples/verify_github_release.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Downloads a tagged release asset and calls the loaded `TinyBus` module. -//! -//! Run it with the release tag URL, platform archive, and archive SHA-256: -//! -//! ```text -//! cargo run --example verify_github_release -- \ -//! https://github.com/tinyhumansai/template/releases/tag/v0.1.4 \ -//! template-0.1.4-ubuntu-24.04-x86_64.tar.gz \ -//! -//! ``` - -use std::io; -use std::time::Duration; - -use template::{GreetRequest, GreetResponse, names}; -use tinybus::Connection; -use tinybus::broker::Broker; -use tinybus::module::ModuleHost; -use tinybus::transport::memory::MemoryBus; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let (release_url, archive, sha256) = arguments()?; - let bus = MemoryBus::new(); - let broker = Broker::new(); - let broker_task = broker.spawn(bus.clone()); - let module_host = ModuleHost::new(broker); - let info = module_host.load_github_release( - &release_url, - &archive, - Some(&sha256), - serde_json::Value::default(), - )?; - - if info.name != env!("CARGO_PKG_NAME") { - return Err(io::Error::other(format!( - "loaded module `{}` instead of `{}`", - info.name, - env!("CARGO_PKG_NAME") - )) - .into()); - } - - let client = Connection::connect(bus.connect().await?).await?; - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let claimed = client.list_names().await?; - if claimed.iter().any(|name| name.as_str() == names::INTERFACE) { - return tinybus::Result::Ok(()); - } - tokio::task::yield_now().await; - } - }) - .await??; - - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("TinyBus"),)) - .await?; - if reply.greeting != "Hello, TinyBus!" { - return Err(io::Error::other(format!( - "module returned an unexpected greeting: {}", - reply.greeting - )) - .into()); - } - - println!( - "verified {archive} from {release_url} as TinyBus module `{}`", - info.name - ); - broker_task.abort(); - Ok(()) -} - -fn arguments() -> Result<(String, String, String), io::Error> { - let mut args = std::env::args().skip(1); - let usage = "usage: cargo run --example verify_github_release -- \ - "; - let release_url = args - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, usage))?; - let archive = args - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, usage))?; - let sha256 = args - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, usage))?; - if args.next().is_some() { - return Err(io::Error::new(io::ErrorKind::InvalidInput, usage)); - } - Ok((release_url, archive, sha256)) -} diff --git a/crates/template/examples/verify_module.rs b/crates/template/examples/verify_module.rs deleted file mode 100644 index 6e3856e..0000000 --- a/crates/template/examples/verify_module.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Loads a built module through the real `TinyBus` dynamic loader. - -use std::io; -use std::path::PathBuf; -use std::time::Duration; - -use template::{GreetRequest, GreetResponse, names}; -use tinybus::Connection; -use tinybus::broker::Broker; -use tinybus::module::ModuleHost; -use tinybus::transport::memory::MemoryBus; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let module = module_argument()?; - let bus = MemoryBus::new(); - let broker = Broker::new(); - let broker_task = broker.spawn(bus.clone()); - let module_host = ModuleHost::new(broker); - let info = module_host.load_file(&module)?; - - if info.name != env!("CARGO_PKG_NAME") { - return Err(io::Error::other(format!( - "loaded module `{}` instead of `{}`", - info.name, - env!("CARGO_PKG_NAME") - )) - .into()); - } - - let client = Connection::connect(bus.connect().await?).await?; - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let claimed = client.list_names().await?; - if claimed.iter().any(|name| name.as_str() == names::INTERFACE) { - return tinybus::Result::Ok(()); - } - tokio::task::yield_now().await; - } - }) - .await??; - - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("TinyBus"),)) - .await?; - if reply.greeting != "Hello, TinyBus!" { - return Err(io::Error::other(format!( - "module returned an unexpected greeting: {}", - reply.greeting - )) - .into()); - } - - println!( - "verified {} as TinyBus module `{}`", - module.display(), - info.name - ); - broker_task.abort(); - Ok(()) -} - -fn module_argument() -> Result { - std::env::args_os() - .nth(1) - .map(PathBuf::from) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "usage: cargo run --example verify_module -- ", - ) - }) -} diff --git a/crates/template/src/error/mod.rs b/crates/template/src/error/mod.rs deleted file mode 100644 index b8ddbe0..0000000 --- a/crates/template/src/error/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Crate-wide error and result types. -//! -//! Every fallible public function in this crate returns [`Result`], and every -//! failure mode is a distinct [`Error`] variant. Add a variant rather than -//! encoding new context into an existing message: callers match on variants, -//! and message text is not a stable API. -//! -//! Variants carry the data a caller needs to react, keep their `#[error]` -//! message lowercase and free of trailing punctuation, and are documented so -//! the rendered rustdoc explains when each one occurs. - -/// Errors returned by this crate. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum Error { - /// A required name was empty or contained only whitespace. - #[error("name must not be empty")] - EmptyName, -} - -/// The crate's standard result type. -/// -/// Use this alias in public signatures instead of spelling out -/// `std::result::Result`. -pub type Result = std::result::Result; - -#[cfg(test)] -mod test; diff --git a/crates/template/src/error/test.rs b/crates/template/src/error/test.rs deleted file mode 100644 index 4c5d609..0000000 --- a/crates/template/src/error/test.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Unit tests for the crate-wide error type. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn renders_a_human_readable_message() { - assert_eq!(Error::EmptyName.to_string(), "name must not be empty"); -} - -#[test] -fn is_a_standard_error() { - fn assert_error(_: &E) {} - - assert_error(&Error::EmptyName); -} diff --git a/crates/template/src/greeting/mod.rs b/crates/template/src/greeting/mod.rs deleted file mode 100644 index 862fa21..0000000 --- a/crates/template/src/greeting/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Greeting behavior used to demonstrate the template's module layout. -//! -//! A module root like this one documents the module, wires its pieces -//! together, and exposes the smallest useful API. Substantial type definitions -//! belong in a sibling `types.rs`, and unit tests belong in `test.rs`, wired in -//! at the bottom of this file. -//! -//! Replace this module with the crate's first real feature area. - -use crate::{Error, Result}; - -/// Returns a friendly greeting for `name`. -/// -/// Surrounding whitespace is trimmed before the greeting is built. -/// -/// # Examples -/// -/// ``` -/// # use template::greet; -/// assert_eq!(greet(" Ferris ")?, "Hello, Ferris!"); -/// # Ok::<(), template::Error>(()) -/// ``` -/// -/// # Errors -/// -/// Returns [`Error::EmptyName`] when `name` is empty or contains only -/// whitespace. -pub fn greet(name: &str) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err(Error::EmptyName); - } - - Ok(format!("Hello, {name}!")) -} - -#[cfg(test)] -mod test; diff --git a/crates/template/src/greeting/test.rs b/crates/template/src/greeting/test.rs deleted file mode 100644 index de04ef4..0000000 --- a/crates/template/src/greeting/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the greeting module. -//! -//! Unit tests live next to the code they cover and may reach into private -//! items. Tests of the public contract belong in `tests/` instead. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn greets_a_named_person() { - assert_eq!(greet("Ferris").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn trims_the_name() { - assert_eq!(greet(" Ferris ").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn rejects_an_empty_name() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} - -#[test] -fn rejects_a_whitespace_only_name() { - assert_eq!(greet(" \t\n ").unwrap_err(), Error::EmptyName); -} diff --git a/crates/template/src/lib.rs b/crates/template/src/lib.rs deleted file mode 100644 index 566fa7e..0000000 --- a/crates/template/src/lib.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! A production-ready starting point for an installable `TinyBus` module. -//! -//! This crate is a template. It ships the layout, lint configuration, error -//! handling, testing, and documentation conventions described in `AGENTS.md`. -//! The compiled `cdylib` exports `TinyBus` module ABI v1 and serves the example -//! [`greet`] behavior over the bus. -//! -//! # Layout -//! -//! This is the implementation half of a two-crate workspace: -//! -//! - [`template_bus`] — the wire contract. Member names, payload types, and the -//! contract version, with no transport and no behavior. A host that only -//! makes calls depends on that crate alone. -//! - `template` — this crate. The behavior, the crate-wide error type, and the -//! `TinyBus` adapter that serves them, built as both an `rlib` and the -//! `cdylib` the loader consumes. -//! -//! Within this crate: -//! -//! - `src/error/` holds the crate-wide [`Error`] enum and the [`Result`] alias -//! returned by every fallible public function. -//! - Each feature area lives in its own module directory with a `mod.rs` -//! module root, an optional `types.rs`, and a `test.rs` holding its unit -//! tests. -//! - Every public item is re-exported from here — including all of -//! [`template_bus`] — so downstream users have a single predictable surface -//! and `template::GreetRequest` is the *same type* as -//! `template_bus::GreetRequest`, not a structural twin. -//! - `tinybus_module` adapts the public behavior to `TinyBus` and exports the -//! module descriptor, embedded manifest, and initialization entrypoint. -//! -//! # Example -//! -//! ``` -//! use template::{greet, Error, GreetRequest}; -//! -//! assert_eq!(greet("Ferris")?, "Hello, Ferris!"); -//! assert_eq!(greet(" ").unwrap_err(), Error::EmptyName); -//! assert_eq!(GreetRequest::new("Ferris").name, "Ferris"); -//! # Ok::<(), template::Error>(()) -//! ``` -//! -//! Replace the `greeting` module with the first real feature area, keep the -//! conventions, and update this documentation to describe the new crate. - -mod error; -mod greeting; -mod tinybus_module; - -pub use error::{Error, Result}; -pub use greeting::greet; - -// The wire contract, re-exported by module rather than by item so every path -// through this crate resolves to the same definitions the contract crate -// publishes. A host may depend on `template-bus` directly and get exactly these -// types; nothing here redefines them. -pub use template_bus; -pub use template_bus::{ - CONTRACT_VERSION, GreetRequest, GreetResponse, INTERFACE, METHODS, OBJECT_PATH, is_compatible, - names, version, -}; diff --git a/crates/template/src/tinybus_module/README.md b/crates/template/src/tinybus_module/README.md deleted file mode 100644 index 2c05772..0000000 --- a/crates/template/src/tinybus_module/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# TinyBus Adapter - -This module is the boundary between ordinary feature code and TinyBus module -ABI v1. `GreetingService` converts the crate's public `greet` function into the typed -`Greet` bus method, while `setup` registers its object and claims the well-known -interface name. Neither the name, the object path, nor the payload types are -spelled here: they come from `template-bus`, so a rename is a compile error in -every consumer instead of an `UnknownMethod` at runtime. - -`tinybus_module::module_export!` emits the descriptor, embedded manifest, and -initialization symbols consumed by the dynamic loader. The manifest method list -must stay aligned with the interface macro's dispatch table and with -`template_bus::names::METHODS`; the unit tests check both relationships. -Integration tests use TinyBus's in-memory transport, and -`crates/template/examples/verify_module.rs` loads a compiled `cdylib` through -the real dynamic loader before a release archive is accepted. - -Generated projects should replace the example interface, object path, and method -declarations together — here and in `crates/template-bus/src/names/`. They must not retain Rust-owned data across the -ABI boundary or bypass the SDK exports with an ad hoc FFI surface. diff --git a/crates/template/src/tinybus_module/mod.rs b/crates/template/src/tinybus_module/mod.rs deleted file mode 100644 index 1c9c2f0..0000000 --- a/crates/template/src/tinybus_module/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! `TinyBus` module entrypoint and bus-facing interface. -//! -//! This adapter keeps the feature implementation independent from `TinyBus` -//! while exposing it as an installable, dynamically loaded integration. The -//! names and payload types it serves come from [`template_bus`], so a host -//! spells them from the contract crate instead of repeating string literals. - -use template_bus::{GreetRequest, GreetResponse, names}; -use tinybus::{Connection, Result as TinyBusResult}; - -struct GreetingService; - -#[tinybus::interface(name = "ai.tinyhumans.template.Greeting")] -impl GreetingService { - async fn greet(&self, request: GreetRequest) -> TinyBusResult { - std::future::ready(crate::greet(&request.name)) - .await - .map(GreetResponse::new) - .map_err(|error| tinybus::Error::failed(error.to_string())) - } -} - -async fn setup(connection: Connection) -> TinyBusResult<()> { - connection - .serve_at(names::OBJECT_PATH.try_into()?, GreetingService) - .await?; - connection.request_name(names::INTERFACE).await?; - Ok(()) -} - -tinybus_module::module_export! { - setup = setup, - worker_threads = 1, - provides = ["ai.tinyhumans.template.Greeting"], - methods = ["Greet"], - signals = [], - requires = [], - optional = [], - lazy = false, -} - -#[cfg(test)] -mod test; diff --git a/crates/template/src/tinybus_module/test.rs b/crates/template/src/tinybus_module/test.rs deleted file mode 100644 index d5fe71a..0000000 --- a/crates/template/src/tinybus_module/test.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Tests for the `TinyBus` module adapter and its declared surface. - -use super::{GreetingService, setup}; -use template_bus::{GreetRequest, GreetResponse, names}; -use tinybus::broker::Broker; -use tinybus::transport::memory::MemoryBus; -use tinybus::{Connection, Interface}; - -#[test] -fn declared_methods_match_the_dispatch_table() { - let methods = GreetingService - .members() - .into_iter() - .map(|member| member.to_string()) - .collect::>(); - - assert_eq!(methods, names::METHODS.to_vec()); -} - -#[test] -fn the_served_interface_name_matches_the_contract() { - assert_eq!(GreetingService.name().to_string(), names::INTERFACE); -} - -#[tokio::test] -async fn module_serves_greetings_over_a_real_bus() -> tinybus::Result<()> { - let bus = MemoryBus::new(); - Broker::new().spawn(bus.clone()); - - let service = Connection::connect(bus.connect().await?).await?; - setup(service.clone()).await?; - - let client = Connection::connect(bus.connect().await?).await?; - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let reply: GreetResponse = proxy - .call(names::methods::GREET, (GreetRequest::new("Ferris"),)) - .await?; - - assert_eq!(reply, GreetResponse::new("Hello, Ferris!")); - Ok(()) -} - -#[tokio::test] -async fn module_rejects_an_empty_name_over_the_bus() -> tinybus::Result<()> { - let bus = MemoryBus::new(); - Broker::new().spawn(bus.clone()); - - let service = Connection::connect(bus.connect().await?).await?; - setup(service.clone()).await?; - - let client = Connection::connect(bus.connect().await?).await?; - let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; - let result = proxy - .call::(names::methods::GREET, (GreetRequest::new(" "),)) - .await; - - let Err(error) = result else { - return Err(tinybus::Error::failed( - "whitespace-only names unexpectedly succeeded", - )); - }; - assert!(error.to_string().contains("name must not be empty")); - Ok(()) -} diff --git a/crates/template/tests/public_api.rs b/crates/template/tests/public_api.rs deleted file mode 100644 index 256b71c..0000000 --- a/crates/template/tests/public_api.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Integration tests for the public crate surface. -//! -//! These tests link against the crate as a downstream consumer would: they can -//! only use what `src/lib.rs` re-exports. Treat them as the regression suite -//! for the crate's public contract — if a change breaks a test here, it is a -//! breaking change for users. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use template::{Error, greet}; - -#[test] -fn greeting_is_available_to_consumers() { - assert_eq!(greet("Rust").unwrap(), "Hello, Rust!"); -} - -#[test] -fn errors_are_available_to_consumers() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} diff --git a/crates/tinysecurity-bus/Cargo.toml b/crates/tinysecurity-bus/Cargo.toml new file mode 100644 index 0000000..5c1727e --- /dev/null +++ b/crates/tinysecurity-bus/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "tinysecurity-bus" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "TinySecurity wire contract" +keywords = ["security", "tinybus", "policy"] +categories = ["authentication"] +publish = false + +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/tinysecurity-bus/src/error.rs b/crates/tinysecurity-bus/src/error.rs new file mode 100644 index 0000000..874ad15 --- /dev/null +++ b/crates/tinysecurity-bus/src/error.rs @@ -0,0 +1,33 @@ +//! Content-free failures crossing the module boundary. +use serde::{Deserialize, Serialize}; +/// Typed module/callback failures; diagnostics never carry credentials. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] +#[serde(rename_all = "snake_case")] +pub enum Error { + /// Incompatible wire vocabulary. + #[error("incompatible contract version")] + IncompatibleVersion, + /// Invalid init configuration. + #[error("invalid configuration")] + InvalidConfig, + /// Invalid invocation payload. + #[error("invalid arguments")] + InvalidArguments, + /// Capability has not been implemented. + #[error("capability unavailable")] + Unavailable, + /// Bounded wait expired; caller must not retry effects. + #[error("module or callback timed out")] + Timeout, + /// Process fault is latched until a fresh host restart. + #[error("module faulted")] + Faulted, + /// Storage has not confirmed persistence. + #[error("persistence failed")] + Persistence, + /// Required consent was refused. + #[error("consent denied")] + ConsentDenied, +} +/// Contract result type. +pub type Result = std::result::Result; diff --git a/crates/tinysecurity-bus/src/future.rs b/crates/tinysecurity-bus/src/future.rs new file mode 100644 index 0000000..1792cf2 --- /dev/null +++ b/crates/tinysecurity-bus/src/future.rs @@ -0,0 +1,503 @@ +//! Reserved typed contracts for capabilities not yet advertised by the module. +use crate::{CallerContext, Decision}; +use serde::{Deserialize, Serialize}; +/// `ApprovalState` vocabulary, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalState { + /// Pending state. + Pending, + /// Allowed state. + Allowed, + /// Denied state. + Denied, + /// Expired state. + Expired, + /// Executed state. + Executed, +} +/// `ExecutionOutcome` vocabulary, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExecutionOutcome { + /// Succeeded state. + Succeeded, + /// Failed state. + Failed, + /// Cancelled state. + Cancelled, +} +/// `ReplyVerdict` vocabulary, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReplyVerdict { + /// `AllowOnce` state. + AllowOnce, + /// Deny state. + Deny, + /// Unrecognized state. + Unrecognized, +} +/// `RedactMode` vocabulary, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RedactMode { + /// Text state. + Text, + /// `StructuredArguments` state. + StructuredArguments, + /// Endpoint state. + Endpoint, + /// `HomePath` state. + HomePath, +} +/// `ScanVerdict` vocabulary, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScanVerdict { + /// Clean state. + Clean, + /// Suspicious state. + Suspicious, + /// Blocked state. + Blocked, +} +/// `PrivacyMode` vocabulary, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyMode { + /// `LocalOnly` state. + LocalOnly, + /// Standard state. + Standard, + /// Sensitive state. + Sensitive, +} +/// `SourceTrust` vocabulary, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceTrust { + /// `TrustedHost` state. + TrustedHost, + /// `UntrustedMcp` state. + UntrustedMcp, + /// `UntrustedSkill` state. + UntrustedSkill, + /// Downloaded state. + Downloaded, +} +/// `SandboxBackend` vocabulary, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SandboxBackend { + /// Namespace state. + Namespace, + /// `MicroVm` state. + MicroVm, + /// Jail state. + Jail, + /// Docker state. + Docker, + /// Noop state. + Noop, +} +/// `NetworkPolicy` vocabulary, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NetworkPolicy { + /// None state. + None, + /// `EgressProxy` state. + EgressProxy, + /// Host state. + Host, +} +/// `AuditKind` vocabulary, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditKind { + /// `PolicyChanged` state. + PolicyChanged, + /// Approval state. + Approval, + /// Judge state. + Judge, + /// Execution state. + Execution, + /// Sandbox state. + Sandbox, +} +/// `StoreOperation` vocabulary, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StoreOperation { + /// `LoadPending` state. + LoadPending, + /// `LoadDecisions` state. + LoadDecisions, + /// Persist state. + Persist, +} +/// `ApprovalDecision` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalDecision { + /// Persisted request identity. + pub request_id: String, + /// Human verdict. + pub allow: bool, + /// Verified deciding identity. + pub caller: CallerContext, +} +/// `ApprovalRecord` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalRecord { + /// Persisted approval identity. + pub request_id: String, + /// Canonical original call fingerprint. + pub fingerprint: String, + /// Original verified caller. + pub caller: CallerContext, + /// Absolute expiration in host clock milliseconds. + pub expires_at_ms: u64, + /// Durable lifecycle state. + pub state: ApprovalState, +} +/// `ListPendingRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ListPendingRequest { + /// Verified scope identity. + pub caller: CallerContext, +} +/// `ListPendingResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ListPendingResponse { + /// Pending records within caller scope. + pub records: Vec, +} +/// `ListDecisionsRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ListDecisionsRequest { + /// Verified scope identity. + pub caller: CallerContext, + /// Bounded result limit. + pub limit: u32, +} +/// `ListDecisionsResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ListDecisionsResponse { + /// Scoped durable records. + pub records: Vec, +} +/// `RecordExecutionRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecordExecutionRequest { + /// Approval identity. + pub request_id: String, + /// Executed call fingerprint. + pub fingerprint: String, + /// Verified executor. + pub caller: CallerContext, + /// Terminal execution result. + pub outcome: ExecutionOutcome, +} +/// `RecordExecutionResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecordExecutionResponse { + /// True only after confirmed persistence. + pub committed: bool, +} +/// `PreauthorizeFlowRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PreauthorizeFlowRequest { + /// Verified origin and flow. + pub caller: CallerContext, + /// Exact reviewed call fingerprints. + pub fingerprints: Vec, + /// Bounded expiration. + pub expires_at_ms: u64, +} +/// `PreauthorizeFlowResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PreauthorizeFlowResponse { + /// Persisted grant identity. + pub grant_id: String, +} +/// `ParseReplyRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ParseReplyRequest { + /// Pending identity. + pub request_id: String, + /// User reply; never persisted without redaction. + pub reply: String, +} +/// `ParseReplyResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ParseReplyResponse { + /// Parsed intent, not permission by itself. + pub verdict: ReplyVerdict, +} +/// `RedactRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RedactRequest { + /// Sensitive input. + pub text: String, + /// Redaction surface. + pub mode: RedactMode, +} +/// `RedactResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RedactResponse { + /// Sanitized output. + pub text: String, + /// Whether substitutions occurred. + pub changed: bool, +} +/// `ScanPromptRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScanPromptRequest { + /// Prompt to scan. + pub text: String, +} +/// `ScanToolDefinitionRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScanToolDefinitionRequest { + /// Tool identity. + pub name: String, + /// Tool schema encoded as JSON. + pub definition_json: String, +} +/// `ScanResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScanResponse { + /// Typed scan result. + pub verdict: ScanVerdict, + /// Content-free matching rule identifiers. + pub rule_ids: Vec, +} +/// `EgressCheckRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EgressCheckRequest { + /// Verified origin. + pub caller: CallerContext, + /// Configured privacy policy. + pub privacy: PrivacyMode, + /// Outgoing data categories. + pub descriptor: DataDescriptor, + /// Destination URL. + pub destination: String, +} +/// `EgressCheckResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EgressCheckResponse { + /// Permission over the supplied data and destination. + pub decision: Decision, +} +/// `UrlGuardRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UrlGuardRequest { + /// URL to resolve. + pub url: String, + /// Previous hop for redirect checks. + pub redirect_from: Option, +} +/// `UrlGuardResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UrlGuardResponse { + /// Authorization result. + pub decision: Decision, + /// TLS verification hostname. + pub hostname: String, + /// Addresses the transport MUST pin; this response alone is not rebinding protection. + pub approved_addresses: Vec, +} +/// `SandboxPlanRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SandboxPlanRequest { + /// Verified access and origin. + pub caller: CallerContext, + /// Executable source trust. + pub source: SourceTrust, + /// Backends actually supported by the executor. + pub capabilities: Vec, + /// Requested filesystem reach. + pub grants: Vec, + /// Requested network reach. + pub network: NetworkPolicy, + /// Requested bounded resources. + pub limits: ResourceLimits, +} +/// `SandboxPlanResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SandboxPlanResponse { + /// Policy authorization. + pub decision: Decision, + /// Approved backend, absent on denial. + pub backend: Option, + /// Effective filesystem grants. + pub grants: Vec, + /// Effective network policy. + pub network: NetworkPolicy, + /// Effective resource limits. + pub limits: ResourceLimits, +} +/// `PathGrant` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PathGrant { + /// Absolute grant root. + pub path: String, + /// Write authorization. + pub write: bool, +} +/// `ResourceLimits` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourceLimits { + /// Memory ceiling. + pub memory_bytes: u64, + /// CPU time ceiling. + pub cpu_millis: u64, + /// Process count ceiling. + pub processes: u32, +} +/// `DataDescriptor` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DataDescriptor { + /// Whether credentials are present. + pub contains_credentials: bool, + /// Whether personal identifiers are present. + pub contains_personal_data: bool, + /// Whether data must stay local. + pub local_only: bool, +} +/// `AuditRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuditRequest { + /// Sanitized audit event. + pub event: AuditEvent, + /// Whether a persistence failure denies the associated operation. + pub required: bool, +} +/// `AuditResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuditResponse { + /// True only after confirmed sink persistence. + pub committed: bool, +} +/// `AuditEvent` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuditEvent { + /// Policy generation. + pub generation: u64, + /// Verified subject context. + pub caller: CallerContext, + /// Event category. + pub kind: AuditKind, + /// Sanitized actor attribution. + pub attribution: String, + /// Content-free applied rules. + pub rule_ids: Vec, +} +/// `ApprovalStoreRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalStoreRequest { + /// Load or persist operation. + pub operation: StoreOperation, + /// Verified scoped caller. + pub caller: CallerContext, + /// Records to persist, empty for reads. + pub records: Vec, +} +/// `ApprovalStoreResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalStoreResponse { + /// Persisted scoped records. + pub records: Vec, + /// Whether requested writes are durably committed. + pub committed: bool, +} +/// `ApprovalPromptRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalPromptRequest { + /// Approval awaiting the human. + pub record: ApprovalRecord, + /// Redacted user-facing summary. + pub summary: String, + /// Bounded callback deadline. + pub timeout_ms: u32, +} +/// `ApprovalPromptResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalPromptResponse { + /// Verified human reply, absent if unavailable. + pub decision: Option, +} +/// `AuditSinkRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuditSinkRequest { + /// Sanitized ordered events. + pub events: Vec, + /// Bounded callback deadline. + pub timeout_ms: u32, +} +/// `AuditSinkResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuditSinkResponse { + /// True only after durable append. + pub committed: bool, +} +/// `KeyringConsentRequest` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeyringConsentRequest { + /// Verified requesting identity. + pub caller: CallerContext, + /// Redacted purpose. + pub purpose: String, + /// Bounded wait. + pub timeout_ms: u32, +} +/// `KeyringConsentResponse` payload, reserved until its engine is implemented. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeyringConsentResponse { + /// Verified human consent. + pub allowed: bool, +} +/// Verified human decision supplied to the reserved Decide member. +pub type DecideRequest = ApprovalDecision; +/// Durable record returned after the reserved Decide member confirms storage. +pub type DecideResponse = ApprovalRecord; diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs new file mode 100644 index 0000000..ff3f13d --- /dev/null +++ b/crates/tinysecurity-bus/src/lib.rs @@ -0,0 +1,18 @@ +//! Versioned, transport-free `TinySecurity` wire contract. +//! +//! Hosts link this crate; the policy engine and native module stay isolated. +//! Only Evaluate, Check and `PolicyInfo` are implemented in the bootstrap. +mod error; +mod future; +mod policy; +pub use error::*; +pub use future::*; +pub use policy::*; +pub mod names; +/// Current wire vocabulary version, independent of the artifact version. +pub const CONTRACT_VERSION: (u32, u32) = (1, 0); +/// Whether the module supplies this host's vocabulary. +#[must_use] +pub const fn is_compatible(version: (u32, u32)) -> bool { + version.0 == CONTRACT_VERSION.0 && version.1 >= CONTRACT_VERSION.1 +} diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs new file mode 100644 index 0000000..9c2cdbe --- /dev/null +++ b/crates/tinysecurity-bus/src/names.rs @@ -0,0 +1,55 @@ +//! Stable interface and reserved member names. +/// Main policy interface. +pub const INTERFACE: &str = "ai.tinyhumans.tinysecurity.Policy"; +/// Main policy object. +pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinysecurity/Policy"; +/// Method names; availability is reported by `PolicyInfo`. +pub mod methods { + /// Evaluate member. + pub const EVALUATE: &str = "Evaluate"; + /// Check member. + pub const CHECK: &str = "Check"; + /// `PolicyInfo` member. + pub const POLICY_INFO: &str = "PolicyInfo"; + /// Decide member. + pub const DECIDE: &str = "Decide"; + /// `ListPending` member. + pub const LIST_PENDING: &str = "ListPending"; + /// `ListDecisions` member. + pub const LIST_DECISIONS: &str = "ListDecisions"; + /// `RecordExecution` member. + pub const RECORD_EXECUTION: &str = "RecordExecution"; + /// `PreauthorizeFlow` member. + pub const PREAUTHORIZE_FLOW: &str = "PreauthorizeFlow"; + /// `ParseReply` member. + pub const PARSE_REPLY: &str = "ParseReply"; + /// Redact member. + pub const REDACT: &str = "Redact"; + /// `ScanPrompt` member. + pub const SCAN_PROMPT: &str = "ScanPrompt"; + /// `ScanToolDefinition` member. + pub const SCAN_TOOL_DEFINITION: &str = "ScanToolDefinition"; + /// `EgressCheck` member. + pub const EGRESS_CHECK: &str = "EgressCheck"; + /// `UrlGuard` member. + pub const URL_GUARD: &str = "UrlGuard"; + /// Plan member. + pub const PLAN: &str = "Plan"; + /// Audit member. + pub const AUDIT: &str = "Audit"; +} +/// Bootstrap implemented methods only. +pub const METHODS: &[&str] = &[methods::EVALUATE, methods::CHECK, methods::POLICY_INFO]; +/// Accepted policy activation event. +pub const POLICY_CHANGED: &str = "PolicyChanged"; +/// Reserved host callback identities. +pub mod callbacks { + /// `ApprovalStore` callback interface. + pub const APPROVAL_STORE: &str = "ai.tinyhumans.tinysecurity.ApprovalStore"; + /// `ApprovalPrompt` callback interface. + pub const APPROVAL_PROMPT: &str = "ai.tinyhumans.tinysecurity.ApprovalPrompt"; + /// `AuditSink` callback interface. + pub const AUDIT_SINK: &str = "ai.tinyhumans.tinysecurity.AuditSink"; + /// `KeyringConsent` callback interface. + pub const KEYRING_CONSENT: &str = "ai.tinyhumans.tinysecurity.KeyringConsent"; +} diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs new file mode 100644 index 0000000..233444d --- /dev/null +++ b/crates/tinysecurity-bus/src/policy.rs @@ -0,0 +1,243 @@ +//! Policy invocation and SDK configuration types. +use serde::{Deserialize, Serialize}; +use std::fmt; +/// Verified host context; never populated directly from model arguments. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CallerContext { + /// Acting agent identity. + pub agent: String, + /// Conversation identity. + pub thread: Option, + /// Workflow identity. + pub flow: Option, + /// Original call identity. + pub call_id: String, + /// Verified request origin. + pub origin: Origin, + /// Verified access tier. + pub tier: AccessTier, +} +/// Origin supplied by the host's authenticated dispatch. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Origin { + /// Local operator. + #[default] + Operator, + /// Interactive copilot. + Copilot, + /// Delegated agent. + SubAgent, + /// Scheduled execution. + Cron, + /// Remote service caller. + Saas, +} +/// Access ceiling verified by the host. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccessTier { + /// Reads only. + #[default] + Read, + /// Read and write authority, subject to policy. + Write, + /// Operator's full authority, subject to unconditional floor. + Full, +} +/// Original tool call and complete host-derived checks. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ToolCall { + /// Stable tool name; unknown names fail closed. + pub tool: String, + /// Original arguments encoded as JSON text, never a credential carrier. + pub arguments_json: String, + /// Checks derived by the trusted host adapter. + pub checks: Vec, +} +/// A check of a concrete operation. The bootstrap has no URL authorization. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Check { + /// Filesystem operation over its actual target path. + Path { + /// Target path. + path: String, + /// Whether mutation is requested. + write: bool, + }, + /// Shell command; caller-provided classification is never trusted. + Command { + /// Entire command text. + command: String, + }, + /// Pure local, side-effect-free diagnostic. + LocalDiagnostic {}, + /// An effect not supported by this engine. + Unknown { + /// Content-free descriptor. + description: String, + }, +} +/// Evaluation request. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EvaluateRequest { + /// Verified caller. + pub caller: CallerContext, + /// Original operation. + pub call: ToolCall, +} +/// Ordered batch of in-tool checks. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CheckRequest { + /// Verified caller. + pub caller: CallerContext, + /// Checks in execution order. + pub checks: Vec, +} +/// Ordered batch results, from one policy generation. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CheckResponse { + /// Effective policy generation. + pub generation: u64, + /// One decision per input check. + pub decisions: Vec, +} +/// Policy decision; permission does not attest to later execution confinement. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Decision { + /// Effective policy generation. + pub generation: u64, + /// Permission outcome. + pub verdict: Verdict, + /// Whether static caching is authorized. Bootstrap always returns false. + pub cacheable: bool, +} +/// Permission outcome. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Verdict { + /// Policy permits the checked operation. + Allow, + /// Policy refuses the operation. + Deny { + /// Stable denial category, without sensitive payloads. + reason: DenialReason, + }, + /// Future approval engine parks an operation; bootstrap never returns this. + RequireApproval { + /// Persisted approval identity bound to the original call. + request_id: String, + }, +} +/// Stable denial categories. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DenialReason { + /// Unconditional credential/system/path safety floor. + Floor, + /// Unsupported effect or unknown tool. + UnsupportedEffect, + /// No usable human approval capability. + ApprovalUnavailable, + /// Caller has insufficient access. + Access, + /// Outside allowed root or configured forbidden root. + PathScope, + /// Path cannot be securely resolved. + PathResolution, + /// Command grammar or executable not safely supported. + Command, + /// Missing verified caller identity. + InvalidContext, +} +/// Effective policy settings. Bootstrap intentionally exposes no unimplemented budgets. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PolicyConfig { + /// Enables discretionary boundaries and approval gates. + pub enabled: bool, + /// Absolute permitted action root when enabled. + pub action_dir: String, + /// Internal host state root, inaccessible to enabled acting tools. + pub workspace_dir: String, + /// Allowed commands; bootstrap supports only argument-free diagnostics. + pub allowed_commands: Vec, + /// Additional forbidden absolute roots when enabled. + pub forbidden_paths: Vec, +} +impl Default for PolicyConfig { + fn default() -> Self { + Self { + enabled: false, + action_dir: String::new(), + workspace_dir: String::new(), + allowed_commands: vec!["pwd".into(), "date".into(), "uname".into(), "whoami".into()], + forbidden_paths: vec![], + } + } +} +/// Secret injected at initialization only. Its Debug output is always redacted. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Secret(pub String); +impl fmt::Debug for Secret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Secret([REDACTED])") + } +} +/// Future optional judge configuration; bootstrap rejects configured judges. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JudgeConfig { + /// Init-only backend credential. + pub credential: Secret, + /// Approval confidence in thousandths (0..=1000). + pub threshold_millis: u16, + /// Bounded request timeout. + pub timeout_ms: u32, + /// Explicit origin opt-ins. + pub origins: Vec, +} +/// SDK init/reinit configuration, never a bus method argument. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ModuleConfig { + /// Required wire vocabulary. + pub contract_version: (u32, u32), + /// Effective policy requested by the host. + pub policy: PolicyConfig, + /// Optional future judge capability. + pub judge: Option, +} +impl Default for ModuleConfig { + fn default() -> Self { + Self { + contract_version: crate::CONTRACT_VERSION, + policy: PolicyConfig::default(), + judge: None, + } + } +} +/// Effective configuration and currently implemented capabilities. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PolicyInfo { + /// Wire version. + pub contract_version: (u32, u32), + /// Effective generation. + pub generation: u64, + /// Effective public policy settings; no judge credentials. + pub policy: PolicyConfig, + /// Advertised implemented methods. + pub capabilities: Vec, +} +/// Accepted configuration change notification. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PolicyChanged { + /// New generation. + pub generation: u64, +} diff --git a/crates/tinysecurity-bus/tests/fixtures/v1.json b/crates/tinysecurity-bus/tests/fixtures/v1.json new file mode 100644 index 0000000..5000d43 --- /dev/null +++ b/crates/tinysecurity-bus/tests/fixtures/v1.json @@ -0,0 +1,695 @@ +{ + "ApprovalDecision": { + "request_id": "fixture", + "allow": false, + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + } + }, + "ApprovalRecord": { + "request_id": "fixture", + "fingerprint": "fixture", + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "expires_at_ms": 1, + "state": "pending" + }, + "ListPendingRequest": { + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + } + }, + "ListPendingResponse": { + "records": [ + { + "request_id": "fixture", + "fingerprint": "fixture", + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "expires_at_ms": 1, + "state": "pending" + } + ] + }, + "ListDecisionsRequest": { + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "limit": 1 + }, + "ListDecisionsResponse": { + "records": [ + { + "request_id": "fixture", + "fingerprint": "fixture", + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "expires_at_ms": 1, + "state": "pending" + } + ] + }, + "RecordExecutionRequest": { + "request_id": "fixture", + "fingerprint": "fixture", + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "outcome": "succeeded" + }, + "RecordExecutionResponse": { + "committed": false + }, + "PreauthorizeFlowRequest": { + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "fingerprints": [ + "fixture" + ], + "expires_at_ms": 1 + }, + "PreauthorizeFlowResponse": { + "grant_id": "fixture" + }, + "ParseReplyRequest": { + "request_id": "fixture", + "reply": "fixture" + }, + "ParseReplyResponse": { + "verdict": "allow_once" + }, + "RedactRequest": { + "text": "fixture", + "mode": "text" + }, + "RedactResponse": { + "text": "fixture", + "changed": false + }, + "ScanPromptRequest": { + "text": "fixture" + }, + "ScanToolDefinitionRequest": { + "name": "fixture", + "definition_json": "fixture" + }, + "ScanResponse": { + "verdict": "clean", + "rule_ids": [ + "fixture" + ] + }, + "EgressCheckRequest": { + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "privacy": "local_only", + "descriptor": { + "contains_credentials": false, + "contains_personal_data": false, + "local_only": false + }, + "destination": "fixture" + }, + "EgressCheckResponse": { + "decision": { + "generation": 1, + "verdict": { + "kind": "deny", + "reason": "floor" + }, + "cacheable": false + } + }, + "UrlGuardRequest": { + "url": "fixture", + "redirect_from": "fixture" + }, + "UrlGuardResponse": { + "decision": { + "generation": 1, + "verdict": { + "kind": "deny", + "reason": "floor" + }, + "cacheable": false + }, + "hostname": "fixture", + "approved_addresses": [ + "fixture" + ] + }, + "SandboxPlanRequest": { + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "source": "trusted_host", + "capabilities": [ + "namespace" + ], + "grants": [ + { + "path": "fixture", + "write": false + } + ], + "network": "none", + "limits": { + "memory_bytes": 1, + "cpu_millis": 1, + "processes": 1 + } + }, + "SandboxPlanResponse": { + "decision": { + "generation": 1, + "verdict": { + "kind": "deny", + "reason": "floor" + }, + "cacheable": false + }, + "backend": "namespace", + "grants": [ + { + "path": "fixture", + "write": false + } + ], + "network": "none", + "limits": { + "memory_bytes": 1, + "cpu_millis": 1, + "processes": 1 + } + }, + "PathGrant": { + "path": "fixture", + "write": false + }, + "ResourceLimits": { + "memory_bytes": 1, + "cpu_millis": 1, + "processes": 1 + }, + "DataDescriptor": { + "contains_credentials": false, + "contains_personal_data": false, + "local_only": false + }, + "AuditRequest": { + "event": { + "generation": 1, + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "kind": "policy_changed", + "attribution": "fixture", + "rule_ids": [ + "fixture" + ] + }, + "required": false + }, + "AuditResponse": { + "committed": false + }, + "AuditEvent": { + "generation": 1, + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "kind": "policy_changed", + "attribution": "fixture", + "rule_ids": [ + "fixture" + ] + }, + "ApprovalStoreRequest": { + "operation": "load_pending", + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "records": [ + { + "request_id": "fixture", + "fingerprint": "fixture", + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "expires_at_ms": 1, + "state": "pending" + } + ] + }, + "ApprovalStoreResponse": { + "records": [ + { + "request_id": "fixture", + "fingerprint": "fixture", + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "expires_at_ms": 1, + "state": "pending" + } + ], + "committed": false + }, + "ApprovalPromptRequest": { + "record": { + "request_id": "fixture", + "fingerprint": "fixture", + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "expires_at_ms": 1, + "state": "pending" + }, + "summary": "fixture", + "timeout_ms": 1 + }, + "ApprovalPromptResponse": { + "decision": { + "request_id": "fixture", + "allow": false, + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + } + } + }, + "AuditSinkRequest": { + "events": [ + { + "generation": 1, + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "kind": "policy_changed", + "attribution": "fixture", + "rule_ids": [ + "fixture" + ] + } + ], + "timeout_ms": 1 + }, + "AuditSinkResponse": { + "committed": false + }, + "KeyringConsentRequest": { + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "purpose": "fixture", + "timeout_ms": 1 + }, + "KeyringConsentResponse": { + "allowed": false + }, + "CallerContext": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "ToolCall": { + "tool": "path_check", + "arguments_json": "{}", + "checks": [ + { + "kind": "path", + "path": "/home/operator/project/file", + "write": false + } + ] + }, + "EvaluateRequest": { + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "call": { + "tool": "fixture", + "arguments_json": "fixture", + "checks": [ + { + "kind": "path", + "path": "/home/operator/project/file", + "write": false + } + ] + } + }, + "CheckRequest": { + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "checks": [ + { + "kind": "path", + "path": "/home/operator/project/file", + "write": false + } + ] + }, + "CheckResponse": { + "generation": 1, + "decisions": [ + { + "generation": 1, + "verdict": { + "kind": "deny", + "reason": "floor" + }, + "cacheable": false + } + ] + }, + "Decision": { + "generation": 1, + "verdict": { + "kind": "deny", + "reason": "floor" + }, + "cacheable": false + }, + "PolicyConfig": { + "enabled": false, + "action_dir": "fixture", + "workspace_dir": "fixture", + "allowed_commands": [ + "fixture" + ], + "forbidden_paths": [ + "fixture" + ] + }, + "JudgeConfig": { + "credential": "secret-fixture-must-not-appear-in-debug", + "threshold_millis": 1, + "timeout_ms": 1, + "origins": [ + "operator" + ] + }, + "ModuleConfig": { + "contract_version": [ + 1, + 0 + ], + "policy": { + "enabled": false, + "action_dir": "fixture", + "workspace_dir": "fixture", + "allowed_commands": [ + "fixture" + ], + "forbidden_paths": [ + "fixture" + ] + }, + "judge": null + }, + "PolicyInfo": { + "contract_version": [ + 1, + 0 + ], + "generation": 1, + "policy": { + "enabled": false, + "action_dir": "fixture", + "workspace_dir": "fixture", + "allowed_commands": [ + "fixture" + ], + "forbidden_paths": [ + "fixture" + ] + }, + "capabilities": [ + "fixture" + ] + }, + "PolicyChanged": { + "generation": 1 + }, + "ApprovalState": "pending", + "ExecutionOutcome": "succeeded", + "ReplyVerdict": "allow_once", + "RedactMode": "text", + "ScanVerdict": "clean", + "PrivacyMode": "local_only", + "SourceTrust": "trusted_host", + "SandboxBackend": "namespace", + "NetworkPolicy": "none", + "AuditKind": "policy_changed", + "StoreOperation": "load_pending", + "Origin": "operator", + "AccessTier": "read", + "DenialReason": "floor", + "Secret": "secret-fixture-must-not-appear-in-debug", + "Check": { + "kind": "path", + "path": "/home/operator/project/file", + "write": false + }, + "Verdict": { + "kind": "deny", + "reason": "floor" + }, + "errors": [ + "incompatible_version", + "invalid_config", + "invalid_arguments", + "unavailable", + "timeout", + "faulted", + "persistence", + "consent_denied" + ], + "variants": { + "ApprovalState": [ + "pending", + "allowed", + "denied", + "expired", + "executed" + ], + "ExecutionOutcome": [ + "succeeded", + "failed", + "cancelled" + ], + "ReplyVerdict": [ + "allow_once", + "deny", + "unrecognized" + ], + "RedactMode": [ + "text", + "structured_arguments", + "endpoint", + "home_path" + ], + "ScanVerdict": [ + "clean", + "suspicious", + "blocked" + ], + "PrivacyMode": [ + "local_only", + "standard", + "sensitive" + ], + "SourceTrust": [ + "trusted_host", + "untrusted_mcp", + "untrusted_skill", + "downloaded" + ], + "SandboxBackend": [ + "namespace", + "micro_vm", + "jail", + "docker", + "noop" + ], + "NetworkPolicy": [ + "none", + "egress_proxy", + "host" + ], + "AuditKind": [ + "policy_changed", + "approval", + "judge", + "execution", + "sandbox" + ], + "StoreOperation": [ + "load_pending", + "load_decisions", + "persist" + ], + "Origin": [ + "operator", + "copilot", + "sub_agent", + "cron", + "saas" + ], + "AccessTier": [ + "read", + "write", + "full" + ], + "DenialReason": [ + "floor", + "unsupported_effect", + "approval_unavailable", + "access", + "path_scope", + "path_resolution", + "command", + "invalid_context" + ] + }, + "DecideRequest": { + "request_id": "fixture", + "allow": false, + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + } + }, + "DecideResponse": { + "request_id": "fixture", + "fingerprint": "fixture", + "caller": { + "agent": "fixture", + "thread": "fixture", + "flow": "fixture", + "call_id": "fixture", + "origin": "operator", + "tier": "read" + }, + "expires_at_ms": 1, + "state": "pending" + } +} diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs new file mode 100644 index 0000000..00f3b42 --- /dev/null +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -0,0 +1,322 @@ +//! Golden wire compatibility for every public payload family. +use serde_json::Value; +use tinysecurity_bus::*; +fn fixtures() -> std::result::Result { + serde_json::from_str(include_str!("fixtures/v1.json")) +} +macro_rules! golden { + ($type:ty, $name:ident) => { + #[test] + fn $name() -> std::result::Result<(), Box> { + let v = fixtures()?[stringify!($type)].clone(); + let decoded: $type = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(&decoded)?, v); + assert_eq!( + serde_json::from_str::<$type>(&serde_json::to_string(&decoded)?)?, + decoded + ); + Ok(()) + } + }; +} +golden!(ApprovalDecision, roundtrip_approval_decision); +golden!(ApprovalRecord, roundtrip_approval_record); +golden!(ListPendingRequest, roundtrip_list_pending_request); +golden!(ListPendingResponse, roundtrip_list_pending_response); +golden!(ListDecisionsRequest, roundtrip_list_decisions_request); +golden!(ListDecisionsResponse, roundtrip_list_decisions_response); +golden!(RecordExecutionRequest, roundtrip_record_execution_request); +golden!(RecordExecutionResponse, roundtrip_record_execution_response); +golden!(PreauthorizeFlowRequest, roundtrip_preauthorize_flow_request); +golden!( + PreauthorizeFlowResponse, + roundtrip_preauthorize_flow_response +); +golden!(ParseReplyRequest, roundtrip_parse_reply_request); +golden!(ParseReplyResponse, roundtrip_parse_reply_response); +golden!(RedactRequest, roundtrip_redact_request); +golden!(RedactResponse, roundtrip_redact_response); +golden!(ScanPromptRequest, roundtrip_scan_prompt_request); +golden!( + ScanToolDefinitionRequest, + roundtrip_scan_tool_definition_request +); +golden!(ScanResponse, roundtrip_scan_response); +golden!(EgressCheckRequest, roundtrip_egress_check_request); +golden!(EgressCheckResponse, roundtrip_egress_check_response); +golden!(UrlGuardRequest, roundtrip_url_guard_request); +golden!(UrlGuardResponse, roundtrip_url_guard_response); +golden!(SandboxPlanRequest, roundtrip_sandbox_plan_request); +golden!(SandboxPlanResponse, roundtrip_sandbox_plan_response); +golden!(PathGrant, roundtrip_path_grant); +golden!(ResourceLimits, roundtrip_resource_limits); +golden!(DataDescriptor, roundtrip_data_descriptor); +golden!(AuditRequest, roundtrip_audit_request); +golden!(AuditResponse, roundtrip_audit_response); +golden!(AuditEvent, roundtrip_audit_event); +golden!(ApprovalStoreRequest, roundtrip_approval_store_request); +golden!(ApprovalStoreResponse, roundtrip_approval_store_response); +golden!(ApprovalPromptRequest, roundtrip_approval_prompt_request); +golden!(ApprovalPromptResponse, roundtrip_approval_prompt_response); +golden!(AuditSinkRequest, roundtrip_audit_sink_request); +golden!(AuditSinkResponse, roundtrip_audit_sink_response); +golden!(KeyringConsentRequest, roundtrip_keyring_consent_request); +golden!(KeyringConsentResponse, roundtrip_keyring_consent_response); +golden!(CallerContext, roundtrip_caller_context); +golden!(ToolCall, roundtrip_tool_call); +golden!(EvaluateRequest, roundtrip_evaluate_request); +golden!(CheckRequest, roundtrip_check_request); +golden!(CheckResponse, roundtrip_check_response); +golden!(Decision, roundtrip_decision); +golden!(PolicyConfig, roundtrip_policy_config); +golden!(JudgeConfig, roundtrip_judge_config); +golden!(ModuleConfig, roundtrip_module_config); +golden!(PolicyInfo, roundtrip_policy_info); +golden!(PolicyChanged, roundtrip_policy_changed); +golden!(ApprovalState, roundtrip_approval_state); +golden!(ExecutionOutcome, roundtrip_execution_outcome); +golden!(ReplyVerdict, roundtrip_reply_verdict); +golden!(RedactMode, roundtrip_redact_mode); +golden!(ScanVerdict, roundtrip_scan_verdict); +golden!(PrivacyMode, roundtrip_privacy_mode); +golden!(SourceTrust, roundtrip_source_trust); +golden!(SandboxBackend, roundtrip_sandbox_backend); +golden!(NetworkPolicy, roundtrip_network_policy); +golden!(AuditKind, roundtrip_audit_kind); +golden!(StoreOperation, roundtrip_store_operation); +golden!(Origin, roundtrip_origin); +golden!(AccessTier, roundtrip_access_tier); +golden!(DenialReason, roundtrip_denial_reason); +golden!(Secret, roundtrip_secret); +golden!(Check, roundtrip_check); +golden!(Verdict, roundtrip_verdict); +#[test] +fn all_approval_state_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["ApprovalState"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: ApprovalState = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_execution_outcome_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["ExecutionOutcome"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: ExecutionOutcome = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_reply_verdict_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["ReplyVerdict"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: ReplyVerdict = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_redact_mode_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["RedactMode"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: RedactMode = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_scan_verdict_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["ScanVerdict"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: ScanVerdict = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_privacy_mode_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["PrivacyMode"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: PrivacyMode = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_source_trust_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["SourceTrust"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: SourceTrust = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_sandbox_backend_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["SandboxBackend"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: SandboxBackend = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_network_policy_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["NetworkPolicy"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: NetworkPolicy = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_audit_kind_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["AuditKind"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: AuditKind = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_store_operation_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["StoreOperation"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: StoreOperation = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_origin_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["Origin"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: Origin = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_access_tier_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["AccessTier"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: AccessTier = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn all_denial_reason_variants_have_stable_wire_names() +-> std::result::Result<(), Box> { + for v in fixtures()?["variants"]["DenialReason"] + .as_array() + .ok_or("fixture must contain an array")? + { + let decoded: DenialReason = serde_json::from_value(v.clone())?; + assert_eq!(serde_json::to_value(decoded)?, *v); + } + assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + Ok(()) +} +#[test] +fn every_error_has_a_stable_wire_form() -> std::result::Result<(), Box> { + for v in fixtures()?["errors"] + .as_array() + .ok_or("fixture must contain an array")? + { + let e: Error = serde_json::from_value(v.clone())?; + assert_ne!(e.to_string(), ""); + assert_eq!(serde_json::to_value(e)?, *v); + } + Ok(()) +} +#[test] +fn init_secrets_are_redacted_and_never_present_in_call_types() +-> std::result::Result<(), Box> { + let config = fixtures()?["JudgeConfig"].clone(); + let judge: JudgeConfig = serde_json::from_value(config)?; + let debug = format!("{judge:?}"); + assert!(debug.contains("REDACTED")); + assert!(!debug.contains("secret-fixture")); + let config = ModuleConfig { + judge: Some(judge), + ..Default::default() + }; + assert!(!format!("{config:?}").contains("secret-fixture")); + Ok(()) +} +#[test] +fn unknown_fields_and_enum_tags_are_rejected_at_security_boundaries() +-> std::result::Result<(), Box> { + let mut v = fixtures()?["EvaluateRequest"].clone(); + v["class"] = "read".into(); + assert!(serde_json::from_value::(v).is_err()); + assert!( + serde_json::from_str::(r#"{"kind":"url","url":"https://example.com"}"#).is_err() + ); + assert!( + serde_json::from_str::(r#"{"kind":"local_diagnostic","unexpected":true}"#).is_err() + ); + assert!(serde_json::from_str::("{}").is_err()); + assert!(!is_compatible((2, 0))); + assert!(is_compatible((1, 0))); + assert!(is_compatible((1, 4))); + Ok(()) +} + +golden!(DecideRequest, roundtrip_decide_request); +golden!(DecideResponse, roundtrip_decide_response); diff --git a/crates/tinysecurity-module/Cargo.toml b/crates/tinysecurity-module/Cargo.toml new file mode 100644 index 0000000..4cb3ee0 --- /dev/null +++ b/crates/tinysecurity-module/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "tinysecurity-module" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "TinySecurity native security policy" +keywords = ["security", "tinybus", "policy"] +categories = ["authentication"] +publish = false + +[lib] +crate-type = ["rlib", "cdylib"] + +[dependencies] +tinysecurity = { workspace = true } +tinysecurity-bus = { workspace = true } +tinybus = { workspace = true } +tinybus-module = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } +tokio = { workspace = true } + +[lints] +workspace = true diff --git a/crates/tinysecurity-module/examples/verify_module.rs b/crates/tinysecurity-module/examples/verify_module.rs new file mode 100644 index 0000000..ef0208a --- /dev/null +++ b/crates/tinysecurity-module/examples/verify_module.rs @@ -0,0 +1,199 @@ +//! Real native loader smoke test and reproducible policy hot-path timing. +use std::{ + io, + path::PathBuf, + time::{Duration, Instant}, +}; +use tinybus::{Connection, broker::Broker, module::ModuleHost, transport::memory::MemoryBus}; +use tinysecurity_bus::{ + CallerContext, Check, CheckRequest, CheckResponse, Decision, DenialReason, EvaluateRequest, + ModuleConfig, PolicyInfo, ToolCall, Verdict, names, +}; +#[tokio::main] +async fn main() -> std::result::Result<(), Box> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + let config = serde_json::to_value(ModuleConfig::default())?; + let info = match args.as_slice() { + [path] => { + let path = PathBuf::from(path); + verify_bad_digests(&path)?; + assert!( + host.load_file(&path).is_err(), + "missing init configuration must reject" + ); + host.load_file_with_config(path, config)? + } + [url, archive, digest] => host.load_github_release(url, archive, Some(digest), config)?, + _ => { + return Err(io::Error::other( + "usage: verify_module OR ", + ) + .into()); + } + }; + if info.name != "tinysecurity-module" { + return Err(io::Error::other("unexpected module identity").into()); + } + let client = Connection::connect(bus.connect().await?).await?; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if client + .list_names() + .await? + .iter() + .any(|n| n.as_str() == names::INTERFACE) + { + return tinybus::Result::Ok(()); + } + tokio::task::yield_now().await; + } + }) + .await??; + let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; + let caller = CallerContext { + agent: "smoke".into(), + call_id: "smoke-1".into(), + ..Default::default() + }; + let checks = CheckRequest { + caller: caller.clone(), + checks: vec![ + Check::LocalDiagnostic {}, + Check::Path { + path: "/etc/passwd".into(), + write: false, + }, + ], + }; + let response: CheckResponse = proxy.call(names::methods::CHECK, (checks.clone(),)).await?; + assert_eq!(response.decisions[0].verdict, Verdict::Allow); + assert_eq!( + response.decisions[1].verdict, + Verdict::Deny { + reason: DenialReason::Floor + } + ); + assert!( + proxy + .call::(names::methods::EVALUATE, ()) + .await + .is_err() + ); + assert!( + proxy + .call::(names::methods::DECIDE, ()) + .await + .is_err() + ); + verify_reconfiguration(&host, &proxy, &info.name).await?; + let evaluate = EvaluateRequest { + caller, + call: ToolCall { + tool: "local_diagnostic".into(), + arguments_json: "{}".into(), + checks: vec![Check::LocalDiagnostic {}], + }, + }; + benchmark(&proxy, &evaluate, &checks).await?; + host.shutdown(Duration::from_secs(1)).await; + assert!( + host.reinitialize(&info.name, serde_json::to_value(ModuleConfig::default())?) + .await + .is_err() + ); + task.abort(); + Ok(()) +} + +async fn benchmark( + proxy: &tinybus::Proxy, + evaluate: &EvaluateRequest, + checks: &CheckRequest, +) -> std::result::Result<(), Box> { + let mut evaluate_times = Vec::new(); + let mut check_times = Vec::new(); + for _ in 0..500 { + let start = Instant::now(); + let decision: Decision = proxy + .call(names::methods::EVALUATE, (evaluate.clone(),)) + .await?; + assert_eq!(decision.verdict, Verdict::Allow); + evaluate_times.push(start.elapsed()); + let start = Instant::now(); + let _: CheckResponse = proxy.call(names::methods::CHECK, (checks.clone(),)).await?; + check_times.push(start.elapsed()); + } + evaluate_times.sort_unstable(); + check_times.sort_unstable(); + println!("native module allow/deny, malformed args and failed/accepted reinit verified"); + println!( + "500 samples per method, one Evaluate bus call/sample, one Check bus call/sample for two checks" + ); + println!( + "Evaluate p50={:?} p99={:?}; Check p50={:?} p99={:?}", + evaluate_times[250], evaluate_times[494], check_times[250], check_times[494] + ); + Ok(()) +} + +fn verify_bad_digests( + path: &std::path::Path, +) -> std::result::Result<(), Box> { + use std::io::Write; + let directory = + PathBuf::from("target").join(format!("native-admission-{}", std::process::id())); + std::fs::create_dir_all(&directory)?; + let name = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or("native library name is not UTF-8")?; + let copy = directory.join(name); + std::fs::copy(path, ©)?; + let allowlist = directory.join("modules.toml"); + std::fs::write(&allowlist, format!("\"{name}\" = \"{}\"\n", "0".repeat(64)))?; + let host = ModuleHost::new(Broker::new()); + assert!( + host.load_file_with_config(©, serde_json::to_value(ModuleConfig::default())?) + .is_err() + ); + let digest = tinybus::module::sha256_file(©)?; + std::fs::write(&allowlist, format!("\"{name}\" = \"{digest}\"\n"))?; + std::fs::OpenOptions::new() + .append(true) + .open(©)? + .write_all(b"tampered")?; + assert!( + host.load_file_with_config(©, serde_json::to_value(ModuleConfig::default())?) + .is_err() + ); + std::fs::remove_dir_all(directory)?; + Ok(()) +} + +async fn verify_reconfiguration( + host: &ModuleHost, + proxy: &tinybus::Proxy, + module: &str, +) -> std::result::Result<(), Box> { + let before: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?; + let invalid = ModuleConfig { + contract_version: (9, 0), + ..Default::default() + }; + assert!( + host.reinitialize(module, serde_json::to_value(invalid)?) + .await + .is_err() + ); + let unchanged: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?; + assert_eq!(before, unchanged); + host.reinitialize(module, serde_json::to_value(ModuleConfig::default())?) + .await?; + let after: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?; + assert_eq!(after.generation, before.generation + 1); + Ok(()) +} diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs new file mode 100644 index 0000000..4a47103 --- /dev/null +++ b/crates/tinysecurity-module/src/adapter.rs @@ -0,0 +1,101 @@ +//! SDK-configured native policy service, with no lifecycle RPC members. +use std::sync::{Arc, OnceLock, RwLock}; +use tinybus::{Connection, Result as BusResult}; +use tinysecurity::Policy; +use tinysecurity_bus::{ + CheckRequest, CheckResponse, Decision, EvaluateRequest, ModuleConfig, PolicyInfo, names, +}; + +static ACTIVE: OnceLock>> = OnceLock::new(); +struct PolicyService { + policy: Arc>, +} +fn failure(error: &tinysecurity_bus::Error) -> tinybus::Error { + tinybus::Error::failed(error.to_string()) +} +impl PolicyService { + fn read(&self) -> BusResult> { + self.policy + .read() + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted)) + } +} +#[tinybus::interface(name = "ai.tinyhumans.tinysecurity.Policy")] +impl PolicyService { + async fn evaluate(&self, request: EvaluateRequest) -> BusResult { + let result = { + self.read()? + .evaluate(&request) + .map_err(|error| failure(&error)) + }; + std::future::ready(result).await + } + async fn check(&self, request: CheckRequest) -> BusResult { + let result = { + self.read()? + .check(&request) + .map_err(|error| failure(&error)) + }; + std::future::ready(result).await + } + async fn policy_info(&self) -> BusResult { + let info = { self.read()?.info() }; + std::future::ready(Ok(info)).await + } +} +async fn setup(connection: Connection, config: ModuleConfig) -> BusResult<()> { + // Validate before activation. The SDK serializes reinitializations and + // preserves a fault latch; this adapter never provides retry RPCs. + let candidate = Policy::new(config.clone()).map_err(|error| failure(&error))?; + if let Some(active) = ACTIVE.get() { + let mut guard = active + .write() + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))?; + let mut candidate = guard.clone(); + candidate + .reinitialize(config) + .map_err(|error| failure(&error))?; + let event = tinysecurity_bus::PolicyChanged { + generation: candidate.info().generation, + }; + let signal = tinybus::Message::signal( + names::OBJECT_PATH.try_into()?, + names::INTERFACE.try_into()?, + names::POLICY_CHANGED.try_into()?, + serde_json::to_value((event,))?, + ); + // Queue publication before activation while holding the state lock. + // A full/closed outbox rejects reinit and leaves the old state intact. + connection.try_send(signal)?; + *guard = candidate; + return Ok(()); + } + let policy = Arc::new(RwLock::new(candidate)); + connection + .serve_at( + names::OBJECT_PATH.try_into()?, + PolicyService { + policy: Arc::clone(&policy), + }, + ) + .await?; + connection.request_name(names::INTERFACE).await?; + ACTIVE + .set(policy) + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))?; + Ok(()) +} +tinybus_module::module_export! { + setup = setup, + config = ModuleConfig, + worker_threads = 1, + provides = ["ai.tinyhumans.tinysecurity.Policy"], + methods = ["Evaluate", "Check", "PolicyInfo"], + signals = ["PolicyChanged"], + requires = [], + optional = [], + lazy = false, +} +#[cfg(test)] +#[path = "adapter_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-module/src/adapter_tests.rs b/crates/tinysecurity-module/src/adapter_tests.rs new file mode 100644 index 0000000..6b883c7 --- /dev/null +++ b/crates/tinysecurity-module/src/adapter_tests.rs @@ -0,0 +1,128 @@ +//! Contract dispatch and malformed request tests through real in-memory `TinyBus`. +use super::*; +use tinybus::{Interface, broker::Broker, transport::memory::MemoryBus}; +use tinysecurity_bus::*; +#[test] +fn only_implemented_members_are_advertised() -> std::result::Result<(), Box> +{ + let service = PolicyService { + policy: Arc::new(RwLock::new(Policy::new(ModuleConfig::default())?)), + }; + assert_eq!(service.name().as_str(), names::INTERFACE); + assert_eq!( + service + .members() + .iter() + .map(ToString::to_string) + .collect::>(), + names::METHODS + ); + Ok(()) +} +#[tokio::test] +async fn native_dispatch_rejects_malformed_args_and_serves_ordered_checks() +-> std::result::Result<(), Box> { + let bus = MemoryBus::new(); + let task = Broker::new().spawn(bus.clone()); + let server = Connection::connect(bus.connect().await?).await?; + server + .serve_at( + names::OBJECT_PATH.try_into()?, + PolicyService { + policy: Arc::new(RwLock::new(Policy::new(ModuleConfig::default())?)), + }, + ) + .await?; + server.request_name(names::INTERFACE).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; + let info: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?; + assert_eq!(info.generation, 1); + assert!( + proxy + .call::(names::methods::EVALUATE, ()) + .await + .is_err() + ); + assert!( + proxy + .call::(names::methods::DECIDE, ()) + .await + .is_err() + ); + let response: CheckResponse = proxy + .call( + names::methods::CHECK, + (CheckRequest { + caller: CallerContext { + agent: "a".into(), + call_id: "c".into(), + ..Default::default() + }, + checks: vec![ + Check::LocalDiagnostic {}, + Check::Path { + path: "/etc/passwd".into(), + write: false, + }, + ], + },), + ) + .await?; + assert_eq!(response.decisions[0].verdict, Verdict::Allow); + assert_eq!( + response.decisions[1].verdict, + Verdict::Deny { + reason: DenialReason::Floor + } + ); + task.abort(); + Ok(()) +} +#[tokio::test] +async fn sdk_setup_accepts_generation_and_rejects_invalid_reinit() +-> std::result::Result<(), Box> { + let bus = MemoryBus::new(); + let task = Broker::new().spawn(bus.clone()); + let server = Connection::connect(bus.connect().await?).await?; + setup(server.clone(), ModuleConfig::default()).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; + let before: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?; + assert!( + setup( + server.clone(), + ModuleConfig { + contract_version: (2, 0), + ..Default::default() + } + ) + .await + .is_err() + ); + let unchanged: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?; + assert_eq!(unchanged, before); + setup(server.clone(), ModuleConfig::default()).await?; + let after: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?; + assert_eq!(after.generation, before.generation + 1); + let decision: Decision = proxy + .call( + names::methods::EVALUATE, + (EvaluateRequest { + caller: CallerContext { + agent: "a".into(), + call_id: "c".into(), + ..Default::default() + }, + call: ToolCall { + tool: "local_diagnostic".into(), + arguments_json: "{}".into(), + checks: vec![Check::LocalDiagnostic {}], + }, + },), + ) + .await?; + assert_eq!(decision.verdict, Verdict::Allow); + task.abort(); + Ok(()) +} diff --git a/crates/tinysecurity-module/src/lib.rs b/crates/tinysecurity-module/src/lib.rs new file mode 100644 index 0000000..fba328e --- /dev/null +++ b/crates/tinysecurity-module/src/lib.rs @@ -0,0 +1,5 @@ +//! Native `TinyBus` ABI adapter for the conservative security bootstrap. +//! +//! Initialize through the SDK with [`tinysecurity_bus::ModuleConfig`]. Hosts +//! depend only on the transport-free contract, never this implementation. +mod adapter; diff --git a/crates/tinysecurity-policy/Cargo.toml b/crates/tinysecurity-policy/Cargo.toml new file mode 100644 index 0000000..c2ab908 --- /dev/null +++ b/crates/tinysecurity-policy/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "tinysecurity-policy" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "TinySecurity native security policy" +keywords = ["security", "tinybus", "policy"] +categories = ["authentication"] +publish = false + +[dependencies] +tinysecurity-bus = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/tinysecurity-policy/src/command.rs b/crates/tinysecurity-policy/src/command.rs new file mode 100644 index 0000000..7b1a2e2 --- /dev/null +++ b/crates/tinysecurity-policy/src/command.rs @@ -0,0 +1,25 @@ +//! Conservative shell bootstrap: only argument-free read diagnostics. +//! +//! The host's rich shell grammar is not claimed migrated here. Substitution, +//! scripts, executable paths, redirection and compound commands fail closed. +use crate::{DenialReason, PolicyConfig, path}; +pub(super) fn check(config: &PolicyConfig, command: &str) -> std::result::Result<(), DenialReason> { + // Apply the same credential/system floor even when discretionary policy is off. + // Unknown shell constructs cannot hide paths because they are never allowed. + if command.split_whitespace().any(path::floor) { + return Err(DenialReason::Floor); + } + let command = command.trim(); + if !matches!(command, "pwd" | "date" | "uname" | "whoami") { + return Err(DenialReason::Command); + } + if config.enabled + && !config + .allowed_commands + .iter() + .any(|allowed| allowed == command) + { + return Err(DenialReason::Command); + } + Ok(()) +} diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs new file mode 100644 index 0000000..d8eedc9 --- /dev/null +++ b/crates/tinysecurity-policy/src/engine.rs @@ -0,0 +1,164 @@ +//! Atomic policy activation and ordered evaluation. +use crate::{ + AccessTier, CONTRACT_VERSION, CallerContext, Check, CheckRequest, CheckResponse, Decision, + DenialReason, Error, EvaluateRequest, ModuleConfig, Origin, PolicyInfo, Result, Verdict, + command, names, path, +}; +/// Active validated policy instance. Failed reconfiguration never changes it. +#[derive(Clone, Debug)] +pub struct Policy { + config: ModuleConfig, + generation: u64, +} +impl Policy { + /// Validate and activate the first policy generation. + /// # Errors + /// Rejects incompatible wire versions, unsupported judges and invalid roots. + pub fn new(config: ModuleConfig) -> Result { + validate(&config)?; + Ok(Self { + config, + generation: 1, + }) + } + /// Activate a new generation after validating all settings. + /// # Errors + /// Rejects invalid settings or generation overflow without changing old state. + pub fn reinitialize(&mut self, config: ModuleConfig) -> Result<()> { + validate(&config)?; + let generation = self.generation.checked_add(1).ok_or(Error::InvalidConfig)?; + self.config = config; + self.generation = generation; + Ok(()) + } + /// Inspect effective public configuration, without init-only credentials. + #[must_use] + pub fn info(&self) -> PolicyInfo { + PolicyInfo { + contract_version: CONTRACT_VERSION, + generation: self.generation, + policy: self.config.policy.clone(), + capabilities: names::METHODS.iter().map(|s| (*s).into()).collect(), + } + } + /// Authorize a supported host-owned tool and its complete concrete checks. + /// # Errors + /// Reserved for module failures; ordinary denial is a successful result. + pub fn evaluate(&self, request: &EvaluateRequest) -> Result { + let matching = !request.call.checks.is_empty() + && match request.call.tool.as_str() { + "local_diagnostic" => request + .call + .checks + .iter() + .all(|c| matches!(c, Check::LocalDiagnostic {})), + "path_check" => request + .call + .checks + .iter() + .all(|c| matches!(c, Check::Path { .. })), + "command_check" => request + .call + .checks + .iter() + .all(|c| matches!(c, Check::Command { .. })), + _ => false, + }; + if !matching { + return Ok(self.denied(DenialReason::UnsupportedEffect)); + } + for check in &request.call.checks { + let decision = self.one(&request.caller, check); + if decision.verdict != Verdict::Allow { + return Ok(decision); + } + } + Ok(self.allowed()) + } + /// Evaluate an ordered batch against a single active generation. + /// # Errors + /// Reserved for module failures; individual denials remain ordered results. + pub fn check(&self, request: &CheckRequest) -> Result { + Ok(CheckResponse { + generation: self.generation, + decisions: request + .checks + .iter() + .map(|check| self.one(&request.caller, check)) + .collect(), + }) + } + fn one(&self, caller: &CallerContext, check: &Check) -> Decision { + if caller.agent.is_empty() || caller.call_id.is_empty() { + return self.denied(DenialReason::InvalidContext); + } + let result = match check { + Check::LocalDiagnostic {} => Ok(()), + Check::Unknown { .. } => Err(DenialReason::UnsupportedEffect), + Check::Path { path, write } => { + path::check(&self.config.policy, path, *write).and_then(|()| { + if *write && caller.tier == AccessTier::Read { + Err(DenialReason::Access) + } else if *write + && (caller.origin == Origin::Cron || self.config.policy.enabled) + { + Err(DenialReason::ApprovalUnavailable) + } else { + Ok(()) + } + }) + } + Check::Command { command } => command::check(&self.config.policy, command), + }; + match result { + Ok(()) => self.allowed(), + Err(reason) => self.denied(reason), + } + } + fn allowed(&self) -> Decision { + Decision { + generation: self.generation, + verdict: Verdict::Allow, + cacheable: false, + } + } + fn denied(&self, reason: DenialReason) -> Decision { + Decision { + generation: self.generation, + verdict: Verdict::Deny { reason }, + cacheable: false, + } + } +} +fn validate(config: &ModuleConfig) -> Result<()> { + if config.contract_version.0 != CONTRACT_VERSION.0 + || config.contract_version.1 > CONTRACT_VERSION.1 + { + return Err(Error::IncompatibleVersion); + } + // No approval or judge engine is advertised. Do not accept inert credentials. + if let Some(judge) = &config.judge { + if judge.threshold_millis > 1000 + || judge.timeout_ms == 0 + || judge.timeout_ms > 600_000 + || judge.credential.0.is_empty() + { + return Err(Error::InvalidConfig); + } + return Err(Error::Unavailable); + } + let p = &config.policy; + if p.enabled && (!path::valid_root(&p.action_dir) || !path::valid_root(&p.workspace_dir)) { + return Err(Error::InvalidConfig); + } + if p.forbidden_paths + .iter() + .any(|root| !std::path::Path::new(root).is_absolute() || path::floor(root)) + || p.allowed_commands + .iter() + .any(|c| c.is_empty() || c.chars().any(char::is_whitespace)) + { + return Err(Error::InvalidConfig); + } + Ok(()) +} diff --git a/crates/tinysecurity-policy/src/lib.rs b/crates/tinysecurity-policy/src/lib.rs new file mode 100644 index 0000000..3b7b7cb --- /dev/null +++ b/crates/tinysecurity-policy/src/lib.rs @@ -0,0 +1,7 @@ +//! Conservative security bootstrap engine, private to the native module. +//! Only concrete supported checks can authorize effects; unknown tools deny. +pub use tinysecurity_bus::*; +mod command; +mod engine; +mod path; +pub use engine::Policy; diff --git a/crates/tinysecurity-policy/src/path.rs b/crates/tinysecurity-policy/src/path.rs new file mode 100644 index 0000000..2b57c0c --- /dev/null +++ b/crates/tinysecurity-policy/src/path.rs @@ -0,0 +1,134 @@ +//! Native ancestor resolution and unconditional floor ported from `OpenHuman`. +use crate::{DenialReason, PolicyConfig}; +use std::path::{Path, PathBuf}; +pub(super) fn floor(path: &str) -> bool { + let normalized = path.to_ascii_lowercase().replace('\\', "/"); + // Windows canonicalize emits an extended-length prefix. It must not hide + // the system-root floor when this engine runs natively on Windows. + let normalized = normalized + .strip_prefix("//?/") + .or_else(|| normalized.strip_prefix("/??/")) + .unwrap_or(&normalized); + let segments: Vec<_> = normalized.split('/').filter(|s| !s.is_empty()).collect(); + // Both slash dialects are checked on every OS. The additional macOS + // /private aliases are protected before canonicalization too. + if path.contains('\0') + || segments.contains(&"..") + || segments.iter().any(|s| { + let component = s + .split(':') + .next() + .unwrap_or(s) + .trim_end_matches([' ', '.']); + [".ssh", ".gnupg", ".aws", ".azure", ".kube", "keychains"].contains(&component) + }) + || segments.windows(2).any(|w| { + w[0] == "microsoft" && matches!(w[1], "protect" | "credentials" | "crypto" | "vault") + }) + { + return true; + } + [ + "/etc", + "/root", + "/boot", + "/proc", + "/sys", + "/system", + "/private/etc", + "/private/var/root", + "c:/windows", + "c:/program files", + "c:/program files (x86)", + "c:/programdata", + ] + .iter() + .any(|prefix| normalized == *prefix || normalized.starts_with(&format!("{prefix}/"))) +} +pub(super) fn valid_root(root: &str) -> bool { + Path::new(root).is_absolute() + && !floor(root) + && Path::new(root) + .canonicalize() + .is_ok_and(|p| !floor(&p.to_string_lossy()) && p.is_dir()) +} +fn resolved(path: &Path) -> std::io::Result { + // Canonicalize the nearest existing ancestor for prospective file writes. + // A broken symlink or permission error is not treated as a missing suffix. + let mut ancestor = path.to_path_buf(); + let mut suffix = Vec::new(); + loop { + match ancestor.canonicalize() { + Ok(mut canonical) => { + for segment in suffix.iter().rev() { + canonical.push(segment); + } + return Ok(canonical); + } + Err(error) => { + if error.kind() != std::io::ErrorKind::NotFound + || ancestor.symlink_metadata().is_ok() + { + return Err(error); + } + let name = ancestor.file_name().ok_or(error)?.to_os_string(); + suffix.push(name); + if !ancestor.pop() { + return Err(std::io::Error::other("no resolvable ancestor")); + } + } + } + } +} +pub(super) fn check( + config: &PolicyConfig, + path: &str, + _write: bool, +) -> std::result::Result<(), DenialReason> { + if floor(path) { + return Err(DenialReason::Floor); + } + // Tilde and foreign OS paths cannot be interpreted securely on this host. + if path.is_empty() + || path.starts_with('~') + || (cfg!(unix) && (path.contains('\\') || path.as_bytes().get(1) == Some(&b':'))) + { + return Err(DenialReason::PathResolution); + } + let input = Path::new(path); + let absolute = if input.is_absolute() { + input.to_path_buf() + } else if config.enabled { + Path::new(&config.action_dir).join(input) + } else { + std::env::current_dir() + .map_err(|_| DenialReason::PathResolution)? + .join(input) + }; + if floor(&absolute.to_string_lossy()) { + return Err(DenialReason::Floor); + } + let canonical = resolved(&absolute).map_err(|_| DenialReason::PathResolution)?; + if floor(&canonical.to_string_lossy()) { + return Err(DenialReason::Floor); + } + if !config.enabled { + return Ok(()); + } + let action = Path::new(&config.action_dir) + .canonicalize() + .map_err(|_| DenialReason::PathResolution)?; + let workspace = Path::new(&config.workspace_dir) + .canonicalize() + .map_err(|_| DenialReason::PathResolution)?; + if !canonical.starts_with(action) || canonical.starts_with(workspace) { + return Err(DenialReason::PathScope); + } + for forbidden in &config.forbidden_paths { + let forbidden = resolved(Path::new(forbidden)).map_err(|_| DenialReason::PathResolution)?; + if canonical.starts_with(forbidden) { + return Err(DenialReason::PathScope); + } + } + Ok(()) +} diff --git a/crates/tinysecurity-policy/tests/policy.rs b/crates/tinysecurity-policy/tests/policy.rs new file mode 100644 index 0000000..fffcd8a --- /dev/null +++ b/crates/tinysecurity-policy/tests/policy.rs @@ -0,0 +1,413 @@ +//! Regression tests for bootstrap permission boundaries. +use tinysecurity_policy::*; +fn caller() -> CallerContext { + CallerContext { + agent: "operator".into(), + call_id: "call-1".into(), + tier: AccessTier::Full, + ..Default::default() + } +} +#[test] +fn disabled_policy_keeps_credential_system_and_traversal_floor() +-> std::result::Result<(), Box> { + let policy = Policy::new(ModuleConfig::default())?; + for path in [ + "/home/user/.ssh/id", + "C:\\Users\\me\\.aws\\credentials", + "/etc/passwd", + "\\\\?\\C:\\Windows\\System32\\config", + "C:\\Users\\me\\.ssh.\\key", + "C:\\Users\\me\\.aws:stream", + "/private/etc/passwd", + "a/../b", + "a\0b", + "~/Library/Keychains/login", + ] { + let result = policy.check(&CheckRequest { + caller: caller(), + checks: vec![Check::Path { + path: path.into(), + write: false, + }], + })?; + assert_eq!( + result.decisions[0].verdict, + Verdict::Deny { + reason: DenialReason::Floor + }, + "{path}" + ); + } + Ok(()) +} +#[test] +fn claimed_read_checks_never_authorize_unknown_tools() +-> std::result::Result<(), Box> { + let policy = Policy::new(ModuleConfig::default())?; + let result = policy.evaluate(&EvaluateRequest { + caller: caller(), + call: ToolCall { + tool: "send_email".into(), + arguments_json: "{}".into(), + checks: vec![Check::LocalDiagnostic {}], + }, + })?; + assert_eq!( + result.verdict, + Verdict::Deny { + reason: DenialReason::UnsupportedEffect + } + ); + Ok(()) +} +#[test] +fn failed_reinit_preserves_old_generation_and_configuration() +-> std::result::Result<(), Box> { + let mut policy = Policy::new(ModuleConfig::default())?; + let old = policy.info(); + let bad = ModuleConfig { + contract_version: (2, 0), + ..Default::default() + }; + assert_eq!(policy.reinitialize(bad), Err(Error::IncompatibleVersion)); + assert_eq!(policy.info(), old); + let mut next = ModuleConfig::default(); + next.policy.enabled = true; + assert_eq!(policy.reinitialize(next), Err(Error::InvalidConfig)); + assert_eq!(policy.info(), old); + policy.reinitialize(ModuleConfig::default())?; + assert_eq!(policy.info().generation, 2); + Ok(()) +} +#[test] +fn ordered_batch_allows_diagnostic_and_denies_unknown_effect() +-> std::result::Result<(), Box> { + let policy = Policy::new(ModuleConfig::default())?; + let r = policy.check(&CheckRequest { + caller: caller(), + checks: vec![ + Check::LocalDiagnostic {}, + Check::Unknown { + description: "unknown".into(), + }, + Check::Command { + command: "mystery".into(), + }, + ], + })?; + assert_eq!(r.decisions[0].verdict, Verdict::Allow); + assert_eq!( + r.decisions[1].verdict, + Verdict::Deny { + reason: DenialReason::UnsupportedEffect + } + ); + assert_eq!( + r.decisions[2].verdict, + Verdict::Deny { + reason: DenialReason::Command + } + ); + Ok(()) +} +#[test] +fn native_path_scope_access_and_approval_checks_are_conservative() +-> std::result::Result<(), Box> { + let fixture = tempfile::tempdir()?; + let action = fixture.path().join("action"); + let workspace = action.join("state"); + let forbidden = action.join("private"); + std::fs::create_dir_all(&workspace)?; + std::fs::create_dir_all(&forbidden)?; + let config = ModuleConfig { + policy: PolicyConfig { + enabled: true, + action_dir: action.to_string_lossy().into(), + workspace_dir: workspace.to_string_lossy().into(), + forbidden_paths: vec![forbidden.to_string_lossy().into()], + ..Default::default() + }, + ..Default::default() + }; + let policy = Policy::new(config)?; + for (path, write, reason) in [ + ("readable", false, None), + ("readable", true, Some(DenialReason::ApprovalUnavailable)), + ("state/new", false, Some(DenialReason::PathScope)), + ("private/new", false, Some(DenialReason::PathScope)), + ( + fixture.path().to_str().ok_or("utf8 fixture")?, + false, + Some(DenialReason::PathScope), + ), + ] { + let response = policy.check(&CheckRequest { + caller: caller(), + checks: vec![Check::Path { + path: path.into(), + write, + }], + })?; + assert_eq!( + response.decisions[0].verdict, + reason.map_or(Verdict::Allow, |reason| Verdict::Deny { reason }) + ); + } + let mut reader = caller(); + reader.tier = AccessTier::Read; + let denied = policy.check(&CheckRequest { + caller: reader, + checks: vec![Check::Path { + path: "readable".into(), + write: true, + }], + })?; + assert_eq!( + denied.decisions[0].verdict, + Verdict::Deny { + reason: DenialReason::Access + } + ); + let disabled = Policy::new(ModuleConfig::default())?; + let mut cron = caller(); + cron.origin = Origin::Cron; + let denied = disabled.check(&CheckRequest { + caller: cron, + checks: vec![Check::Path { + path: action.to_string_lossy().into(), + write: true, + }], + })?; + assert_eq!( + denied.decisions[0].verdict, + Verdict::Deny { + reason: DenialReason::ApprovalUnavailable + } + ); + Ok(()) +} +#[cfg(unix)] +#[test] +fn symlinked_credential_store_and_outside_root_never_bypass_policy() +-> std::result::Result<(), Box> { + let fixture = tempfile::tempdir()?; + let action = fixture.path().join("action"); + let state = fixture.path().join("state"); + let secrets = fixture.path().join(".ssh"); + std::fs::create_dir_all(&action)?; + std::fs::create_dir_all(&state)?; + std::fs::create_dir_all(&secrets)?; + std::os::unix::fs::symlink(&secrets, action.join("credentials"))?; + std::os::unix::fs::symlink(&state, action.join("outside"))?; + std::os::unix::fs::symlink(action.join("missing"), action.join("broken"))?; + let policy = Policy::new(ModuleConfig { + policy: PolicyConfig { + enabled: true, + action_dir: action.to_string_lossy().into(), + workspace_dir: state.to_string_lossy().into(), + ..Default::default() + }, + ..Default::default() + })?; + for (path, reason) in [ + ("credentials/new", DenialReason::Floor), + ("outside/new", DenialReason::PathScope), + ("broken/new", DenialReason::PathResolution), + ] { + let response = policy.check(&CheckRequest { + caller: caller(), + checks: vec![Check::Path { + path: path.into(), + write: false, + }], + })?; + assert_eq!(response.decisions[0].verdict, Verdict::Deny { reason }); + } + Ok(()) +} +#[test] +fn unknown_commands_and_shell_expansion_are_denied_without_trusting_read_claims() +-> std::result::Result<(), Box> { + let policy = Policy::new(ModuleConfig::default())?; + for command in [ + "pwd; curl example.com", + "$(whoami)", + "date > file", + "C:\\evil\\pwd", + "pwd | whoami", + "cat << 'EOF'\nsafe data\nEOF", + "Get-Content file", + "", + "mystery", + ] { + let r = policy.check(&CheckRequest { + caller: caller(), + checks: vec![Check::Command { + command: command.into(), + }], + })?; + assert_eq!( + r.decisions[0].verdict, + Verdict::Deny { + reason: DenialReason::Command + }, + "{command}" + ); + } + for command in ["pwd", "date", "uname", "whoami"] { + let r = policy.check(&CheckRequest { + caller: caller(), + checks: vec![Check::Command { + command: command.into(), + }], + })?; + assert_eq!(r.decisions[0].verdict, Verdict::Allow); + } + let r = policy.check(&CheckRequest { + caller: caller(), + checks: vec![Check::Command { + command: "cat /etc/passwd".into(), + }], + })?; + assert_eq!( + r.decisions[0].verdict, + Verdict::Deny { + reason: DenialReason::Floor + } + ); + Ok(()) +} +#[test] +fn unavailable_judge_and_invalid_configuration_do_not_activate() +-> std::result::Result<(), Box> { + let mut policy = Policy::new(ModuleConfig::default())?; + let old = policy.info(); + let judge = JudgeConfig { + credential: Secret("fixture-secret".into()), + threshold_millis: 800, + timeout_ms: 1000, + origins: vec![Origin::Operator], + }; + assert_eq!( + policy.reinitialize(ModuleConfig { + judge: Some(judge.clone()), + ..Default::default() + }), + Err(Error::Unavailable) + ); + let invalid = JudgeConfig { + threshold_millis: 1001, + ..judge + }; + assert_eq!( + policy.reinitialize(ModuleConfig { + judge: Some(invalid), + ..Default::default() + }), + Err(Error::InvalidConfig) + ); + let invalid = ModuleConfig { + policy: PolicyConfig { + allowed_commands: vec!["bad command".into()], + ..Default::default() + }, + ..Default::default() + }; + assert_eq!(policy.reinitialize(invalid), Err(Error::InvalidConfig)); + assert_eq!(policy.info(), old); + let r = policy.check(&CheckRequest { + caller: CallerContext::default(), + checks: vec![Check::LocalDiagnostic {}], + })?; + assert_eq!( + r.decisions[0].verdict, + Verdict::Deny { + reason: DenialReason::InvalidContext + } + ); + let r = policy.evaluate(&EvaluateRequest { + caller: caller(), + call: ToolCall { + tool: "path_check".into(), + arguments_json: "{}".into(), + checks: vec![], + }, + })?; + assert_eq!( + r.verdict, + Verdict::Deny { + reason: DenialReason::UnsupportedEffect + } + ); + let r = policy.evaluate(&EvaluateRequest { + caller: caller(), + call: ToolCall { + tool: "local_diagnostic".into(), + arguments_json: "{}".into(), + checks: vec![Check::LocalDiagnostic {}], + }, + })?; + assert_eq!(r.verdict, Verdict::Allow); + Ok(()) +} +#[test] +fn enabled_command_allowlist_cannot_authorize_unknown_executables() +-> std::result::Result<(), Box> { + let fixture = tempfile::tempdir()?; + let action = fixture.path().join("action"); + let state = fixture.path().join("state"); + std::fs::create_dir_all(&action)?; + std::fs::create_dir_all(&state)?; + let policy = Policy::new(ModuleConfig { + policy: PolicyConfig { + enabled: true, + action_dir: action.to_string_lossy().into(), + workspace_dir: state.to_string_lossy().into(), + allowed_commands: vec!["pwd".into(), "unknown".into()], + ..Default::default() + }, + ..Default::default() + })?; + for (command, verdict) in [ + ("pwd", Verdict::Allow), + ( + "date", + Verdict::Deny { + reason: DenialReason::Command, + }, + ), + ( + "unknown", + Verdict::Deny { + reason: DenialReason::Command, + }, + ), + ] { + let r = policy.check(&CheckRequest { + caller: caller(), + checks: vec![Check::Command { + command: command.into(), + }], + })?; + assert_eq!(r.decisions[0].verdict, verdict); + } + Ok(()) +} +#[test] +fn configuration_requiring_a_future_minor_is_rejected_without_activation() +-> std::result::Result<(), Box> { + let future = ModuleConfig { + contract_version: (CONTRACT_VERSION.0, CONTRACT_VERSION.1 + 1), + ..Default::default() + }; + assert_eq!( + Policy::new(future.clone()).map(|p| p.info()), + Err(Error::IncompatibleVersion) + ); + let mut active = Policy::new(ModuleConfig::default())?; + let before = active.info(); + assert_eq!(active.reinitialize(future), Err(Error::IncompatibleVersion)); + assert_eq!(active.info(), before); + Ok(()) +} diff --git a/crates/tinysecurity/Cargo.toml b/crates/tinysecurity/Cargo.toml new file mode 100644 index 0000000..9e08849 --- /dev/null +++ b/crates/tinysecurity/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "tinysecurity" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "TinySecurity native security policy" +keywords = ["security", "tinybus", "policy"] +categories = ["authentication"] +publish = false + +[dependencies] +tinysecurity-policy = { workspace = true } +tinysecurity-bus = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/tinysecurity/src/lib.rs b/crates/tinysecurity/src/lib.rs new file mode 100644 index 0000000..9dd4424 --- /dev/null +++ b/crates/tinysecurity/src/lib.rs @@ -0,0 +1,2 @@ +//! Internal implementation umbrella. Hosts must depend on tinysecurity-bus only. +pub use tinysecurity_policy::*; diff --git a/deny.toml b/deny.toml index 1134b6a..3f2b2d9 100644 --- a/deny.toml +++ b/deny.toml @@ -11,7 +11,7 @@ ignore = [] [licenses] # Licenses accepted for this crate and its dependencies. Keep GPL-3.0-only for -# the template crate itself; the remaining entries cover compatible dependency +# the module crate itself; the remaining entries cover compatible dependency # licenses commonly encountered by Rust projects. allow = [ "Apache-2.0", diff --git a/docs/adr/0002-bus-only-delivery.md b/docs/adr/0002-bus-only-delivery.md new file mode 100644 index 0000000..fb492b1 --- /dev/null +++ b/docs/adr/0002-bus-only-delivery.md @@ -0,0 +1,29 @@ +# 2. Deliver policy through a native bus module + +- Status: Accepted +- Date: 2026-10-10 + +## Context + +Linking security engines into OpenHuman shares their dependency graph and +permits callers to bypass the intended module admission boundary. + +## Decision + +Hosts depend only on `tinysecurity-bus`, with `serde` and `thiserror` as its +normal dependencies. Native modules contain all behavior and private engines. +Host persistence, prompts, audit forwarding and consent use typed bus callbacks. +Credential configuration crosses init/reinit only, with redacted Debug output. +Requests carry verified host context rather than task-locals or model claims. + +OpenHuman loads through digest admission and `attested_proxy`. Capability +migration waits for an upstream release with published archives and checksums. +Only implemented members are advertised. Bootstrap is real Evaluate/Check +behavior, not an empty set of future engine crates. + +## Consequences + +CI must enforce transport-free contracts and the host dependency boundary. +Hot-path measurements precede migration; batching and safe caching address +latency without linking engines. Golden wire fixtures and version checks are +required because the native ABI cannot enforce Rust type compatibility alone. diff --git a/docs/adr/0003-fail-closed.md b/docs/adr/0003-fail-closed.md new file mode 100644 index 0000000..13549b5 --- /dev/null +++ b/docs/adr/0003-fail-closed.md @@ -0,0 +1,30 @@ +# 3. Deny effects after a module fault + +- Status: Accepted +- Date: 2026-10-10 + +## Context + +A missing module or fallback to old policy can turn infrastructure failure +into permission. Retrying a faulted native module in-process is unsafe. + +## Decision + +Absent, incompatible, uninitialized, faulted or timed-out modules deny external +effects. Faults latch until process restart and fresh attestation. Reinit +cannot clear a fault; no linked fallback exists. Normal policy denial remains +a successful response. Invalid reinit retains the prior policy and generation. + +Policy updates invalidate static decisions by generation. Cache permissions +require explicit eligibility and verified context. Mutable approvals, flow +trust, rate accounting and DNS authorization are never reusable solely because +the policy generation has not changed. + +The unused host cost-cap setting will be removed with migration. The module +does not claim enforcement without a cost reservation/accounting callback. + +## Consequences + +Hosts need explicit unhealthy-module diagnostics and denial tests. Availability +loss can interrupt external work. Safe local diagnostics remain possible. +Tests distinguish ordinary denials, rejected config and fatal lifecycle faults. diff --git a/docs/adr/0004-policy-vs-sandbox.md b/docs/adr/0004-policy-vs-sandbox.md new file mode 100644 index 0000000..7f73bc3 --- /dev/null +++ b/docs/adr/0004-policy-vs-sandbox.md @@ -0,0 +1,28 @@ +# 4. Decide sandbox placement here and execute in TinyBox + +- Status: Accepted +- Date: 2026-10-10 + +## Context + +Policy classification and isolation execution currently overlap across host +helpers and sandbox crates, leaving some spawners unconfined. + +## Decision + +TinySecurity owns command/path classification and declarative SandboxPlan +decisions. TinyBox owns process launch, isolation backends and capability +verification. OpenHuman translates plans to TinyBox contracts and presents +results. SaaS cannot disable isolation. Untrusted sources refuse Noop; +credential stores never become grants. Unsupported isolation denies. + +Network policy likewise needs an execution seam. A URL verdict is insufficient +for DNS rebinding defense: the transport must connect to approved addresses, +preserve TLS hostname checks and revalidate/pin redirects. A transport unable +to enforce this must use verified egress mediation or deny protected access. + +## Consequences + +TinyBox changes land upstream before host gitlinks move. Every spawner is +inventoried and tested. Unit tests can validate plans, but actual OS isolation +and transport pinning need native integration tests on supported platforms. diff --git a/docs/adr/0005-jev-judge-only.md b/docs/adr/0005-jev-judge-only.md new file mode 100644 index 0000000..faadcea --- /dev/null +++ b/docs/adr/0005-jev-judge-only.md @@ -0,0 +1,30 @@ +# 5. Use Jev only to judge an existing approval request + +- Status: Accepted +- Date: 2026-10-10 + +## Context + +Probabilistic scores do not establish user permission. A judge sees untrusted +tool content and can fail, leak secrets or overgeneralize past decisions. + +## Decision + +Jev is off by default with explicit per-origin opt-in. It can change only +RequireApproval to Allow, never Deny or a mandatory privacy/isolation rule. +Redaction precedes submission. Independent intent, reversibility and exfiltration +questions feed locally owned thresholds and bounded request budgets. +Credentials arrive through init/reinit only and have no secret Debug output. + +Timeout, error, malformed response, low confidence or unavailable judge keeps +human approval required. An unavailable human channel denies. Decisions are +bound to the original verified caller and call fingerprint. Judge caches cannot +override fresh checks of mutable grants, rate state or execution conditions. +Every verdict records sanitized scores, thresholds and auto:jev attribution; +auto_approve_all also audits. + +## Consequences + +Local stub judges test every failure path on Linux, macOS and Windows. +The module owns threshold and budget semantics; inference clients only return +scores. SaaS must provide a verified approval channel or deny unresolved work. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..2c1dff5 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,38 @@ +# Bootstrap policy latency + +The native verifier sends 500 sequential Evaluate calls (one pure diagnostic) +and 500 Check calls (two checks, allow plus unconditional floor deny) through +the real MemoryBus broker and dynamically loaded native module. Each sample +uses exactly one bus call. This is a synthetic check batch, not a measured +product conversation; calls per representative turn still need measurement. + +Measured on Linux 7.0.0-31-generic x86_64, Rust 1.99.0 (b940084d7), debug build, +October 10, 2026: + +| Native method | p50 | p99 | +| --- | --- | --- | +| Evaluate | 486 us | 4.336 ms | +| Check (two checks) | 537 us | 4.442 ms | + +A repeat after contention subsided measured Evaluate 189 us p50 / 385 us p99 +and Check 210 us p50 / 395 us p99, showing why the final release gate must use +a controlled runner and representative workload. + +The characterized host debug path over two checks was 35 us p50 / 63 us p99. +The native boundary is substantially slower in this debug workload. These +numbers do not establish release-build performance, or a product migration +budget. A provisional gate to measure before migration is <=1 ms p50 and +<=10 ms p99 per batched Check on the same supported native runner. That gate +must be agreed against a representative released-module workload and its +number of calls per turn before host callers move. Bootstrap remains +release-gated irrespective of this synthetic result. + +Reproduce with: + +```sh +cargo build --all-targets --all-features +cargo run -p tinysecurity-module --example verify_module -- target/debug/libtinysecurity_module.so +``` + +Use the platform library extension on macOS/Windows. Archive timing can be run +with release URL, archive name and published digest as the three arguments. diff --git a/docs/plans/README.md b/docs/plans/README.md index 0a5db16..78f9edb 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -18,5 +18,3 @@ Use the same kebab-case stem as the specification. A useful plan includes: Prefer tasks that can be implemented and reviewed independently. Include short code snippets when they remove ambiguity, but do not paste entire future files into the plan. - -See [`example-retry-policy.md`](example-retry-policy.md) for a test-first sample. diff --git a/docs/plans/example-retry-policy.md b/docs/plans/example-retry-policy.md deleted file mode 100644 index fe42c4c..0000000 --- a/docs/plans/example-retry-policy.md +++ /dev/null @@ -1,71 +0,0 @@ -# Example plan: Retry policy - -- **Status:** Example -- **Specification:** - [`../specs/example-retry-policy.md`](../specs/example-retry-policy.md) - -> This is a sample implementation plan, not active work. Replace or remove it -> when generating a real project from this template. - -## Goal - -Add the specified typed retry policy through small red-green-refactor steps, -without adding a runtime, timers, or new dependencies. - -## Task 1: Add the constructor contract - -**Files:** `src/retry/mod.rs`, `src/retry/types.rs`, `src/retry/test.rs` - -1. Create the module skeleton and a failing test for zero attempts: - - ```rust - #[test] - fn rejects_zero_max_attempts() { - assert_eq!( - RetryPolicy::new(0).unwrap_err(), - Error::ZeroMaxAttempts, - ); - } - ``` - -2. Add `Error::ZeroMaxAttempts` in `src/error/mod.rs` and its message assertion - in `src/error/test.rs`. -3. Implement `RetryPolicy::new` using `NonZeroU32`, keeping the field private. -4. Run `cargo test retry` and `cargo clippy --all-targets --all-features -- -D warnings`. - -## Task 2: Add attempt-boundary behavior - -**Files:** `src/retry/mod.rs`, `src/retry/test.rs` - -1. Add failing tests for attempts `0`, `1`, the maximum, and one past it. -2. Implement the smallest boundary check: - - ```rust - #[must_use] - pub fn allows_attempt(self, attempt: u32) -> bool { - attempt != 0 && attempt <= self.max_attempts.get() - } - ``` - -3. Run `cargo test retry`. - -## Task 3: Publish and document the API - -**Files:** `src/lib.rs`, `tests/public_api.rs`, `README.md` - -1. Re-export `RetryPolicy` from `src/lib.rs`. -2. Add an integration test using only `template::{Error, RetryPolicy}`. -3. Add a runnable README example and rustdoc `# Errors` documentation. -4. Run `cargo test --doc` and `cargo test --test public_api`. - -## Task 4: Full verification - -- [ ] `cargo fmt --all -- --check` -- [ ] `cargo clippy --all-targets --all-features -- -D warnings` -- [ ] `cargo build --all-targets --all-features` -- [ ] `cargo test --all-features` -- [ ] `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features` -- [ ] `cargo deny check all` - -When all checks pass, mark the specification Implemented and replace this -example status with the actual completion state. diff --git a/docs/plans/security-module.md b/docs/plans/security-module.md new file mode 100644 index 0000000..53615db --- /dev/null +++ b/docs/plans/security-module.md @@ -0,0 +1,266 @@ +# TinySecurity implementation sequence + +- Status: T0/T1 bootstrap in progress; remaining tasks not implemented +- Specification: [accepted behavior](../specs/security-module.md) +- Trackers: [TinySecurity #1](https://github.com/tinyhumansai/tinysecurity/issues/1), + [OpenHuman #7328](https://github.com/tinyhumansai/openhuman/issues/7328), + [TinyBox #27](https://github.com/tinyhumansai/tinybox/issues/27) + +## Delivery and validation rules + +Use one cumulative PR per affected repository. Task numbers mark dependency +order, not separate PRs. Preserve all checkpoint commits. Open PRs against +canonical upstreams. A PR description distinguishes implemented capabilities +from the remaining tracker; this bootstrap does not close either tracker. + +For every behavior: add the failing test, run it against existing code, implement +the smallest complete behavior, rerun it, then run the owning crate suite. +Port host characterization tests before deleting the implementation they pin. +Use sibling `*_tests.rs` modules and deterministic time/filesystem/resolver seams. +No stub engines, blanket lint allowances or ignored failures. + +At each completed task run from the root: + +```sh +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo build --all-targets --all-features +cargo test --all-features +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features +``` + +Run dependency/license, MSRV, per-file coverage and native matrix checks as they +are introduced. Record actual outcomes. Worktrees retain build output under +the checkout/shared configured target; never export `CARGO_TARGET_DIR`. + +## T0 — Replace the template with a useful native module + +**Files:** root `Cargo.toml`, `Cargo.lock`, `AGENTS.md`, `README.md`, `MODULE.md`, +`ROADMAP.md`, `SECURITY.md`, `.github/workflows/{ci,release}.yml`, +`crates/tinysecurity-bus/`, `crates/tinysecurity-policy/`, +`crates/tinysecurity/`, `crates/tinysecurity-module/`, and these design documents. + +1. Pin the existing TinyBus submodule. Inspect its init/reinit, ABI exports and + call dispatch; reuse its SDK rather than inventing RPC lifecycle members. +2. Rename template-bus to tinysecurity-bus and template to tinysecurity-module. + Implement policy in tinysecurity-policy, with a tinysecurity internal umbrella + re-exporting that engine and tinysecurity-module owning only SDK/ABI dispatch. + Each crate has a concrete role; create further engines when they contain + behavior. Set canonical repository metadata and security contact. +3. Delete greeting types, handlers and examples. Replace them with an example + that loads the native module and proves one allow plus one floor denial. +4. Write integration tests asserting interface/object names, advertised + Evaluate/Check/PolicyInfo members and absence of unimplemented handlers. +5. Implement the minimal deterministic policy: safe read versus external + effect, credential/path floor, conservative unknown command handling, + configuration validation and generation. Bootstrap denies any effect it + cannot fully authorize; it does not create nonfunctional approvals. +6. Test malformed args, missing config, denied effects, accepted/rejected reinit + and ordered Check batches through the real TinyBus module loader. +7. Update CI/release crate names and build the module on supported native runners. + Verify package manifests and checksum production, not just Rust compilation. + +**Exit:** no template/greeting behavior; useful policy calls through a native +module, accurate bootstrap docs and all root checks green. + +## T1 — Publish the versioned contract and golden fixtures + +**Files:** `crates/tinysecurity-bus/src/{names,version,error,policy,approval, +redact,egress,sandbox,audit,callbacks}/`, `src/lib.rs`, +`crates/tinysecurity-bus/tests/`, `tests/fixtures/`, `MODULE.md`. + +1. Add failing serde golden tests and round trips for PolicyConfig, verified + CallerContext, ToolCall, Decision, batched Check, PolicyInfo and generation. + Use `#[non_exhaustive]` where source evolution is expected; define strict + wire version compatibility rather than relying on that annotation. +2. Define SDK init/reinit configuration separately from invocation args. Secrets + have explicit redacted Debug. Test that formatted config never contains the + fixture credential. Reject invalid thresholds/TTLs/config before activation. +3. Define typed future contracts/constants for Decide, ListPending, + ListDecisions, RecordExecution, PreauthorizeFlow, ParseReply; Redact, + ScanPrompt, ScanToolDefinition, EgressCheck, UrlGuard; Plan; Audit; and + ApprovalStore, ApprovalPrompt, AuditSink, KeyringConsent callbacks. Pin every + public payload and error with fixtures. Do not advertise unimplemented members. +4. Specify caller-verified context, policy generations, conservative cache + eligibility and callback failure semantics. Omit an unenforced cost-cap field. +5. Assert normal dependencies are serde+thiserror only with `cargo metadata` + and the CI contract-dependency check. Golden serialization uses dev deps. +6. Verify incompatible version rejection, unknown enum/field behavior and + transport-free compilation. Test all exported error variants. + +**Exit:** complete typed contract is available; only implemented capabilities +are callable. SDK config and wire fixtures agree with actual native behavior. + +## T2 — Full policy engine and characterization parity + +**Files:** `crates/tinysecurity-policy/src/{rules,command,path,rate}/`, module +policy adapter; host `tests/security_policy_characterization.rs` and its explicit +`crates/openhuman-cli/Cargo.toml` test entry. + +1. Characterize OpenHuman disabled/enabled autonomy, always-forbidden floor, + action/trusted roots, unknown command writes, tool rules and rate exhaustion. +2. Port classification/scanning from host security and TinyBox shell policy + into the owning policy engine. Reuse the single TinyTools type copy. +3. Test POSIX substitutions, quoted heredoc data versus unquoted expansion, + PowerShell/cmd escaping, executable resolution and compound commands. +4. Implement native path normalization and symlink-aware checks. Test APFS + aliases/case, Windows drive/UNC/device/ADS/reserved/short names, traversal + and NUL, trusted-root intersection and protected credential roots. +5. Add deterministic rate reservation/accounting tests; no generation cache + bypass. Migrate the unused host daily-cost setting out of config with tests. +6. Run policy parity tests across all three OSes. Do not migrate host callers + before T9 release and latency gates. + +## T3 — Unified redaction and scanning + +**Files:** `crates/tinysecurity-redact/src/{patterns,structured,prompt}/`, shared +redaction fixtures; module Redact/ScanPrompt/ScanToolDefinition adapters. + +1. Capture every host redactor's results over one secret/PII/path/URL corpus. +2. Add failing corpus tests for each mode, escaped JSON, nested args, URL userinfo, + home paths and malformed inputs; preserve useful nonsecret diagnostics. +3. Implement one bounded pattern registry and structured key rules. Define + scanning ownership with TinyMCP/TinySkills before importing duplicated logic. +4. Add idempotence and bounded-work properties plus fuzz cases. Test all output + destined for judge/storage/events, including callback errors, is sanitized. +5. Admit bus handlers only after complete tests. Document unavailable-module + logging suppression/floor so no early boot secret leaks are introduced. + +## T4 — Privacy, URL guards and actual egress enforcement + +**Files:** `crates/tinysecurity-egress/src/{privacy,url,resolver}/`, module +egress adapters; owning host/browser HTTP transport connection seams. + +1. Test literal metadata, CGNAT, IPv6 local/mapped/zone forms, unusual IPv4 forms, + domain allowlists and mixed public/private DNS answers with a fake resolver. +2. Define distinct Sensitive behavior with descriptor tests or remove it through + host migration. Test LocalOnly denial for every actual egress entry point. +3. Implement resolution and validation of every candidate address. Build the + real transport seam that connects to approved IPs and retains TLS host checks. +4. Stub a resolver whose second answer becomes private; prove the transport + does not resolve again or dial the changed answer. Recheck and pin redirects. + Exercise browser/proxy connectors; require mediation or deny if no seam exists. +5. Audit protected connections on Linux/macOS/Windows. Never describe URL-only + tests as proof of DNS rebinding protection. + +## T5 — Sandbox planning and TinyBox execution contract + +**Files:** `crates/tinysecurity-sandbox/src/{plan,capabilities}/`, module Plan +adapter; TinyBox upstream contracts/backends; host sandbox translation. + +1. Add tests for SaaS/env/agent/config precedence, untrusted sources, network + grants, protected paths, resource limits and unsupported capabilities. +2. Implement deterministic plans; keep all launch code in TinyBox. Namespace, + microVM, jail and Docker choices require actual reported capabilities. +3. Work TinyBox #27 upstream: suitability/Noop refusal, plan execution and + backend resource/network enforcement. Remove unsupported host config fields + with migrations instead of silently accepting them. +4. Inventory cron, MCP stdio, runtime pools, hooks, workflows, installers and + agent/browser helper processes. Route each untrusted spawner through TinyBox + and document its source trust, placement and cross-OS tests. +5. Confirm SaaS off override denial and floor grants on native runners. TinyBox + release precedes OpenHuman execution migration. + +## T6 — Audit with confirmed persistence + +**Files:** `crates/tinysecurity-audit/src/{events,sink,rotation}/`, AuditSink +callback client and module Audit handlers. + +1. Test sanitized policy, approval, execution, sandbox and judge rows, ordering, + rotation limits, permission restrictions and sink errors. +2. Implement one event model and explicit required-audit failure behavior. + Confirm persistence before acknowledging a committed row. +3. Test Unix 0600 and Windows ACL/locking/long-path rotation behavior. Decide + TinyMCP audit ownership and use a facade rather than a second engine. + +## T7 — Durable approval lifecycle + +**Files:** `crates/tinysecurity-approval/src/{state,store,reply,flow}/`, callback +contracts/client and module approval handlers; host storage/prompt server. + +1. Characterize host RPCs and gate short circuits, ten-/three-minute TTL, + restart survival, deny, once/tool/flow trust, preauthorization and cron. +2. Add fake-clock/storage callback tests for pending→decided/expired→executed, + duplicate decisions, replay, caller/fingerprint mismatch and unavailable store. +3. Implement the state machine; keep future parking and UX host-side. Bind + grants to verified context and preserve existing RPC/event wire shapes. +4. Redact all persisted/broadcast data. Audit auto_approve_all and execution + outcomes. Test SaaS configured prompt channel and denial when absent. +5. Exercise native callback round trips, expiry and restart on all three OSes. + +## T8 — Optional Jev judge + +**Files:** `crates/tinysecurity-auto/src/{rules,judge,budget}/`, module config; +local stub judge integration tests; host approval log presentation. + +1. Test Deny/floor immutability, per-origin opt-in, redacted input and safe + credential Debug/init boundaries before adding any HTTP client. +2. Integrate TinyInference Decisions inside the module. Implement independent + intent/reversibility/exfiltration questions and explicit local thresholds. +3. Stub allow, deny, low confidence, malformed output, HTTP failure, timeout + and unavailable service; every uncertain result retains human approval. +4. Test request budgets and any cache against changed caller/call/mutable + authorization state. Audit sanitized questions/scores/thresholds/auto:jev. +5. Add host UI verdict presentation and translated strings only after release. + Never treat the inference client's score as permission by itself. + +## T9 — Complete native adapter, latency and upstream release + +**Files:** `crates/tinysecurity-module/`, `.github/workflows/`, release packaging, +native conformance tests, +`benches/`, `MODULE.md`. + +1. Implement every finished engine's ABI dispatch and host callbacks with + typed failures, bounded calls, config activation and fault latching. +2. Test incompatible versions/digests, tampering, missing/faulted/timed-out + modules and no in-process retry. Reinit cannot reset the fault latch. +3. Benchmark p50/p99 Evaluate/Check and calls per representative turn against + host characterization. Agree and commit budgets before moving callers. +4. Validate all eleven release host keys, package names, immutable archives + and checksum manifest. Run published-archive loader smoke tests for allow, + floor deny, config changes and callbacks on supported native runners. +5. Merge/release upstream capabilities before host caller migration. A local + artifact may prove tests but may never supply a production registry digest. + +## T10 — Reviewed policy changes + +**Files:** `crates/tinysecurity-policy/src/{schema,diff,activation}/`, module +policy generation adapter and host policy review UI. + +1. Test versioned declarative policy validation and diffs that expand path, + host, tool, class, auto-approve origin or judge reach. +2. Require verified human review before activating expanded reach. Reject + unreviewed changes; preserve old generation after failed config/review. +3. Emit PolicyChanged only after accepted activation. Test host stale-result + rejection and static-cache invalidation during concurrent policy changes. + +## T11 — Crypto, keyring and pairing last + +**Files:** `crates/tinysecurity-crypto/src/{password,keyring,device,pairing}/`, +KeyringConsent callback and module crypto adapters; host consent/socket UX. + +1. Capture legacy Argon2/AES-GCM and device X25519/HKDF test vectors; test old + encrypted data compatibility, wrong passwords and tampered ciphertext. +2. Consolidate implementations without moving auth/credential ownership. + Test secret handling, consent refusal/unavailability and pairing expiry. +3. Run native Secret Service/file, Keychain and Credential Manager tests with + controlled credentials; document OS capability gaps explicitly. +4. Release upstream before host encryption/keyring/device implementation removal. + +## OpenHuman integration and final verification + +Within its single cumulative PR, first add characterization tests, feature-chain +and bus-only manifest checks, then the attested host adapter and callbacks. +After upstream releases, pin gitlinks and copy published checksums. Preserve +approval.*, security.*, sandbox.* and encryption.* RPC names. Consolidate config +through one translator, then migrate callers in tracker order and remove their +old engines/tests only after parity passes. The one security middleware must +use verified context and retain TinyAgents-owned generic behavior upstream. + +Run feature on/off builds, layout/chain/pin/forwarding/i18n/docs checks and real +released-module three-OS E2E. Assert a killed module denies effects, all untrusted +spawners refuse Noop, the unified corpus passes and actual network pinning holds. +Update user-facing capability docs and generated architecture markers. + +Close the tracker only when these results, releases and final host migrations +exist. Report bootstrap progress and remaining tasks accurately until then. diff --git a/docs/plans/tinybus-module-release.md b/docs/plans/tinybus-module-release.md deleted file mode 100644 index f9eaa18..0000000 --- a/docs/plans/tinybus-module-release.md +++ /dev/null @@ -1,11 +0,0 @@ -# Implement TinyBus Module Releases - -Linked specification: [`../specs/tinybus-module-release.md`](../specs/tinybus-module-release.md) - -1. Add the pinned TinyBus host types and module SDK as path dependencies. -2. Export the template greeting behavior through TinyBus module ABI v1. -3. Exercise the declared interface over the real in-memory bus. -4. Replace TinyBus host bundles with tagged `template` module archives for - every supported platform runner and distribution container. -5. Run the repository validation and coverage contracts, push `main`, and - trigger a patch release. diff --git a/docs/specs/README.md b/docs/specs/README.md index a8286ae..72771de 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -19,5 +19,3 @@ should contain: After the specification is accepted, create a linked implementation plan in [`../plans/`](../plans/README.md). Keep code snippets small enough to clarify the contract; production code still belongs under `src/`. - -See [`example-retry-policy.md`](example-retry-policy.md) for a complete sample. diff --git a/docs/specs/example-retry-policy.md b/docs/specs/example-retry-policy.md deleted file mode 100644 index fe0c1a8..0000000 --- a/docs/specs/example-retry-policy.md +++ /dev/null @@ -1,63 +0,0 @@ -# Example: Retry policy - -- **Status:** Example -- **Owner:** Maintainers -- **Plan:** [`../plans/example-retry-policy.md`](../plans/example-retry-policy.md) - -> This demonstrates the expected specification format. Replace or remove it -> when generating a real project from this template. - -## Problem - -Callers need a typed way to limit retries without duplicating attempt counting -and validation. The crate currently has no retry behavior. - -## Goals - -- Expose an immutable retry policy with a non-zero maximum attempt count. -- Let callers determine whether another attempt is permitted. -- Reject zero attempts through the crate-wide error type. - -## Non-goals - -- Sleeping, backoff, jitter, or executing operations. -- Deciding which application-specific errors are retryable. -- Persisting retry state. - -## Proposed behavior - -The public surface is deliberately small: - -```rust -use template::{RetryPolicy, Result}; - -fn policy() -> Result { - let policy = RetryPolicy::new(3)?; - assert!(policy.allows_attempt(1)); - assert!(!policy.allows_attempt(4)); - Ok(policy) -} -``` - -`RetryPolicy::new(0)` returns a dedicated `Error::ZeroMaxAttempts` variant. -Attempt numbers are one-based: attempt `1` is the initial call, not the first -retry. - -## Invariants and constraints - -- `max_attempts` is always greater than zero after construction. -- `allows_attempt(n)` is true exactly when `1 <= n <= max_attempts`. -- The type is cheap to copy and does not perform I/O or observe time. -- New public items have rustdoc and are re-exported from `src/lib.rs`. - -## Acceptance criteria - -- Construction succeeds for `1` and `u32::MAX` and fails for `0`. -- Boundary checks cover attempts `0`, `1`, `max_attempts`, and - `max_attempts + 1` when representable. -- Integration tests prove the policy and its error are available to consumers. -- Formatting, Clippy, build, tests, rustdoc, and cargo-deny pass. - -## Open questions - -None for this example. diff --git a/docs/specs/security-module.md b/docs/specs/security-module.md new file mode 100644 index 0000000..cdd8b84 --- /dev/null +++ b/docs/specs/security-module.md @@ -0,0 +1,190 @@ +# TinySecurity module behavior + +- Status: Accepted design; incremental implementation +- Tracks: [TinySecurity #1](https://github.com/tinyhumansai/tinysecurity/issues/1), + [OpenHuman #7328](https://github.com/tinyhumansai/openhuman/issues/7328) +- Implementation: [ordered plan](../plans/security-module.md) + +## Ownership and delivery + +TinySecurity decides policy, approval, privacy, audit and sandbox placement. +TinyBox executes isolation plans. OpenHuman owns authentication, configuration +translation, storage and interactive callbacks, RPC presentation and execution. +Generic harness behavior remains in TinyAgents and generic tool types in +TinyTools. A fix belongs in the repository that owns that behavior. + +The production boundary is a native TinyBus module. Hosts depend only on +`tinysecurity-bus`, whose normal dependencies are `serde` and `thiserror`. +It contains wire types, version rules, errors and member constants, with no +runtime, transport, filesystem access or policy implementation. All engines +and their dependencies are private to the module. Host callbacks cross the +bus; Rust traits, futures and task-local state do not cross the ABI. + +The interface is `ai.tinyhumans.tinysecurity.Policy`, at +`/ai/tinyhumans/tinysecurity/Policy`. Advertise only implemented members. +The bootstrap has four implemented roles: `tinysecurity-bus` contract, +`tinysecurity-policy` engine, `tinysecurity` internal umbrella re-exporting +policy, and `tinysecurity-module` native adapter/cdylib. Released artifact names +use `tinysecurity-module`; none of these implementation crates is host-linked. +Removing greeting examples and shipping real `Evaluate` and batched `Check` +is the initial useful milestone. `PolicyInfo` exposes effective configuration; +`PolicyChanged` identifies accepted generation changes. Approval, redaction, +network, sandbox, audit +and crypto members become available only when their engines and tests exist. +Do not create empty engine crates or claim full tracker completion at bootstrap. + +OpenHuman cannot migrate a caller until the upstream capability has merged, +released and published platform archives and `checksum.toml`. Registry digests +must be copied from that release, never a local build. Keep one cumulative PR +per affected repository for this work; stages below are implementation and +verification boundaries, not instructions to open additional PRs. + +## Trust and module lifecycle + +The host admits the module through digest verification and `attested_proxy`. +It supplies policy through SDK init/reinit configuration, not invented `Init` +or `Reinit` RPC members. A request's origin, +agent, thread, flow and access tier come from verified host context, never +model arguments. A claimed command class cannot authorize a call: the engine +must compute it or conservatively strengthen it. Unknown commands are writes. + +Judge credentials are resolved by the host's backend credential resolver and +injected only at init/reinit. They never appear in per-call arguments, debug +formatting, logs, approval records or audit events. Debug output for any config +carrying secrets is redacted. Configuration validation precedes activation; +failed reinit retains the old effective policy and generation. + +Missing, incompatible, uninitialized, faulted or timed-out modules deny external +effects. A fault is latched for that process: no repeated initialization, retry +or fallback to linked policy code. Reinit cannot clear a fault. A host restart +and fresh attestation are required. An ordinary policy denial is a successful +decision and does not fault the module. Pure local diagnostics may continue +only when they cannot cause an external effect. + +## Policy decisions + +`Evaluate` returns `Allow`, `Deny` with a stable reason, or `RequireApproval` +when the approval capability exists. `Check` evaluates an ordered batch of +in-tool path, command and eventually URL checks with individual decisions. +The bootstrap may return an explicit approval-required denial before it has +an approval store; it must not mint unusable request identifiers. + +Autonomy is disabled by default. When disabled, command allowlists, action +budgets, workspace boundaries and interactive policy gates are inactive. +The unconditional floor still denies credential stores (`.ssh`, `.gnupg`, +`.aws`), protected system roots, parent traversal and NUL paths. It applies +before discretionary allowances and trusted-root grants. Enabled policy +honors action roots, forbidden roots, read/write grants, command classes, +tool rules and rate limits. Symlink and OS path normalization failures deny; +full path enforcement is not claimed by a lexical bootstrap check. + +Time, resolver, filesystem and approval dependencies are deterministic seams +inside the module, with production adapters and non-network test adapters. +Security checks must address the operation's actual path and connection, +including time-of-check/time-of-use constraints; a decision over a string alone +cannot promise confinement of an unrelated later operation. + +Remove OpenHuman's unused `max_cost_per_day_cents` during host configuration +migration. No cost cap is advertised until a cost accounting/reservation +callback and explicit accounting semantics exist. Do not silently accept an +unenforced replacement setting. + +## Generation and performance + +Every accepted policy change advances the generation and emits `PolicyChanged`. +Evaluation results identify the effective generation. Hosts reject stale +results. Cache keys cover generation, canonical call fingerprint and verified +caller identity/tier/origin. Never cache mutable approval grants, flow trust, +rate reservations, execution outcomes or DNS authorization across requests. +Generation alone does not describe those states. Static policy decisions can +be cached only when the result explicitly permits it; default to uncached. + +Before migration, measure released-module `Evaluate` and `Check` p50/p99 and +bus calls per representative turn against the in-process characterization +baseline. Commit the workload, machine details and agreed budget. Resolve a +miss with batching or safe caching; do not bypass the module boundary. No +latency or coverage result is claimed without measured evidence. + +## Redaction and network privacy + +One redactor owns secret-token, PII/identifier, endpoint, home-path, structured +argument and free-text modes. The existing host redactors become a shared +acceptance corpus before deletion. Redaction is idempotent, bounded and applied +before persistence, broadcast, judge submission and audit. Diagnostics cannot +leak secrets when the module is unavailable: suppress sensitive payloads or +retain a documented minimum floor until boot makes the module mandatory. + +`LocalOnly` denies nonlocal egress. `Standard` applies declared data and endpoint +policy. `Sensitive` requires a separately tested stricter descriptor policy; +remove/migrate it if no meaningful semantics are implemented. Prompt and tool +definition scanning returns typed verdicts integrated into policy; ownership +of existing TinyMCP/TinySkills scanning is resolved before duplication. + +The URL guard rejects forbidden literal addresses and resolves and validates +every candidate address, including metadata/link-local, CGNAT, IPv6 ULA, +IPv4-mapped IPv6 and unusual numeric forms. DNS rebinding protection additionally +requires the caller's actual transport to connect to approved pinned addresses +while preserving TLS hostname verification. Redirects are rechecked and pinned +individually. A returned address list or URL validator without that connection +seam is not DNS rebinding protection. Browser and proxy transports that cannot +honor pinning require a verified egress proxy or deny the protected operation. + +## Approvals, auto-approval and audit + +The module owns the approval state machine and expiration; the host owns the +parked future and prompt surface. Host-served `ApprovalStore`, `ApprovalPrompt`, +`AuditSink` and `KeyringConsent` callbacks have typed errors and bounded waits. +State and decisions survive restart through the storage callback. Approval +identities bind to the original call fingerprint, origin and caller context. +Expired, mismatched, replayed or denied grants cannot authorize execution. +Default TTL is ten minutes; copilot and sub-agent TTL is three minutes. +`RecordExecution` produces the terminal execution outcome, separately from +permission. Cron external effects deny. SaaS has no implicit bypass: configure +a verified per-user prompt channel or deny when human approval is needed. + +Jev is an optional judge, disabled by default and enabled per origin. It can +only convert `RequireApproval` to `Allow`; it cannot override any `Deny`, floor, +privacy or sandbox requirement. Redacted input and independent questions test +user intent, reversibility and exfiltration. Thresholds and budgets belong to +TinySecurity. Error, timeout, malformed response, unavailable service or low +confidence preserve human approval; an unavailable human channel denies. +Every verdict records sanitized scores, thresholds and `auto:jev` attribution. +`auto_approve_all` also writes audit rows. Judge-result caching cannot become a +reusable approval for a different call or bypass mutable authorization checks. + +Audit covers policy changes, approvals, judge verdicts, execution and sandbox +decisions, with configurable bounded rotation and OS-appropriate restricted +permissions. Audit delivery failure behavior is explicit for required-audit +operations. The module never claims an audit row committed before storage or +the configured sink confirms it. + +## Sandbox and crypto + +`Plan` returns a declarative backend, grants, network policy and resource limits. +It does not spawn processes. SaaS always requires isolation regardless of an +`OPENHUMAN_SANDBOX=off` override. Untrusted MCP, skill and downloaded sources +cannot fall back to `Noop`. No grant opens credential stores. Host-network +access requires explicit policy permission and applicable approval. Unsupported +capabilities return a typed denial rather than an optimistic backend choice. +TinyBox verifies actual runtime suitability before execution. + +Crypto is last: password encryption, keyring backends, device encryption and +pairing have one implementation with consent callbacks. Authentication, +credential ownership, sockets and consent UX stay host-side. Existing encrypted +data compatibility and migration are acceptance requirements. + +## Verification and completion + +Each moved behavior first receives a characterization test in OpenHuman and a +matching contract/engine regression test. Golden serde fixtures pin versioned +wire forms. Tests cover allow, denial and every typed error. Unit tests avoid +live services and wall clocks. Native integration loads the real module and +tests bad digests, timeouts, latching and accepted reinit. + +Linux, macOS and Windows CI exercises path/command semantics, native loading, +approval restart, audit permissions, network pinning, sandbox capability and +keyring behavior. Explicit unsupported-platform records explain any omission. +Full completion also requires fuzz/property tests, 90% per-file coverage, +dependency/license checks, MSRV and warning-free rustdoc, released artifacts, +OpenHuman migration and deletion of duplicated implementations. Bootstrap +checks passing establish only bootstrap completion. diff --git a/docs/specs/tinybus-module-release.md b/docs/specs/tinybus-module-release.md deleted file mode 100644 index adae9b4..0000000 --- a/docs/specs/tinybus-module-release.md +++ /dev/null @@ -1,33 +0,0 @@ -# TinyBus Module Release - -## Purpose - -Generated projects must be usable as native TinyBus integrations and -distributable without also shipping the TinyBus host runtime. - -## Contract - -- The library builds as both an `rlib` and a native `cdylib`. -- The `cdylib` exports TinyBus module ABI v1, an embedded manifest, and the - initialization entrypoint. -- The example module provides `ai.tinyhumans.template.Greeting.Greet` at - `/ai/tinyhumans/template/Greeting`. -- Each release archive is named - `template--.` and contains only this - module, its SHA-256 `modules.toml`, license, and installation documentation. -- Each GitHub release publishes a separate `checksum.toml` mapping every - archive filename to its SHA-256 digest for TinyBus's release loader. -- Release builds cover the stable native Ubuntu, macOS, and Windows runners, - Fedora 43/44 containers, and rolling Arch Linux where official runners or - images exist for the architecture. -- TinyBus itself remains a pinned SDK submodule and is not shipped as a release - asset from this repository. - -## Verification - -CI exercises the bus interface through TinyBus's in-memory transport, enforces -90% line coverage in every source file, and builds the `cdylib`. The release -workflow builds each native module from the tagged source and records its exact -digest in the adjacent allowlist. After publishing, it downloads the Ubuntu -x86_64 archive through TinyBus's GitHub release API and calls `Greet` over an -in-memory bus. From 3b6ba7296e4b69edb7fed6a3eef337abbaa7ad5f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 18:33:31 +0300 Subject: [PATCH 2/4] fix(policy): resolve operation paths and protect native roots Co-authored-by: Medulla --- MODULE.md | 10 +-- ROADMAP.md | 5 +- .../src/{error.rs => error/mod.rs} | 0 crates/tinysecurity-bus/src/lib.rs | 7 +- crates/tinysecurity-bus/src/policy.rs | 20 ++++- crates/tinysecurity-bus/tests/wire.rs | 10 ++- crates/tinysecurity-policy/src/path.rs | 57 +++++++++--- .../tests/path_boundaries.rs | 86 +++++++++++++++++++ crates/tinysecurity-policy/tests/policy.rs | 14 ++- docs/specs/security-module.md | 25 ++++-- 10 files changed, 197 insertions(+), 37 deletions(-) rename crates/tinysecurity-bus/src/{error.rs => error/mod.rs} (100%) create mode 100644 crates/tinysecurity-policy/tests/path_boundaries.rs diff --git a/MODULE.md b/MODULE.md index 357e769..c32edd3 100644 --- a/MODULE.md +++ b/MODULE.md @@ -5,8 +5,8 @@ is `ai.tinyhumans.tinysecurity.Policy`, with object `/ai/tinyhumans/tinysecurity/Policy` and methods `Evaluate`, `Check`, `PolicyInfo`. Accepted SDK reinit advances a generation and emits `PolicyChanged`; rejected configurations retain the old policy and generation. There are no Init/Reinit -RPC methods. Configuration must include `contract_version`, `policy`, and -`judge` (`null` in this bootstrap). +RPC methods. Configuration requires `contract_version` and `policy`. The optional +`judge` field may be omitted or set to `null` in this bootstrap. ```json { @@ -42,7 +42,8 @@ identities are `local_diagnostic`, `path_check`, and `command_check`, each requiring matching nonempty checks. Unknown tools deny even with claimed safe checks. Check is an internal trusted-host operation, not a model-visible tool. -Path checks resolve existing ancestors, including symlinks, and enforce the +Path checks require the host's absolute operation-resolved target, resolve existing +ancestors including symlinks, and enforce the credential/system/traversal floor on lexical and resolved forms even when policy is disabled. Unknown native path dialects, broken symlinks and failed resolution deny. Enabled action roots must resolve to existing directories; @@ -87,7 +88,6 @@ The same verifier is used for local and downloaded release artifacts. Local verification also rejects a wrong digest and tampered bytes before activation, rejects missing SDK configuration, and confirms stopped modules refuse reinit. Fault-injection and timeout latching remain SDK conformance work, not a -bootstrap claim. Timing -uses 500 sequential calls per method, one Evaluate call for one diagnostic or +bootstrap claim. Timing uses 500 sequential calls per method, one Evaluate call for one diagnostic or one Check call for two checks per sample. See measured results and migration budgets in [performance](docs/performance.md). diff --git a/ROADMAP.md b/ROADMAP.md index a5456cd..5432e02 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,8 @@ # TinySecurity roadmap -The bootstrap implements conservative policy evaluation, ordered checks, -versioned contracts, SDK init/reinit and native loading. It intentionally does +The current bootstrap PR implements conservative policy evaluation, ordered checks, +versioned wire contracts, SDK init/reinit and native loading. Publishing those +contracts/artifacts remains pending upstream review and release. It intentionally does not close the security extraction tracker. The [implementation plan](docs/plans/security-module.md) orders the remaining diff --git a/crates/tinysecurity-bus/src/error.rs b/crates/tinysecurity-bus/src/error/mod.rs similarity index 100% rename from crates/tinysecurity-bus/src/error.rs rename to crates/tinysecurity-bus/src/error/mod.rs diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index ff3f13d..7c603c0 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -11,7 +11,12 @@ pub use policy::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. pub const CONTRACT_VERSION: (u32, u32) = (1, 0); -/// Whether the module supplies this host's vocabulary. +/// Whether a remote module's advertised version supplies this host's vocabulary. +/// +/// `version` is the provider version received by the host, not a host requirement +/// received by the module. An equal-major, newer provider minor supplies this +/// host's older vocabulary. Conversely, module init rejects a requested host +/// vocabulary newer than its own implementation. #[must_use] pub const fn is_compatible(version: (u32, u32)) -> bool { version.0 == CONTRACT_VERSION.0 && version.1 >= CONTRACT_VERSION.1 diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index 233444d..55c4896 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -2,6 +2,10 @@ use serde::{Deserialize, Serialize}; use std::fmt; /// Verified host context; never populated directly from model arguments. +/// +/// This is a trusted host-to-module contract, not an authentication mechanism. +/// Only the authenticated host adapter may construct it. The host must not expose +/// policy members to untrusted callers or forward their context/check claims. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CallerContext { @@ -54,7 +58,8 @@ pub struct ToolCall { pub tool: String, /// Original arguments encoded as JSON text, never a credential carrier. pub arguments_json: String, - /// Checks derived by the trusted host adapter. + /// Checks derived by the trusted host adapter from the operation it will execute. + /// Never forward checks supplied by a model, tool client or remote caller. pub checks: Vec, } /// A check of a concrete operation. The bootstrap has no URL authorization. @@ -63,7 +68,8 @@ pub struct ToolCall { pub enum Check { /// Filesystem operation over its actual target path. Path { - /// Target path. + /// Absolute native target resolved by the host against the operation's actual + /// working directory. Relative paths fail closed in both policy modes. path: String, /// Whether mutation is requested. write: bool, @@ -182,6 +188,12 @@ impl Default for PolicyConfig { } } /// Secret injected at initialization only. Its Debug output is always redacted. +/// +/// Serialization intentionally carries plaintext to SDK init/reinit over the +/// trusted in-process loading boundary. This is not a safe diagnostic serializer: +/// never serialize initialization configuration into logs, public RPC responses, +/// transcripts, approval records or audit events. No bus invocation or +/// [`PolicyInfo`] contains this type. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct Secret(pub String); @@ -230,7 +242,9 @@ pub struct PolicyInfo { pub contract_version: (u32, u32), /// Effective generation. pub generation: u64, - /// Effective public policy settings; no judge credentials. + /// Effective settings for the trusted host's policy UI; no judge credentials. + /// Includes host paths, so this response must not be exposed to model tools + /// or unauthenticated callers. pub policy: PolicyConfig, /// Advertised implemented methods. pub capabilities: Vec, diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 00f3b42..6524ff4 100644 --- a/crates/tinysecurity-bus/tests/wire.rs +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -312,11 +312,15 @@ fn unknown_fields_and_enum_tags_are_rejected_at_security_boundaries() serde_json::from_str::(r#"{"kind":"local_diagnostic","unexpected":true}"#).is_err() ); assert!(serde_json::from_str::("{}").is_err()); - assert!(!is_compatible((2, 0))); - assert!(is_compatible((1, 0))); - assert!(is_compatible((1, 4))); Ok(()) } golden!(DecideRequest, roundtrip_decide_request); golden!(DecideResponse, roundtrip_decide_response); + +#[test] +fn remote_provider_with_newer_minor_supplies_this_hosts_older_vocabulary() { + assert!(is_compatible(CONTRACT_VERSION)); + assert!(is_compatible((CONTRACT_VERSION.0, CONTRACT_VERSION.1 + 4))); + assert!(!is_compatible((CONTRACT_VERSION.0 + 1, 0))); +} diff --git a/crates/tinysecurity-policy/src/path.rs b/crates/tinysecurity-policy/src/path.rs index 2b57c0c..75bf993 100644 --- a/crates/tinysecurity-policy/src/path.rs +++ b/crates/tinysecurity-policy/src/path.rs @@ -29,6 +29,12 @@ pub(super) fn floor(path: &str) -> bool { return true; } [ + "/bin", + "/sbin", + "/lib", + "/lib64", + "/usr", + "/library", "/etc", "/root", "/boot", @@ -37,13 +43,41 @@ pub(super) fn floor(path: &str) -> bool { "/system", "/private/etc", "/private/var/root", - "c:/windows", - "c:/program files", - "c:/program files (x86)", - "c:/programdata", ] .iter() .any(|prefix| normalized == *prefix || normalized.starts_with(&format!("{prefix}/"))) + || windows_system_root(normalized) +} +// Match Windows system roots on every drive, including native/extended aliases. +// This is lexical on every OS so a foreign path cannot evade the floor. +fn windows_system_root(path: &str) -> bool { + let components: Vec<_> = path.split('/').filter(|part| !part.is_empty()).collect(); + let root_index = if components.first().is_some_and(|part| { + let bytes = part.as_bytes(); + (bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':') + || part.starts_with("volume{") + }) { + 1 + } else if components.first() == Some(&"device") + && components + .get(1) + .is_some_and(|part| part.starts_with("harddiskvolume")) + { + 2 + } else { + 0 + }; + components.get(root_index).is_some_and(|part| { + let root = part + .split(':') + .next() + .unwrap_or(part) + .trim_end_matches([' ', '.']); + matches!( + root, + "windows" | "program files" | "program files (x86)" | "programdata" + ) + }) } pub(super) fn valid_root(root: &str) -> bool { Path::new(root).is_absolute() @@ -96,15 +130,12 @@ pub(super) fn check( return Err(DenialReason::PathResolution); } let input = Path::new(path); - let absolute = if input.is_absolute() { - input.to_path_buf() - } else if config.enabled { - Path::new(&config.action_dir).join(input) - } else { - std::env::current_dir() - .map_err(|_| DenialReason::PathResolution)? - .join(input) - }; + // The host must resolve against the operation's real working directory. + // Module cwd/action roots cannot stand in for that execution context. + if !input.is_absolute() { + return Err(DenialReason::PathResolution); + } + let absolute = input.to_path_buf(); if floor(&absolute.to_string_lossy()) { return Err(DenialReason::Floor); } diff --git a/crates/tinysecurity-policy/tests/path_boundaries.rs b/crates/tinysecurity-policy/tests/path_boundaries.rs new file mode 100644 index 0000000..2f2a5d6 --- /dev/null +++ b/crates/tinysecurity-policy/tests/path_boundaries.rs @@ -0,0 +1,86 @@ +//! Regression coverage for host-resolved paths and native system roots. +use tinysecurity_policy::*; + +fn request(path: &str) -> CheckRequest { + CheckRequest { + caller: CallerContext { + agent: "operator".into(), + call_id: "path-check".into(), + tier: AccessTier::Full, + ..Default::default() + }, + checks: vec![Check::Path { + path: path.into(), + write: true, + }], + } +} + +#[test] +fn relative_operation_paths_are_rejected_in_both_policy_modes() +-> std::result::Result<(), Box> { + let fixture = tempfile::tempdir()?; + let action = fixture.path().join("action"); + let workspace = fixture.path().join("state"); + std::fs::create_dir_all(&action)?; + std::fs::create_dir_all(&workspace)?; + for enabled in [false, true] { + let policy = Policy::new(ModuleConfig { + policy: PolicyConfig { + enabled, + action_dir: action.to_string_lossy().into(), + workspace_dir: workspace.to_string_lossy().into(), + ..Default::default() + }, + ..Default::default() + })?; + for path in ["passwd", "./passwd", "nested/file"] { + assert_eq!( + policy.check(&request(path))?.decisions[0].verdict, + Verdict::Deny { + reason: DenialReason::PathResolution + }, + "enabled={enabled}, path={path}" + ); + } + } + Ok(()) +} + +#[test] +fn disabled_policy_protects_executable_and_library_roots_on_every_platform() +-> std::result::Result<(), Box> { + let policy = Policy::new(ModuleConfig::default())?; + for path in [ + "/bin/tool", + "/sbin/tool", + "/lib/library", + "/lib64/library", + "/usr/bin/tool", + "/usr/sbin/tool", + "/usr/lib/library", + "/usr/lib64/library", + "/usr/local/bin/tool", + "/usr/local/lib/library", + "/Library/Frameworks/library", + "D:\\Windows\\System32\\config", + "Z:\\Program Files\\tool", + "E:\\Program Files (x86)\\tool", + "F:\\ProgramData\\state", + "\\\\?\\D:\\Windows\\System32\\config", + "\\??\\E:\\Windows\\System32", + "\\Device\\HarddiskVolume2\\Windows\\System32", + "\\\\?\\Volume{fixture}\\Windows\\System32", + "D:\\Windows.\\System32", + "E:\\Windows:stream", + ] { + assert_eq!( + policy.check(&request(path))?.decisions[0].verdict, + Verdict::Deny { + reason: DenialReason::Floor + }, + "{path}" + ); + } + Ok(()) +} diff --git a/crates/tinysecurity-policy/tests/policy.rs b/crates/tinysecurity-policy/tests/policy.rs index fffcd8a..3cc6d57 100644 --- a/crates/tinysecurity-policy/tests/policy.rs +++ b/crates/tinysecurity-policy/tests/policy.rs @@ -145,7 +145,11 @@ fn native_path_scope_access_and_approval_checks_are_conservative() let response = policy.check(&CheckRequest { caller: caller(), checks: vec![Check::Path { - path: path.into(), + path: if std::path::Path::new(path).is_absolute() { + path.into() + } else { + action.join(path).to_string_lossy().into() + }, write, }], })?; @@ -159,7 +163,7 @@ fn native_path_scope_access_and_approval_checks_are_conservative() let denied = policy.check(&CheckRequest { caller: reader, checks: vec![Check::Path { - path: "readable".into(), + path: action.join("readable").to_string_lossy().into(), write: true, }], })?; @@ -195,11 +199,13 @@ fn symlinked_credential_store_and_outside_root_never_bypass_policy() let action = fixture.path().join("action"); let state = fixture.path().join("state"); let secrets = fixture.path().join(".ssh"); + let outside = fixture.path().join("outside"); + std::fs::create_dir_all(&outside)?; std::fs::create_dir_all(&action)?; std::fs::create_dir_all(&state)?; std::fs::create_dir_all(&secrets)?; std::os::unix::fs::symlink(&secrets, action.join("credentials"))?; - std::os::unix::fs::symlink(&state, action.join("outside"))?; + std::os::unix::fs::symlink(&outside, action.join("outside"))?; std::os::unix::fs::symlink(action.join("missing"), action.join("broken"))?; let policy = Policy::new(ModuleConfig { policy: PolicyConfig { @@ -218,7 +224,7 @@ fn symlinked_credential_store_and_outside_root_never_bypass_policy() let response = policy.check(&CheckRequest { caller: caller(), checks: vec![Check::Path { - path: path.into(), + path: action.join(path).to_string_lossy().into(), write: false, }], })?; diff --git a/docs/specs/security-module.md b/docs/specs/security-module.md index cdd8b84..451dd4b 100644 --- a/docs/specs/security-module.md +++ b/docs/specs/security-module.md @@ -45,13 +45,22 @@ The host admits the module through digest verification and `attested_proxy`. It supplies policy through SDK init/reinit configuration, not invented `Init` or `Reinit` RPC members. A request's origin, agent, thread, flow and access tier come from verified host context, never -model arguments. A claimed command class cannot authorize a call: the engine -must compute it or conservatively strengthen it. Unknown commands are writes. +model arguments. `CallerContext` and operation checks are serialized only by +that trusted adapter; the module is not a public authentication endpoint. +Hosts must not expose its members to untrusted callers or forward their claims. +Filesystem checks require an absolute native path resolved against the actual +operation's working directory; the module rejects relative targets even when +discretionary policy is disabled. A claimed command class cannot authorize a +call: the engine must compute it or conservatively strengthen it. Unknown commands are writes. Judge credentials are resolved by the host's backend credential resolver and injected only at init/reinit. They never appear in per-call arguments, debug formatting, logs, approval records or audit events. Debug output for any config -carrying secrets is redacted. Configuration validation precedes activation; +carrying secrets is redacted. Init configuration serialization intentionally +carries credentials to the trusted SDK loading boundary and must never be used +for diagnostics or public responses. `PolicyInfo` excludes judge configuration; +its effective roots are available only to trusted host policy UI, not acting +tools or unauthenticated callers. Configuration validation precedes activation; failed reinit retains the old effective policy and generation. Missing, incompatible, uninitialized, faulted or timed-out modules deny external @@ -72,7 +81,8 @@ an approval store; it must not mint unusable request identifiers. Autonomy is disabled by default. When disabled, command allowlists, action budgets, workspace boundaries and interactive policy gates are inactive. The unconditional floor still denies credential stores (`.ssh`, `.gnupg`, -`.aws`), protected system roots, parent traversal and NUL paths. It applies +`.aws`), protected system roots (including Unix executables/libraries and Windows +roots on every drive), parent traversal and NUL paths. It applies before discretionary allowances and trusted-root grants. Enabled policy honors action roots, forbidden roots, read/write grants, command classes, tool rules and rate limits. Symlink and OS path normalization failures deny; @@ -155,8 +165,11 @@ reusable approval for a different call or bypass mutable authorization checks. Audit covers policy changes, approvals, judge verdicts, execution and sandbox decisions, with configurable bounded rotation and OS-appropriate restricted permissions. Audit delivery failure behavior is explicit for required-audit -operations. The module never claims an audit row committed before storage or -the configured sink confirms it. +operations: when a required sink cannot confirm a row within its bounded wait, +deny the associated operation before execution. Optional audit may report a +sanitized degraded-health event without claiming persistence. The bootstrap +advertises no audit capability. The module never claims an audit row committed +before storage or the configured sink confirms it. ## Sandbox and crypto From c8a68ddad6518ebaeb8759f0666f22c4d13629b1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 18:34:42 +0300 Subject: [PATCH 3/4] docs: render security RPC patterns as code Co-authored-by: Medulla --- docs/plans/security-module.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/security-module.md b/docs/plans/security-module.md index 53615db..caddb59 100644 --- a/docs/plans/security-module.md +++ b/docs/plans/security-module.md @@ -252,7 +252,7 @@ KeyringConsent callback and module crypto adapters; host consent/socket UX. Within its single cumulative PR, first add characterization tests, feature-chain and bus-only manifest checks, then the attested host adapter and callbacks. After upstream releases, pin gitlinks and copy published checksums. Preserve -approval.*, security.*, sandbox.* and encryption.* RPC names. Consolidate config +`approval.*`, `security.*`, `sandbox.*` and `encryption.*` RPC names. Consolidate config through one translator, then migrate callers in tracker order and remove their old engines/tests only after parity passes. The one security middleware must use verified context and retain TinyAgents-owned generic behavior upstream. From 39aa1e267298f0f29e407a29dc59ed4eb3e60c1c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 18:44:38 +0300 Subject: [PATCH 4/4] fix(policy): protect device roots in the unconditional floor Co-authored-by: Medulla --- crates/tinysecurity-policy/src/path.rs | 1 + .../tests/path_boundaries.rs | 42 +++++++++++++++++++ docs/plans/security-module.md | 7 +++- docs/specs/security-module.md | 4 +- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-policy/src/path.rs b/crates/tinysecurity-policy/src/path.rs index 75bf993..8470ad4 100644 --- a/crates/tinysecurity-policy/src/path.rs +++ b/crates/tinysecurity-policy/src/path.rs @@ -35,6 +35,7 @@ pub(super) fn floor(path: &str) -> bool { "/lib64", "/usr", "/library", + "/dev", "/etc", "/root", "/boot", diff --git a/crates/tinysecurity-policy/tests/path_boundaries.rs b/crates/tinysecurity-policy/tests/path_boundaries.rs index 2f2a5d6..2102a46 100644 --- a/crates/tinysecurity-policy/tests/path_boundaries.rs +++ b/crates/tinysecurity-policy/tests/path_boundaries.rs @@ -84,3 +84,45 @@ fn disabled_policy_protects_executable_and_library_roots_on_every_platform() } Ok(()) } + +#[test] +fn disabled_policy_denies_device_roots_without_opening_devices() +-> std::result::Result<(), Box> { + let policy = Policy::new(ModuleConfig::default())?; + for path in ["/dev", "/dev/sda", "/dev/null", "/dev/mapper/system"] { + assert_eq!( + policy.check(&request(path))?.decisions[0].verdict, + Verdict::Deny { + reason: DenialReason::Floor + }, + "{path}" + ); + } + Ok(()) +} + +#[test] +fn every_windows_drive_prefix_preserves_system_and_credential_floors() +-> std::result::Result<(), Box> { + let policy = Policy::new(ModuleConfig::default())?; + // Pure lexical checks do not require a mounted Windows drive on the runner. + for drive in 'A'..='Z' { + for suffix in [ + "Windows\\System32\\config", + "Program Files\\tool", + "Program Files (x86)\\tool", + "ProgramData\\state", + "Users\\me\\.aws\\credentials", + ] { + let path = format!("{drive}:\\{suffix}"); + assert_eq!( + policy.check(&request(&path))?.decisions[0].verdict, + Verdict::Deny { + reason: DenialReason::Floor + }, + "{path}" + ); + } + } + Ok(()) +} diff --git a/docs/plans/security-module.md b/docs/plans/security-module.md index caddb59..0b5186c 100644 --- a/docs/plans/security-module.md +++ b/docs/plans/security-module.md @@ -45,7 +45,8 @@ the checkout/shared configured target; never export `CARGO_TARGET_DIR`. 2. Rename template-bus to tinysecurity-bus and template to tinysecurity-module. Implement policy in tinysecurity-policy, with a tinysecurity internal umbrella re-exporting that engine and tinysecurity-module owning only SDK/ABI dispatch. - Each crate has a concrete role; create further engines when they contain + Register each concrete crate in workspace members, with its Cargo manifest + and `src/lib.rs` target. Create further engines only when they contain behavior. Set canonical repository metadata and security contact. 3. Delete greeting types, handlers and examples. Replace them with an example that loads the native module and proves one allow plus one floor denial. @@ -75,7 +76,9 @@ redact,egress,sandbox,audit,callbacks}/`, `src/lib.rs`, wire version compatibility rather than relying on that annotation. 2. Define SDK init/reinit configuration separately from invocation args. Secrets have explicit redacted Debug. Test that formatted config never contains the - fixture credential. Reject invalid thresholds/TTLs/config before activation. + fixture credential. Serialization remains confined to trusted SDK config + transfer, never logs/public responses. Reject invalid thresholds/TTLs/config + before activation. 3. Define typed future contracts/constants for Decide, ListPending, ListDecisions, RecordExecution, PreauthorizeFlow, ParseReply; Redact, ScanPrompt, ScanToolDefinition, EgressCheck, UrlGuard; Plan; Audit; and diff --git a/docs/specs/security-module.md b/docs/specs/security-module.md index 451dd4b..35a2d75 100644 --- a/docs/specs/security-module.md +++ b/docs/specs/security-module.md @@ -81,8 +81,8 @@ an approval store; it must not mint unusable request identifiers. Autonomy is disabled by default. When disabled, command allowlists, action budgets, workspace boundaries and interactive policy gates are inactive. The unconditional floor still denies credential stores (`.ssh`, `.gnupg`, -`.aws`), protected system roots (including Unix executables/libraries and Windows -roots on every drive), parent traversal and NUL paths. It applies +`.aws`), protected system roots (including Unix executables/libraries, device +roots and Windows roots on every drive), parent traversal and NUL paths. It applies before discretionary allowances and trusted-root grants. Enabled policy honors action roots, forbidden roots, read/write grants, command classes, tool rules and rate limits. Symlink and OS path normalization failures deny;