diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c808685c..5da99292 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 ba8c2fcb..3e51b0be 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 4acf3793..cd58ba84 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 ee8fdfcc..b851fab0 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 123aab5c..3351d3e1 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 fae3c383..a71d5377 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 651906ea..c32edd34 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 requires `contract_version` and `policy`. The optional +`judge` field may be omitted or set to `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 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; +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 67a4e39c..bfc08c41 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 11340249..5432e025 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,26 +1,14 @@ -# 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 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. -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 a30dd850..00000000 --- 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 7f8e99c8..00000000 --- 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 f810aabd..00000000 --- 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 1a30000c..00000000 --- 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 d70b376f..00000000 --- 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 a1857d1c..00000000 --- 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 4da1547d..00000000 --- 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 bf7bea20..00000000 --- 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 ada372d7..00000000 --- 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 3fd3edf2..00000000 --- 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 e1bcdf46..00000000 --- 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 99233ec7..00000000 --- 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 9b173fe4..00000000 --- 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 6e3856ed..00000000 --- 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 b8ddbe0f..00000000 --- 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 4c5d609a..00000000 --- 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 862fa21f..00000000 --- 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 de04ef4e..00000000 --- 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 566fa7e0..00000000 --- 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 2c057725..00000000 --- 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 1c9c2f0c..00000000 --- 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 d5fe71ab..00000000 --- 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 256b71c3..00000000 --- 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 00000000..5c1727e1 --- /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/mod.rs b/crates/tinysecurity-bus/src/error/mod.rs new file mode 100644 index 00000000..874ad158 --- /dev/null +++ b/crates/tinysecurity-bus/src/error/mod.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 00000000..1792cf29 --- /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 00000000..7c603c06 --- /dev/null +++ b/crates/tinysecurity-bus/src/lib.rs @@ -0,0 +1,23 @@ +//! 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 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/names.rs b/crates/tinysecurity-bus/src/names.rs new file mode 100644 index 00000000..9c2cdbec --- /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 00000000..55c48969 --- /dev/null +++ b/crates/tinysecurity-bus/src/policy.rs @@ -0,0 +1,257 @@ +//! Policy invocation and SDK configuration types. +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 { + /// 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 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. +#[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 { + /// 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, + }, + /// 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. +/// +/// 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); +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 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, +} +/// 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 00000000..5000d43f --- /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 00000000..6524ff4b --- /dev/null +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -0,0 +1,326 @@ +//! 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()); + 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-module/Cargo.toml b/crates/tinysecurity-module/Cargo.toml new file mode 100644 index 00000000..4cb3ee03 --- /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 00000000..ef0208a7 --- /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 00000000..4a47103c --- /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 00000000..6b883c7f --- /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 00000000..fba328e9 --- /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 00000000..c2ab908e --- /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 00000000..7b1a2e27 --- /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 00000000..d8eedc9e --- /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 00000000..3b7b7cb8 --- /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 00000000..8470ad49 --- /dev/null +++ b/crates/tinysecurity-policy/src/path.rs @@ -0,0 +1,166 @@ +//! 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; + } + [ + "/bin", + "/sbin", + "/lib", + "/lib64", + "/usr", + "/library", + "/dev", + "/etc", + "/root", + "/boot", + "/proc", + "/sys", + "/system", + "/private/etc", + "/private/var/root", + ] + .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() + && !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); + // 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); + } + 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/path_boundaries.rs b/crates/tinysecurity-policy/tests/path_boundaries.rs new file mode 100644 index 00000000..2102a461 --- /dev/null +++ b/crates/tinysecurity-policy/tests/path_boundaries.rs @@ -0,0 +1,128 @@ +//! 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(()) +} + +#[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/crates/tinysecurity-policy/tests/policy.rs b/crates/tinysecurity-policy/tests/policy.rs new file mode 100644 index 00000000..3cc6d57d --- /dev/null +++ b/crates/tinysecurity-policy/tests/policy.rs @@ -0,0 +1,419 @@ +//! 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: if std::path::Path::new(path).is_absolute() { + path.into() + } else { + action.join(path).to_string_lossy().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: action.join("readable").to_string_lossy().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"); + 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(&outside, 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: action.join(path).to_string_lossy().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 00000000..9e08849f --- /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 00000000..9dd4424f --- /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 1134b6a1..3f2b2d9f 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 00000000..fb492b1d --- /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 00000000..13549b53 --- /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 00000000..7f73bc39 --- /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 00000000..faadcea1 --- /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 00000000..2c1dff5e --- /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 0a5db163..78f9edba 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 fe42c4ce..00000000 --- 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 00000000..0b5186ca --- /dev/null +++ b/docs/plans/security-module.md @@ -0,0 +1,269 @@ +# 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. + 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. +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. 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 + 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 f9eaa18f..00000000 --- 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 a8286ae3..72771de3 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 fe0c1a86..00000000 --- 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 00000000..35a2d75a --- /dev/null +++ b/docs/specs/security-module.md @@ -0,0 +1,203 @@ +# 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. `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. 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 +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 (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; +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: 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 + +`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 adae9b4a..00000000 --- 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.